SSE 流式输出偶发丢字:网络分块不等于一条事件
用可运行的本地例子拆开 UTF-8 解码、SSE 事件组装与模型协议解析,检查中文乱码、多行 data、保活和未完成事件,避免逐 chunk 解析 JSON。

流式回复偶尔出现乱码、漏字,或者报 Unexpected end of JSON input,先看程序是不是把一次网络读取当成了一条完整消息。同一条 SSE 事件可以跨多次读取,一个汉字也可以跨两个字节块。 换模型和反复重试,都修不好这种边界处理错误。
本文于 2026 年 11 月 10 日核对 WHATWG、MDN 和 eventsource-parser 4.1.3。下文例子使用本地合成字节流,已运行;没有调用模型 API,也没有验证某家代理的实际断流。
一段流,要依次跨过三道边界
网络给程序的是字节;UTF-8 解码把字节变成文字;SSE 解析再把文字组装成事件。最后,应用才按所用协议解释事件中的 data。
常见错误是:每次 reader.read() 后直接 JSON.parse,或者对每个字节块新建一个 TextDecoder。前者可能拿到半段 JSON,后者可能把被切开的中文编码变成替换字符。一个读取块也可能同时包含前一条事件的尾部、若干完整事件和下一条的开头。
WHATWG 规范规定了 UTF-8 与逐行处理规则。事件以空行完成分隔;同一事件的多行 data: 用换行连接;以冒号开头的行是注释。合法换行还包括 CRLF 与单独 CR,不能只按 \n\n 硬拆。
用逐字节输入暴露问题
下面故意每次只投递一个字节,让汉字编码、字段和 CRLF 都可能被切开。先在独立演示目录安装依赖,使用支持这些 Web Streams API 的现代 Node.js:
npm install eventsource-parser@4.1.3
node demo.mjs
保存为 demo.mjs:
import assert from 'node:assert/strict';
import { EventSourceParserStream } from 'eventsource-parser/stream';
async function readEvents(response) {
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const type = response.headers.get('content-type')?.split(';')[0].trim();
if (type !== 'text/event-stream' || !response.body) {
throw new Error('Expected an SSE response body');
}
const reader = response.body
.pipeThrough(new TextDecoderStream('utf-8', { fatal: true }))
.pipeThrough(new EventSourceParserStream({ maxBufferSize: 65536 }))
.getReader();
const events = [];
try {
while (true) {
const { value, done } = await reader.read();
if (done) break;
events.push({ event: value.event || 'message', data: value.data });
}
} finally {
await reader.cancel();
reader.releaseLock();
}
return events;
}
const bytes = new TextEncoder().encode(
': keepalive\r\n\r\nevent: note\r\ndata: 你\r\ndata: 好\r\n\r\ndata: unfinished'
);
const body = new ReadableStream({
start(controller) {
for (const byte of bytes) controller.enqueue(Uint8Array.of(byte));
controller.close();
},
});
const response = new Response(body, {
headers: { 'Content-Type': 'text/event-stream; charset=utf-8' },
});
const events = await readEvents(response);
assert.deepEqual(events, [{ event: 'note', data: '你\n好' }]);
console.log(events);
本地结果是一条 note 事件,内容为 你\n好。断言同时检查了四件事:中文没有损坏;两条 data: 合成一条事件;注释没有变成正文;没有空行结束的 unfinished 没被当成完整事件。
这是有限长度的解析演示,events 数组会积累所有结果。生产流应逐条消费,并设置连接、总时限和结果大小限制。maxBufferSize 限制解析器尚未完成的字符缓冲,不代表整条响应的总长度上限。库的用法见项目说明。
SSE 完整,不代表模型任务完成
SSE 只负责事件边界,不规定 data 必须是 JSON,也不规定哪条事件代表模型已经完成。上例的 你\n好 就是普通字符串。
接模型 API 时,继续按具体协议检查文本增量、工具调用和结束状态。不要让一个通用解析器同时猜测几种协议,也不要把任意数据中的结束字符串都当成全局终止信号。Nbility 的Chat Completions 文档说明了该接口的使用方式;其他协议要用各自的事件规则。
遇到 200 OK 加 HTML、错误 JSON 或其他内容类型,应先按 HTTP 和应用错误处理。上例为此检查了状态与 Content-Type。空文本的其他成因见API 空内容排查,不必把每次空白都归给 SSE。
保活、流结束和重新请求分别意味着什么
MDN 的 SSE 说明提到注释可用于保持连接。收到 : keepalive 说明传输有活动,没有证明模型在产出答案。若要区分网络静默和内容静默,应分别记录;关于长思考超时,可接着看保活与输出计时。
浏览器原生 EventSource 和应用通过 fetch 消费流也不一样。后者不会因为使用了 SSE 解析库就自动获得原生客户端的重连行为。即使重新连上,原请求能否恢复、是否有事件补发,仍取决于服务协议。已经显示的半段答案不能直接拼上一次新生成的答案。
排查时先运行逐字节例子,再替换为脱敏的有限响应样本。基础解析通过后,检查应用是否处理了终止事件、取消和工具增量;只有确认故障可恢复,才按有限重试规则发起新请求。保留发生时间、内容类型、最后一个完整事件类型和异常类别即可,不要把用户正文或认证头整段贴进日志。


