技术笔记
SSE 流是怎么把一块块增量拼回整段回复的
大模型接口常把一条回复拆成很多 SSE 事件。这篇说明帧的格式、delta 怎么按路径合并,以及工具调用的参数为什么要当成字符串拼接而不是一次次 JSON。
作者 Brook/更新于 2026-10-09/约 13 分钟
一条流不是一个 JSON
把一段流式响应粘进普通 JSON 格式化,几乎一定会失败。失败是对的:你粘进去的是很多帧,帧和帧之间是空行,每帧有自己的 data: 行。其中某一行可能是 JSON,整段文本不是。先按帧切开,再解析每一帧的数据,顺序不能反。
Server-Sent Events 是服务端单向推文本的约定。连接保持打开,服务端反复写帧,客户端读到一帧就处理一帧。它和 WebSocket 不同,客户端不在这条连接上再发消息。大模型的「打字机效果」用它就够了:模型每多生成一点,服务端就多推一帧。
一帧长什么样
- 01字节原始文本含 data: 前缀、空行和 [DONE]。
- 02切帧事件空行分隔。注释行以冒号开头。
- 03解析每帧的 JSON失败的帧单独留下,不拖垮整段。
- 04合并回复与工具调用按字段把增量接到已有文本上。
一帧可以有若干 field: value 行,以空行结束。大模型接口最常见的是只有 data:。data: 后面的空格可有可无,值通常是一段 JSON。有的实现用 event: 区分事件名。以冒号开头的行是注释,要跳过,不能当成数据。连续两个换行才是帧的边界,帧内部的单个换行只是字段分隔。
流的结尾常见一个 data: [DONE]。它不是 JSON,解析器要在尝试 JSON.parse 之前认出来。如果某帧的 JSON 坏了,应该记下这帧的原文和位置,然后继续后面的帧。一帧损坏不等于整段回复都不存在,调试工具把它们混成一次失败,就很难看出是哪一块增量写坏了。
增量要按路径往上接,不是互相覆盖
OpenAI 风格的流里,每帧是一个 chat.completion.chunk。真正的新内容在 choices[].delta 里,而不是一整段 message。delta.content 是正文的下一块,可能只是一个字。合并方式是按 choice 的下标,把这块接到该 choice 已经拼好的正文后面。后一帧没有 content 字段,表示这一拍正文没有增长,不能把已有正文清掉。
有的接口把思考过程放在 reasoning_content 或类似字段,和给用户看的正文分开。合并时也要分开放。如果接到同一个字符串里,事后就分不清哪句是思考、哪句是回复,再想单独折叠思考过程也做不到。用法统计 usage 常常只在最后一帧出现,要单独留下,不要要求每一帧都有。
- 同一 choice 的 content 按到达顺序拼接。
- 不同 choice 的下标不要并成一条。
- 缺字段表示「这次没有增量」,不是「改成空字符串」。
- 最后的停止原因 finish_reason 盖过之前的空值,但不要回头改已拼接的正文。
工具调用的参数是分片字符串
模型要调用工具时,增量里会出现 tool_calls。每一项有自己的 index。名字 function.name 往往在较早的帧里给全,参数 function.arguments 则是一段段 JSON 文本,很多帧里只是 { 或 "city": 这种残片。这些残片在全部到齐之前不是合法 JSON。合并时按 index 把 arguments 当字符串相接,全部帧结束之后再解析一次。中途解析失败是正常的,不能当成接口错误。
- 01index 0名字已到齐function.name 在较早的帧给出,后面的帧可以不再重复。
- 02index 0参数还是残片每一帧只追加 arguments 的下一段,不在中途解析。
- 03结束后再解析一次拼完的字符串才是一份 JSON。在此之前的解析失败可以忽略。
index 才是「这是第几个工具调用」的键。有的帧只带 index 和一小段参数,不再重复名字。如果用名字当键,同名工具被调用两次时会拼错;如果每帧都新建一个调用,参数会碎成很多条。正确的表是「choice 下标 + tool_calls 的 index」。
别的供应商只是字段路径不同
Anthropic 的流用 content_block_delta,文本增量在 delta.text,工具参数增量在 delta.partial_json,块的身份由 index 决定。Gemini 风格常把增量放在 candidates[].content.parts。看起来差别很大,合并动作是一样的:找到「这块内容的身份」和「这次新增的文本」,追加,不要覆盖。
也有接口不用 SSE,而是每行一个 JSON(NDJSON)。这时分隔符是换行,没有 data: 前缀,也往往没有 [DONE]。调试器可以先看前几行:如果行首是 data:,按 SSE 切;如果每行自己能解析成 JSON,按 NDJSON 切。两种都不是「整段一个 JSON」。
出了问题先对帧,再看拼出来的句子
回复缺半句,先查是不是某一帧没被认成 data:,或者空行丢失导致两帧粘在一起。工具参数解析失败,先看拼接后的字符串,而不是单独看最后一帧。思考过程混进正文,多半是合并时把两个字段写进了同一个缓冲。这些都不是模型「答得不好」,是客户端把流读错了。
本站的 SSE 调试工具按这个顺序做:切帧、认出 [DONE] 和注释、逐帧解析、按路径合并正文、思考和工具参数,并留下解析失败的帧。输入停在浏览器里。把它当成对照:你的客户端拼出来的结果,应该和这里按同样规则拼出来的一致。