一个必填下拉框,没有"不适用"这个选项。校验永远通过,数据看起来干干净净。可那些根本没有正确答案的用户,随手点了列表第一项,你的报表从此把他们算了进去。按 token 逐个强制执行的 JSON schema,是同一个毛病。

打开网易新闻 查看精彩图片

强制 JSON 模式会删掉 schema 禁止的 token,再把剩下的重新归一化,所以输出永远能解析。但选哪个合法 token,仍然是模型自己决定的。当它本来想说点不合法的话时,它只能猜,而这个猜出来的结果,看起来和真答案一样笃定。

先讲个五岁小孩能懂的例子

老师发下一张测验卷,每道题有三个方框,必须勾一个,没有"我不知道"这个框。于是你不会的时候,还是得勾一个。老师看到的是一张每个框都勾好的整洁卷子,但分不清哪些是你真会、哪些是你蒙的。修法很简单:加第四个框,写上"我不知道"。

这个 bug 你在没有 AI 的系统里早就上过线

注册表单要填国家,字段必填,下拉列表里没有"其他"。来自列表外国家的用户还是得提交,于是他们选了第一项。几个月后,分析师问:为什么这么多用户住在阿富汗?校验一次都没失败过,它只保证了每个答案都是合法答案。

结构化输出是同一个 bug。你给模型一个 JSON schema,引擎保证响应符合它。OpenAI、Gemini、vLLM、SGLang 和 llama.cpp 都提供这个能力。团队打开它来消灭解析错误,解析错误确实没了。

然后更安静的错误开始了。FriendliAI 拿 Gemma 4 31B 跑一个复杂 schema,输出被换行符填满直到撞上 token 上限,JSON 始终没有闭合。在另一些流水线里,一个只允许三个取值的字段,即便面对模型根本读不懂的文档,也会拿到三者之一。这个值看起来和其他值一样确定。

这些都来自同一条规则:引擎决定哪些 token 合法,模型仍在合法 token 里做选择,而且它必须选,哪怕它本来想说别的。

引擎是怎么强制出合法 JSON 的

模型一次写一个 token,一个词或词的一部分。每一步它给词表里每个 token 一个概率。约束解码多加一步:引擎检查由你的 schema 生成的语法,删掉所有会破坏语法的 token,把剩下的重新缩放使总和为一,然后照常采样。

那当模型想要一个非法 token 时会发生什么?它的意愿被删掉,剩下的被放大。拿一个必须取 high、medium 或 low 的字段来说:在一张清晰的发票上,这三个值占了模型 95% 的概率;在一张模糊的扫描件上,它们只占 15%,因为模型想写"unknown"。重新缩放之后,模糊扫描件仍然有 40% 的概率拿到 high。两种输出看起来一模一样。

输出有时为什么会被空白填满?JSON 允许字段之间有空格和换行,所以语法从不删除它们。一点点残留空白单独做不到这件事——即便每一步占 99%,填满 2048 个 token 的概率也大约只有十亿分之一。这个循环需要模型在每一步都近乎确定,而这发生在重复自我喂养的时候:每一个新换行都让下一个换行更可能出现。

字段顺序有影响吗?引擎按 schema 列出的顺序从左到右写字段。写在 reasoning 字段之前的 confidence 值,无法依赖那段推理。

这对你意味着什么

什么时候要紧:你在用 JSON 模式、响应 schema 或严格工具调用,并且代码会根据字段值采取行动。Agent 一直在这么干——一次工具调用就是模型必须填的 schema,一个路由步骤读一个枚举,一个抽取任务往数据库写行。

怎么影响你:解析错误率降到零,建立在它之上的仪表盘看起来很健康。失败转移到了值里面。一次被迫的选择就是一行没人标记的错误数据;一个空白循环就是一次超时,或者一张 2048 个空 token 的账单。

这和国家下拉框是同一个 bug。校验保证每个答案都是合法答案,它对模型到底有没有答案只字不提。

现在能做什么:打开你代码会据以行动的 schema,对每个枚举问一句——模型不知道时该说什么?如果没有合法答案,就加一个。接着,把任何推理字段放在依赖它的字段之前,是否提升准确率取决于任务和模型,测试很便宜,在你的任务上量一下,别假设。然后限制输出长度,把被截断的响应当作失败。如果你自己托管模型,关掉自由空白,并开始记录语法删掉了多少概率。

落地时四个角色各管一段

  • 写 schema 的人:给每个代码会据以行动的字段一个合法的"我不知道",把推理放最前,在供应商 schema 子集允许时用 maxLength 约束自由字符串。在 CI 里用 check_schema() 检查每个 schema,它会标出没有出口的枚举、无界字符串、以及写在推理之前的答案。
  • 写 prompt 的人:放一个填好的输出示例。FriendliAI 的空白循环在 prompt 带上具体示例后就消失了。掩码只删除选项,示例把概率放到正确的选项上。用真实记录,别用占位符——一个满是 "string" 的示例会教模型写 "string"。也展示一个 unknown 的案例,让模型看到这条出路是允许的。
  • 跑服务的人:vLLM 可以禁止 JSON 字段之间的空白,堵掉循环的那扇门,但只在 xgrammar 和 guidance 后端下有效。用 transformers 时,包一层语法处理器记录它删掉了多少概率。
  • 读响应的人:被截断的响应是失败,不是待修复的东西。finish_reason 为 length 就抛异常,重试或转人工,绝不解析。Gemini 把同样的情况报为 MAX_TOKENS。按 schema 统计截断次数,因为一个坏 schema 会藏在健康的平均值里。

怎么知道生效了:一周流量上,每个字段看三个数。截断率应接近零;unknown 率应大于零,并大致匹配人工抽样中找不到答案的频率——如果它恰好是零,说明模型在某处仍被强迫。自己托管的话,只看值 token 上的保留概率,{ 这类结构 token 每次请求都会因无害原因损失概率。一个保留概率不到一半的值位置,就是值得复查的猜测。

什么时候强制 JSON 是错的工具

当模型需要空间思考时:从第一个 token 就开始的 schema 没给自由推理留位置。难任务先让模型用纯文本回答,再发一次便宜的第二调用把答案格式化成 JSON。

当 schema 巨大或每次请求都变时:引擎在第一个 token 之前编译每个新语法。缓存过的 schema 成本很低,但每次请求都换新 schema 会给每个首 token 增加延迟,语法引擎缓存编译结果正是为此。

当小模型配难 schema 时:强制能让输出解析通过,但没法让弱模型知道答案。嵌套的可选字段正是小模型拿剩下的东西乱填的地方。

当供应商的 schema 子集表达不了你的规则时:有些关键字被静默忽略或直接拒绝。无论如何,在你自己的代码里拿完整 schema 再校验一遍响应。

采用之前先问三句:每个字段在模型不知道时该说什么?我的代码据以行动的是哪些字段,有没有东西写在它的推理之前?我怎样才能发现一个被模型被迫选出的值?