跳到主要内容

流式 JSON 解析器:Token 与类型化对象之间的鸿沟

· 阅读需 13 分钟
Tian Pan
Software Engineer

模型正在逐个 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-parserpartial-json-parser-js 这样的库存在的意义 —— 它们能从 [1, 2, {"a": "apple"] 中返回解析后的结构,而不是因为缺少括号而报错。正确的默认方案取决于具体应用,且它应该是一个 参数,而不是解析器内部的假设。

类型感知的渐进式产出。 解析器应该在字段被 确定 的那一刻立即产出该字段 —— 这意味着值的类型已固定,且值要么已完成,要么正在沿已知方向增长(字符串变长,数组增加更多元素)。这让 UI 能够分别绑定到各个字段,并在每个字段稳定时重新渲染。Vercel AI SDK 的 streamObjectuseObject Hook 暴露的 partialObjectStream 正是这样做的:每个产出的对象都是截至目前的 Schema 形状状态,后续字段会在到达时填充。

这些并不是独立的功能。一个只做增量 Token 化但没有 Schema 校验的解析器只是一个多此一举的 Token 生成器。一个能校验类型但不能产出部分字符串的解析器对于聊天 UI 毫无用处。解析器的形态取决于你希望提供给应用的契约形态。

为什么现有的 JSON 工具是错误的起点

生产环境中的大多数 JSON 解析器都是为这样一个世界构建的:输入伴随着 content-length 头部且有明确的终点。它们的有限状态机并未模拟“输入可能会无限持续”的情况。它们的错误路径假设 EOF 处不匹配的大括号是一个 bug,而不是 尚未完成。当你要求它们处理流时,它们处理流的方式与处理文件相同:将整个内容读入缓冲区,然后进行解析。

这就是为什么几乎每个交付 LLM 驱动的结构化输出的团队,最终要么编写自己的部分解析器(partial parser),要么引入现有的开源解析器之一(如 partial-json-parserbest-effort-json-parserincomplete-json-parsergjp-4-gptstreamjsonopenai-partial-stream),或者从像 Vercel AI 的 streamObject 这样更高层级的 SDK 中免费获得一个部分解析器形式的抽象。这些库的激增本身就是一种诊断——如果标准工具管用,它们就都不会存在。

注意它们的共同点:每一个都是“流式解析器”加上“产出契约”(yield contract)。有趣的设计选择不在于解析——JSON 的上下文无关文法是教科书级别的材料。有趣的选择在于:你何时产出一个部分值,以什么形式产出,你如何传达“这仍在编写中”,以及你如何让消费者针对不同的字段类型选择不同的修复策略。正是这些选择区分了一个好用的流式解析器和一个仅仅是聪明的解析器。

还有一个平行的层级是“首先让模型发出有效的 JSON”——通过 XGrammar、llguidance 和 Outlines 等库进行语法约束解码(grammar-constrained decoding)。这些库在解码时屏蔽掉无效的 token,因此从结构上讲,流在构建时就是正确的。但约束解码并不能解决解析问题。它只是保证无论你目前解析了什么,如果文档在此处结束,它将会是有效的。你仍然需要一个流式解析器来增量提取数值;约束解码只是让你免于担心数值在语法上是破碎的。

没人做的评估规范

流式解析器在单元测试中往往不会暴露出那些微妙的缺陷。标准的测试固件是“给它一个完整的 JSON 文档,观察它返回解析后的值”。有趣的失败发生在别处。

在每个字节边界处切割。 正确的评估(eval)会生成合成流,这些流以各种可能的尺寸分块到达——一次一个字节、两个字节、不规则的爆发、或者一次性发送整个文档。解析器必须在每个切割点产生连贯的部分结果 清晰的错误,绝不能出现未定义行为,绝不能出现陈旧字段,也绝不能出现与下一块延续内容相矛盾的值。如果你的评估只覆盖“完整文档以一个块的形式到达”,那么你测试的是乏味的情况,却交付了 bug。

在转义序列中间切割。 比较有趣的情况是切割在 \u 转义中间、代理对(surrogate-pair)中间、或多字节 UTF-8 中间。一个能静默处理这些情况(不产出破碎的字符,等待下一块)的解析器才是正确的。如果解析器产出了一个格式错误的码位(code point)并将问题推给渲染器,这就是一个 bug,最终会在某人的聊天记录中显示为方块。

在没有闭合引号的字符串末尾切割。 "answer": "hello world 是完整的吗?解析器无从得知。正确的行为应该是可配置的——临时产出它,将其作为结尾截断提示产出,或者按住不发。错误的行为是“将其作为字段的最终值产出”,因为随后下一块到达时,你将不得不撤回它,而消费者可能无法优雅地处理这种情况。

在每个前缀处进行 Schema 校验。 如果 Schema 规定一个字段必须是三个字符串组成的枚举,那么在模型发出不匹配值的第四个字节时,解析器就应该发出违规信号,而不是等到文档关闭时。这要求 Schema 验证器具备 前缀意识(prefix-aware)——知道哪些验证可以在部分输入上进行评估,哪些需要完整内容。

大多数团队跳过这种评估的原因是失败是无声的。一个返回错误中间状态但最终状态正确的解析器能通过大多数验收测试。这种错误会表现为 UI 中的闪烁,或者表现为一个工具因上游解析器将输入一半的值作为最终值产出而触发了错误的参数,再或者表现为由于瞬态部分状态未能通过验证而触发的重试循环。这些问题的调试结果都不会是“JSON 解析器坏了”。它们会被调试成一百个互不相关的 UI bug。

架构层面的认知

隐藏在这一切背后的教训是:结构化输出和流式输出并不是你在应用层组合的功能。它们是一个必须在 API 层设计的单一契约。

看看杠杆实际上在哪里。模型 API 决定了分块方式。模型 API 决定了是为工具调用参数显示增量事件(delta events),还是在最后将其作为一个整体块发送(这是好用的流式工具调用 UI 与令人沮丧的 UI 之间的区别——这种区别因供应商、模型版本以及工具注册时是否带有预流式提示而异)。SDK 决定了解析器契约。应用程序是两层之上所做决定的消费者。

当模型 API 和 SDK 没有为此进行协同设计时,应用程序得到的就是勉强能用的拼凑流——流式传输 token 但不传输解析增量的工具调用参数,流式传输字符但不传输字段的结构化输出,以及 SDK 无法告诉你该渲染还是该等待的部分文档。每一个这样的鸿沟都变成了应用团队必须构建的解析器、必须编写的评估套件、以及必须通过惨痛教训学习的一类 bug。

交付响应式、结构化 AI UI 的团队并不是那些拥有最聪明解析器的团队。他们是那些选择了这样一种 SDK 的团队:其 streamObject(或同类功能)开箱即用地产出符合类型的分段状态,并且其模型 API 暴露了解析器完成工作所需的字段级增量。其他所有人都在糟糕地重复造轮子,并发现(通常是在发布截止日期前)“流式传输 token”只是问题中简单的那一半。

下一个拐点可能不是更好的解析器。它将是产出 有类型增量(typed deltas)而非字节增量的模型 API——发出“score 字段现在是 0.87summary 字段正在追加这些 token”,而不是“这是接下来的 12 个字节”。当有线协议(wire protocol)使用应用程序的词汇时,解析器问题就消解了。在那之前,请编写在每个字节边界处切割的评估脚本,并选择一个产出契约你能够放心的库。

References:Let's stay in touch and Follow me for more thoughts and updates