浏览器不可信长流解析:资源上限、Pull 背压与提交后恢复
基础 SSE 解析之外,生产客户端还要约束恶意长记录、慢消费者和断线重放。本文用有界 framing、按需拉取和提交后游标建立可恢复链路。
本文要点
- 主题聚焦:AI、TypeScript、DevOps
- 适合人群:独立开发者、全栈工程师、AI 应用开发者
- 核心价值:快速理解背景、实践路径、常见误区和工程建议
response.body.getReader() 返回任意大小的字节块:UTF-8 字符和 JSON 记录都可跨 chunk,一个 chunk 也可含多条记录。LLM 流式输出协议 已讲 SSE 字段、跨 chunk carry 与基础解析;本篇不重复协议入门,只处理不可信长流的资源硬上限、pull-driven 背压、Schema 拒绝和提交后恢复。
Framing 是资源与恢复合同的前提
裸 JSON 没有连续记录边界。协议应选择并版本化 framing:SSE 按规范解析字段;长度前缀先读固定头再读指定字节;JSON Text Sequences 按 RFC 7464 添加 0x1E Record Separator 并以换行结束。不要把“服务端每次 write 一行”当作协议。
每条 envelope 至少包含 protocolVersion、streamId、单调 sequence、kind 和 payload。客户端拒绝错误 streamId、未知必需版本和倒退序号;重复 sequence 只能产生同一次应用效果。演进原则可参考 REST API 版本管理,传输可解析不等于业务兼容。
解码器必须保留跨块状态
可使用 TextDecoderStream("utf-8", { fatal: true }) 保留未完成的多字节序列;手动解码必须对中间块调用 decoder.decode(bytes, { stream: true }),结束时 flush。逐块新建 decoder 会破坏跨块字符。fatal: true 让非法 UTF-8 成为协议错误,避免替换字符进入签名、游标或 JSON 验证。
const decoded = response.body!
.pipeThrough(byteLimitAndMetrics())
.pipeThrough(new TextDecoderStream("utf-8", { fatal: true }));
for await (const value of parseJsonSequence(decoded, {
maxRecordChars: 262_144,
maxCarryChars: 262_144,
})) {
const event = decodeCompleteEnvelope(value); // value 先保持 unknown
await applyIdempotently(event);
await commitCursor(event.sequence);
}
parser 维护 carry,只对完整记录调用 JSON.parse。流结束仍有残片时报告 truncated_record,不得将其当成结果。TypeScript 异步迭代 可负责消费语法,但不会处理 framing 或运行时类型。
不完整记录也必须受硬上限约束
无结束符的数据可一直播大 carry。解析器同时限制单条原始字节、解码后字符、嵌套深度、数组元素、字符串、累计记录和响应总字节。上限在生产 manifest 中版本化;超限时取消 reader 并中止 fetch,不记录完整恶意 payload。字符限制不能代替原始字节和解码内存限额。
JSON 解析成功后,值仍是 unknown。只有完整记录才能进入 schema decoder,检查 envelope 与该 kind 对应 payload;不能对半截字符串做正则“抢救字段”,也不能边收到 token 边把未验证对象合并进状态。结构化输出约束 适用于模型结果,网络 envelope 还需要独立的大小、版本和序列验证。
坏记录的恢复策略由 framing 决定。JSON Text Sequences 可扫描下一个 RS 隔离损坏元素,但是否继续必须由业务类型决定:遥测可记录 gap 后继续,账本、工具调用或对话状态应立即终止并从已确认游标重放。不要笼统承诺“解析器会自动恢复”。
背压来自按需读取,而不是延时循环
highWaterMark 是何时继续拉取的信号,不是队列硬上限。若一个网络 chunk 含一万条小记录,同一次 transform() 中循环调用一万次 enqueue() 只会让 desiredSize 变成负数;enqueue() 不会等待消费者排空。因此不能用“小 HWM + 同步批量 enqueue”证明内存有界。
解析器应采用 pull-driven ReadableStream 或 async generator:只在一次 pull() / next() 需求中产出一条完整记录,其余内容保留为尚未解析对象的有界 carry;消费者完成 await applyIdempotently 后才请求下一条。新 chunk 加入前同时核对当前 carry、chunk 和总响应预算,超过容量就取消源而不是复制后继续。浏览器和网络栈仍可能预读有限字节,因此这里约束的是应用队列与 carry,不能宣称背压会让远端模型立刻停产。
如果 UI 只需要每 50 毫秒刷新,可以把已验证事件聚合后渲染,但事实处理和 cursor 不应以 React render 次数计量。队列达到高水位时继续排空已接受记录,并停止读取或明确取消;随机丢弃中间 delta 会让最终文本和 sequence 无法验证。端到端资源释放可结合 Next.js AI 流式响应治理。
游标只在副作用提交后前进
用户停止、页面切换、超时和解析错误都通过同一个 AbortController 取消 fetch,并让 reader、parser 和消费任务结束。日志区分 user_cancel、deadline、invalid_utf8、oversized_record、schema_rejected 与 sequence_gap。取消后到达的旧 streamId 事件一律忽略。
恢复 cursor 代表“最后一条已幂等应用的 sequence”,不是最后收到或解析的记录。应用副作用与本地 cursor 尽量放在同一个事务;断线重连携带 cursor,服务端从其后重放。若确认前崩溃,同一记录可能再来,客户端必须按 (streamId, sequence) 去重。服务端无法提供稳定重放窗口时,只能标记当前结果不完整并重新生成,不能拼接两条不同 generation 的流。
测试用代理在每个字节位置切块,特别覆盖中文、emoji、RS、超长数字和结束 EOF;再把数万条小记录塞进单个 chunk,配合慢消费者断言每次 demand 最多产出一条且 carry 不越界。还要注入无分隔符洪泛、非法 UTF-8、坏 JSON、schema 错误、中途取消、cursor 提交前崩溃和过期重放窗口。门禁要求任意分块得到同一对象序列、损坏记录不进入状态、取消后连接释放、恢复不重复副作用。做到这些,流式结构化数据才是协议,而不是对网络分块的猜测。
延伸阅读
Related