§ 10.4 · Section

结构化输出

Structured Outputs

前面三节,我们都在教模型「怎么把话说好」。这一节换一个方向:怎么让模型把话说成程序能直接读的样子。你写一个应用,需要模型返回一个订单对象——里面有商品名、数量、单价、是否加急。模型很乐意帮你,可它今天返回 {"qty": 3},明天返回 {"quantity": "三"},后天在 JSON 前面加一句「好的,这是您要的结果:」。你的程序当场崩掉。这一节要讲的,就是把这件事从「求模型配合」变成「结构上不可能出错」的那套机制:JSON Schema、受约束解码、strict 模式、function calling 与 tool use。顺带我们还要拆一个流传很广的说法——「OpenAI 保证 100% 符合 schema」,这句话哪儿说对了,哪儿说漏了。

生活场景
🏦 银行柜台的那张表格

你去银行办一笔转账。理论上你完全可以拿一张白纸,写「麻烦把我账户里的三千块转给我妈,她卡号是……」,柜员看得懂,事儿也办得了。但银行从来不这么干——它给你一张印好格子的表格:收款人姓名一栏、账号一栏、金额一栏(还专门印了小数点位置),下面签名,右下角盖章。

为什么非得用表格?不是柜员看不懂白话,而是白纸后面那套自动处理流程看不懂。表格扫描进系统,第 3 格固定是账号、第 5 格固定是金额,机器按格子取值,一秒钟处理一千笔。要是每个人写法都不一样,就得雇一屋子人一张张读、一笔笔录,还得防着有人把「三千」写成「叁仟元整」再写成「3k」。

更狠的一招是:金额那一栏印了固定格数的小方格,你只能一格填一个数字。这样你连「写错格式」的机会都没有——不是靠提醒你「请规范书写」,是靠格子在物理上不让你乱写。

先把「结构化输出」这个词彻底说清

结构化输出(Structured Outputs)这个词听着玄,其实就是一句话:让模型返回的不是一段人读的话,而是一份程序能直接解析的数据。

什么叫「程序能直接解析」?举个最直白的对比。你让模型帮你分析一条用户评价,两种回答方式:

【方式一 · 自然语言】
这条评价整体偏正面,用户主要称赞了物流速度,
但对包装有些不满意。我判断情感倾向为 4 分(满分 5 分)。

【方式二 · 结构化】
{
  "sentiment": "positive",
  "score": 4,
  "aspects": [
    { "topic": "logistics", "polarity": "positive" },
    { "topic": "packaging",  "polarity": "negative" }
  ]
}

方式一读起来更亲切,但你的程序拿它没办法——你得写正则去抠「4 分」,抠出来还得判断是不是「4.5 分」,还得处理模型改口说「四分」的情况。方式二可以一行代码解决:json.loads(text)["score"]换成大白话:方式一是手写的便签,方式二是填好的表格。人看便签方便,机器看表格方便。

这里要先纠正一个常见的理解偏差。很多人以为结构化输出是「模型的一个高级功能,让它变聪明了」。完全不是。结构化输出不会让模型的判断变准一点点——它判断这条评价是 4 分还是 3 分,跟有没有开结构化输出毫无关系。它保证的只有一件事:格式对。内容对不对,还是靠模型本身的能力,靠你前三节学的那些提示词技巧。

把这件事分清很重要,因为它决定了你排查问题的方向:程序解析报错,是格式问题,开结构化输出能治;模型把「加急」判断错了,是理解问题,得改提示词或换模型,开一百遍结构化输出也没用。

为什么「求模型配合」这条路走不通

在结构化输出这套机制出现之前,全世界的开发者都在用三种土办法,而这三种办法都有各自的死法。这段值得讲清,因为它解释了为什么后来要动到解码层去。

土办法一:在提示词里跪着求。「请只返回 JSON,不要有任何其他文字,不要加代码块标记,不要解释。」这句话大概是人类写给 AI 的话里重复次数最多的一句。它大部分时候有效——问题就在「大部分」。假设成功率 97%,听起来很高,可你一天处理十万条请求,就有三千条崩掉。打个比方:这就相当于一家餐厅在门口贴「请勿大声喧哗」,绝大多数人会遵守,但你没法靠这张纸保证今晚绝对安静。

