Learning AI Quality 返回 KuthorX Blog II博客首页

第 09 章

第 2 周:模型怎么'调用'一个函数?

工具调用与结构化输出:模型只写调用请求,执行的是你的代码。工具描述就是 prompt,边界处一律校验,失败回 is_error 让模型重试。一段讲解视频,一个可以改 schema 实时校验的互动演示。

用户说:“订单 ORD-20260917 的杯子摔碎了,退我 19.99 元。“Agent 回复"已为您提交退款”。中间那一步"调用退款接口”,其实不是模型做的。模型只写了一张调用申请单:调哪个工具、参数是什么。校验、执行、把结果还回去,全是你的代码。这一章讲这张申请单怎么定义、怎么让模型填对,以及填错了怎么办、怎么测。

讲解视频

互动演示

左边是一个完整的工具定义,可以直接改;右边是六种模拟的模型输出:合法、缺必填、类型错、枚举外的值、多余字段、JSON 截断。每次改动都会重新校验,并给出应该还给模型的 tool_result。试试把 schema 放宽,看哪些坏输出会漏过去。下面还有一个小模拟:工具描述每少写一句,pass^k 掉多少。页面底部有自动判分的练习。

互动演示:工具调用与结构化输出 在新标签页打开

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

  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\"}"}

只要 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漏填了也不报错,下游拿到 nullrequired 写全,可选字段注明"可选"

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

  • 站在模型的位置读一遍:只看描述和参数,能不能一眼看懂怎么用?好的定义会写示例、边界情况、输入要求,以及和其他工具的分工。
  • 把参数名和描述当成写给新人的文档字符串。几个工具很像的时候尤其要写清楚区别。
  • 用大量输入实测,看模型犯什么错,然后改定义,反复迭代。
  • 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
0100%100%
190.0%43.0%
281.0%18.5%
372.9%8.0%
559.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: falsetool_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'"}
  1. 错误信息要可操作:说字段名、期望什么、收到什么。“参数错误"四个字模型没法改。
  2. JSON 本身解析不了(截断、多余字符):同样回 is_error,把原始文本放进去,例如 {"INVALID_JSON": "..."},用 JSON 库构造,别拼字符串。如果是 stop_reason == "max_tokens" 造成的截断,应该调大 max_tokens 重新请求,而不是让模型重试。
  3. 设重试上限:比如同一个调用最多重试 2 次。超过就终止任务、记录 trace、这条用例判失败。
  4. 工具执行本身失败(下游超时、订单不存在)也用 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”:描述是模型了解工具的唯一途径,少一句说明就多一处要猜。