流式输出 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 都选它:
| 维度 | SSE | WebSocket | 轮询 |
|---|---|---|---|
| 方向 | 服务端 → 客户端,单向 | 双向 | 客户端拉取 |
| 协议 | 普通 HTTP | 独立协议(ws://) | 普通 HTTP |
| 实现成本 | 极低,curl 就能调 | 需要专门服务端与心跳 | 高延迟 |
| 断线重连 | 浏览器自动重连 | 需自己实现 | 天然无状态 |
| 适用场景 | LLM 流式、通知推送 | 协同编辑、游戏 | 低频数据 |
LLM 场景是典型的「一次请求、一段持续输出」,单向就够,SSE 是成本最低的选择。
报文长什么样:data: 行与 [DONE]
OpenAI 兼容接口开启流式,请求里加 "stream": true,响应体就变成一串事件,每个事件用空行分隔:
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 看原始流
调试流式接口的第一步永远是绕开你的代码,直接看原始字节:
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 通常经过你的后端转发给前端,流式在这里最容易断。三个经典问题:
- 代理缓冲。Nginx 默认会缓冲上游响应,把流攒成一坨再发。反向代理要加
proxy_buffering off;(或响应头X-Accel-Buffering: no);CDN 同理,很多默认不透传text/event-stream。 - 读超时太短。模型「思考」的间隔可能超过 30 秒,网关把静默当成断连掐掉。流式链路的读超时要给足,或者服务端发 SSE 注释行(
:开头的行)当心跳保活。 - 错误处理时机。HTTP 200 已经发出后模型才报错,没法再改状态码——正确做法是在流里输出一个带错误信息的 JSON 事件,让前端按事件类型处理,而不是让连接无声地挂掉。
逐层排除的顺序:curl 直连正常 → 检查你的后端日志里 chunk 是否持续到达 → 再看代理配置。曾经有一类「换了 base_url 之后前端全卡住」的问题(详见改 base_url 后哪些功能会失效),一部分根源就是新链路上多了个缓冲层。
第三层调试:前端 ReadableStream 渲染
浏览器端不需要任何库,fetch + ReadableStream 就能消费 SSE:
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 进状态即可,别每次全量重设。
计费与流式:三个必须知道的点
最后回到钱上,流式不影响计费总量,但影响你的统计方式:
- token 按全部增量之和计费,和你一次性请求的总量一致——流式省的是体感延迟,不是钱;
- 统计用量要么开
include_usage读最后的 chunk,要么事后在控制台按模型对账(对不上时按计费与报错手册排查); max_tokens在流式下依然生效,触顶时 finish_reason 是length而不是stop,监控里要分开统计这两类结束。
完整的最小可运行示例(含流式)在快速开始里,注册后自带测试额度,用 curl 跑一遍上面的命令,两分钟就能亲眼看到第一条 SSE 流。