土办法二:事后清洗 + 重试。拿到回复先把前后的废话切掉,把三个反引号加 json 这类代码围栏标记剥掉,然后 json.loads,失败就重新问一遍。这条路能把成功率推到 99.9%,代价是:每次重试都要重新付一遍钱、重新等一遍时间,而且你永远不知道那 0.1% 什么时候撞上一个关键用户。更麻烦的是「合法 JSON」和「我要的 JSON」是两回事——模型可能返回一个语法完全正确、但字段名写成 quantity 而不是 qty 的对象,json.loads 顺利通过,你的程序在下一行才炸。

土办法三:给一堆示例(Few-shot)。这招确实有用,我们在 §10.2 专门讲过——示例比说明书好使。但它治的是「模型不知道你想要什么样子」,治不了「模型偶发地想加一句客套话」。而且示例要占 token,格式复杂时示例得写好几个,成本不低。

做法治什么不治什么代价
提示词里要求「只返回 JSON」大部分格式跑偏偶发的客套话、代码围栏、字段改名几乎为零,但没有下限保证
清洗 + 失败重试把成功率推高一个量级本质随机性;重试可能连续失败多付钱、多等时间、代码变脏
Few-shot 示例让模型知道「长什么样」不保证每次都照做吃 token,复杂 schema 要写好几个例子
受约束解码(Structured Outputs)格式层面的结构性保证内容正确性;拒答与截断两种例外需要模型和 API 支持;schema 有子集限制

看这张表的最后一行你就明白思路转向了哪里:既然「让模型配合」永远只能逼近 100%,那就换个思路——不让它有机会不配合。

JSON Schema:那张印好格子的表格

要让机器检查格式,先得有一份「格式说明书」,而且这份说明书本身也得是机器能读的。这就是 JSON Schema(JSON 模式,读作「JSON 斯基马」)。

JSON Schema 说白了就是:用 JSON 写的一份「这个 JSON 该长什么样」的规定。它规定有哪些字段、每个字段是什么类型(字符串还是数字)、哪些字段必须有、字符串只能从哪几个词里选、数组里装的是什么。

直接看一份完整实例。假设我们要从一段自然语言里抽出一个外卖订单:

{
  "type": "object",
  "properties": {
    "customer": {
      "type": "string",
      "description": "下单人姓名,从原文中原样抄出,不要编造"
    },
    "items": {
      "type": "array",
      "description": "订单里的所有商品行",
      "items": {
        "type": "object",
        "properties": {
          "name":  { "type": "string" },
          "qty":   { "type": "integer" },
          "price": { "type": "number" }
        },
        "required": ["name", "qty", "price"],
        "additionalProperties": false
      }
    },
    "urgent": {
      "type": "boolean",
      "description": "原文是否明确表达了加急需求"
    },
    "channel": {
      "type": "string",
      "enum": ["app", "phone", "walk_in"],
      "description": "下单渠道,只能是这三个值之一"
    }
  },
  "required": ["customer", "items", "urgent", "channel"],
  "additionalProperties": false
}

逐个字段翻译一遍,这些术语一个都不许留着不解释:

注意 description 那一条。很多人把 schema 当成纯粹的类型声明,只填 type 就交差了,这是浪费。schema 里的每个 description 都会随请求一起进入模型的上下文,它就是贴在格子旁边的小字提示。换成大白话:银行表格上「金额一栏请用阿拉伯数字大写不作数」这行小字,就是 description。

Analogy · 工厂流水线上的定位夹具

想象一条给玻璃杯印花的流水线。杯子沿传送带过来,机械臂负责在杯身正面印一朵花。

做法一 · 靠提醒:在车间墙上贴一张告示——「请把杯子正面朝上摆放」。工人绝大多数时候会照做,但一天几万个杯子,总有几百个摆歪了,花印在杯底上,成为废品。这就是「在提示词里要求只返回 JSON」。

做法二 · 事后挑废品:印完之后加一道人工检查,印歪的挑出来重新洗掉再印一遍。良品率上去了,但每个废品都白花了一次墨、一次工时。这就是「解析失败就重试」。

做法三 · 装一个夹具:在传送带上焊一个凹槽,形状正好只有杯子正面朝上才放得进去。摆歪的杯子根本卡不进凹槽,压根到不了机械臂那一步。这就是「受约束解码」——它不是在结果上纠错,而是在过程里让错误无法发生。

