博客

跨 OpenAI 兼容 API 的结构化输出:严格模式、受支持的 schema 与失效模式

结构化输出承诺可解析、符合 schema 的 JSON,而两家主要厂商都以真实的限制交付它:JSON Schema 的一个子集、拒绝的边界情况,以及 SDK 差异。要么把这些限制当作一等公民,要么在生产里调试它们。

跨 OpenAI 兼容 API 的结构化输出:严格模式、受支持的 schema 与失效模式

从 LLM API 拿到可靠的 JSON,过去意味着小心翼翼地写 prompt、防御性地解析。结构化输出改变了这一点,但它带来两个层次,团队把它们混为一谈时后果自负:“响应是有效的 JSON”和“响应符合你的 schema”。第二个层次如今在两个主要生态里都存在,而且都带着同一个星号:只支持 JSON Schema 的一个子集,而不是全部。把严格模式当成一揽子保证的架构,会在生产里碰上那个星号。本指南比较两家厂商各自如何实现结构化输出,逐一走一遍在严格模式下依然存在的三类失效模式,并列出无论文档承诺什么都值得保留的防线。

结构化输出的 API 契约,分两部分

两个生态都把这项能力拆成同样的两种形态,选对是第一个决定。

约束答案格式。OpenAI 文档记录了response_format在 Chat Completions 接口上配合 schema 定义使用的方式,type: "json_schema"以及等价的配置块text.format同样配合 schema 定义type: "json_schema"和格式类型strict: true在较新的 Responses 接口上的用法。Anthropic 把 schema 放进output_config.format输出配置里,配合type: "json_schema"格式类型使用。

约束工具调用。当模型调用你的函数时,真正重要的 schema 是函数的参数 schema。OpenAI 在函数定义里应用strict: true严格模式;Anthropic 通过strict: true提供严格工具调用,文档把它描述为对工具名和输入保证 schema 校验。

两家厂商对分工都说得很明确:一个机制管模型说什么,另一个管它如何调用你的代码。Anthropic 的页面把 JSON 输出框定为“Claude 说什么”,把严格工具调用框定为“Claude 如何调用你的函数”,并指出两者可以独立使用,也可以在同一请求里一起用。如果你的集成目前把两者搞混了,仅这一点就能解释一类间歇性的诡异现象。

json schema 严格模式到底承诺了什么

来自文档本身的诚实版本。OpenAI 的对比表把两种模式区分得很清楚:JSON 模式保证响应在语法上有效,但不遵循 schema;Structured Outputs 则遵循 schema,前提是在受支持的子集之内。列出的好处值得照原样读:可靠的类型安全,免去校验或重试格式错误响应的需要;对基于安全的场景提供可被程序检测的拒绝;以及更简单的 prompt。

Anthropic 把它的机制描述为受约束解码,同样具备三个特性:始终有效(没有解析错误)、类型安全(保证字段类型和必填字段)、可靠(不会因违反 schema 而需要重试),而 schema 本身受文档记载的限制约束。

所以准确的说法是:严格模式消除了由格式错误或不符合 schema 的输出所导致的那一类失败——前提是 schema 落在受支持的子集内。它并没有消除处理拒绝、截断,以及 schema 落在子集之外的各种情形的需要。记住这个区别;下面的一切都建立在它之上。

OpenAI:response_format json_schema、strict 与受支持的子集

在 OpenAI 上,现代做法取代了更早的 JSON 模式,type: "json_object"后者只保证 JSON 有效,response_format而 json schema 形式从gpt-4o-mini和gpt-4o-2024-08-06快照及之后版本开始支持,所以更老的模型仍停留在较弱的模式上。

Supported schemas 那一节是你在写第一个 schema 之前该读的部分。受支持的类型包括 String、Number、Boolean、Integer、Object、Array、EnumanyOf、null 等类型。受支持的约束包括:pattern字符串格式取值的白名单、format数字的上下界(例如最小值与minimum最大值)、maximum数组的长度限制(例如最小条数与minItems最大条数)maxItems。任何不在受支持列表内的东西都不在保证范围内,即使请求成功。实用的习惯是:发布 schema 之前对照那一节扫一遍,并让严格 schema 保持无聊;enum、必填字段和朴素类型通行无阻,奇特的组合则走不通。

同一页面还有两条运维说明:拒绝是可被程序检测的,所以它应该是你的代码要分支处理的状态,而不是记进日志的异常;不完整的响应,例如在 token 上限处被截断的响应,是一个你必须显式处理的边界情况。对于仍在使用 JSON 模式的人:它需要一条显式指令来要求输出 JSON,否则可能行为异常;这也是更该选有 schema 支撑的模式的另一个理由。

Anthropic:output_config.format 与严格工具调用

Anthropic 的结构化输出页面把该特性描述为从 agent 工作流获取经过校验的 JSON 结果,通过两个互补的特性交付:一个是 JSON 输出,用于抽取数据、生成报告或格式化 API 响应;output_config.format另一个是严格工具调用,用于经过 schema 校验的工具名和输入;前者在输出配置里开启,strict: true后者在工具定义的相关选项里开启。

