模型从不执行函数,它只写一张"调用申请单"
你把工具的名字、说明、参数 schema 交给模型;模型回一段 tool_use JSON;执行的是你的代码,结果再用 tool_result 还给它。
所以工具调用出错,一半是"申请单"写错了(参数不合法),一半是"表格"设计得让人容易填错(描述和 schema)。两头都能测。
1一次工具调用到底发生了什么
用户说:"订单 ORD-20260917 的杯子摔碎了,退我 19.99 元。"你的 Agent 有一个退款工具 refund_order。一轮下来是这样的:
- 你发请求:
messages里放用户这句话,tools里放refund_order的定义。 - 模型回复:
stop_reason是"tool_use",content里有一个tool_use块,写着要调哪个工具、参数是什么。到这一步,钱一分都没退。 - 你的代码校验参数,调真正的退款接口。
- 你再发一次请求:把模型上一轮的
content原样追加为 assistant 消息,再追加一条 user 消息,里面是tool_result块,tool_use_id对上第 2 步的 id。 - 模型读到结果,回复"已为您提交退款"。
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\"}"}
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"} | 不许用工具 |
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 | 漏填了也不报错,下游拿到 null | required 写全,可选字段在描述里注明"可选" |
文章里几条可以直接照做的建议:
- 站在模型的位置读一遍:只看描述和参数,能不能一眼看懂怎么用?好的定义会写示例、边界情况、输入要求,以及和其他工具的分工。
- 把参数名和描述当成写给新人的文档字符串。几个工具很像的时候尤其要写清楚区别。
- 用大量输入实测,看模型犯什么错,然后改定义,反复迭代。
- Poka-yoke(防呆):改参数设计,让错误更难发生。他们做 SWE-bench Agent 时,模型在切换目录后用相对路径老出错;把工具改成必须传绝对路径,这类错误就消失了。
4结构化输出:让模型输出合法 JSON 的几种办法
| 办法 | 怎么做 | 保证程度 |
|---|---|---|
| ① 只靠 prompt | "只输出 JSON,格式如下……",自己 json.loads | 没有保证。多一句解释、少一个括号就解析失败 |
| ② 借工具调用 | 定义一个工具,它的 input_schema 就是你要的输出格式,让模型"调用"它,你只取 input | 模型会尽量按 schema 填,但默认不保证完全合法 |
| ③ strict 工具 | 在工具定义上加 "strict": true(和 name 同级),schema 里每个 object 都要 additionalProperties: false | tool_use.input 保证符合 schema(例外见下) |
| ④ JSON 输出 | 请求里加 output_config: {"format": {"type": "json_schema", "schema": {...}}};Python SDK 可用 client.messages.parse() 配合 pydantic 模型 | 回复文本保证符合 schema(例外见下) |
③④ 是约束解码:生成时就不让模型写出不合 schema 的 token。但它们有边界:
- 不是所有 JSON Schema 关键字都支持:
minimum/maximum、minLength/maxLength、递归 schema 等不支持。原样发送会返回 400,这对 JSON 输出和 strict 工具都一样。上面的refund_order带着minimum: 1和maxLength: 200,直接加"strict": true发出去就是 400。只有 SDK 的辅助方法会自动转换:Python 的client.messages.parse()(配 pydantic)或transform_schema(),TypeScript 的zodOutputFormat()/jsonSchemaOutputFormat(),它们把不支持的约束挪进字段描述。把 dict 直接传给messages.create(tools=[...])不会转换。被挪走的约束 API 不再强制,执行前要自己校验。 - enum / const 的大小写不保证:官方文档写明,结构化输出可能返回只差大小写的值(比如
"Damaged"),不报错、stop_reason也正常。比对时忽略大小写,或者在边界校验里拦下;也别设计只差大小写的枚举值。 stop_reason是"max_tokens"时,输出可能被截断;是"refusal"时,输出可能不符合 schema。- 流式输入:开了
eager_input_streaming的工具,API 不再校验参数,客户端拼出来的 JSON 可能不完整。 - schema 管不了业务规则:退款金额不能超过实付、订单必须属于当前用户,这些 schema 都写不出来。
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'"}
- 错误信息要可操作:说字段名、期望什么、收到什么。"参数错误"四个字模型没法改。
- JSON 本身解析不了(截断、多余字符):同样回
is_error,把原始文本放进去,例如{"INVALID_JSON": "..."},用 JSON 库构造,别拼字符串。如果是stop_reason == "max_tokens"造成的截断,应该调大max_tokens重新请求,而不是让模型重试。 - 设重试上限:比如同一个调用最多重试 2 次。超过就终止这个任务、记录 trace、这条用例判失败。没有上限的 loop 会一直烧 token。
- 工具执行本身失败(下游超时、订单不存在)也用
is_error: true回,不要把这个tool_result丢掉。
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,看六种模型输出过不过校验
左边是完整的工具定义,可以直接改。右边是六种模拟的模型输出,每次改动都会用 JS 重新按 input_schema 校验,并给出应该还给模型的 tool_result。点每一行展开细节。校验器支持 JSON Schema 的常用子集:type、required、properties、additionalProperties、enum、const、pattern、minimum / maximum、minLength / maxLength、items、minItems / maxItems。
工具定义(可编辑)
模拟的模型输出(tool_use.input)
▶小模拟:工具描述少写一句,pass^k 掉多少
勾选的项会写进右边的工具定义。每少勾一项,就多一处模型要"猜"的地方。设每处猜对的概率为 q,并粗略假设各处相互独立:单次参数全对 \(\approx q^m\)。若 k 次运行也相互独立,k 次都对 \(\approx q^{mk}\);但同一处歧义的误读往往每次都一样,真实值介于 \(q^{mk}\) 和 \(q^m\) 之间。这里只算歧义导致的错,不代表其他错误不会发生。
✎练习
stop_reason 是 "tool_use",content 里有一个 refund_order 的 tool_use 块。此时退款发生了吗?"skip_review": true,schema 写了 additionalProperties: false。你的代码应该?"strict": true,还需要在执行前校验参数吗?7面试要点与代码
一句话讲清楚
模型不执行函数,它根据工具的 name、description 和 input_schema 输出一个结构化的调用请求,由我的代码校验后执行,再用 tool_result 把结果还给它。描述和 schema 本身就是 prompt,要用单位、枚举、必填和示例消除歧义。不管用没用 strict 或结构化输出,执行前都在边界上校验一次,失败就回 is_error 让模型重试,并设重试上限。评测上,把"选对工具、参数对"写成可断言的用例,每条跑多次,按 pass^k 统计。
常见错误说法
❌ "用了 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 源码,交互部分不受影响。