三种做法的本质差别是:前两种把「保证正确」的责任交给了自觉和事后检查,第三种把它变成了物理约束。所以后面你会看到,OpenAI 的官方说法是 constrained decoding——「受约束的解码」,重点在「约束」这个词是硬的。

★ 受约束解码:把不合法的 token 直接屏蔽掉

这一段是本节技术含量最高、也最值得讲透的部分。OpenAI 在官方博文里明确说明,Structured Outputs 的实现机制是 constrained decoding(受约束解码)——不是靠提示,也不是靠重试,而是在生成的每一步直接干预可选的 token。

要理解它,先回忆 §8.2 讲过的生成过程。模型每生成一个 token,实际上是先算出「词表里每一个 token 接在这里的可能性有多大」,然后按某种采样策略挑一个。词表大约 20 万个 token,所以每一步都是一次「从 20 万个候选里选一个」。

受约束解码做的事,就是在「选一个」之前,把当前不可能合法的候选全部划掉——把它们的可能性直接压成零。走一遍例子你立刻就懂:

目标 schema: { "urgent": boolean }
模型已经生成了:   { "urgent":

现在这一步,词表里 20 万个 token 中,
在 JSON 语法 + 这份 schema 下,合法的只有极少数几个:

    合法:  true    false    (以及可能的前导空格变体)
    非法:  "       0        [        {       hello    好的    ...

于是解码器把所有非法 token 的概率强制置为 0(业内叫「掩码」,mask)。
模型现在哪怕内心极想写一句「好的,这是您要的结果」,
它也无从下笔——那些 token 已经不在候选里了。

再走一步:
模型已经生成了:   { "urgent": true
    合法:  }      ,(如果 schema 里还有别的必填字段)
    非法:  其余全部

所以最后一个字符必然是 },JSON 不可能少一个括号。

这套机制的精妙之处在于:它不需要模型「理解」JSON 语法,也不需要模型「愿意」配合。约束在解码器那一层,是程序化的、确定的。好比考试用的答题卡——你想在选择题那一栏写作文也写不了,因为那里只有四个小方框。

技术上它是怎么知道「此刻合法的是哪些」的?OpenAI 博文里提到的做法是把 JSON Schema 先转成一种语法(可以理解成一台状态机——「现在处在哪个格子里、下一步允许出现什么」),然后在每一步查这台状态机,得到允许的 token 集合。第一次用某个新 schema 时需要做一次这样的预处理,所以官方也提到首次请求会有额外延迟,之后这份「语法」会被缓存复用。

把这个机制的价值换算成可感知的量:假设不用约束时格式成功率是 99%,你一天十万次调用就有一千次要重试;重试也可能失败,还有一百次要二次重试。上了受约束解码,这一千次重试的钱、时间和监控告警一起消失了。而且更关键的是,你的代码里可以删掉那一大坨「先切前缀再剥围栏再 try except」的清洗逻辑——省下的维护成本比省下的 token 钱更值。

JSON mode 与 Structured Outputs:一步之差,天壤之别

这两个名字长得像,很多人混着用,但它们保证的东西完全不是一回事。这是本节第二个必须记牢的点。

JSON mode(JSON 模式)是 OpenAI 在 2023 年 DevDay 开发者大会上推出的。它保证的是:输出是一段合法的 JSON。仅此而已。合法的意思是括号配对、引号成对、逗号位置对、能被 json.loads 顺利吃下。但它不保证这段 JSON 长成你要的样子。

Structured Outputs(结构化输出)是 2024 年 8 月 6 日随官方博文《Introducing Structured Outputs in the API》推出的。它保证的是:输出会 exactly match JSON Schemas provided by developers——严格匹配你给出的 JSON Schema

差别有多大?看一个具体的翻车现场:

你的 schema 要求:
  { "qty": integer, "urgent": boolean }

【JSON mode 下可能出现的合法但错误的输出】
  { "quantity": 3, "is_urgent": "yes" }     ← 合法 JSON!字段名不对、类型不对
  { "qty": "three", "urgent": true }        ← 合法 JSON!数量成了字符串
  { "qty": 3 }                              ← 合法 JSON!少了一个必填字段
  { "qty": 3, "urgent": true, "note": "…" } ← 合法 JSON!多了一个字段

  这四种 json.loads 全都能过。
  你的程序在下一行 data["urgent"] 或 int(data["qty"]) 才炸。

