模型从不执行函数,它只写一张"调用申请单"

你把工具的名字、说明、参数 schema 交给模型;模型回一段 tool_use JSON;执行的是你的代码,结果再用 tool_result 还给它。

所以工具调用出错,一半是"申请单"写错了(参数不合法),一半是"表格"设计得让人容易填错(描述和 schema)。两头都能测。

1一次工具调用到底发生了什么

用户说:"订单 ORD-20260917 的杯子摔碎了,退我 19.99 元。"你的 Agent 有一个退款工具 refund_order。一轮下来是这样的:

  1. 你发请求:messages 里放用户这句话,tools 里放 refund_order 的定义。
  2. 模型回复:stop_reason 是 "tool_use",content 里有一个 tool_use 块,写着要调哪个工具、参数是什么。到这一步,钱一分都没退。
  3. 你的代码校验参数,调真正的退款接口。
  4. 你再发一次请求:把模型上一轮的 content 原样追加为 assistant 消息,再追加一条 user 消息,里面是 tool_result 块,tool_use_id 对上第 2 步的 id。
  5. 模型读到结果,回复"已为您提交退款"。stop_reason 变成 "end_turn",循环结束。
// 第 2 步:模型返回的 tool_use 块
{"type": "tool_use", "id": "toolu_01A...", "name": "refund_order",
 "input": {"order_id": "ORD-20260917", "amount_cents": 1999, "reason": "damaged"}}

// 第 4 步:你还给模型的 tool_result 块
{"type": "tool_result", "tool_use_id": "toolu_01A...",
 "content": "{\"refund_id\": \"RF-20260917\", \"status\": \"submitted\"}"}
这就是 agent loop 的最小单元:只要 stop_reason == "tool_use" 就执行工具、回结果、再请求,直到模型不再要工具。本周从零写的 agent loop,核心就是这几行。

一个回复里可能有多个 tool_use 块(并行调用)。这时要把所有 tool_result 放进同一条 user 消息里一起还回去。

2工具定义:name、description、input_schema

{
  "name": "refund_order",
  "description": "给已签收的订单发起退款。只在用户明确要求退款、并给出订单号时调用;取消未发货的订单请用 cancel_order。金额单位是分,不能超过实付金额。",
  "input_schema": {
    "type": "object",
    "properties": {
      "order_id":     {"type": "string", "pattern": "^ORD-[0-9]{8}$", "description": "订单号,ORD- 加 8 位数字,例如 ORD-20260917"},
      "amount_cents": {"type": "integer", "minimum": 1, "description": "退款金额,单位:分。19.99 元写 1999"},
      "reason":       {"type": "string", "enum": ["damaged", "not_received", "wrong_item", "other"]},
      "note":         {"type": "string", "maxLength": 200, "description": "可选,给客服的备注"}
    },
    "required": ["order_id", "amount_cents", "reason"],
    "additionalProperties": false
  }
}
字段作用谁在读
name工具的标识,模型在 tool_use.name 里填它,你的代码按它分发模型 + 你的路由代码
description什么时候用、什么时候不用、有什么限制只有模型。它就是 prompt 的一部分
input_schema参数的 JSON Schema:类型、必填、枚举、格式模型照着填;你的代码拿它校验

tool_choice:要不要让模型用工具

取值行为
{"type": "auto"}默认。模型自己决定用不用、用哪个
{"type": "any"}必须用某个工具
{"type": "tool", "name": "..."}必须用指定的工具
{"type": "none"}不许用工具
版本差异:Claude Opus 5.5、Sonnet 5.5、Fable 5.1 不接受强制调用,any 和 tool 会直接返回 400。在这些模型上用 auto,在 prompt 里说清楚该用哪个工具,并且检查回复里到底有没有 tool_use 块,没有就重新提示。任何取值都可以加 "disable_parallel_tool_use": true,限制一次最多调一个工具。

3工具描述就是 prompt

模型看不到你的函数实现,它对这个工具的全部了解就是 name、description 和 schema。Anthropic 在 Building effective agents(Erik Schluntz、Barry Zhang,2024 年 12 月)的附录 2「Prompt engineering your tools」里说:工具定义应该得到和整体 prompt 一样多的 prompt 工程投入。他们把这叫做 ACI(agent-computer interface),要像做人机界面(HCI)一样用心做。

