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

仙人之下我无敌,
仙人之上一换一。

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

手写 SSE 解析器:2 条事件到 6 条的四步

2026-9-17 / 0 评论 / 16 阅读

手写 SSE 解析器:2 条事件到 6 条的四步

212 字节的流式响应,被切成了 8 块,最直接的解析写法只能读出 2 条事件,正确的结果是 6 条。差的 4 条不是被丢掉,是从来没能拼成一个完整的事件就被当成半截数据处理了。下面四步逐层修掉,每一步都贴上本机跑出来的结果,脚本在 tools/verify-sse-parse.mjs,用 ~/.hermes/node/bin/node 直接跑。

实验数据与装置

桩数据里塞进了真实流式接口会出现的各种麻烦:开头一行 keep-alive 注释,一个事件被切成两块,一个中文字符正好卡在两块的缝上,一个用 CRLF 行尾的事件,一个带 event 名的事件,一个用两行 data 承载的事件,还有 [DONE] 哨兵和一个正常事件挤在同一块里。切块长度是固定序列,结果可复现:

原始字节 212 个,切成 8 块:7 + 24 + 2 + 37 + 1 + 49 + 80 + 12
(期望结果:6 条事件数据,其中一条是两行 data 拼起来的,最后一条是 [DONE])

这段字节流是手工构造的桩,不是真实模型返回的数据。模型实际的分片大小和时机没实测,这里验证的是解析器在各种边界上的行为。

步骤一:每块单独解析

最直观的写法是拿到一块就转成字符串,按空行切开,取 data: 开头的内容,不等下一块:

for (const c of chunks) {
  for (const part of c.toString('utf8').split('\n\n')) {
    if (part.startsWith('data: ')) events.push(part.slice(6))
  }
}

检查点:这一步只该读出 2 条,说明丢数据了。

== 第 1 步:每块单独 toString 后立刻按空行切 ==
  得到 2 条:
    "{\"id\":1,\"delta\":\"A\"}\r\n\r"
    "{\"id\":4,\"delta\":\"最后一块\"}"

2 条里还有一条是残的,结尾挂着 \r\n\r。原因很直白:事件的分隔符是空行,而空行很少正好落在块的边界上,一块的尾巴多半是一个没写完的事件,切开就等于把半截 JSON 交给下游。

步骤二:加一个缓冲

把没闭合的内容留在缓冲里,只有遇到完整空行才处理:

buf += c.toString('utf8')
while ((i = buf.indexOf('\n\n')) >= 0) {
  const block = buf.slice(0, i)
  buf = buf.slice(i + 2)
  // 再从这个 block 里取 data: 行
}

检查点:条数应该从 2 涨到 5,但仍然不对。

== 第 2 步:加一个缓冲,只处理已经闭合的事件块 ==
  得到 5 条:
    "{\"id\":1,\"delta\":\"��好\"}"
    "{\"id\":1,\"delta\":\"A\"}\r\n{\"id\":2,\"delta\":\"二\"}"
    "{\"id\":3,\"a\":1}\n{\"id\":3,\"b\":2}"
    "{\"id\":4,\"delta\":\"最后一块\"}"
    "[DONE]"

两个问题暴露出来了。第一条里的中文变成了两个替换符,第二条把两个事件粘成了一条,中间夹着 \r\n。前者是字节和字符的边界问题,后者是行尾问题,得分两次修。

步骤三:块的切分认 CRLF

把切块用的分隔符从写死的两个换行改成 /\\r?\\n\\r?\\n/,块内取行时也按 \r?\n 切:

const SEP = /\r?\n\r?\n/
while ((m = buf.match(SEP))) {
  const block = buf.slice(0, m.index)
  buf = buf.slice(m.index + m[0].length)
}

检查点:条数到 6,但中文还是乱的。

== 第 3 步:块的切分改成 \r?\n\r?\n(CRLF 的事件不再和下一个粘在一起)==
  得到 6 条:
    "{\"id\":1,\"delta\":\"��好\"}"
    "{\"id\":1,\"delta\":\"A\"}"
    "{\"id\":2,\"delta\":\"二\"}"
    "{\"id\":3,\"a\":1}\n{\"id\":3,\"b\":2}"
    "{\"id\":4,\"delta\":\"最后一块\"}"
    "[DONE]"

第四条的 \n 是正常的,那不是两个事件粘住,是一个事件里两行 data 按规范拼起来的结果。

步骤四:跨块保留半个字符

import { StringDecoder } from 'node:string_decoder'
const dec = new StringDecoder('utf8')
buf += dec.write(c)          // 块尾的半个字符留在 decoder 里
// ... 循环处理事件
buf += dec.end()

检查点:6 条,中文正确,收尾缓冲为空。

== 第 4 步:用 StringDecoder 跨块保留半个字符(中文不再变乱码)==
  得到 6 条:
    "{\"id\":1,\"delta\":\"你好\"}"
    "{\"id\":1,\"delta\":\"A\"}"
    "{\"id\":2,\"delta\":\"二\"}"
    "{\"id\":3,\"a\":1}\n{\"id\":3,\"b\":2}"
    "{\"id\":4,\"delta\":\"最后一块\"}"
    "[DONE]"
  收尾时缓冲里剩余:""

乱码的量级也顺手测了,同一批块直接拼接和用 decoder 拼接的差别:

== 对照:不用 StringDecoder,直接拼接 chunk.toString() 的中文 ==
  直接拼接出现替换符 \uFFFD:2 处
  StringDecoder 出现替换符:0 处
  直接拼接里烂掉的那段:["d\":1,\"delta\":\"��好\"}"]

一个 UTF-8 汉字占 3 个字节,只要这 3 个字节落在两个块上,单独解码就是坏字符。中文越多的响应越容易撞上,英文流式响应基本不会暴露这个问题,所以它在测试环境里经常看不见。

规范里对应的四条

上面几步做的事在规范里都有出处,逐条对一下更清楚。字段名为 data 的行,把字段值追加到 data 缓冲后面,再追加一个换行,所以多行 data 是按换行拼起来的,这里的实现和它一致。分发事件前,如果 data 缓冲的最后一个字符是换行就删掉它。以冒号开头的行整行忽略,keep-alive 心跳就走这条路径,它不会产生空事件。字段值拼完如果 data 缓冲还是空字符串,直接重置返回,不派发。

[DONE] 这个哨兵不在规范里,它是流式接口自己的约定,解析器只能把它当普通数据传出来,由上层判断。这一条值得单独写测试,因为哨兵经常和最后一个正常事件挤在同一个块里。

落到工程上的取舍

自己写解析器之前先看一眼手上的库。Node 侧有 eventsource-parser 这类成熟实现,浏览器侧如果上游是 fetch,response.body.getReader() 拿到的就是 Uint8Array,配一个 TextDecoder({ stream: true }) 就够了,不需要 StringDecoder,那是 Node 专有的东西。反过来,如果只想要一个不依赖第三方包的实现,上面四步加起来不到 40 行。

超出这四步的部分才是容易出事故的地方。流断了要重连,重连后要靠 Last-Event-ID 续上断点,长时间没有数据要判超时,[DONE] 之后要释放读端,这些在桩数据里看不出来。我的取舍是解析层只做「字节流进、事件出」,重连和超时交给调用方,这样解析器能单独测,出事时也分得清是哪一层的问题。

什么时候不需要这一套:接口一次性返回完整 JSON,await res.json() 就够了,硬套流式解析只会多一层缓冲。判断标准是首字节到达时间的意义大不大,边生成边看的场景才值得这份复杂度,比如聊天这类要逐字渲染的界面。

    🤞 分享