【Structured Outputs 下】
  { "qty": 3, "urgent": true }              ← 只有这一种形状可能被生成出来

换成大白话:JSON mode 保证你交上来的是一张表格而不是一封信;Structured Outputs 保证你交上来的是我指定的那张表格,格子数一个不多一个不少。前者只管「有没有格式」,后者管「格式对不对」。

维度JSON modeStructured Outputs
推出时间2023 年 DevDay2024 年 8 月 6 日
保证「是合法 JSON」
保证「符合指定 schema」(有两个前提,见下节)
字段名会不会被改可能会不会
会不会漏必填字段可能会不会
会不会多塞字段可能会additionalProperties: false 后不会
你还需要写校验代码吗必须写可省,但仍建议保留一层兜底

所以在能用 Structured Outputs 的场合,没有任何理由继续用 JSON mode。JSON mode 今天主要的价值是兼容老代码、以及在不支持新特性的模型上退而求其次。

★★ 那个「100%」:评测分数不等于产品承诺

这一段是本节最需要小心的地方,也是中文技术文章出错最集中的一处。

你会在无数篇介绍文章里看到「OpenAI 保证 100% 符合 schema」。这句话把两件不同的事混成了一件。让我们把官方原话拆开看。

第一件事 · 评测分数。官方博文的原文是:On our evals of complex JSON schema following, our new model gpt-4o-2024-08-06 with Structured Outputs scores a perfect 100%. In comparison, gpt-4-0613 scores less than 40%. 翻译过来:在 OpenAI 自家的「复杂 JSON schema 遵循」评测集上,开了 Structured Outputs 的 gpt-4o-2024-08-06 拿了满分 100%;作为对照,gpt-4-0613 不到 40%。

请盯住这句话的限定语:「在我们自己的评测集上」。这是一个 benchmark(基准测试)分数,是在一批特定题目上跑出来的结果,不是对所有情况的无条件承诺。好比一款车在厂家测试场的标准工况下跑出百公里油耗 4.9 升——这个数字是真的,也是可核对的,但你不能把它读成「这车在任何路况下油耗都是 4.9 升」。

第二件事 · 产品层的可靠性表述,它带两个明确前提。官方另一处原文是:When the response does not include a refusal and the model's response has not been prematurely interrupted (as indicated by finish_reason), then the model's response will reliably produce valid JSON matching the supplied schema.

拆成两个必须成立的条件:

所以正确的措辞是什么?

写法评价为什么
「OpenAI 保证 100% 符合 schema」不准确,别这么写把评测分数当成了无条件承诺,还漏掉了拒答与截断两个前提
「在 OpenAI 自家复杂 schema 评测集上达到 100%」准确如实交代了这是评测结果和它的范围
「在没有拒答、也没有被截断的前提下,输出会可靠地符合 schema」准确这是官方产品层表述的忠实翻译,两个前提都在
「上了 Structured Outputs 就不用做任何校验了」危险拒答和截断都要处理;网络错误、超时也照样存在

这件事之所以值得单开一节,是因为它是「实验室基准 vs 日常使用」这个鸿沟的一个标准案例。你以后会在 AI 领域反复遇到同一个套路:某个能力在一份基准测试上刷到接近满分,然后被简化成「AI 已经解决了这个问题」,最后在你的真实业务里出问题。养成一个习惯:看到一个漂亮的百分比数字,先问三句——在哪个数据集上测的?谁测的?有没有附加前提?这三句话能帮你过滤掉一大半技术宣传噪音。

两条使用路径:function calling 与 response_format

官方博文里给了两种用法,很多人只知道其中一种,结果在选型时纠结错了方向。这两条路不是二选一的竞品,而是对应两种完全不同的场景。

路径一 · 给 function calling 装上 strict。在你定义的函数(tools)里加一句 strict: true。按发布时点的说明,这条路径支持所有支持 tools 的模型,从 gpt-4-0613gpt-3.5-turbo-0613 那一批开始往后都行。

路径二 · 用 response_format 的 json_schema。response_format 这个参数新增了一个 json_schema 选项,配上 strict: true。按发布时点(2024 年 8 月)的说明,支持 gpt-4o-2024-08-06gpt-4o-mini-2024-07-18

