用 JSON Schema 实现结构化输出:实用指南
别再从散文里解析 JSON 了。OpenAI 严格 json_schema、Gemini responseSchema 与 Anthropic 工具使用如何强制模型返回符合你所定义 schema 的数据。
一旦你试图把模型的回答喂进代码,"返回 JSON"就不再够用了。客气地请求,你会得到裹在一句道歉里的 JSON、夹在 Markdown 代码围栏里的 JSON,或者语法合法却缺了你解析器所需字段的 JSON。可靠的修法是停止请求、开始约束:告诉提供方你期望的确切 schema,并让它保证——或至少强力约束——响应与之匹配。本文介绍当前实现这一目标的机制:OpenAI 的严格 json_schema、Gemini 的 responseSchema,以及 Anthropic 原生的 output_config.format(严格工具使用则是函数调用侧的对应能力),还会说明各自的 schema 规则,以及为什么事后仍然必须校验。
两种失败模式,一个根源
自由文本 JSON 有两种截然不同的破法,给它们命名很有帮助。语法失败是根本无法解析的输出:一个尾随逗号、一句多出来的话、一段代码围栏。语义失败是能解析、但对你的程序而言是错的输出:一个你从未定义过的枚举值、一个缺失的必填字段、一个你本需要数字的地方却出现了字符串。提示词工程("只用 JSON 回复")能减少第一种,对第二种却几乎无能为力。受 schema 约束的生成同时打击两者,因为 schema 是在解码期间强制的,而不是在事后检查的。
OpenAI:strict: true 就是这个功能的全部
OpenAI 的结构化输出是较旧的 JSON 模式的演进,区别就在一个标志。普通 JSON 模式(response_format: { "type": "json_object" })保证输出是合法 JSON,但对它的形状一无保证。结构化输出加上了一个 schema 和一个把模型绑定到它的 strict 开关。
在 Chat Completions 上你传入一个类型为 json_schema 的 response_format:
from openai import OpenAI
client = OpenAI(base_url="https://your-gateway.example.com/v1", api_key="AVE_GATEWAY_API_KEY")
resp = client.chat.completions.create(
model="gpt-5.6",
messages=[{"role": "user", "content": "Extract the invoice fields."}],
response_format={
"type": "json_schema",
"json_schema": {
"name": "invoice",
"strict": True,
"schema": {
"type": "object",
"properties": {
"vendor": {"type": "string"},
"total": {"type": "number"},
"currency": {"type": "string", "enum": ["USD", "EUR", "JPY"]},
},
"required": ["vendor", "total", "currency"],
"additionalProperties": False,
},
},
},
)
在 Responses API 上,同一个 schema 会移到不同的字段——使用 text.format 而不是 response_format。普通的 responses.create 调用不会自动提供顶层 output_parsed;要得到解析后的对象,请使用 SDK 的 parse 辅助方法和带类型的格式,或者读取 response.output_text 后自行校验:
from pydantic import BaseModel
class Invoice(BaseModel):
vendor: str
total: float
currency: str
resp = client.responses.parse(
model="gpt-5.6",
input="Extract the invoice fields.",
text_format=Invoice, # SDK builds the strict json_schema and parses the result
)
invoice = resp.output_parsed # a validated Invoice instance
如果更喜欢原始路径,可在 responses.create 上发送 text={"format": {"type": "json_schema", "name": "invoice", "strict": True, "schema": SCHEMA}},然后显式解析并校验 resp.output_text;不要期待解析字段自动出现。
把 response_format 发给 Responses、或把 text.format 发给 Chat Completions,是一个常见且令人困惑的错误——变的是字段名,不是概念。经由网关的覆盖范围取决于适配器,并不统一:这个网关会把 OpenAI 形状的 response_format 映射为 Gemini 的 responseSchema,但目前不会把它转换成 Claude 原生的结构化输出字段。因此,对 Claude 应发送下文所示的 Anthropic 原生格式,而不是假定 response_format 会原样抵达。
严格 schema 的子集有它的规则
strict: true 支持 JSON Schema 的大部分但非全部,而且约束严到一个在普通校验器里能通过的 schema 在这里可能被拒:
- 每个属性都必须列在
required里。 没有真正可选的字段。要表达"可以缺席",请把字段做成一个可空联合——"type": ["string", "null"]——而不是把它从required里省略。 additionalProperties: false是强制的,对每个对象都是。模型不得发明额外的键。- 根必须是一个对象。 顶层数组不被允许;把它包进一个带数组类型属性的对象里。
- 递归使用
{"$ref": "#"}来引用根,这就是你表达嵌套树形结构的方式。
当模型因安全原因拒绝时,那会以消息上的一个 refusal 字段、而不是以符合 schema 形状的内容、以编程方式呈现出来,所以你要在信任解析结果之前先检查是否有拒绝。而一个响应可能因长度被截断:检查 finish_reason(值为 "length" 意味着 JSON 被截断),因为即便 schema 被强制了,一个被截断的严格响应仍然是无效的 JSON。
Gemini:responseSchema 加上一个 JSON mime 类型
Gemini 通过 generationConfig 约束输出。你把 responseMimeType 设为 "application/json",并提供一个 responseSchema(一个 OpenAPI 风格的 schema 对象)——或者,对于标准 JSON Schema,用 responseJsonSchema:
from google import genai
from google.genai import types
client = genai.Client()
resp = client.models.generate_content(
model="gemini-3.5-flash",
contents="Classify the ticket.",
config=types.GenerateContentConfig(
response_mime_type="application/json",
response_schema={
"type": "object",
"properties": {
"category": {"type": "string", "enum": ["billing", "bug", "feature"]},
"priority": {"type": "integer"},
},
"required": ["category", "priority"],
},
),
)
Gemini 把生成约束到 schema,支持用于分类的 enum,并让你用 propertyOrdering 固定字段顺序——这很有用,因为顺序会影响质量和一致性。注意文档明确指出的两个警告:并非每个 JSON Schema 特性都被支持,以及非常复杂的 schema 可能被拒。Gemini 还强调输出是合法的、符合 schema 形状的 JSON,但你仍应校验值——它可能产出一个符合 schema、语义上却是错的东西(一个貌似合理却不正确的分类)。这个机制保证的是形状,不是真相。
Anthropic:原生 output_config.format 与严格工具使用
Anthropic 现在提供原生结构化输出,因此强制工具调用不再是唯一方案,而是兼容性后备。两项互补能力覆盖这类需求。JSON 输出在请求上放置 output_config 对象,其中 format 为 {"type": "json_schema", "schema": ...},Claude 的答案会作为符合 schema 的 JSON 返回在文本内容中。严格工具使用则在工具的 input_schema 上添加 strict: true,保证工具参数符合 schema。两者可以单独使用,也可以组合使用。
import anthropic
client = anthropic.Anthropic(base_url="https://your-gateway.example.com", api_key="AVE_GATEWAY_API_KEY")
msg = client.messages.create(
model="claude-opus-4-8",
max_tokens=1024,
output_config={
"format": {
"type": "json_schema",
"schema": {
"type": "object",
"properties": {"vendor": {"type": "string"}, "total": {"type": "number"}},
"required": ["vendor", "total"],
"additionalProperties": False,
},
}
},
messages=[{"role": "user", "content": "Extract the invoice fields."}],
)
data = msg.content[0].text # valid JSON matching your schema
当前有几个限制值得注意:additionalProperties 必须为 false;数值或字符串约束(如 minimum、maxLength)以及递归 schema 尚不受支持,发送这些内容会返回 400。Python SDK 还提供 client.messages.parse(..., output_format=PydanticModel),可一步构建 schema 并解析回复。如果你明确需要模型去调用某个动作,那么严格工具使用才是惯用路径:定义一个 input_schema 为目标 schema 的工具,加入 strict: true,再用 tool_choice 强制调用。
msg = client.messages.create(
model="claude-opus-4-8",
max_tokens=1024,
tools=[{
"name": "record_invoice",
"strict": True,
"input_schema": {
"type": "object",
"properties": {"vendor": {"type": "string"}, "total": {"type": "number"}},
"required": ["vendor", "total"],
"additionalProperties": False,
},
}],
tool_choice={"type": "tool", "name": "record_invoice"},
messages=[{"role": "user", "content": "Extract the invoice fields."}],
)
data = next(b.input for b in msg.content if b.type == "tool_use")
强制工具调用使行为确定:模型必须调用 record_invoice,其 input 就是目标对象。需要带类型的答案时使用 JSON 输出;需要模型用保证有效的参数去调用某个动作时使用严格工具。缺少 strict 的强制工具调用仍可作为旧模型或少数工具循环形态的后备,但在当前模型上应优先使用原生能力。如果产品已经跨提供方路由工具,现有定义可以沿用——参见 跨提供方的可移植工具调用。
结构化输出 vs 函数调用:按意图选择
JSON Schema 的这两种用法看起来相似,也很容易混淆。区别在于意图。当模型应该决定是否以及如何调用一个执行动作的工具时——查询数据库、调用 API、运行代码——使用函数调用。当你只想让模型的答案以程序可消费的数据形态返回时,使用结构化响应格式(json_schema / text.format / responseSchema / output_config.format)。三家提供方现在都同时支持两类能力:OpenAI 在响应格式和工具中支持严格 schema,Anthropic 将原生 JSON 输出与严格工具使用配对,Gemini 则分别提供响应 schema 和函数声明。只想要带类型的答案却使用工具调用,会增加一次不必要的往返。
你仍然要校验——始终如此
Schema 强制不是跳过校验的许可证,有三个具体理由。第一,强制保证的是形状,绝非正确性:模型可以返回一个类型完好、事实却错误的值,而只有你的业务规则才能抓住它。第二,截断会发生——一个受长度限制或超时的响应会产出不完整的 JSON,任何 schema 模式都救不了它,所以你必须处理 finish_reason/status 以及解析错误。第三,schema 支持是一个因提供方而异的子集,因此一个在某上游严格强制的 schema 在另一个上游可能被更宽松地对待。把解析后的对象在边界处过一遍校验器(Pydantic、Zod、JSON Schema),并把校验失败当成一个正常的、预期中的分支,而不是一个异常。这与约束任何外部输入背后的纵深防御原则是同一个。
网关在这里的帮助在于:提供一个发送 response_format 的位置,以及一个查看哪些请求使用了结构化输出、重试频率如何的位置,而无需让每个客户端都理解三家提供方的字段名。在 OpenAI 兼容的 API 网关 背后,你只写一次 OpenAI 形状的 response_format,网关再把它路由到服务该模型的渠道。请注意,转换能力取决于适配器:它会原样到达 OpenAI,也会映射到 Gemini 的 responseSchema,但目前不会由此生成 Claude 原生的 output_config.format,所以针对 Claude 时应使用 Anthropic 原生格式。
常见问题
JSON 模式和结构化输出有什么区别?
JSON 模式(response_format: {"type": "json_object"})保证输出能解析为 JSON,但对它的形状一无所说——字段、类型和枚举都不受约束。结构化输出加上一个带 strict: true 的 json_schema,把模型绑定到你的 schema。只要提供方支持,就优先用结构化输出;把裸的 JSON 模式留给你确实没有固定形状的情形。
为什么我的严格 schema 在别处能通过校验、在这里却被拒?
严格子集比完整的 JSON Schema 更窄。常见原因是缺少 additionalProperties: false、某个属性没有列入 required,或者根节点不是对象。把每个字段都设为必填(可选字段使用可空联合),在每个对象上设置 additionalProperties: false,并把顶层数组包进对象。对 Claude 而言,数值或字符串约束以及递归 schema 也会被直接拒绝,因此还要移除这些特性。
结构化输出能保证值是正确的吗?
不能。它保证的是响应匹配 schema 的形状——类型、必填字段、允许的枚举值——而不是内容准确。模型可以返回一个类型完美却错误的答案。始终在解析后对照你的业务规则校验值,并把符合 schema 当成一个语法保证,而不是正确性保证。
我怎么从 Claude 获得受 schema 约束的输出?
如果需要带类型的答案,请发送 output_config,其中 format 为 {"type": "json_schema", "schema": ...};如果模型应该调用某个动作,则在被强制的工具上添加 strict: true。这两项都是当前模型的原生能力;旧式的“强制调用但不设置 strict”仍可用,但现在只是后备方案,而不是推荐路径。
删除一段正则
找出代码中一处仍在解析模型自由文本答案、再用正则抽取 JSON 的位置。把提示词改为使用 json_schema(Claude 则使用 output_config.format),设置 strict: true,并用 schema 校验器包住解析结果。然后发送一个刻意刁难的输入,确认两点:请求无需清理代码围栏中的 JSON 也能成功;拒绝或截断会进入受控分支,而不是导致崩溃。删除那段正则,就是约束真正接管工作的具体信号。