坏写法模型会怎么填错好写法
amount: number,描述"退款金额"19.99 元填成 19.99 还是 1999?两个都能通过类型校验参数名叫 amount_cents,类型 integer,描述写"单位:分。19.99 元写 1999"
reason: string"broken"、"摔碎了"、"质量问题"……下游对不上enum 列出全部取值
id: string填用户 id?订单 id?带不带前缀?叫 order_id,写格式和示例
描述只有"退款"两个字用户说"不要了,别发货"也来调退款写清楚什么时候用、什么时候改用 cancel_order
必填字段没写进 required漏填了也不报错,下游拿到 nullrequired 写全,可选字段在描述里注明"可选"

文章里几条可以直接照做的建议:

和第 1 周连起来看:一个描述里有 m 处歧义,每处模型猜对的概率是 q,单次调用"参数全对"的概率大约是 \(q^m\),同一请求跑 k 次都对就是 \(q^{mk}\)。q = 90%、m = 3、k = 8 时只有 8.0%。消掉一处歧义只要改一行描述,效果相当于把每处的猜对率都提高好几个点,而后者往往得换更强的模型。(这里假设各处歧义相互独立,k 次运行之间也相互独立,只是粗略估算。歧义引起的误读往往每次都一样,k 次之间高度相关,真实的 pass^k 介于 \(q^{mk}\) 和 \(q^m\) 之间:上例在 8.0% 到 72.9% 之间。独立性假设什么时候不成立,见第 1 周。)

4结构化输出:让模型输出合法 JSON 的几种办法

办法怎么做保证程度
① 只靠 prompt"只输出 JSON,格式如下……",自己 json.loads没有保证。多一句解释、少一个括号就解析失败
② 借工具调用定义一个工具,它的 input_schema 就是你要的输出格式,让模型"调用"它,你只取 input模型会尽量按 schema 填,但默认不保证完全合法
③ strict 工具在工具定义上加 "strict": true(和 name 同级),schema 里每个 object 都要 additionalProperties: falsetool_use.input 保证符合 schema(例外见下)
④ JSON 输出请求里加 output_config: {"format": {"type": "json_schema", "schema": {...}}};Python SDK 可用 client.messages.parse() 配合 pydantic 模型回复文本保证符合 schema(例外见下)

③④ 是约束解码:生成时就不让模型写出不合 schema 的 token。但它们有边界:

结论:无论用哪种办法,在执行工具的那一刻都要再校验一次。约束解码减少了出错,但边界校验是你对下游系统的承诺,不能交给模型。

5校验失败怎么办:把错误还给模型

参数不合法时,不要执行,也不要直接抛异常结束。回一个 is_error: true 的 tool_result,把具体哪里错写清楚,模型通常会自己改正再调一次:

{"type": "tool_result", "tool_use_id": "toolu_01A...", "is_error": true,
 "content": "参数校验失败,请修正后重新调用:\namount_cents: '19.99' is not of type 'integer'"}
  1. 错误信息要可操作:说字段名、期望什么、收到什么。"参数错误"四个字模型没法改。
  2. JSON 本身解析不了(截断、多余字符):同样回 is_error,把原始文本放进去,例如 {"INVALID_JSON": "..."},用 JSON 库构造,别拼字符串。如果是 stop_reason == "max_tokens" 造成的截断,应该调大 max_tokens 重新请求,而不是让模型重试。
  3. 设重试上限:比如同一个调用最多重试 2 次。超过就终止这个任务、记录 trace、这条用例判失败。没有上限的 loop 会一直烧 token。
  4. 工具执行本身失败(下游超时、订单不存在)也用 is_error: true 回,不要把这个 tool_result 丢掉。
别把重试想得太乐观。如果每次失败概率是 20% 且相互独立,重试 2 次(共 3 次)后仍失败的概率是 0.2³ = 0.8%。但同一个上下文里的重试不独立:模型因为描述有歧义而填错,看到报错后很可能换一种同样错的填法。这正是第 1 周「独立性假设」讲的问题,实际的最终失败率要跑出来看,不能按 0.2³ 算。

6测开视角:工具契约测试,和"选对工具"的评测

契约测试:不经过模型,直接测工具边界

