流式输出 SSE 原理与调试:从 curl 到前端

大模型 API 的流式输出本质是一条 SSE 长连接。本文拆解 text/event-stream 报文格式、stream:true 的分片结构、curl 与前端 ReadableStream 的调试方法,以及代理缓冲、[DONE]、usage 丢失等高频坑位。

打开任何一个 AI 聊天产品,答案都是「一个字一个字蹦出来的」。这不是模型在逐字生成后逐字推送的魔法,而是一条早就存在的 Web 老技术:SSE(Server-Sent Events)。大模型 API 的流式输出,本质上就是服务端把一条 HTTP 响应挂着不关,按行往里写数据。

理解 SSE 不只是满足好奇心——调试流式接口时的绝大多数诡异问题(响应卡住、前端收不到数据、计费对不上),根源都在这条链路的某一环。本文从协议格式讲到 curl 实测,再到前端渲染,把整条链路拆开看。

SSE 是什么:挂在 HTTP 上的单向推送

SSE 不是新协议,就是一次迟迟不结束的普通 HTTP 响应。服务器返回 Content-Type: text/event-stream,然后持续往响应体里写文本,客户端边读边处理。它和 WebSocket 的差别决定了为什么大模型 API 都选它:

维度SSEWebSocket轮询
方向服务端 → 客户端,单向双向客户端拉取
协议普通 HTTP独立协议(ws://)普通 HTTP
实现成本极低,curl 就能调需要专门服务端与心跳高延迟
断线重连浏览器自动重连需自己实现天然无状态
适用场景LLM 流式、通知推送协同编辑、游戏低频数据

LLM 场景是典型的「一次请求、一段持续输出」,单向就够,SSE 是成本最低的选择。

报文长什么样:data: 行与 [DONE]

OpenAI 兼容接口开启流式,请求里加 "stream": true,响应体就变成一串事件,每个事件用空行分隔:

TEXT
data: {"id":"chatcmpl-xxx","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"content":"你"},"finish_reason":null}]}

data: {"id":"chatcmpl-xxx","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"content":"好"},"finish_reason":null}]}

data: {"id":"chatcmpl-xxx","object":"chat.completion.chunk","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}

data: [DONE]

三个关键字段:

  • delta.content:每次新增的一小段文本(注意不是全量,前端要自己做拼接);
  • finish_reason:出现 stop 表示正常结束,length 表示被 max_tokens 截断——排查「回答突然没了」先看它;
  • data: [DONE]:流结束的哨兵,不是 JSON,解析时要先判断。

还有一个高频坑:默认流式响应里没有 usage(token 用量)字段。需要计费统计时,请求要加 "stream_options": {"include_usage": true},平台会在最后一个 chunk 里附带用量。漏了这个参数,是「流式跑得好好的但账单对不上」的第一大原因。

第一层调试:用 curl 看原始流

调试流式接口的第一步永远是绕开你的代码,直接看原始字节:

Shell
curl -N https://api.zaimod.com/v1/chat/completions \
  -H "Authorization: Bearer sk-..." \
  -H "Content-Type: application/json" \
  -d '{
    "model": "deepseek-v4.1-flash",
    "messages": [{"role": "user", "content": "用三句话介绍 SSE"}],
    "stream": true,
    "stream_options": {"include_usage": true}
  }'

-N(--no-buffer)是灵魂参数,它禁用 curl 的输出缓冲,让你看到数据「到达即显示」。不加它,你看到的可能是攒了几秒一次性吐出来的假象,误判成服务端卡顿。

在这一层能确认的事情:服务端是否真的在流式输出(还是憋到最后给全量);每个 chunk 的 JSON 是否合法;finish_reason 与 [DONE] 是否正常出现。如果 curl 层就异常,问题在服务端或网络,与你自己的代码无关。接入参数不熟的话,先过一遍 OpenAI 接口文档。

第二层调试:服务端转发

生产环境里 API 通常经过你的后端转发给前端,流式在这里最容易断。三个经典问题:

  1. 代理缓冲。Nginx 默认会缓冲上游响应,把流攒成一坨再发。反向代理要加 proxy_buffering off;(或响应头 X-Accel-Buffering: no);CDN 同理,很多默认不透传 text/event-stream。
  2. 读超时太短。模型「思考」的间隔可能超过 30 秒,网关把静默当成断连掐掉。流式链路的读超时要给足,或者服务端发 SSE 注释行(: 开头的行)当心跳保活。
  3. 错误处理时机。HTTP 200 已经发出后模型才报错,没法再改状态码——正确做法是在流里输出一个带错误信息的 JSON 事件,让前端按事件类型处理,而不是让连接无声地挂掉。

逐层排除的顺序:curl 直连正常 → 检查你的后端日志里 chunk 是否持续到达 → 再看代理配置。曾经有一类「换了 base_url 之后前端全卡住」的问题(详见改 base_url 后哪些功能会失效),一部分根源就是新链路上多了个缓冲层。

第三层调试:前端 ReadableStream 渲染

浏览器端不需要任何库,fetch + ReadableStream 就能消费 SSE:

JAVASCRIPT
const resp = await fetch("https://api.zaimod.com/v1/chat/completions", {
  method: "POST",
  headers: {
    "Authorization": "Bearer sk-...",
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    model: "deepseek-v4.1-flash",
    messages: [{ role: "user", content: "用三句话介绍 SSE" }],
    stream: true,
  }),
});

const reader = resp.body.getReader();
const decoder = new TextDecoder();
let buf = "";

while (true) {
  const { done, value } = await reader.read();
  if (done) break;
  buf += decoder.decode(value, { stream: true });

  // 按空行切分完整事件,残余留到下一轮
  const parts = buf.split("\n\n");
  buf = parts.pop();
  for (const part of parts) {
    const line = part.replace(/^data: /, "");
    if (line === "[DONE]") continue;
    const chunk = JSON.parse(line);
    const text = chunk.choices[0]?.delta?.content ?? "";
    if (text) appendToBubble(text);  // 增量渲染
  }
}

两个容易踩的细节:网络分包不按事件边界来,必须用缓冲区攒齐空行再解析,上面代码里 parts.pop() 就是干这个的;TextDecoder 要传 { stream: true },否则一个多字节中文字符被切在两个分包里会解析成乱码。React/Vue 里把增量文本 append 进状态即可,别每次全量重设。

计费与流式:三个必须知道的点

最后回到钱上,流式不影响计费总量,但影响你的统计方式:

  1. token 按全部增量之和计费,和你一次性请求的总量一致——流式省的是体感延迟,不是钱;
  2. 统计用量要么开 include_usage 读最后的 chunk,要么事后在控制台按模型对账(对不上时按计费与报错手册排查);
  3. max_tokens 在流式下依然生效,触顶时 finish_reason 是 length 而不是 stop,监控里要分开统计这两类结束。

完整的最小可运行示例(含流式)在快速开始里,注册后自带测试额度,用 curl 跑一遍上面的命令,两分钟就能亲眼看到第一条 SSE 流。

延伸阅读

#SSE 流式输出#text/event-stream#stream true#前端渲染#调试
免费创建 API Key

相关阅读

base_url 迁移后失效的 5 个功能实测

只改 base_url 就能迁移?我们把 function calling、流式输出、多模态、结构化输出和 prompt 缓存逐项对照,列出 5 个会静默降级的功能、自检脚本与绕过方案,附真实挂单价。

DeepSeek 免费档能干什么:5 个生产级用法

平台上有一个按次计费 0 元的 deepseek-v4-flash-free 免费档,每天限流。本文给出 5 个真正能上生产的用法:意图分类、内容打标签、结构化抽取、会话摘要、批量预处理,并附用量测算与降级策略。