机制是:你把 JSON schema 放在output_config.format输出配置里,并指定格式类型type: "json_schema",Claude 就会在响应的文本内容块里返回与该 schema 匹配的有效 JSON。SDK 可以替你完成接线:Python 的client.messages.parse(..., output_format=PydanticModel)助手接受一个 Pydantic model,转换它的 schema、发送并校验响应,最后以parsed_output解析后的对象返回;TypeScript 用一个辅助函数包裹 Zod schema,并从zodOutputFormat()响应里读取parsed_output解析后的client.messages.parse()内容。

限制那一节值得给 OpenAI 那一节同样的尊重。有些约束关键字不会被发送到线上:文档记载的转换步骤会移除不受支持的约束,例如minimum数值上下界maximum、minLength字符串模式maxLength等关键字,把它们追加到字段描述里,给对象加上additionalProperties: false禁止额外属性的设置,并把字符串格式过滤到一个受支持的列表。如果你传入使用不受支持特性的原始 schema,会得到带详情的 400 错误。SDK 助手各有各的脾性:transform_schema()有的只返回转换后的 schema 而不发送任何请求,在你想检查实际会发出去什么时很有用;Java 的 SDK 会在任何请求发出之前做本地校验、拒绝不受支持的约束关键字,但要注意它检查的是关键字而不是取值;而jsonSchemaOutputFormat(..., { transform: false })另一个则原样发送你的 schema;与zodOutputFormat()需要校验的那个助手不同,它不校验响应,所以违反不受支持约束的情况会直接流过。如果你要从这一节带走一句运维上的话:搞清楚你的助手是转换、校验响应、两者都做,还是都不做。

三类在严格模式下依然存在的失效模式

Schema 子集违规。最常见的生产故障不是输出格式错误,而是一个从未真正可被强制执行的 schema。数值边界在不同厂商那里行为不同:OpenAI 的受支持列表包含它们,Anthropic 的转换会把它们剥成描述,这意味着该约束只能靠会校验的 SDK 在返回时强制执行,而完全不校验的 SDK 则根本不执行。把 schema 写在文档记载的子集内,凡是无法强制执行的约束,就像厂商自己的工具那样写进字段描述,然后在你的本地校验里执行它。

拒绝。基于安全的拒绝是模型的正当行为,不是格式 bug。在 OpenAI 上它可以被程序检测到;在任何生态里,正确的处理方式都是把它当作一等状态:检测它、把它暴露给调用方,不要把它和解析失败混为一谈,也不要按解析失败去重试。把拒绝混进“无效 JSON”报表的流水线,会误导读这些报表的人。

SDK 与 API 的漂移。结构化输出请求的确切形态在不同接口和版本之间不同:response_format一套字段在 OpenAI 的一个接口上,text.format另一套在另一个接口上;助手的默认行为还会决定响应是否被校验。把你集成实际依赖的调用形态固定下来,并在集成测试里放一个 schema 往返用例,这样某次助手升级导致不再校验时,会被 CI 抓住,而不是被下游消费者抓住。

无论如何都要保留的防线

严格模式是失败率的降低器,不是错误处理的替代品。在响应进入系统的边界保留本地 schema 校验;它开销很低,能抓住子集缺口,也能抓住以类型有效但不完整的载荷形式到来的拒绝和截断。为截断输出和拒绝保留显式的、有名字的状态处理。并且保留你的状态码分诊;schema 问题常常在请求时就以 400 出现,而不是以坏响应的形式出现,而这一类错误有自己的分诊路径。防线分层重要的另一个原因:严格工具调用正是 agent 循环所依赖的东西,而依赖工具调用有效性的 agent 在 CI 指南里有讲;当护栏被一个不做校验的助手绕过时,agent 不会慢下来,而是静默地行为异常。

而当 schema 或背后的模型发生变化时,让变更先过一遍黄金集再到达用户;变更验证流程讲了其中的顺序。

从统一端点调用它

如果你通过一个 OpenAI 兼容端点访问多个模型,在信任结构化输出之前,你的集成里应该做三项检查:确认客户端发送的是哪种请求形态、端点是否接受它;核实拒绝是否以可被程序检测的形式返回;并弄清楚 schema 子集限制是在哪一侧被执行的,是你这边还是上游那边。协议形态的问题正是兼容性检查清单的主题,而如果你正处在第一次成功调用的起点,快速开始是更好的入口;你所调用服务的端点级细节都在它自己的文档里。

其中一个形态问题就是结构化输出本身:response_format两套字段text.format分属不同的 API 形态,正如形态指南所解释的。

检查清单

  1. 按意图选择机制:响应内容用 schema 支撑的答案格式,函数调用用严格的工具定义。
  2. 在写代码之前,先把 schema 放进文档记载的受支持子集。
  3. 对于线上 schema 无法承载的约束,像厂商工具那样把它们移到描述里,并在本地强制执行。
  4. 搞清楚你的 SDK 助手的行为:转换、校验响应、两者都做,还是都不做。
  5. 把拒绝和截断当作有名字的状态,而不是解析错误。
  6. 即使开着严格模式,也保留本地校验和 400 类错误的分诊。
  7. 固定你的调用形态,并在 CI 里测试 schema 往返。
  8. 模型轮换时重跑 schema 回归,沿用弃用手册里的迁移习惯,因为受支持的子集可能随模型代际演进而变化。

结构化输出是现代 API 工具箱里杠杆最高的特性之一,恰恰因为用对了会很无聊。得到无聊结果的团队,正是那些先读子集那一节的团队。