★这里必须插一句时效提醒:上面这两串模型名单是 2024 年 8 月发布时点的信息。AI 领域一年就是一代,到你读这篇文章的时候支持范围早已扩大。具体支持哪些模型,请查 OpenAI 官方文档的 Structured Outputs 指南,本文列出的仅为发布时点快照,不要当成当前现状。本书在这类快速变化的事实上一律只写「有出处的时点信息 + 明确标注」,不写「据说现在支持全部模型」这种无法核实的话。

那两条路该怎么选?关键看结构化的结果要给谁用

你的场景选哪条路为什么
模型要去调用你系统里的一个函数(查库存、下订单、发邮件)function calling + strict: true结构化的目的是「组装函数参数」,参数错了函数就调不动
模型直接把结构化数据回给你/回给前端渲染response_formatjson_schema没有函数要调,只是要一份规整的数据回执
从一段文本里抽取实体、做分类打标response_format典型的「一次抽取,直接入库」,不涉及外部动作
让模型在多个工具之间自己选一个用function calling选哪个工具本身就是要模型决定的事

把 function calling 和 structured outputs 的关系彻底说清,因为这是初学者最容易搞混的一对概念:

动手:OpenAI Structured Outputs 的完整可运行示例

先看 response_format 这条路。下面这段代码可以直接跑,它做的事是从一段乱糟糟的口语订单里抽出规整数据:

# pip install openai
import json
from openai import OpenAI

client = OpenAI()   # 从环境变量 OPENAI_API_KEY 读密钥

ORDER_SCHEMA = {
    "type": "object",
    "properties": {
        "customer": {"type": "string", "description": "下单人姓名,原文没提就填空字符串"},
        "items": {
            "type": "array",
            "items": {
                "type": "object",
                "properties": {
                    "name": {"type": "string"},
                    "qty":  {"type": "integer"},
                },
                "required": ["name", "qty"],
                "additionalProperties": False,
            },
        },
        "urgent":  {"type": "boolean", "description": "原文是否明确要求加急"},
        "channel": {"type": "string", "enum": ["app", "phone", "walk_in"]},
    },
    "required": ["customer", "items", "urgent", "channel"],
    "additionalProperties": False,
}

raw = "喂?我是三号楼的王姐,来两份宫保鸡丁、一份米饭,麻烦快点儿啊,孩子等着吃"

resp = client.chat.completions.create(
    model="gpt-4o-2024-08-06",          # ← 支持范围请查官方文档,此处为发布时点示例
    messages=[
        {"role": "system", "content": "你是订单录入员。只按 schema 抽取原文里明确出现的信息,绝不编造。"},
        {"role": "user",   "content": raw},
    ],
    response_format={
        "type": "json_schema",
        "json_schema": {
            "name": "order",
            "schema": ORDER_SCHEMA,
            "strict": True,             # ★ 这一行才是开启严格约束的开关
        },
    },
)

msg = resp.choices[0].message

# ★ 两个前提都要检查,缺一个就是隐患
if getattr(msg, "refusal", None):                 # 前提一:模型有没有拒答
    raise RuntimeError("模型拒绝了这个请求:" + msg.refusal)
if resp.choices[0].finish_reason != "stop":       # 前提二:有没有被截断
    raise RuntimeError("输出未正常结束,finish_reason=" + resp.choices[0].finish_reason)

data = json.loads(msg.content)     # 到这里可以放心解析
print(json.dumps(data, ensure_ascii=False, indent=2))

这段代码里有三行是最容易被教程省略、而生产环境绝不能省的:

再看 function calling 加 strict 这条路。区别只在参数怎么传:

tools = [{
    "type": "function",
    "function": {
        "name": "check_stock",
        "description": "查询某个商品在某个仓库的实时库存",
        "parameters": {                 # ★ OpenAI 这里叫 parameters
            "type": "object",
            "properties": {
                "sku":       {"type": "string",  "description": "商品编码,形如 A17-BLK"},
                "warehouse": {"type": "string",  "enum": ["north", "south", "east"]},
            },
            "required": ["sku", "warehouse"],
            "additionalProperties": False,
        },
        "strict": True,                 # ★ 给 function calling 装上结构化输出
    },
}]