类别用例期望
schema 校验合法、缺必填、类型错、枚举外、多余字段、JSON 截断、边界值(amount_cents = 0 / 1)合法的执行;不合法的回 is_error,错误信息指明字段
非法参数金额超过实付、订单已退过款、订单号格式对但不存在业务层拒绝,回 is_error,不产生副作用
越权参数模型塞进 skip_review: true、user_id 换成别人、退别人的订单additionalProperties: false 挡住多余字段;归属校验用会话里的用户身份,不用模型给的
幂等同一个 tool_use_id 的请求重放两次;网络超时后模型又发起一次调用(新的 tool_use_id)只退一次款。重放按 tool_use_id 去重;模型重新发起的调用 id 不同,要靠业务幂等键(订单号 + 退款状态,或代码按会话 + 订单生成的退款单号),执行前先查有没有进行中或已完成的退款。幂等键由代码决定,不让模型填

评测:模型选对工具、填对参数

把每条用例写成可断言的期望:tool_use.name == "refund_order",input.amount_cents == 1999,input.reason == "damaged"。"用户只是问退款政策"这种用例,期望是不调任何工具。

每条用例跑 n 次,数通过 c 次,按第 1 周的无偏估计算 pass^k:\(\binom{c}{k}/\binom{n}{k}\)。例如跑 10 次对 9 次,pass^3 = 84/120 = 70%。退款这种不可撤销的操作,看 pass^k,不看 pass@k。

分层统计比一个总分有用:选错工具、参数不合 schema、参数合 schema 但值错了(该填 1999 却填了 199900)、该调没调,这四类的修法完全不同。前两类改描述和 schema,第三类加业务校验和示例,第四类改 prompt。

▶互动演示:改 schema,看六种模型输出过不过校验

左边是完整的工具定义,可以直接改。右边是六种模拟的模型输出,每次改动都会用 JS 重新按 input_schema 校验,并给出应该还给模型的 tool_result。点每一行展开细节。校验器支持 JSON Schema 的常用子集:type、required、properties、additionalProperties、enum、const、pattern、minimum / maximum、minLength / maxLength、items、minItems / maxItems。

一键改 schema
通过校验
应回 is_error
放过的坏输出

工具定义(可编辑)

模拟的模型输出(tool_use.input)

自己写一个试试:

▶小模拟:工具描述少写一句,pass^k 掉多少

勾选的项会写进右边的工具定义。每少勾一项,就多一处模型要"猜"的地方。设每处猜对的概率为 q,并粗略假设各处相互独立:单次参数全对 \(\approx q^m\)。若 k 次运行也相互独立,k 次都对 \(\approx q^{mk}\);但同一处歧义的误读往往每次都一样,真实值介于 \(q^{mk}\) 和 \(q^m\) 之间。这里只算歧义导致的错,不代表其他错误不会发生。

预设
歧义处数 m
单次参数全对 ≈ qm

✎练习

题 1(概念):模型回复的 stop_reason 是 "tool_use",content 里有一个 refund_order 的 tool_use 块。此时退款发生了吗?
模型只生成文本(这里是结构化的调用请求),不接触你的系统。执行、校验、权限都在你的代码里,这也是能做门禁和契约测试的地方。
题 2(处理):模型给的参数里多了一个 "skip_review": true,schema 写了 additionalProperties: false。你的代码应该?
多余字段可能是越权尝试(跳过审核),不能默默执行。把错误还给模型,它通常能自己改正;加重试上限防止无限循环。直接终止会让本来能恢复的任务失败。
题 3(算):一个任务要连续调 4 次工具,每次参数填对的概率 95%(假设独立)。同一个任务跑 3 次,3 次都全部填对的概率(pass^3)是多少?
%
单个任务全对:0.954 ≈ 81.5%。3 次都对:0.8153 = 0.9512 ≈ 54.0%。单次 95% 听着很高,多步 × 多次一乘就只剩一半。
题 4(算):用例跑了 10 次,模型 9 次选对工具且参数正确。用无偏估计算 pass^3。
%
C(9,3)/C(10,3) = 84/120 = 70%。直接代入 0.93 = 72.9%,偏高。和第 1 周的 pass^k 无偏估计量是同一个公式。
题 5(判断):工具已经加了 "strict": true,还需要在执行前校验参数吗?
strict 保证的是"符合 schema",不是"值是对的"。19.99 元填成 1999 分还是 199900 分都是合法的 integer。边界校验是你对下游的承诺。

