两个模型对同一结构化输出 Schema 的不同理解
当你的备用路由(fallback route)第一次在生产环境中触发时,绝不是发现两个供应商对你的 schema 定义存在分歧的好时机。在两个客户端配置中,JSON Schema 看起来完全一样。验证器对两个输出都通过了。下游代码按名称读取字段并获取一个值。接着,账单总额以数字字符串而非整数的形式出现,或者长度为一的列表以纯对象而非单元素数组的形式到达,一段已经正常运行了六个月的代码路径会静默地返回错误答案。
结构化输出引人入胜之处在于它消除了一类错误——无法解析的 JSON、幻觉字段、缺失的键——因此让人感觉它彻底解决了解析问题。实际上它所做的是将解析问题向上移动了一层,从词法分析器(lexer)移到了类型系统,在那里问题变得更难被察觉。两个供应商可以都遵循 JSON Schema,但仍然产生不可互换的输出,因为在这个生态系统的角落里,“遵循”至少有四种不同的含义,而你的 schema 并没有指明你想要哪一种。
你编写的 Schema 并非供应商执行的 Schema
第一处分歧发生在 API 边界,甚至在生成第一个 token 之前。OpenAI 的严格模式(strict mode)要求 properties 中的每个属性都必须出现在 required 中,要求每个对象都有 additionalProperties: false,并拒绝省略这两者的 schema。Anthropic 原生并不接受 json_schema 响应格式——你需要将契约表达为工具的 input_schema 并强制模型调用它。Gemini 接受 JSON Schema 的子集,但历史上要求每个数组的 items 都要声明显式类型,而规范允许 items: {} 表示“任何类型”。每个供应商都对自己实现的 JSON Schema 子集有明确的见解,且这些见解并不完全重叠。
这意味着同一个 schema 投入三个供应商,会在请求流水线中走三条不同的路径。一条路径通过受限生成(constrained generation)在解码时强制执行。一条路径在 API 边缘验证 schema,并在生成任何 token 之前将其拒绝。另一条路径将 schema 视为提示词(prompt)阶段的暗示,并应用较宽松的强制措施。合规率各有不同——OpenAI 的严格模式报告解析失败率低于 0.1%,Anthropic 的工具调用报告低于 0.2%,Gemini 的响应 schema 低于 0.3%——但更危险的差距不在于失败率,而在于每个供应商如何处理 schema 未明确规定的情况。
四个总会出现分歧的边界情况
那些真正跨供应商移植过结构化输出流水线的实践者,总结出了一份行为各异的“清单”。
Null 与缺失。 声明为 {"type": ["string", "null"]} 的字段可以返回为 "field": null,也可以从 JSON 中完全省略。在常见的解释下,这两者都可以说是有效的,但在你的下游代码中它们会被解析成不同的东西——一种会产生一个值为 None 的键,另一种会抛出 KeyError 或返回默认值。一个供应商在相同的输入下可能会持续输出显式的 null;另一个可能会持续省略。你执行 payload["address"] 的代码在一个供应商下运行正常,在另一个下就会崩溃。Schema 无法区分“模型知道没有地址”和“模型对地址无话可说”,但你的运行时环境可以。
单元素数组与裸值。 当只有一项内容合适时,声明为字符串数组的字段有时会被输出为 ["one item"],有时则是 "one item"。严格模式能捕捉到这一点;非严格模式通常捕捉不到,特别是对于模型在训练数据看到的格式之间进行插值的工具参数路径。下游的 for item in payload["tags"] 随后要么遍历数组,要么遍历字符串的字符,如果你的标签恰好是合理的英文单词,后者需要很长时间才能被察觉。
整数与数字字符串。 当 schema 规定为 integer 时,在海量网络文本上训练的模型总会忍不住输出 "42" 而不是 42,特别是对于那些“感觉像”标识符或金额的字段。一些供应商的受限解码会在生成时捕捉到这一点。另一些则依赖事后验证器。不同 SDK 中的验证器会做出不同的强制转换选择——Pydantic 会静默地将 "32" 转换为 32;更严格的验证器则会拒绝它。你的流水线在一条路由中吸收了这种转换,在另一条路由中则拒绝,这种分歧在你不并排对比输出时是不可见的。
空数组与缺失字段。 当模型对于列表类型字段没有内容可返回时,它可能会输出 []、省略该字段,或者最糟糕的情况是——为一个声明为不可空数组的字段输出 null。将缺失视为“无项目”处理的下游代码在三种情况中的前两种有效,但在第三种情况下会出错。在至少一个主流推理引擎中,存在一个关于结构化生成未能为数组类型字段生成空数组的待解决问题,这意味着即使是同一个供应商,其行为也可能在不同版本之间发生波动。
这些都不是特定供应商的 bug。它们是规范未明确的点,每个供应商都做出了合乎情理的选择——而这些合乎情理的选择并不一致。
- https://logic.inc/resources/structured-outputs-guide
- https://medium.com/@rosgluk/structured-output-comparison-across-popular-llm-providers-openai-gemini-anthropic-mistral-and-1a5d42fa612a
- https://futuresearch.ai/blog/llm-provider-quirks/
- https://developers.openai.com/api/docs/guides/structured-outputs
- https://ai.google.dev/gemini-api/docs/structured-output
- https://cran.r-project.org/web/packages/LLMR/vignettes/about-schema.html
- https://collinwilkins.com/articles/structured-output
- https://arxiv.org/html/2511.07585v1
