
模型的流式输出走 SSE,事件从服务端一条条推过来,中间隔着一层 TCP。这层按自己的节拍切字节流,一次 recv 拿到三个字节,下一次拿到几百个,跟事件的边界没有关系。解析器要做的只有一件事:按字节到达的先后,把被切开的事件重新拼回来。
构造这段流没有连模型,内容手工写死:event: 行给事件起名,data: 行装载荷,一个空行收尾。其中一条事件里塞了一行以冒号开头的心跳,还有一条事件带两行 data。整段 234 字节,按规范整段解析应得 5 条事件,运行环境 node v26.8.1。
== 1. 场景与原始流 ==
node v26.8.1 darwin arm64
原始流字节数 = 234,按规范整段解析应得 5 条事件
切成 1 字节一片 -> 234 个分片
切成 3 字节一片 -> 78 个分片
切成 7 字节一片 -> 34 个分片
原始流(\n 显式标出):
"event: message_start"
"data: {\"type\":\"message_start\"}"
""
"data: {\"type\":\"content_block_delta\",\"delta\":{\"text\":\"流式\"}}"
""
": ping"
""
"data: {\"type\":\"content_block_delta\",\"delta\":{\"text\":\"输出\"}}"
""
"data: {\"type\":\"message_stop\"}"
""
"data: [DONE]"
""
""
== 1. 结束 ==
四种实现的差别只在两处:一个分片到手之后,是立刻当完整内容用,还是先攒起来;一条事件什么时候算结束。前三种各错一个地方,最后一种把该管的都管上。
每个分片各自切行
拿到一个分片就在里面找换行,哪一行以 data: 开头,就把冒号后面的内容当成一条完整事件推给下游。分片边界正好落在行尾时它能歪打正着,边界落在一个数据行中间,切出来的两半谁都不以 data: 开头,两半一起被丢掉。1 字节那档切出 234 个分片,跑完一条事件都没派发,也不报错。7 字节那档切出 34 个分片,其中两个分片恰好以 data: 开头,尾巴是半个 JSON 对象,照样被当成事件收下了。
== 2. 1 字节一片:四种解析器各喂一遍 ==
分片数 234
[A] 派发 0 条 / 有效 JSON 0 条 / 与整段解析:条数差 5
[B] 派发 5 条 / 有效 JSON 4 条 / 与整段解析:一致
[C] 派发 5 条 / 有效 JSON 4 条 / 与整段解析:第 2 条的载荷不同:"{\"type\":\"content_block_delta\",\"delta\":{\"text\":\"������\"}}"
[D] 派发 5 条 / 有效 JSON 4 条 / 与整段解析:一致
正确的 D 派发内容:
"{\"type\":\"message_start\"}"
"{\"type\":\"content_block_delta\",\"delta\":{\"text\":\"流式\"}}"
"{\"type\":\"content_block_delta\",\"delta\":{\"text\":\"输出\"}}"
"{\"type\":\"message_stop\"}"
"[DONE]"
== 2. 结束 ==
这段流正确的答案是 5 条,它给出来的是 0 条和 2 条。派发 0 条那两档最麻烦,抛错至少还有堆栈可查,沉默什么线索都不留,上层看到的是模型什么都没说。
跨分片缓冲到行,单行 data 全对
第二种实现补上了字节边界:分片接进缓冲,读满一行才动,行尾的 \r 顺手去掉。单行 data 的流上它跑得完全正确,5 条事件一条不差,载荷里的汉字也照样解析,看起来已经赢了。它的漏洞不在边界处理上,在一条事件什么时候算结束。规范规定空行才是事件的终结点,一个事件里可以排好几行 data,这些行要按换行拼成一个整体再交给下游,而不是一行一条。
== 4. 7 字节一片:四种解析器各喂一遍 ==
分片数 34
[A] 派发 2 条 / 有效 JSON 0 条 / 与整段解析:条数差 3
[B] 派发 5 条 / 有效 JSON 4 条 / 与整段解析:一致
[C] 派发 5 条 / 有效 JSON 4 条 / 与整段解析:第 3 条的载荷不同:"{\"type\":\"content_block_delta\",\"delta\":{\"text\":\"输���\"}}"
[D] 派发 5 条 / 有效 JSON 4 条 / 与整段解析:一致
正确的 D 派发内容:
"{\"type\":\"message_start\"}"
"{\"type\":\"content_block_delta\",\"delta\":{\"text\":\"流式\"}}"
"{\"type\":\"content_block_delta\",\"delta\":{\"text\":\"输出\"}}"
"{\"type\":\"message_stop\"}"
"[DONE]"
== 4. 结束 ==
一条事件里的多行 data 要拼起来
实测里原文的第一条事件排了两行 data,跨分片缓冲到行的写法把每一行各自派发,出来 3 条;缓冲到空行的写法出来 2 条,第一条的 data 才是拼好的两行。多出来的那条在下游看来是一次正常的推送,没有报错,也没有缺字段。收尾那行 [DONE] 也在这条规则里,它走的是 data: 前缀,属于正常事件,只是载荷不是合法 JSON,整段 5 条里只有 4 条能被 JSON.parse 接受。
== 8. 一条事件里的多行 data ==
原文:第一条事件有两个 data 行,第二条只有一个
"data: 第一行"
"data: 第二行"
""
"data: 单行"
""
""
[B 缓冲到行] 1 字节一片:派发 ["第一行","第二行","单行"]
[D 空行+标准解码] 1 字节一片:派发 ["第一行\n第二行","单行"]
[D] 第一条事件的 data = "第一行\n第二行"
[B] 条数 = 3,[D] 条数 = 2
== 8. 结束 ==
空行派发对了,逐片解码把汉字拆坏
第三种实现把事件边界补上了,空行才派发,多行 data 也拼好。它唯一没管的是编码,分片一到手就直接 toString('utf8') 转成字符串再累加。UTF-8 里一个汉字占三个字节,1 字节一片等于把一个汉字剁成三段,每段单独解码都还原不出原字,出来的是替代符。JSON 骨架仍然完整,parse 也不报错,坏掉的只是里面那段文本。3 字节那档边界落点把两个汉字都拦腰切断,同样出替代符;7 字节那档坏在另一条事件上。
== 3. 3 字节一片:四种解析器各喂一遍 ==
分片数 78
[A] 派发 0 条 / 有效 JSON 0 条 / 与整段解析:条数差 5
[B] 派发 5 条 / 有效 JSON 4 条 / 与整段解析:一致
[C] 派发 5 条 / 有效 JSON 4 条 / 与整段解析:第 2 条的载荷不同:"{\"type\":\"content_block_delta\",\"delta\":{\"text\":\"����\"}}"
[D] 派发 5 条 / 有效 JSON 4 条 / 与整段解析:一致
正确的 D 派发内容:
"{\"type\":\"message_start\"}"
"{\"type\":\"content_block_delta\",\"delta\":{\"text\":\"流式\"}}"
"{\"type\":\"content_block_delta\",\"delta\":{\"text\":\"输出\"}}"
"{\"type\":\"message_stop\"}"
"[DONE]"
== 3. 结束 ==
这类错误在日志里只表现为一个奇怪的字,抓不到异常,也不会触发任何重试。
两种换行混在一段里
规范把行结束写成 CRLF,实际抓到的流里 LF 更常见,同一段里两样都有的情况也不少,分片边界还可能正好落在 \r 和 \n 中间。挨个 1 字节喂进去,两种缓冲写法都把行尾的 \r 去掉再取值,拿到 3 条事件,值里没有多出来的回车;每片切的那版一条也没拿到。行尾那个回车会不会漏出去,取决于切行时去不去掉它,我只验到两种缓冲写法都去掉了,不处理的写法没测。
== 6. CRLF 与 LF 混在一段里 ==
原文(\r\n 与 \n 混用),字节数 = 48
"data: {\"n\":1}\r"
"\r"
"data: 中文\r"
"\r"
"data: {\"n\":3}"
""
""
[A 每片切] 1 字节一片:派发 []
[B 缓冲到行] 1 字节一片:派发 ["{\"n\":1}","中文","{\"n\":3}"]
[D 空行+标准解码] 1 字节一片:派发 ["{\"n\":1}","中文","{\"n\":3}"]
== 6. 结束 ==
冒号开头的那几行不是事件
有些服务端会定期推一行以冒号开头的内容当心跳,让连接别被中间设备掐断。规范把它定义成注释,收到就当没收到,它不该进入事件流。实测里三条注释行夹在两条真实事件之间,缓冲到空行的写法派发 2 条,注释一行都没漏进来。跨分片缓冲到行的写法在这里同样干净,注释行本来就不带 data: 前缀,按前缀过滤自然把它挡在外面。要认这个字段,判断的就是行首那个冒号。
== 7. 冒号开头的心跳行 ==
原文里的三行注释(心跳)与两条真实事件:
": keep-alive"
""
": 心跳"
""
"data: 一条真实事件"
""
": ping"
""
"data: [DONE]"
""
""
[B 缓冲到行] 1 字节一片:派发 ["一条真实事件","[DONE]"]
[D 空行+标准解码] 1 字节一片:派发 ["一条真实事件","[DONE]"]
[D] 条数 = 2(三条注释一行都没进来)
== 7. 结束 ==
凑齐四件事的写法
把上面几条合到一起,落在二十来行的实现里:分片接进缓冲,StringDecoder 续上被切断的多字节,读到空行才派发,行尾的 \r 先去掉,冒号开头的行直接跳过,多行 data 按换行拼起来。
function parseD(chunks) {
const dec = new StringDecoder('utf8'); // 多字节跨分片时续上,别逐片 toString
let buf = '';
const out = [];
let data = [];
const flush = () => { if (data.length) { out.push(data.join('\n')); data = []; } };
for (const ch of chunks) {
buf += dec.write(ch);
let nl;
while ((nl = buf.indexOf('\n')) !== -1) {
let line = buf.slice(0, nl);
buf = buf.slice(nl + 1);
if (line.endsWith('\r')) line = line.slice(0, -1);
if (line === '') { flush(); continue; } // 空行 = 一条事件的终点
if (line.startsWith(':')) continue; // 注释 / 心跳
const ci = line.indexOf(':');
const field = ci === -1 ? line : line.slice(0, ci);
let value = ci === -1 ? '' : line.slice(ci + 1);
if (value.startsWith(' ')) value = value.slice(1); // 冒号后最多吃掉一个空格
if (field === 'data') data.push(value);
}
}
return out;
}
三种切法把它喂一遍,派发条数与整段解析逐字对得上。
== 5. 三种切法下的对照 ==
切片字节 分片数 | A 每片切 B 缓冲到行 C 空行+片内解码 D 空行+标准解码
1 234 | 0 条/有差 5 条/全对 5 条/有差 5 条/全对
3 78 | 0 条/有差 5 条/全对 5 条/有差 5 条/全对
7 34 | 2 条/有差 5 条/全对 5 条/有差 5 条/全对
== 5. 结束 ==
== 9. 结论 ==
同一段流按规范应得 5 条事件
1 字节一片:A 0 条 / B 5 条 / C 5 条 / D 5 条
3 字节一片:A 0 条 / B 5 条 / C 5 条 / D 5 条
7 字节一片:A 2 条 / B 5 条 / C 5 条 / D 5 条
CRLF 混用、心跳注释、多行 data 三种情形,只有 D 全部还原
== 9. 结束 ==
三种错误写法里,逐片解码那条最不容易被发现。条数对得上,JSON 结构完整,合法性检查也过,只有文本里那几个替代符是证据。真实链路上它表现为模型偶尔吐出一个乱码字,而且只在分片边界落在汉字中间时出现,复现起来还挑输入。
这套逻辑对应的是 SSE 规范里的事件边界:空行结束一条事件,多行 data 拼成一个整体,冒号开头是注释。要复现的另一半这里做不了,真实模型在不同的 token 节奏下会把边界落在哪些位置,模型侧的行为没有实测。