中文字符被拆在两个网络分片里,解析器不能因此改变文字;一个 SSE 事件分三次到达,也不能被当成三个事件。对流式解析器,最值得先守住的性质是:相同字节输入,无论怎样分片,完整事件序列都一致。
这和 UI 是否保持焦点、业务卡片是否允许用户编辑,是不同层的测试。这里只检查字节、UTF-8、SSE 子集与 JSON 事件,不接模型,不画“流畅度”成绩。
先限制协议,再写测试
下面实现的是严格教学子集:UTF-8、LF 或 CRLF 行结束,仅支持 id、data 与注释行;每个有数据的完整帧要求非空 ID,data 合并后是 JSON。它不实现 SSE 的 retry、event、单独 CR 行结束等完整语义,不能直接替代通用 EventSource 解析器。
半个完整 UTF-8 文本帧只标记 incomplete,不作为事件发出;非法 UTF-8 报错。重复帧仍保留在解析输出,去重属于业务应用层。单帧与输入总量都有上限。
import assert from "node:assert/strict"; const encoder = new TextEncoder(); function parse(chunks, maxFrameBytes = 1024, maxTotalBytes = 8192) { const decoder = new TextDecoder("utf-8", { fatal: true }); let pending = "", id = "", data = [], frameBytes = 0, total = 0; const events = []; function feed(text) { pending += text; let nl; while ((nl = pending.indexOf("\n")) >= 0) { const raw = pending.slice(0, nl); pending = pending.slice(nl + 1); frameBytes += encoder.encode(raw + "\n").length; if (frameBytes > maxFrameBytes) throw new Error("frame-limit"); const line = raw.endsWith("\r") ? raw.slice(0, -1) : raw; if (!line) { if (data.length) { if (!/^[A-Za-z0-9_-]{1,64}$/.test(id)) throw new Error("invalid-id"); events.push({ id, data: JSON.parse(data.join("\n")) }); } id = ""; data = []; frameBytes = 0; } else if (!line.startsWith(":")) { const colon = line.indexOf(":"); if (colon < 0) throw new Error("unsupported-field"); const field = line.slice(0, colon); let value = line.slice(colon + 1); if (value.startsWith(" ")) value = value.slice(1); if (field === "id") id = value; else if (field === "data") data.push(value); else throw new Error("unsupported-field"); } } if (frameBytes + encoder.encode(pending).length > maxFrameBytes) throw new Error("frame-limit"); } for (const chunk of chunks) { total += chunk.byteLength; if (total > maxTotalBytes) throw new Error("total-limit"); feed(decoder.decode(chunk, { stream: true })); } feed(decoder.decode()); return { events, incomplete: pending.length > 0 || frameBytes > 0 }; } const wire = encoder.encode( 'id: 1\r\ndata: {"type":"text","value":"你好"}\r\n\r\n' + 'id: 2\ndata: {"type":"finish"}\n\n'); const expected = parse([wire]); assert.equal(expected.events.length, 2); assert.equal(expected.incomplete, false); for (let cut = 0; cut <= wire.length; cut++) assert.deepEqual(parse([wire.slice(0, cut), wire.slice(cut)]), expected); assert.deepEqual(parse([...wire].map(x => Uint8Array.of(x))), expected); for (let seed = 1; seed <= 32; seed++) { let state = seed, offset = 0; const chunks = []; while (offset < wire.length) { state = (Math.imul(state, 1664525) + 1013904223) >>> 0; const end = Math.min(wire.length, offset + 1 + state % 13); chunks.push(wire.slice(offset, end)); offset = end; } assert.deepEqual(parse(chunks), expected, "seed=" + seed); } assert.equal(parse([wire.slice(0, -1)]).incomplete, true); assert.equal(parse([wire.slice(0, -1)]).events.length, 1); assert.throws(() => parse([Uint8Array.of(255)])); assert.throws(() => parse([wire], 10), /frame-limit/); assert.throws(() => parse([wire], 1024, 10), /total-limit/); assert.equal(parse([wire, wire]).events.length, 4); console.log("all cuts, byte chunks, 32 seeds, truncation and limits passed");保存为parser-properties.mjs,运行node parser-properties.mjs。这是一组确定性性质检查:枚举单切点、逐字节输入和 32 个固定随机种子。它不是对所有可能字节串的形式化证明。
图示的是通用分层关系;代码仅实现前述严格子集,图中 event 等通用字段不在本例支持范围。
失败后要保留最小字节输入
记录种子、原始字节、分片边界、参数和期望输出。缩小反例时,先删无关事件,再缩小数据与切片;不要只保留一张控制台截图。UTF-8 截断、JSON 无效和缺业务终态应分开报告,避免把所有问题都归成“网络失败”。
本例返回完整帧不等于整条业务回复完成。应用仍要检查 finish 或明确错误终态;半帧断线不得被当成成功。全量输出也受总字节限制约束,生产实现需要按事件消费并限制历史存储,避免无限累积。
本轮在 Node v26.3.0 执行了所示测试,没有使用真实模型流、浏览器网络故障或性能压测。内存和时间只受示例输入上限约束,尚未建立生产级资源基准;不能从这次测试宣称“零丢字”适用于所有协议。
参考资料:AI SDK 流式协议,滚动文档,本例并非其完整实现;Streams 概念,滚动文档。采用前,应按实际协议扩展语法并重新跑保存的失败集。