SSEStreamingJavaScriptTroubleshootingNbility

SSE 流式输出偶发丢字:网络分块不等于一条事件

用可运行的本地例子拆开 UTF-8 解码、SSE 事件组装与模型协议解析,检查中文乱码、多行 data、保活和未完成事件,避免逐 chunk 解析 JSON。

查看接入文档
SSE 流式输出偶发丢字:网络分块不等于一条事件

流式回复偶尔出现乱码、漏字,或者报 Unexpected end of JSON input,先看程序是不是把一次网络读取当成了一条完整消息。同一条 SSE 事件可以跨多次读取,一个汉字也可以跨两个字节块。 换模型和反复重试,都修不好这种边界处理错误。

本文于 2026 年 11 月 10 日核对 WHATWG、MDN 和 eventsource-parser 4.1.3。下文例子使用本地合成字节流,已运行;没有调用模型 API,也没有验证某家代理的实际断流。

一段流,要依次跨过三道边界

网络给程序的是字节;UTF-8 解码把字节变成文字;SSE 解析再把文字组装成事件。最后,应用才按所用协议解释事件中的 data。

字节流经过持续解码、SSE 组装和应用解析,三层各有自己的边界

常见错误是:每次 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 解析库就自动获得原生客户端的重连行为。即使重新连上,原请求能否恢复、是否有事件补发,仍取决于服务协议。已经显示的半段答案不能直接拼上一次新生成的答案。

先保留失败位置,再检查最小字节流,最后验证协议结束与重试条件

排查时先运行逐字节例子,再替换为脱敏的有限响应样本。基础解析通过后,检查应用是否处理了终止事件、取消和工具增量;只有确认故障可恢复,才按有限重试规则发起新请求。保留发生时间、内容类型、最后一个完整事件类型和异常类别即可,不要把用户正文或认证头整段贴进日志。

相关文章

Node.js 调用 Nbility:从 CLI 小工具到网页应用
Node.jsJavaScriptNbility

Node.js 调用 Nbility:从 CLI 小工具到网页应用

面向 Node.js / JavaScript 开发者的 Nbility OpenAI-compatible API 接入教程:环境变量、openai npm SDK、CLI 小工具、Express 网页接口、流式输出、错误处理、日志与上线检查。

用 Nbility 跑通你的 Agent 工作流

获取 API Key,统一接入 OpenAI 兼容模型和开发工具。

获取 API Key