侧边栏壁纸
博主头像
一笑痕

人生若只如初见,
是可喜亦或者是可悲?

  • 累计撰写 190 篇文章
  • 累计收到 7 条评论

SSE 的 data 行断在分片边界,解析器当没看见

2026-10-14 / 0 评论 / 8 阅读

SSE 的 data 行断在分片边界,解析器当没看见

模型的流式输出走 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 节奏下会把边界落在哪些位置,模型侧的行为没有实测。

    🤞 分享