Agent · 工具调用(Tool Use / Function Calling)

模型按 JSON Schema "填表", 你的代码验表并执行 — 校验闸门是 agent 与玩具的分水岭

工具调用: 模型只按 Schema "填表"输出意图 — 校验与执行都在你的代码里, 一次响应可带多个 tool_call 随请求发送 意图 待验参数 通过 校验失败 → 错误观察回喂 结果 进入下一轮循环 工具注册表 tools=[...] name: search_orders description: 按状态查订单 parameters (JSON Schema): status: enum[pending, paid] limit: integer, default 20 required: [status] strict: true (约束解码) LLM 决策 读 schema 选工具 生成 arguments JSON 工具选择 = 概率, 非精确 tool_call name + call.id arguments: JSON 字符串 '{"status": "paid"}' 参数校验闸门 pydantic / Schema enum · 类型 · 必填 注入检测 执行 dispatch() 真实函数 + 超时 10s 写操作必须幂等 观察回填 role=tool, tool_call_id '{"total": 3, "rows": [...]}' ✗ 校验失败 → 错误观察 pydantic.ValidationError: status: Input should be 'pending' 模型下一轮自改参数重试 校验失败是常态不是异常: 回喂自愈永远好过崩掉或瞎跑 生产三约束 Production Rules 约束① 工具数量 ≤ 25 再多选择准确率显著下降 → 分域两级路由 约束② 结果截断 ≤ 4KB 大结果裁剪回填 + next_cursor 分页 约束③ 写操作必须幂等 Idempotency-Key 防重放, 重试 ≠ 重扣

Schema 契约(机制视角)

  • • 模型选工具靠 description, 填参数靠 properties/enum
  • • required 漏标 = 模型理直气壮漏传
  • • arguments 是 JSON 字符串, 不是 dict

校验闸门(行为视角)

  • • 永远不信 arguments: pydantic 收口类型/枚举/范围
  • • ValidationError 序列化成观察回喂 → 模型自愈
  • • 并行 tool_calls 靠 call.id 配对, 不靠下标

工程约束(生产价值)

  • • 工具 ≤ 25 个: 超了做分域路由, 别硬塞
  • • 结果截断 + cursor 分页, 大结果先裁剪再回填
  • • 写操作一律幂等键, 重试不重扣

💡 一句话理解

工具调用像点外卖: 模型只会照着菜单"念订单"(输出一段 JSON 意图), 而做菜(执行函数)、验单(参数校验)、送餐(结果回填)全是你后厨的事。JSON Schema 就是那份菜单格式约定——description 写得越清楚、enum 圈得越死, 模型下的"订单"就越少出错; 而校验闸门决定错误订单是被退回重下(回喂自愈), 还是直接把厨房炸了(未校验执行)。记住: 模型的参数是"猜"出来的, 你的校验才是"确定"的。

🧠 必知必会 必考 & 必会

JSON Schema 参数定义
工具的三要素: name / description / parameters。参数类型、枚举、范围全写进 Schema——Schema 是你与模型之间唯一的接口契约。
{"type": "object",
 "properties": {"status": {"type": "string",
                            "enum": ["pending", "paid"]}},
 "required": ["status"]}   # 关键: enum 圈死取值, required 圈死必填
description 说明书
模型选工具完全靠 description 做语义匹配。它也是工具的"使用说明": 什么时候该用、什么时候不该用、返回什么, 都值得写进去。
"description": "按状态查询【当前用户】的订单。
  不要用于查询其他用户; 结果最多 20 条/页"
# 关键: 边界条件写进 description, 比事后校验更省一轮
arguments 是字符串
OpenAI 风格里 arguments 是 JSON 字符串, 必须 json.loads; Anthropic 的 input 已经是 dict。两家的坑不一样。
args = json.loads(call.function.arguments)  # OpenAI: 字符串 → dict
# 直接 call.function.arguments["status"]
#   → TypeError: string indices must be integers
参数校验闸门
模型输出是概率性的: 枚举值会编、数字会越界、必填会漏。pydantic 在 dispatch 前收口, 非法参数到不了业务函数。
args = SearchOrders.model_validate_json(raw)  # 校验 + 类型收口
# 模型给了 status="PAID" →
#   ValidationError: status: Input should be 'pending' or 'paid'
tool_choice
控制模型"必须/可以/不许"调工具: auto 自主决定、required 必须调一个、指定名字强制调某个。做"必须走流程"的场景用 required。
client.chat.completions.create(
    ..., tool_choice="required")      # → 强制产出一次 tool_call