resp = client.chat.completions.create(
    model="gpt-4o-2024-08-06",
    messages=[{"role": "user", "content": "帮我看下 A17 黑色在北方仓还有货吗"}],
    tools=tools,
)

call = resp.choices[0].message.tool_calls[0]
args = json.loads(call.function.arguments)   # 有 strict 兜着,这里的形状是确定的
print(args)          # {'sku': 'A17-BLK', 'warehouse': 'north'}

# 你的代码真正去执行,然后把结果以 tool 角色回传(★ OpenAI 用独立的 tool 角色)
result = {"sku": "A17-BLK", "warehouse": "north", "available": 42}
messages_next = [
    {"role": "user", "content": "帮我看下 A17 黑色在北方仓还有货吗"},
    resp.choices[0].message,                       # 把模型那条 tool_calls 消息原样带回
    {"role": "tool",
     "tool_call_id": call.id,
     "content": json.dumps(result, ensure_ascii=False)},
]

请留意最后那个 {"role": "tool", ...}OpenAI 用一个独立的 tool 角色来回传工具执行结果——这是它和 Anthropic 的一个结构性差异,下一段马上对比。

Anthropic 那一边:Tool use 与 input_schema

Anthropic(Claude 的公司)把这套能力叫 Tool use(工具使用)。它的整体思路一致、细节处处不同,而这些不同恰好是最容易在迁移代码时踩坑的地方。

先把一次完整往返走一遍。换成大白话,整个流程就四步:你告诉模型手边有哪些工具 → 模型说「我要用某个工具,参数是这些」 → 你真的去执行 → 你把执行结果交回去,模型接着说人话。

# pip install anthropic
import json
import anthropic

client = anthropic.Anthropic()   # 从环境变量 ANTHROPIC_API_KEY 读密钥

tools = [{
    "name": "check_stock",
    "description": "查询某个商品在某个仓库的实时库存。需要商品编码和仓库代号。",
    "input_schema": {                    # ★ Anthropic 这里叫 input_schema(不是 parameters)
        "type": "object",
        "properties": {
            "sku":       {"type": "string", "description": "商品编码,形如 A17-BLK"},
            "warehouse": {"type": "string", "enum": ["north", "south", "east"]},
        },
        "required": ["sku", "warehouse"],
    },
    "strict": True,                      # ★ 严格等价物:保证参数严格贴合 schema
}]

question = "帮我看下 A17 黑色在北方仓还有货吗"

resp = client.messages.create(
    model="claude-sonnet-4-5",           # ← 模型名请查官方文档,随版本变化
    max_tokens=1024,
    tools=tools,
    tool_choice={"type": "auto"},        # 默认值:由模型自行判断要不要用工具
    messages=[{"role": "user", "content": question}],
)

# 响应的 content 是一个 block 列表,可能同时有 text block 和 tool_use block
tool_use = next(b for b in resp.content if b.type == "tool_use")
print(tool_use.name)    # 'check_stock'
print(tool_use.input)   # {'sku': 'A17-BLK', 'warehouse': 'north'}

# 真正执行
result = {"available": 42}

# ★ 把结果作为 tool_result block 回传,而且角色是 user(不是独立的 tool 角色)
followup = client.messages.create(
    model="claude-sonnet-4-5",
    max_tokens=1024,
    tools=tools,
    messages=[
        {"role": "user",      "content": question},
        {"role": "assistant", "content": resp.content},      # 原样带回模型那一轮
        {"role": "user", "content": [{
            "type": "tool_result",
            "tool_use_id": tool_use.id,
            "content": json.dumps(result, ensure_ascii=False),
        }]},
    ],
)
print(followup.content[0].text)

把两家的差异列成表,这张表建议你收藏——迁移代码时一条条对着看:

对比项OpenAIAnthropic
机制的官方叫法Function calling / Structured OutputsTool use
参数 schema 的字段名parametersinput_schema
严格模式开关strict: truestrict: true(严格等价物,官方表述为「确保 Claude 的工具调用始终精确匹配你的 schema」)
模型请求调工具时返回什么消息里的 tool_calls 数组内容块列表里的 tool_use block
执行结果怎么回传独立的 tool 角色消息tool_result block,角色是 user
并行调多个工具支持默认开启,要关得显式设 disable_parallel_tool_use
工具在哪儿执行都在你的应用里分 client tools 与 server tools 两类

