流式结构化输出:为什么你的解析器会在第 47 个 Token 处卡住
团队第一次构建带有结构化输出的流式 AI 功能时,遇到的 bug 总是如出一辙。模型生成正常,数据块(chunks)接收正常。但在第 47 个 token 左右,解析器挂掉了,UI 冻结了,或者更糟——一个半成型的枚举(enum)值被路由到了下游工具,导致其悄无声息地执行了错误操作。团队在 JSON.parse 周围加了一个 try/catch,觉得自己搞定了,然后发布。两周后,兄弟团队抱怨响应变长后流式 UI 感觉很卡。一个季度后,事故审查询问为什么在一个模型仍在描述为 "DeleteIfEmpty" 的记录上触发了 "Delete" 工具调用。
Bug 不在任何单个 token 中。Bug 在于 token 流式传输和结构化输出在架构上是冲突的,而大多数框架只是用“祈祷”来掩盖这种冲突。Schema 说“这是一个完整的对象”。Token 流说“这是一次一个字节的数据”。从定义上讲,这两个端点之间的每一个中间状态对于 Schema 来说都是无效的。团队的工作是决定在这些中间状态期间该做什么——而大多数团队并没有明确做出这个决定。
这篇文章讨论 流式传输与结构化输出相遇时出现的失效模式:在每个部分状态下都会卡住的幼稚解析器、撒谎的 "is_complete" 布尔值、让模型看起来变慢的 O(n²) 重试循环、隐蔽且危险的部分枚举风险,以及行之有效的架构模式。
幼稚的循环以及它为何失效
流式结构化输出的第一版通常是这样的:团队将数据块累积到缓冲区(buffer)中。每收到一个数据块,就调用 JSON.parse(buffer)。如果抛错,就等待下一个块。如果成功,就把结果交给 UI。
这在开发环境下有效,因为开发输入很短。在单元测试中也有效,因为单元测试将流模拟为单个数据块。但在生产环境中,由于两个因素的叠加,它会失效。
第一个原因是 JSON.parse 是二进制化的:要么缓冲区是一个完整的文档,要么不是。没有部分结果。因此,流式 UI 并没有在“流”起来——它静静地坐着直到闭合括号出现,然后一次性倾倒整个对象。用户看到三秒钟的加载动画,然后是一大堆文字。“流式”一直是个错觉。
第二个原因是性能。随着响应的增长,每一个新数据块都会从头开始重新解析整个缓冲区。对于一个以 5 字符块交付的 12 KB 响应,产生 12,000 字符的输出大约需要 1500 万字符的工作量。耗时成本是真实的:在 3 KB 以下不可见,到 5 KB 时 UI 开始卡顿,超过 8 KB 时解析器成为瓶颈,即使模型仍在快速生成,响应也会感觉像冻结了一样。团队的延迟仪表盘显示“模型很慢”。其实模型不慢,是解析器慢。
团 队接下来通常会采用的修复方案是一个能争取六个月喘息空间的谎言:is_complete 布尔值。
"is_complete" 布尔值是个谎言
一旦团队意识到 JSON.parse 是二进制化的,自然的解决办法就是让模型自己告诉他们文档何时完成。他们在 Schema 中添加一个 is_complete: true 字段,指示模型最后设置它,并且只有在看到它时才进行解析。这就是“完成哨兵”(completion sentinel)模式,它包含了一个好主意和两个坏主意。
好主意是模型可以将自己的输出标记为终点。最后发出的保留键——惯例是 "_done": true ——即使周围的 JSON 是完全可流式传输的,也是一个有用的信号,因为它区分了“模型完成生成”和“连接在第 4096 字节断开”。对于部分完成即存在风险的长响应(例如在部分参数下触发工具调用是不安全的),显式的终止标记是正确的架构原语。
坏主意则很微妙。首先,is_complete 布尔值无法告诉你任何关于中间状态的信息。团队仍然无法渲染任何内容,直到最后一个 token 出现。他们只是把“等待闭合括号”变成了“等待闭合括号加一个标志”,用户可见的行为是完全一样的:沉默的加载动画,然后文字突然全屏显示。流式 UX 并没有改善——团队只是说服自己模型才是无法改善的原因。
其次,这个布尔值是一个模型可以违反的约定。模型偶尔会在格式错误的对象上发出 is_complete: true,或者在较长文档的中间发出 它,或者在 Schema 没有要求的字段上幻觉出它。团队现在正信任一个不可靠的信号来拦截解析决策,而失效模式看起来像是解析器 bug 而不是模型 bug。无论你使用什么信号来标记完成,都必须被强制执行——通过受限生成、结构化输出模式或在服务器端验证形状——而不是靠假设。
更深层的问题是,is_complete 布尔值是在治标。症状是“我无法判断这是否是一个完整的文档”。病根是“我正在使用一个不理解部分文档的解析器”。修复方法是使用一个理解部分文档的解析器。
容错性流式解析本质上是一个状态机
一个在不从头重新解析的情况下处理部分 JSON 的解析器,从字面意义上讲,就是一个状态机:它会记住 chunk 结束时的位置。它跟踪嵌套栈(我们是在对象内部、数组内部,还是两者都有?)。它跟踪 token 状态(处于字符串中间、数字中间、转义序列中间,还是 Unicode 码点中间)。它缓冲不完整的 token,并在下一个 chunk 到达时恢复。当新 chunk 到达时,它从保存的位置继续,仅处理新的字符。
复杂度的下降是巨大的。同样的 12 KB 响应,使用朴素的重新解析循环需要 16.7 秒,而使用增量解析器只需 43 毫秒——快了近 400 倍——因为工作量现在与响应大小成线性关系,而不是平方关系。更重要的是,解析器可以在每个中间状态输出部分结果。当第一对键值对到达时,UI 就可以渲染一个部分对象,随着更多键值对的到来而扩展它,并在闭括号关闭最外层对象时完成渲染。
2025 年围绕这一问题趋于成熟的库——partial-json-parser(Python 和 JS)、PartialJSON(Swift)、streamjson、json-river、gjp-4-gpt——都有着共同的形态。它们公开了一个 Allow 策略,规定调用者愿意呈现哪些类型的不完整性(部分字符串:通常允许;部分数字:通常不允许,因为跨 chunk 分割的 "0.0" 会被解析为两个浮点数;部分布尔值:通常不允许;部分 null:从不允许)。它们在调用之间维持状态。它们公开事件 API(“一个键已到达”、“一个数组元素已完成”)或快照 API(“根据目前所见,这是最深层的有效对象”),或者两者兼有。
如果你在大学编译原理课上写过分词器(tokenizer),这就是那个机器,只是接上了“恢复”按钮。
部分枚举危险(The partial-enum hazard)
流式结构化输出中最危险的故障也是最容易被忽视的:部分值是两个不同完整值的语法有效前缀。典型的例子是枚举(enums)。模型正在为 policy 字段生成 "DeleteIfEmpty"。在流式解析器看到 "Delete 的那一刻,它拥有一个语法上有效的部分字符串。如果消费端代码基于前缀进行匹配——或者如果解析器输出了部分字符串,而下游的 switch 语句执行了 startsWith("Delete") 检查——系统就已经路由到了破坏性分支。当 token 的其余部分到达且值为 "DeleteIfEmpty" 时,副作用已经触发了。
这并非虚构。它是结构化输出版本的经典流式 bug,类似于半加载的 URL 触发了 fetch。解决方法是一种纪律而非库:
- 永远不要对部分枚举值进行分支判断。 如果该值是封闭集合的成员,请在分支处针对该字段的
_done哨兵进行拦截,或者在解析器将该字段标记为“已完成(finalized)”时再处理(大多数流式解析器都会公开这种区分)。 - 使工具调用(tool-call)参数解析严格终结化。 工具调用不是显示状态,而是副作用。流式解析器应仅在参数对象的闭括号到达时才公开
arguments。OpenAI 的流式事件区分了tool_calls.function.arguments.delta(仅用于显示)和最终完成的参数——请利用这种区分。 - 根据 Schema 验证枚举值,而不是根据前缀匹配。 在解析器确认之前,部分字符串不是枚举的成员。
正确的心理模型:流式 JSON 有两个区域。**显示区(Display zone)**是部分值发挥作用的地方,部分渲染就是其全部意义。**决策区(Decision zone)**中部分值是灾难性的,解析器必须将任何未完成的状态视为缺失。大多数框架不会为你划定这条界线。你必须在消费端针对每个字段手动划定。
没人记录的 Schema 流式折中方案
供应商 SDK 已经开发了流式原语——OpenAI 的 tool_calls.function.arguments.delta、Anthropic 的流式事件,以及代理框架中各种 streamObject / streamText 辅助函数——它们很有用,但都避开了一个相同的问题:针对 Schema 验证部分文档意味着什么?
诚实的答案是你无法做到,而那些假装可以的 SDK 在定义上是不严谨的。部分对象无法满足“必填字段”检查。部分数组无法满足 minItems 约束。部分字符串无法满足正则(regex)。Vercel AI SDK 是少数在文档中说明这一点的框架之一:“通过 streamText 流式输出的部分结果无法根据你提供的 Schema 进行验证。”大多数框架对部分状态的验证都会静默通过,仅在最终状态失败时报错。当消费者将部分状态视为仅用于显示时,这没问题。但当消费者将部分状态视为决策输入时,这就是错误的。
在生产环境中真正奏效的折中方案是在同一代码路径中使用两个解析器:一个容错性流式解析器,为显示区输出部分状态;一个 Schema 验证解析器(SDK 内置或 Zod/Pydantic 拦截器),仅在最终负载上运行,并作为决策区的门户。显示区从第一个解析器读取数据。决策区从第二个解析器读取数据。工具调用、持久化写入以及任何用户可见的承诺都经过第二个解析器。
这写下来听起来理所当然。在实践中,每一个构建流式代理(agent)的团队都会通过惨痛的教训重新发现这一点,通常是在发生“部分枚举”事故之后。
周一该做什么
三个具体行动。
首先,审查任何流式结构化输出的代码路径,检查是否存在“双解析器模式”。如果同一个解析值同时供给流式 UI 和工具调用,那么无论故障是否已经发生,你的代码中都潜藏着“部分枚举隐患”(partial-enum hazard)。拆分解析器。让显示解析器容忍部分状态,而让决策解析器在完成时严格执行。
其次,将任何“在 try/catch 循环中等待 JSON.parse 成功”的代码替换为真正的流式解析器。库的选择只是细节;架构上的改变是从 O(n²) 的从零重新解析转向 O(n) 的增量解析。在处理长响应(8 KB 或更多)时,对比前后的流式延迟。如果你的仪表盘目前没有将解析成本与推理成本分开显示,这也说明你的可观测层粒度不够细。
第三,为工具调用和任何其他仅限终态的字段选择一种“完成哨兵”(completion sentinel)惯例并坚持使用。将 _done: true 作为模型最后输出的保留键是一个行之有效的模式。约束生成层应该强制执行这一点,而不是由应用层去信任它。哨兵的意义在于让“模型已完成”成为一个结构性事实,而非一种启发式判断。
流式结构化输出不是魔法,也并非不可实现。它是一个具有已知形态、已知解析器模式和已知失效模式的问题,而框架的默认设置并不会为你拦截这些问题。做得好的团队会将解析器视为 AI 功能的一等组件,在架构层面上与 Prompt 和 Schema 处于同一级别。而做得不好的团队则将解析仅仅视为包裹在 try/catch 中的 JSON.parse 调用——然后花费整整一个季度去追查一个本就注定由解析器(而非模型)引发的故障。
- https://www.aha.io/engineering/articles/streaming-ai-responses-incomplete-json
- https://news.ycombinator.com/item?id=45518033
- https://github.com/itruf/PartialJSON
- https://github.com/easyagent-dev/streamjson
- https://github.com/JacksonKearl/gjp-4-gpt
- https://pypi.org/project/partial-json-parser/
- https://github.com/promplate/partial-json-parser-js
- https://medium.com/@prestonblckbrn/structured-output-streaming-for-llms-a836fc0d35a2
- https://developers.openai.com/api/docs/guides/structured-outputs