7面试要点与代码

一句话讲清楚

模型不执行函数,它根据工具的 name、description 和 input_schema 输出一个结构化的调用请求,由我的代码校验后执行,再用 tool_result 把结果还给它。描述和 schema 本身就是 prompt,要用单位、枚举、必填和示例消除歧义。不管用没用 strict 或结构化输出,执行前都在边界上校验一次,失败就回 is_error 让模型重试,并设重试上限。评测上,把"选对工具、参数对"写成可断言的用例,每条跑多次,按 pass^k 统计。

常见错误说法

❌ "模型调用了我的 API":模型只输出调用请求,执行的是你的代码。
❌ "用了 strict / 结构化输出就不用校验了":它只保证格式,不保证值对,也管不了业务规则和越权。
❌ "参数错了直接抛异常":应该回 is_error 的 tool_result,让模型有机会改正。
❌ "失败就无限重试,总能成功":同一上下文里的重试不独立,而且没有上限会一直烧钱。
❌ "描述写短点省 token":描述是模型了解工具的唯一途径,少一句说明就多一处要猜。
import json
from jsonschema import Draft202012Validator   # pip install jsonschema

REFUND_TOOL = {
    "name": "refund_order",
    "description": (
        "给已签收的订单发起退款。只在用户明确要求退款、并给出订单号时调用;"
        "取消未发货的订单请用 cancel_order。金额单位是分,不能超过实付金额。"
    ),
    "input_schema": {
        "type": "object",
        "properties": {
            "order_id": {"type": "string", "pattern": "^ORD-[0-9]{8}$",
                         "description": "订单号,ORD- 加 8 位数字,例如 ORD-20260917"},
            "amount_cents": {"type": "integer", "minimum": 1,
                             "description": "退款金额,单位:分。19.99 元写 1999"},
            "reason": {"type": "string",
                       "enum": ["damaged", "not_received", "wrong_item", "other"]},
            "note": {"type": "string", "maxLength": 200, "description": "可选,给客服的备注"},
        },
        "required": ["order_id", "amount_cents", "reason"],
        "additionalProperties": False,
    },
}

VALIDATOR = Draft202012Validator(REFUND_TOOL["input_schema"])
MAX_RETRIES = 2  # 同一个调用最多让模型重试 2 次


def check_tool_input(raw):
    """raw 是模型给的参数:dict,或者流式拼出来的 JSON 字符串。返回 (参数, 错误列表)。"""
    if isinstance(raw, str):
        try:
            raw = json.loads(raw)
        except json.JSONDecodeError as e:
            return None, [f"INVALID_JSON: {e.msg}(第 {e.pos + 1} 个字符处)"]
    errors = sorted(VALIDATOR.iter_errors(raw), key=lambda e: list(e.path))
    return raw, [f"{'/'.join(map(str, e.path)) or '(根)'}: {e.message}" for e in errors]


def to_tool_result(tool_use_id, errors, run):
    """校验通过就执行,失败就把错误原样还给模型,让它自己改。"""
    if errors:
        return {"type": "tool_result", "tool_use_id": tool_use_id, "is_error": True,
                "content": "参数校验失败,请修正后重新调用:\n" + "\n".join(errors)}
    return {"type": "tool_result", "tool_use_id": tool_use_id,
            "content": json.dumps(run(), ensure_ascii=False)}


_, errs = check_tool_input({"order_id": "ORD-20260917", "amount_cents": "19.99", "reason": "damaged"})
print(errs)  # ["amount_cents: '19.99' is not of type 'integer'"]
_, errs = check_tool_input('{"order_id": "ORD-20260917", "amount_cents": 1999, "rea')
print(errs)  # ['INVALID_JSON: Unterminated string starting at(第 52 个字符处)']

在 Python 3.9 + jsonschema 4.14 上跑过。用 pydantic 也可以:定义一个 BaseModel,校验失败时把 ValidationError 的内容写进 is_error 的 tool_result。公式显示依赖 KaTeX(CDN),断网时公式会显示成原始 LaTeX 源码,交互部分不受影响。