第 09 章
第 2 周:模型怎么'调用'一个函数?
工具调用与结构化输出:模型只写调用请求,执行的是你的代码。工具描述就是 prompt,边界处一律校验,失败回 is_error 让模型重试。一段讲解视频,一个可以改 schema 实时校验的互动演示。
用户说:“订单 ORD-20260917 的杯子摔碎了,退我 19.99 元。“Agent 回复"已为您提交退款”。中间那一步"调用退款接口”,其实不是模型做的。模型只写了一张调用申请单:调哪个工具、参数是什么。校验、执行、把结果还回去,全是你的代码。这一章讲这张申请单怎么定义、怎么让模型填对,以及填错了怎么办、怎么测。
讲解视频
互动演示
左边是一个完整的工具定义,可以直接改;右边是六种模拟的模型输出:合法、缺必填、类型错、枚举外的值、多余字段、JSON 截断。每次改动都会重新校验,并给出应该还给模型的 tool_result。试试把 schema 放宽,看哪些坏输出会漏过去。下面还有一个小模拟:工具描述每少写一句,pass^k 掉多少。页面底部有自动判分的练习。
一次工具调用到底发生了什么
- 你发请求:
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 消息里一起还回去。
工具定义: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,限制一次最多调一个工具。
工具描述就是 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),要像做人机界面一样用心做。
| 坏写法 | 模型会怎么填错 | 好写法 |
|---|---|---|
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 时,模型在切换目录后用相对路径老出错;把工具改成必须传绝对路径,这类错误就消失了。
和第 1 周连起来看:一个描述里有 m 处歧义,每处模型猜对的概率是 q,单次调用"参数全对"的概率大约是 \(q^m\),同一请求跑 k 次都对就是
$$ \text{pass}^k \approx q^{m k} $$q = 90%、k = 8 时:
| 歧义处数 m | 单次全对 | pass^8 |
|---|---|---|
| 0 | 100% | 100% |
| 1 | 90.0% | 43.0% |
| 2 | 81.0% | 18.5% |
| 3 | 72.9% | 8.0% |
| 5 | 59.0% | 1.5% |
消掉一处歧义只要改一行描述,效果相当于把每处的猜对率都提高好几个点(m 从 3 降到 2,等价于 q 从 90% 提到 93.2%),而后者往往得换更强的模型。这里假设各处歧义相互独立,k 次运行之间也相互独立,只是粗略估算。歧义引起的误读往往每次都一样,k 次之间高度相关:如果每次都犯同样的误读,pass^k 就停在 \(q^m\)。所以真实的 pass^k 介于 \(q^{mk}\) 和 \(q^m\) 之间,m = 3 时在 8.0% 到 72.9% 之间,和第 1 周「独立性假设」一章是同一个道理。m = 0 也只说明没有歧义带来的错,不代表不会出别的错。
结构化输出:让模型输出合法 JSON 的几种办法
| 办法 | 怎么做 | 保证程度 |
|---|---|---|
| ① 只靠 prompt | “只输出 JSON,格式如下……",自己 json.loads | 没有保证 |
| ② 借工具调用 | 定义一个工具,它的 input_schema 就是你要的输出格式,只取 input | 模型会尽量按 schema 填,默认不保证完全合法 |
| ③ strict 工具 | 工具定义上加 "strict": true(和 name 同级),每个 object 都要 additionalProperties: false | tool_use.input 保证符合 schema(例外见下) |
| ④ JSON 输出 | 请求里加 output_config: {"format": {"type": "json_schema", "schema": {...}}};Python SDK 可用 client.messages.parse() 配合 pydantic 模型 | 回复文本保证符合 schema(例外见下) |
③④ 是约束解码,生成时就不让模型写出不合 schema 的内容。但它们有边界:
- 不是所有 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 写不出来。
所以无论用哪种办法,在执行工具的那一刻都要再校验一次。约束解码减少了出错,但边界校验是你对下游系统的承诺,不能交给模型。
校验失败怎么办:把错误还给模型
参数不合法时,不要执行,也不要直接抛异常结束。回一个 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、这条用例判失败。
- 工具执行本身失败(下游超时、订单不存在)也用
is_error: true回,不要把这个tool_result丢掉。
别把重试想得太乐观。如果每次失败概率是 20% 且相互独立,重试 2 次(共 3 次)后仍失败的概率是 \(0.2^3 = 0.8\%\)。但同一个上下文里的重试不独立:模型因为描述有歧义而填错,看到报错后很可能换一种同样错的填法。这就是第 1 周「独立性假设」讲的问题,实际的最终失败率要跑出来看,不能按 \(0.2^3\) 算。
下面这段代码用 jsonschema 校验参数,失败时构造 is_error 的 tool_result(在 Python 3.9 + jsonschema 4.14 上跑过):
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)}
def call_with_retries(attempts, run):
"""attempts 模拟模型每一轮给出的参数。失败就回 is_error,最多重试 MAX_RETRIES 次。"""
for i, raw in enumerate(attempts[: MAX_RETRIES + 1]):
_, errors = check_tool_input(raw)
result = to_tool_result(f"toolu_{i:02d}", errors, run)
print(f"第 {i + 1} 次:{'失败' if errors else '通过'}")
if not errors:
return result
raise RuntimeError(f"重试 {MAX_RETRIES} 次仍不合法,终止任务,这条用例记为失败")
_, 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 个字符处)']
call_with_retries(
[{"order_id": "ORD-20260917", "amount_cents": "19.99", "reason": "damaged"},
{"order_id": "ORD-20260917", "amount_cents": 1999, "reason": "damaged"}],
run=lambda: {"refund_id": "RF-20260917", "status": "submitted"},
) # 第 1 次:失败 第 2 次:通过
用 pydantic 也可以:定义一个 BaseModel,校验失败时把 ValidationError 的内容写进 is_error 的 tool_result。
测开视角:工具契约测试,和"选对工具"的评测
契约测试不经过模型,直接测工具的边界:
| 类别 | 用例 | 期望 |
|---|---|---|
| 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:
$$ \widehat{\text{pass}^k} = \frac{\binom{c}{k}}{\binom{n}{k}} $$跑 10 次对 9 次,pass^3 = 84/120 = 70%(直接代入 \(0.9^3\) 是 72.9%,偏高)。退款这种不可撤销的操作,看 pass^k,不看 pass@k。
失败要分层统计,比一个总分有用:选错工具、参数不合 schema、参数合 schema 但值错了(该填 1999 却填了 199900)、该调没调。这四类修法完全不同:前两类改描述和 schema,第三类加业务校验和示例,第四类改 prompt。
常见错误说法
- “模型调用了我的 API”:模型只输出调用请求,执行的是你的代码。
- “用了 strict / 结构化输出就不用校验了”:它只保证格式,不保证值对,也管不了业务规则和越权。
- “参数错了直接抛异常”:应该回
is_error的tool_result,让模型有机会改正。 - “失败就无限重试,总能成功”:同一上下文里的重试不独立,而且没有上限会一直烧钱。
- “描述写短点省 token”:描述是模型了解工具的唯一途径,少一句说明就多一处要猜。