# 关键: 结构化抽取任务用 required, 防止模型自由发挥说废话
并行 tool_calls
一次响应可带 N 个 tool_call(查天气+查日历+查机票)。并发执行省时延, 回填必须各带各的 tool_call_id。
results = await asyncio.gather(*[run(c) for c in calls])
for call, r in zip(calls, results):   # gather 保序: 结果序=发起序
    messages.append({"role": "tool", "tool_call_id": call.id, ...})
# 关键: 保序的是 gather; 换 as_completed 就得按 call.id 配对
严格模式约束解码
开启 strict: true(OpenAI)后, 模型在解码阶段被约束在 Schema 合法空间内, 生成的参数 100% 可解析、100% 含必填字段。
{"type": "function", "function": {..., "strict": true}}
# 关键: strict 下 additionalProperties 必须 false, 全字段必填
#      可选语义用 "type": ["string", "null"] 表达
错误观察回喂
校验失败/执行异常的正确姿势是序列化成 JSON 观察回给模型——错误原文对模型就是最好的修复指南。
except ValidationError as e:
    return json.dumps({"error": "invalid_arguments",
                       "detail": str(e)[:300]})
# → 模型读到错在哪一个字段, 下一轮自动改对
结果体积控制
工具结果是"观察"不是"数据导出": 只给模型决策需要的字段, 大结果截断, 超长列表给 next_cursor 分页。
return {"rows": [slim(r) for r in rows[:20]],
        "next_cursor": cur, "total_estimate": n}
# 关键: 2MB 全量结果会让之后每一轮都为它付 token 钱
工具粒度设计
粗粒度组合工具优于细碎原子工具: get_order_with_logistics(id) 一个顶俩, 模型少走两轮循环, 你少防一半的中间态。
# 细碎: 模型要连调 3 次才拼出答案, 每次都是出错机会
get_order(id) + get_logistics(id) + get_payment(id)
# 粗粒: get_order_detail(id) 一次带全 → 少 2 轮循环
只读/写分级
工具按危险度分三级: read(放行)/write(审计)/critical(人工确认)。分级写在注册表里, 而不是散在业务代码里。
REGISTRY = {"search_orders": (fn, "read"),
            "issue_refund":  (fn, "critical")}
# 关键: critical 工具先挂起等审批, 绝不静默放行
幂等性
重试是常态(网络抖动/模型重复调用), 写操作必须能安全重放: 幂等键 + 唯一约束, 同键二次调用返回第一次结果。
key = f"refund:{order_id}:{amount}"   # 由业务参数派生
INSERT INTO idem_keys(key) ON CONFLICT DO NOTHING
# rowcount=0 → 重复请求, 返回上次结果, 不再执行
工具命名空间
多个来源(MCP server/插件/内置)的工具会撞名: 给每个来源加前缀, 如 github__create_issue, 冲突在注册时检测。
name = f"{server}__{tool}"        # github__create_issue
# 裸用 create_issue:
#   → 两个 server 都有, 模型调用不知道打到哪一个

🏭 生产实战 real world

场景 1 · pydantic 定义工具参数, 一键生成 Schema

手写 JSON Schema 又长又易错。用 pydantic 模型当"单一事实源": 校验、文档、Schema 三者永远同步。

from pydantic import BaseModel, Field
from enum import Enum

class OrderStatus(str, Enum):
    pending = "pending"; paid = "paid"; shipped = "shipped"

class SearchOrders(BaseModel):
    """按状态查询当前用户的订单, 默认返回最新的 20 条"""  # docstring → 工具描述
    status: OrderStatus = Field(..., description="订单状态")
    limit: int = Field(20, ge=1, le=100, description="每页条数")

schema = SearchOrders.model_json_schema()   # pydantic v2 直接产出合法 JSON Schema
tools = [{"type": "function", "function": {
          "name": "search_orders", "description": SearchOrders.__doc__,
          "parameters": schema}}]

场景 2 · OpenAI 风格: tools 注册与调用解析

chat.completions 上的标准流程, 记住三步: 注册 tools → 判 tool_calls → parse arguments。

resp = client.chat.completions.create(
    model="gpt-4.1", messages=messages, tools=TOOLS)
msg = resp.choices[0].message
messages.append(msg)                          # assistant 消息先入列
if msg.tool_calls:
    for call in msg.tool_calls:
        args = json.loads(call.function.arguments)  # 关键: 字符串 → dict
        result = dispatch(call.function.name, args)
        messages.append({"role": "tool", "tool_call_id": call.id,
                         "content": json.dumps(result, ensure_ascii=False)})