其中结果回传方式那一行是结构性差异,值得再强调一遍。OpenAI 的设计是「对话里多了一种说话人:工具」;Anthropic 的设计是「工具结果是用户递进去的一份材料」。好比你在医院看病:OpenAI 的模型是检验科直接把化验单发到医生工作站(多了一个独立角色),Anthropic 的模型是你自己拿着化验单回诊室交给医生(还是你在说话,只是手里多了一张单子)。两种流程都能看好病,但你写代码时格式完全不同,混着写必然报错。

client tools 与 server tools:OpenAI 没有的那个二分

Anthropic 有一个 OpenAI 没有完全对应概念的划分,理解它能省你不少功夫。

换成大白话,这个区别就是「自己下厨」和「叫外卖」。client tools 是你在自家厨房炒菜——原料、火候、洗碗全是你的事,但你想放多少盐完全自己说了算。server tools 是叫外卖——你只说「一份宫保鸡丁」,谁买菜、谁炒、谁洗锅都不用你管,菜直接送到桌上;代价是你无法干预它怎么做的。

什么时候用哪种?凡是涉及你自己的数据、自己的业务动作(下单、退款、改数据库)——必须是 client tools,因为只有你的系统知道这些事怎么做、以及有没有权限做。凡是通用能力(搜网页、跑一段计算)——用 server tools 更省事,你不必自己维护一个搜索接口和一个沙箱。

怎么控制模型「要不要用工具」

定义了工具,不等于模型每次都会用。这里有一个参数和一个技巧。

参数:tool_choice默认值是 {"type": "auto"}——由模型自己判断这次要不要调工具。这个默认值大多数时候是对的,但有两种情况你会想改它:

你想要的效果怎么设典型场景
模型自己决定(默认){"type": "auto"}通用助手,有时需要查有时不需要
强制这一轮必须调工具设为强制类型(any 或指定某个工具)你的业务流程里这一步必须拿到结构化参数
关闭并行调用{"type": "auto", "disable_parallel_tool_use": true}你的工具有副作用、必须一个个来(比如连续扣款)

技巧:用一句轻量指令抬高调用率。官方给的例子是在提示词里加一句 Use the tools to investigate before responding.(回答之前先用工具查一下)。这一句不强制,但能明显提高模型主动查证的倾向。好比你对一个新来的实习生说「不确定的事先查一下再答」——不是命令他每件事都查,是把他的默认习惯往「查」的方向拨了一格。

★关于并行工具调用那一行,有一个真实的坑必须点出来:Anthropic 默认是开启并行的,也就是模型可以一次性请求调用好几个工具。这在只读查询上是好事(三个查询同时发,更快),但如果你的工具带副作用就危险了——模型可能一次并行发起三笔扣款。这和 OpenAI 的默认行为不同,从 OpenAI 迁过来的代码最容易在这里出事。处理办法就是那行 disable_parallel_tool_use,或者在你自己的执行层加幂等保护(同一个请求重复执行只生效一次)。

schema 设计的六条实战经验

约束机制帮你把「格式对」这件事兜住了,但schema 本身设计得好不好,直接决定内容质量。下面这六条是踩过坑之后的经验,逐条给理由。

反模式 vs 正确做法:一张对照表

把上面的经验反过来写成「常见错法」,对照着看更容易记住:

反模式会出什么事正确做法
写了 schema 但忘了 strict: true退化成 JSON mode,字段名和类型都可能跑偏,而且极难发现(大部分时候是对的)开关一定要打开,并在代码评审时专门检查这一行
拿到结果直接 json.loads,不看 refusalfinish_reason拒答场景解析 None;截断场景解析半截 JSON两个字段都判一遍,异常时走降级或重试逻辑
所有分类字段都用自由字符串同义词满天飞,下游统计口径无法对齐凡是有限集合,一律 enum
把所有字段都写进 required,不给「未知」出口逼出幻觉——原文没有的信息被硬编出来允许空值,或加显式的 found / confidence 字段
结论字段排在推理字段前面模型先拍板再找理由,准确率下降reasoning 在前,conclusion 在后
字段名很省,一个 description 都不写模型靠猜,量纲和取值范围全凭运气每个字段都写清含义、单位、取值范围
用 schema 硬约束去「治」内容错误格式完美、内容照错,还以为已经解决了问题格式归约束,内容归提示词与模型选型,两件事分开治
Anthropic 侧照抄 OpenAI 的 parameters 字段名请求直接报错,或工具压根不被识别Anthropic 用 input_schema,结果用 tool_result block 以 user 角色回传
迁到 Anthropic 后没关并行工具调用带副作用的工具被一次并行触发多次显式 disable_parallel_tool_use,或在执行层做幂等
schema 里塞五六层嵌套模型容易漏字段,你的下游代码也难维护拆成两次调用,先粗后细

