streaming-sse-frame-anatomy · ZH · 2026-10-14

拆开原始 SSE 帧:每一条 data: 行里到底是什么

从原始 SSE 报文出发,逐行拆解聚合站流式响应里的 data: 行,说明每条事件对应的字段含义,以及客户端在收到不同 chunk 时应做的解析、拼接与收尾处理。

为什么值得拆开看

流式接口在多数 SDK 里都被封装成「一行行文本」或「一个个对象」,但真正决定你能否正确拼接、正确结束、正确报错的,是底层那些以 data: 开头的行。聚合站把多家模型统一到一个 OpenAI 兼容的流式协议上,响应本身仍是标准的 Server-Sent Events(SSE)。理解原始帧,能让你在 SDK 出问题时自己定位,而不是盲猜。

SSE 的物理结构

一次流式响应在传输层上是这样的:

  • Content-Type: text/event-stream
  • 每个事件由若干字段行组成,字段名与值之间用冒号加一个空格分隔
  • 事件之间用空行分隔
  • 注释行以冒号开头,通常用于心跳保活

最常见的形式就是单行 data::

data: {"id":"...","choices":[{"delta":{"content":"你"}}]}

收到一个完整的 data: 行后跟一个空行,就代表一个事件边界。客户端应当按事件、而不是按 TCP 分片来处理——网络会把字节切成任意大小,一次 read() 可能只拿到半行。

增量事件:delta 才是主体

绝大多数帧的负载里,真正有用的是 choices 数组中的 delta 对象。它的字段随生成阶段变化:

  • 首个增量通常只带 role,表示「接下来是助手发言」
  • 后续增量带 content,是本次新增的文本片段
  • 部分模型(尤其是带推理过程的)会先输出 reasoning_content 之类的思考字段,再输出正式 content
  • 调用工具时,会出现 tool_calls,且参数是逐段拼接的字符串,不能当完整 JSON 直接解析
  • 结束时可能带 finish_reason,取值为自然结束、长度截断或工具调用等

关键认知:content 是增量,不是累积。客户端要做的是把每段追加到缓冲区,而不是覆盖。

终止符:什么时候可以停

流里有两种「结束」信号,含义不同:

  • data: [DONE]:OpenAI 兼容协议约定的流终止标记,收到即代表不会再有内容
  • 带 finish_reason 的普通帧:表示这一条候选已生成完,但协议上仍可能再发收尾帧

稳妥的做法是两者都处理:遇到 finish_reason 记录结束原因,遇到 [DONE] 再关闭流并释放连接。只依赖其中一种,容易在某些实现下提前断开或多等一个事件循环。

非内容帧:用量与错误

不是所有帧都带文本:

  • 用量帧:在开启用量统计时,末尾会有一个携带 usage 的事件,包含输入输出 token 数。它通常出现在 [DONE] 之前或之后,取决于兼容层实现,解析时不要假设固定顺序
  • 错误帧:上游或网关异常时,可能以 SSE 形式返回一个含 error 字段的对象。它和 HTTP 状态码错误不是一回事,必须同时在事件层做检查
  • 空增量:某些帧的 delta 为空对象,仅用于保活或占位,应跳过而不是当成空字符串追加

客户端该做的四件事

  1. 按行缓冲:维护一个字符串缓冲,遇到换行才切分,处理完删除已消费部分,避免跨分片的半行被误解析
  2. 按事件组装:只在空行出现时把一个完整事件的多个字段行合并
  3. 增量拼接:content 追加、toolcalls 按索引聚合参数、reasoningcontent 与正文分开存放
  4. 收尾幂等:无论先收到 finish_reason 还是 [DONE],关闭逻辑都要能重复执行而不报错

常见误判

  • 把 data: 后面的内容直接 JSON.parse,但不处理跨分片截断,导致偶发解析失败
  • 用「读到 EOF」判断结束,而不是用 [DONE],在网络中断时把错误当成正常完成
  • 忽略 usage 帧,导致无法核算本次调用的实际消耗
  • 把 error 事件当成普通内容显示给终端用户

把这几层分清之后,任何兼容 SSE 的聚合接口在你眼里就不再是黑盒:字节怎么来、事件怎么划、增量怎么拼、什么时候停,都是可预期的。