场景 3 · Anthropic 风格: input_schema 与 content block

Anthropic 的工具调用藏在 content blocks 里, input 已经是 dict——但 stop_reason 判断不能省。

resp = client.messages.create(
    model="claude-sonnet-4-5", max_tokens=1024,
    tools=[{"name": "search_orders",
            "description": "按状态查询当前用户的订单",
            "input_schema": {"type": "object",
                "properties": {"status": {"type": "string",
                                          "enum": ["pending", "paid"]}},
                "required": ["status"]}}],
    messages=messages)
if resp.stop_reason == "tool_use":
    for block in resp.content:
        if block.type == "tool_use":
            result = dispatch(block.name, block.input)  # input 已是 dict

场景 4 · 并行 tool_calls: gather 执行 + call.id 回填

查天气+查日历+查机票互不依赖, 串行 3 次往返太慢。并行跑, 回填时按 id 对号入座。

calls = resp.message.tool_calls          # → [call_a, call_b, call_c]
results = await asyncio.gather(
    *[run_tool(c) for c in calls], return_exceptions=True)
for call, r in zip(calls, results):      # gather 保序: 结果序=发起序
    body = ("工具内部错误: " + str(r)[:150]) if isinstance(r, Exception) \
           else json.dumps(r, ensure_ascii=False)
    messages.append({"role": "tool", "tool_call_id": call.id,
                     "content": body})
# → 3 次串行 2.4s → 并行 0.9s; 异常也不崩, 变成错误观察

场景 5 · 校验失败回喂自愈模板

模型把 "PAID" 传给了小写枚举。别报 500, 把 ValidationError 原文喂回去——它一看就懂。

from pydantic import ValidationError

def guarded_tool(model_cls, fn, raw_args: str) -> str:
    try:
        args = model_cls.model_validate_json(raw_args)  # 校验 + 收口
        return json.dumps(fn(args), ensure_ascii=False)
    except ValidationError as e:
        return json.dumps({"error": "invalid_arguments",
                           "detail": str(e)[:300]})
# → 模型读到 "status: Input should be 'pending' or 'paid'"
#   下一轮自动改传 "paid", 无需人工介入

场景 6 · 大表查询工具: 裁剪 + cursor 分页

订单表百万行, 模型一句"查所有待支付订单"就能拉爆上下文。工具返回值要按"模型视角"瘦身。

def search_orders(status: str, cursor: str | None = None, limit: int = 20):
    rows, next_cursor = db.query(status, cursor=cursor, limit=limit)
    return {
        "total_estimate": count(status),   # 给模型全局感, 防止无脑翻页
        "rows": [slim(r) for r in rows],   # 只留 id/amount/status, 丢大字段
        "next_cursor": next_cursor,        # None 表示没有下一页
    }
# → 每页 3KB 而不是 300KB; 模型按 cursor 自己决定翻不翻页

场景 7 · 写操作幂等: 重试不重扣

网络抖动后模型重发同一个退款调用。幂等键 + 唯一约束, 让"重试"与"重复执行"分家。

def create_refund(order_id: str, amount_cents: int):
    key = f"refund:{order_id}:{amount_cents}"  # 幂等键由业务参数派生
    with db.transaction():
        inserted = db.execute(
            "INSERT INTO idem_keys(key) VALUES (%s) ON CONFLICT DO NOTHING",
            (key,))
        if inserted.rowcount == 0:
            return {"status": "duplicate",     # → 重放被识别
                    "refund_id": lookup(key)}   #   返回首次结果
        return refund_now(order_id, amount_cents)

场景 8 · 工具注册表: 权限三级 + 白名单分发

所有工具收进一张注册表, 危险等级显式声明, 分发只走白名单——权限不再散落在 if/else 里。

REGISTRY = {
  "search_orders": (search_orders, "read"),     # 只读: 默认放行
  "create_ticket": (create_ticket, "write"),    # 写入: 记审计日志
  "issue_refund":  (issue_refund,  "critical"), # 高危: 人工确认
}

def dispatch(name: str, args: dict, user):
    fn, tier = REGISTRY[name]                 # 关键: 白名单, 不用 getattr
    if tier == "critical" and not user.approved(name, args):
        return {"status": "pending_approval"}  # 挂起等审批, 不静默放行
    audit.log(user.id, name, tier, args)
    return fn(**args)

