拆开原始 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为空对象,仅用于保活或占位,应跳过而不是当成空字符串追加
客户端该做的四件事
- 按行缓冲:维护一个字符串缓冲,遇到换行才切分,处理完删除已消费部分,避免跨分片的半行被误解析
- 按事件组装:只在空行出现时把一个完整事件的多个字段行合并
- 增量拼接:
content追加、toolcalls按索引聚合参数、reasoningcontent与正文分开存放 - 收尾幂等:无论先收到
finish_reason还是[DONE],关闭逻辑都要能重复执行而不报错
常见误判
- 把
data:后面的内容直接JSON.parse,但不处理跨分片截断,导致偶发解析失败 - 用「读到 EOF」判断结束,而不是用
[DONE],在网络中断时把错误当成正常完成 - 忽略
usage帧,导致无法核算本次调用的实际消耗 - 把
error事件当成普通内容显示给终端用户
把这几层分清之后,任何兼容 SSE 的聚合接口在你眼里就不再是黑盒:字节怎么来、事件怎么划、增量怎么拼、什么时候停,都是可预期的。