一个容易被忽略的收益:结构化让评测变得可能

前面讲的都是「不崩」。但结构化输出还有一个不那么显眼、长期价值更大的好处:它让「这个提示词到底有没有变好」这件事第一次变得可以量化。

想想自由文本的时代你怎么评测。你改了一版提示词,然后……人工读五十条回答,凭感觉判断「好像好一点」?这不是工程,这是玄学。换成大白话:这就好比学校里改作文——两个老师给同一篇打分能差二十分,你没法拿这个分数去比较两种教学法。

而结构化输出把答案变成了字段。字段可以直接和标准答案比对:

准备一份标注好的测试集(哪怕只有 100 条):
    raw_text  →  期望的结构化结果

跑一遍,逐字段算准确率:
    channel 字段准确率     97%
    urgent  字段准确率     84%   ← 找到问题了
    items   数量完全一致   91%

现在你有了明确的改进方向:
    去看那 16% 判错「加急」的样本,
    发现原文说「孩子等着吃」这类隐含表达经常被漏掉,
    于是在 urgent 字段的 description 里补一句:
    「包括『快点』『等着用』这类隐含的紧急表达」

改完再跑一遍:urgent 准确率 84% → 93%。
这才叫工程——每一步改动都有一个可核对的数字支撑。

这是结构化输出最容易被低估的价值:它不只是让程序不崩,它把提示词工程从「凭感觉调」变成了「看数字调」。相当于做菜从「凭手感撒盐」变成了用厨房秤——不是味觉不重要,而是有了刻度你才能复现和改进。这条思路我们下一节(§10.5 迭代与反模式)还会继续展开。

下面这个小测验检验一下本节最关键的两个区分——「JSON mode 与 Structured Outputs 差在哪」以及「100% 这个数字该怎么读」。

Recap · 收束

结构化输出解决的是一个很具体的问题:让模型的回答从「人读的话」变成「程序能直接用的数据」。它保证格式,不保证内容——这条边界要牢牢记住。

技术路线是从「求配合」到「上约束」的转变:提示词里请求、事后清洗重试、Few-shot 示例,都只能逼近 100%;而 受约束解码(constrained decoding)在解码的每一步直接把不合法的 token 屏蔽掉,这是结构上的保证,就像流水线上那个只让杯子正面朝上放得进去的凹槽。

两个必须分清的对子:JSON mode(2023 年 DevDay)只保证是合法 JSON,Structured Outputs(2024 年 8 月 6 日)才保证严格匹配你给的 schema;function calling 是「让模型调用你的函数」的机制,structured outputs 是施加在它之上的约束能力,加一行 strict: true 就是给前者装上后者,两者不是竞品。

关于那个 100%:准确的说法是「在 OpenAI 自家复杂 schema 评测集上达到 100%」,而产品层的可靠性还带「未拒答」和「未被截断」两个前提——别写成「官方保证 100% 符合 schema」。这是「实验室基准 vs 日常使用」的一个标准案例,以后见到漂亮的百分比,先问在哪个数据集上测的、谁测的、有没有前提。

两家 API 的差异要记牢:OpenAI 用 parameters 和独立的 tool 角色;Anthropic 叫 Tool use,用 input_schema,结果以 tool_result block 走 user 角色回传,还多一个 client tools / server tools 的二分,并且默认开启并行工具调用。模型支持列表是快速变化的信息,本文给的是发布时点快照,实际请查官方文档。

下一节我们把视角拉回到你自己身上——提示词写不出效果时该怎么迭代,以及哪些流传很广的「技巧」其实经不起实证检验。你会读到一个挺有意思的结论:「威胁模型能提升表现」被一位知名科技公司创始人公开背书过,但正经的实证研究并不支持它。

☰ 主页
学海无涯 · 智能篇 · § 10.4