场景 9 · 工具太多: 两级路由先选域再选工具

业务长到 30+ 工具, 选择错误率肉眼可见地涨。拆两级: 便宜模型选域, 主模型只在域内 2~3 个工具里挑。

DOMAINS = {"订单": ["search_orders", "get_logistics"],
           "售后": ["create_refund", "list_returns"],
           "物流": ["track_shipment"]}

# 第一级: 小模型做分类, 成本≈0
domain = llm_small("用户问题属于哪个域? " + str(list(DOMAINS)))
tools = pick(DOMAINS[domain])
resp = llm.create(messages=messages, tools=tools)  # 候选 30 → 2
# → 选择准确率回升, 单次任务 token 反而降(少塞 27 份 schema)

场景 10 · 工具超时与降级: 超时也给"观察"

下游慢是常态。超时后不能让任务吊死, 也不能静默吞掉——给模型一个带建议的观察, 让它决策。

async def resilient_tool(coro, timeout=10):
    try:
        return await asyncio.wait_for(coro, timeout)
    except asyncio.TimeoutError:
        # 关键: 超时也是观察, 让模型决定重试/换路/收尾
        return json.dumps({"error": "timeout", "seconds": timeout,
                           "suggestion": "可稍后重试, 或改用缓存数据源"})
# → 再没有"整个任务卡死在一个 30s 的下游"的连环雪崩

⚠️ 编码注意与常见坑 pitfalls

