JSON格式合规为何仍会输出错误答案
受约束解码能限制输出符合Schema,却无法保证字段内容真实;缺少合法答案时,模型仍可能在允许选项中猜测。文章建议显式提供“不知道”等出口,避免把不确定性包装成有效数据。
AI 深度解读
JSON 能解析,不代表里面的值可信。这篇文章提醒我们:强制结构化输出解决了格式问题,也可能把「不知道」挤成一个合法答案。就像必填下拉框漏了某个国家,用户仍能提交,统计却从此失真。
对用 AI 做信息提取、Agent 路由和工具调用的程序员,这个区别很实际。作者给出的修补思路是:给未知情况留出口,限制无效生成,再检查模型究竟被约束了多少。其中既有具体案例,也有尚需按任务验证的经验建议。
为什么格式合法,答案却可能更难辨认
结构化输出通常通过约束解码(Constrained decoding)工作。模型每生成一个 token,也就是一个词或词的片段,都会给候选 token 分配概率。引擎根据 JSON schema 屏蔽不合法的候选,再把剩余概率重新归一化,最后继续采样。
引擎控制「哪些能写」,模型负责「从中选谁」。
作者用一个概率示例解释风险:字段只接受 high、medium、low。面对清晰发票,这三个答案占模型原始概率的 95%;面对模糊扫描件,它们只占 15%,因为模型更想回答 unknown。屏蔽并归一化之后,模糊扫描件仍可能以 40% 的概率输出 high。
这些数字用于说明机制,不是准确率实验。关键在于,调用方只看到最终的 high,看不到原本被删掉的「不知道」。
校验通过,只能证明答案落在允许范围内。
OpenAI、Gemini、vLLM、SGLang 和 llama.cpp 都提供相关能力,但正常完成的格式保证,也不能直接推广为所有请求都会返回完整 JSON。作者引用的 FriendliAI 案例中,Gemma 4 31B 面对复杂 schema,不断生成换行,直到耗尽 token,JSON 仍未闭合。
原因是 JSON 允许字段之间出现空格和换行,语法约束不会自动排除它们。作者还区分了偶发空白与自我强化的重复:即使每一步生成空白的概率都是 99%,连续生成 2,048 个的概率也约为十亿分之一。持续循环需要更强的重复反馈,让后续换行越来越容易发生。
给未知留出口,还要让业务代码接得住
作者建议先检查代码会据此采取行动的字段。一个 enum 如果没有 unknown,模型即使发现输入缺失,也只能在已有选项里选择。可空字段同理:没有提取到供应商,应该允许返回 null。
下面的 schema 同时展示了未知出口、字段顺序和字符串长度限制:
from typing import Literal
from pydantic import BaseModel, Field
class Invoice(BaseModel):
reasoning: str = Field(max_length=600) # 先生成,供后面的字段参考
vendor: str | None = Field(max_length=120) # None 表示页面上没有该信息
total: float | None
confidence: Literal["high", "medium", "low", "unknown"]
重点看最后一行的 unknown,以及 vendor、total 的可空类型。它们让模型有机会表达缺失;调用方还需要为这些值安排跳过、补充输入或人工复核的分支。
reasoning 放在前面,是因为逐 token 生成时,后面的字段可以利用已经生成的内容。作者认为,先生成 confidence,再生成 reasoning,前者无法依赖后者。不过,字段实际顺序要以所用引擎为准;调整顺序能否提高准确率,也必须按模型和任务测试。
字符串限制同样有条件:提供方支持的 schema 子集必须能表达 maxLength,Pydantic 中写了 max_length,不等于远端一定执行。
作者还建议在 CI 中运行配套 code_example.py 的 check_schema(),查找缺少未知出口的 enum、无界字符串,以及排在推理之前的答案字段。输入材料没有附上这个文件,因此这里只能把它当作可借鉴的检查项。
提示词则负责另一件事:让合法出口更容易被选中。作者引用的 FriendliAI 案例中,加入具体输出示例后,空白循环消失了。示例应使用真实记录,并覆盖 unknown 情况;满篇占位值「string」,可能只会教模型继续填「string」。
监控不能只盯着 JSON 解析成功率
原先的异常容易发现:解析失败,接口报错。现在的错误可能是一条合法记录,继续进入数据库或路由分支。
自部署场景中,作者给出 vLLM 的配置,用于禁止 JSON 字段之间的自由空白:
vllm serve <model> \
--structured-outputs-config '{"backend":"xgrammar","disable_any_whitespace":true}'
注意 disable_any_whitespace,原文说明它仅适用于 xgrammar 和 guidance 后端。它堵住的是空白循环入口,无法提高字段内容的真实性。
调用端还应区分正常完成与截断。原文通过 client.chat.completions.create,设置 response_format={"type": "json_schema", "json_schema": SCHEMA} 和 max_tokens=800;若 choice.finish_reason == "length",就抛出 TruncatedOutput(resp.id),进入重试或复核,正常完成后才用 Invoice.model_validate_json 校验。Gemini 对应的是 finishReason 中的 MAX_TOKENS。
不要把截断的业务结果补个括号就当成功。
作者建议观察一周流量,按 schema 统计截断率,按字段观察 unknown 比例,并与人工抽样中确实无答案的比例比较。unknown 恰好为零可以触发排查,但不能单凭零就断言模型被迫猜测,数据也可能确实完整。
自部署还可以记录 kept mass,即语法屏蔽后保留下来的原始概率质量。原文用 transformers 的 LogitsProcessor 包装语法处理器,通过 torch.softmax 和 torch.isfinite 计算这一指标,并保存到 self.kept。检查应聚焦值 token,因为 { 等结构 token 丢失大量概率可能只是正常格式约束。作者把保留概率低于一半视为值得复核的信号,它并非经过校准的正确率。
我的判断
我认同文章最有用的提醒:schema 也是业务规则。漏掉未知状态,就可能把输入质量问题变成一条看似正常的数据。仅降低解析错误率,无法证明整个流程更可靠。
但文章没有提供完整的对照实验。加入示例解决 Gemma 4 31B 的空白循环,是一个具体案例;reasoning 前置、kept mass 低于一半等建议,还不足以作为跨模型通用结论。可见推理文本也不能直接当作判断正确的证据。
对复杂任务,作者提出先用纯文本回答,再用第二次廉价调用转成 JSON。这给第一步更多表达空间,但也增加一次调用,并可能在转换时丢失信息。它应与单次结构化输出比较任务准确率、延迟和成本,而不是默认更好。
同样,大型或每次变化的 schema 会引入语法编译开销,缓存能减少重复成本;小模型面对复杂嵌套可选字段,也不会因为格式受限就突然掌握答案。提供方不支持的 schema 关键词还可能被拒绝或忽略,因此本地完整校验仍有必要。
能拿来做什么
- 检查 AI 路由和严格工具调用中的 enum,为无法判断的情况增加合法出口,并在 Node 调用层明确处理 unknown 或 null,避免它们继续触发自动操作。
- 给提取任务补充正常记录和缺失信息记录两类示例,用一组人工标注样本比较字段正确率、未知比例和调整字段顺序后的结果。
- 将截断作为独立失败记录,按 schema 统计;先检查
finish_reason或finishReason,再解析、校验和写入数据库。 - 如果模型由团队自部署,先验证空白限制是否适配所用后端,再考虑记录值 token 的 kept mass,用它筛选复核样本,并通过人工结果验证筛选效果。