结构化输出 JSON Schema 函数调用 OpenAI API Gemini API

用 JSON Schema 实现结构化输出:实用指南

别再从散文里解析 JSON 了。OpenAI 严格 json_schema、Gemini responseSchema 与 Anthropic 工具使用如何强制模型返回符合你所定义 schema 的数据。

AveMujica API 2 分钟阅读

一旦你试图把模型的回答喂进代码,"返回 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 也能成功;拒绝或截断会进入受控分支,而不是导致崩溃。删除那段正则,就是约束真正接管工作的具体信号。

参考资料