坑 1 · description 含糊 — 症状: 明明有两个查库工具, 模型总选错那个. 原因: 两个工具的 description 语义重叠且没写边界. 正解: 各写"用途+边界+返回什么"。
# 错: "查询订单信息"          # → 和 get_order 语义撞车
# 对: "按状态+时间范围批量查询【当前用户】订单, 单工具不带详情"
坑 2 · 参数名歧义 — 症状: 模型把日期传错字段. 原因: date 这种名字看不出是下单日还是支付日. 正解: 语义化命名 + description 注明格式。
# 错: {"date": "2026-01-01"}             # → 模型自作主张填当天
# 对: {"paid_after": "ISO8601, 如 2026-01-01T00:00:00+08:00"}
坑 3 · required 漏标 — 症状: 模型经常不传关键参数. 原因: Schema 里忘了写 required, 模型认为可省. 正解: 必填字段全列入 required。
# 错: properties 里有 status 但 required: []   # → 半数调用缺 status
# 对: "required": ["status"]
坑 4 · 不用 enum 圈死 — 症状: 模型传 "PAID"/"payed"/"已支付" 各种花样. 原因: 参数只是 string 没加 enum. 正解: enum 列全合法值。
# 错: {"status": {"type": "string"}}        # → "payed"(拼错) 照样生成
# 对: {"status": {"type": "string", "enum": ["pending", "paid"]}}
坑 5 · arguments 不 parse 直接用 — 症状: 一调用就 TypeError. 原因: OpenAI 的 arguments 是 JSON 字符串. 正解: json.loads 后再用。
# 错: call.function.arguments["status"]
#   → TypeError: string indices must be integers
# 对: args = json.loads(call.function.arguments)
坑 6 · 不校验直接执行 — 症状: 模型拼出的 SQL 直接跑, 一次把表锁了. 原因: dispatch 前无校验闸门, 模型输出直通数据库. 正解: pydantic 收口 + 白名单 SQL 模板。
# 错: db.execute(f"SELECT * FROM t WHERE c = '{args['c']}'")
# 对: db.execute("SELECT ... WHERE c = %s", (validated.c,))
坑 7 · 工具数量失控 — 症状: 40 个工具后选错率飙升, token 也贵. 原因: 全部 schema 塞进每次请求. 正解: ≤25 个, 超了做分域两级路由。
# 错: tools=ALL_40_TOOLS                # → schema 就吃掉几千 token
# 对: tools = pick(DOMAINS[route_first(messages)])
坑 8 · 返回超大结果 — 症状: 一次导出后每轮输入 100k tokens. 原因: 工具把全表 JSON 塞进观察. 正解: 瘦身字段 + 截断 + cursor。
# 错: return json.dumps(db.fetch_all(sql))    # → 2MB 观察入列
# 对: return {"rows": rows[:20], "next_cursor": cur}
坑 9 · 写操作不幂等 — 症状: 重试后用户被扣两次款. 原因: 退款/下单无幂等键, 重试即重复执行. 正解: 幂等键 + 唯一约束。
# 错: def refund(o, amt): refund_now(o, amt)  # → 重放=双倍退款
# 对: key=f"refund:{o}:{amt}"; ON CONFLICT DO NOTHING
坑 10 · 并行结果按完成序配对 — 症状: 天气结果配到了日历的 id 上. 原因: as_completed 按完成顺序返回, 却按位置 zip. 正解: 用 gather 保序或按 call.id 配对。
# 错: for c, fut in zip(calls, as_completed(tasks)): ...  # → 错配
# 对: results = await asyncio.gather(*tasks)  # 保序, 或按 call.id 查表
坑 11 · dispatch 异常冒泡 — 症状: 一个下游抖动整个 agent 500. 原因: 工具异常没接住. 正解: try/except 序列化成错误观察。
# 错: result = requests.get(url, timeout=10)   # → ConnectionError 冒泡
# 对: except Exception as e: return {"error": str(e)[:150]}
坑 12 · 时间参数不带时区 — 症状: 报表比实际早 8 小时. 原因: 模型生成的 "2026-01-01" 无时区, 服务端按 UTC 解析. 正解: Schema 里规定 ISO8601+时区, 校验时强制。
# 错: "date": "2026-01-01"       # → 被当 UTC, 北京时间差 8h
# 对: "date": "2026-01-01T00:00:00+08:00"  # description 写明必须带时区
坑 13 · 分页不返回 cursor — 症状: 模型反复全量拉取同一张表. 原因: 返回里没有翻页凭据, 它只能重查. 正解: 每页带 next_cursor, 并在 description 说明。
# 错: return rows[:20]              # → 模型不知道还有下一页
# 对: return {"rows": rows, "next_cursor": cur}  # None=到底了
坑 14 · Schema 嵌套过深 — 症状: 三层嵌套对象参数, 模型生成的结构千奇百怪. 原因: 深嵌套超出模型可靠生成能力. 正解: 压扁成顶层字段或改传 JSON 字符串再服务端解析。
# 错: filter: {range: {time: {gte: ..., lte: ...}}}  # → 3 层嵌套乱填
# 对: time_from / time_to 两个顶层 string 字段
坑 15 · 滥用 oneOf/anyOf — 症状: 模型在多种参数形态间反复横跳, 调用成功率低. 原因: oneOf 分支太自由. 正解: 拆成多个独立工具, 各自 Schema 简单。
# 错: oneOf: [按 id 查, 按名查, 按邮箱查]   # → 3 选 1 还经常混填
# 对: get_user_by_id / get_user_by_email 两个工具
坑 16 · 工具名跨源冲突 — 症状: 同名工具永远只有一个被调用. 原因: 两个 MCP server/插件都有 get_user. 正解: 注册时加来源前缀并检测冲突。
# 错: 两个 get_user 直接注册           # → 后者被静默覆盖
# 对: "github__get_user" / "crm__get_user" + 冲突即报错
坑 17 · 敏感数据进工具日志 — 症状: 日志平台出现用户身份证号. 原因: dispatch 打了 args 全量日志. 正解: 参数日志脱敏 + 分级脱敏规则。
# 错: log.info("tool=%s args=%s", name, args)  # → PII 全落盘
# 对: log.info("tool=%s args=%s", name, redact(args))
坑 18 · Schema 与实现漂移 — 症状: 改了函数签名, 模型还按旧 Schema 传参, 运行时才炸. 原因: 手写 Schema 和代码两套事实源. 正解: 从 pydantic/类型注解生成 Schema。
# 错: 手写 schema 写 amount, 函数已改名 amount_cents  # → 运行时才炸
# 对: schema = ToolModel.model_json_schema()  # 单一事实源
坑 19 · 数字当字符串传 — 症状: "20" + 1 之类类型错乱, 排序按字典序. 原因: 模型把 number 生成成 string 且闸门没拦. 正解: Schema 标 type:number, pydantic 强转。
# 错: {"limit": "20"}  # → 字符串混进 SQL LIMIT 语法错
# 对: limit: int = Field(20)  # pydantic 校验层强转+拦截
坑 20 · 结果不回填就继续 — 症状: 模型对执行结果"一问三不知", 开始编造. 原因: 执行了工具但忘了 append tool 消息. 正解: 执行与回填写在同一个代码路径里。
# 错: run_tool(call); # 忘了 messages.append(...) → 模型只能编
# 对: messages.append({"role": "tool", "tool_call_id": call.id, "content": body})