流式 JSON 解析器:Token 与类型化对象之间的鸿沟
模型正在逐个 Token 地输出 JSON。你的 UI 希望在字段出现的那一刻就进行渲染 —— 在冗长的回答正文之前显示置信度得分,或者在模型填充工具调用参数时实时显示它们。接着,有人尝试在每个数据块(chunk)上调用 JSON.parse,结果整个系统就崩溃了,因为 JSON.parse 是“全或无”的。它需要一个结构完整的文档才能返回任何结果。在模型输出闭合括号之前,你什么也显示不出来。
这不是一个可以通过 try/catch 解决的解析器问题。标准 JSON 解析器是针对内容长度已知的 HTTP 响应设计的。部分输入并不是它所建模的状态 —— 而是被视为“输入错误”。当你将 Token 流视为 HTTP 正文处理时,你继承了三十年来“文档要么完整,要么无效”的传统,而你的 UI 则为此付出了代价。
解决方法不是“更拼命地解析”。解决方法是认识到 结构化输出 和 流式输出 并非你在运行时组合的正交功能。它们是一个单一的设计问题,模型 API 和客户端库必须共同解决 —— 而率先发布产品的团队通常是通过编写自己的部分解析器来实现的,然后在三个月后发现第一版是 O(n²) 时再重写一遍。
“全或无”陷阱及其真实代价
每个遇到此问题的团队的第一反应都是相同的:“只需缓冲直到看到闭合括号,然后再解析。”这在开发环境中可行,在你的评估集中也行。但在生产环境中会因为两个原因失败。
“延迟税”比你想象的要重。 一个来自前沿模型的 12 KB 结构化响应需要数秒才能完全输出。如果你的 UI 等待最后一个 Token,用户在整个过程中只能盯着加载动画。行业分析报告指出,渐进式渲染带来的感官延迟提升大约在 60–70% —— 这不是因为响应变快了,而是因为用户看到了动态。工作速度是一样的,但 等待 的形式改变了。一个在五秒内逐渐填充的字段让人感觉它是活的。而一个持续五秒才消失的加载动画则让人感觉程序出故障了。
天真的“增量”修复比等待更糟糕。 团队最常犯的错误是保持缓冲,但在每个新块上调用 JSON.parse(或 JSON 修复库)以“查看目前掌握的情况”。这会导致解析器在每个新 Token 到来时重新解析整个前缀。对于一个以 5 个字符为单位到达的流,处理 12 KB 的响应意味着当你实际只有 12,000 个字符时,却要遍历大约 1500 万个字符 —— 典型的 O(n²) 行为。一份详细的工程分析报告直接测量了这一点:对于 12 KB 的文档,天真方法耗时 16.7 秒,最后几个块每个耗时 19–20 毫秒;而一个有状态的增量解析器运行相同工作负载仅需 43 毫秒,每个块的延迟低于 30 微秒。这是 388 倍的加速,且天真版本在 5 KB 左右就开始变得不可用 —— 进入了打字明显停滞的“卡顿区”。
这个陷阱不在于天真的解析器是错误的,而在于它在你的原型(响应仅 200 字节)中表现良好,并随着 Prompt 的增长而静默退化。当你发现它很糟糕时,你已经把它发布了。
真正的部分解析器需要做什么
部分 JSON 解析器并不是放宽限制的 JSON 解析器。它是一个显式建模 文档中开放位置 的状态机:哪些字符串处理到了一半,哪些数组处理到了中间元素,哪些对象的括号尚未闭合。基于该状态,它必须做出标准解析器从未需要做出的决定。
在字符串中间截断的情况下存活的增量 Token 化。 当流在一个值的中间截断时 —— 例如 "answer": "Sure, the capi —— 解析器必须决定产出什么。库的可选方案包括“按原样产出部分字符串”、“产出带有不完整标记的部分字符串”或“在闭合引号到达前不产出此字段”。不同的应用需要不同的答案。聊天 UI 可能需要部分字符串。函数调用的参数可能不需要,因为使用输入到一半的参数执行工具比多等一个块更糟糕。
运行中的 Schema 校验。 如果模型输出了 {"score": "high"} 而你的 Schema 规定 score 是一个数字,你不会想等到文档结束才发现。具有 Schema 感知的流式解析器可以在类型确定的那一刻标记违规 —— 这一点很重要,因为模型尚未输出文 档的其余部分,此时重新提示(re-prompt)或重试的成本较低。在闭合括号之后才捕获错误则是浪费了一次完整的生成。
截断情况下的修复策略。 当上游连接在文档中间断开时,你有一个选择:丢弃部分内容,返回任何解析清晰的子结构,或者尝试启发式地闭合开放结构。第三种方案是像 best-effort-json-parser 和 partial-json-parser-js 这样的库存在的意义 —— 它们能从 [1, 2, {"a": "apple"] 中返回解析后的结构,而不是因为缺少括号而报错。正确的默认方案取决于具体应用,且它应该是一个 参数,而不是解析器内部的假设。
类型感知的渐进式产出。 解析器应该在字段被 确定 的那一刻立即产出该字段 —— 这意味着值的类型已固定,且值要么已完成,要么正在沿已知方向增长(字符串变长,数组增加更多元素)。这让 UI 能够分别绑定到各个字段,并在每个字段稳定时重新渲染。Vercel AI SDK 的 streamObject 和 useObject Hook 暴露的 partialObjectStream 正是这样做的:每个产出的对象都是截至目前的 Schema 形状状态,后续字段会在到达时填充。
这些并不是独立的功能。一个只做增量 Token 化但没有 Schema 校验的解析器只是一个多此一举的 Token 生成器。一个能校验类型但不能产出部分字符串的解析器对于聊天 UI 毫无用处。解析器的形态取决于你希望提供给应用的契约形态。
