Agentpath
原理4 / 31 节 · 预计 30 分钟

tool calling 协议:模型怎么「动手」

工具调用不是魔法,是一段结构化的 JSON 往返。看懂这段往返,你就能自己造工具,也能读懂 agent 卡在哪。

学完这节你能做到

  • 手写一个工具的 JSON Schema,并说明每个字段会怎样影响模型的调用准确率
  • 画出 tool_use → 执行 → tool_result 的完整往返,包括并行调用与失败回传
  • 解释为什么工具的错误信息要写给模型看,而不只是打日志

工具调用听起来像黑魔法,其实就是一段有格式约定的 JSON 往返。看懂这段往返有两个直接收益: 自己造工具时知道该写什么,agent 出问题时知道该看哪一段。

工具声明:三个字段

{
  "name": "read_file",
  "description": "读取工作目录下某个文件的内容。路径必须是相对路径。超过 2000 行会被截断,需要看后面的部分请用 offset 参数。",
  "input_schema": {
    "type": "object",
    "properties": {
      "path": { "type": "string", "description": "相对于工作目录的文件路径" },
      "offset": { "type": "integer", "description": "从第几行开始读,默认 0" }
    },
    "required": ["path"]
  }
}

这三个字段全都会进 prompt。也就是说:

  • 它们占上下文。二十个工具、每个 200 token,就是 4000 token 的常驻开销,每一轮都要重发。
  • description 比 schema 重要得多。schema 只管格式合法,description 才决定模型 「什么时候用它、用错了怎么办」。上面那句「超过 2000 行会被截断」就是在提前回答模型的下一个问题。
description 是提示词,不是文档

读者是模型,不是同事。该写的是使用条件、边界、常见误用,而不是实现细节。 「返回 JSON 数组」不如「找不到匹配时返回空数组,不是报错」。

一次完整往返

① 请求:messages + tools
② 响应:stop_reason = "tool_use"
        content = [ {type:"tool_use", id:"tu_01", name:"read_file", input:{path:"src/app.ts"}} ]
③ 你执行:读文件,拿到内容或报错
④ 再请求:把 ② 的 assistant 消息原样带上,再追加
        [ {type:"tool_result", tool_use_id:"tu_01", content:"..."} ]
⑤ 响应:stop_reason = "end_turn" → 这一轮结束
        或者又是 tool_use → 回到 ③

三条硬规则,违反了就报错或者行为诡异:

  1. assistant 那条消息要原样带回去,不能只带 tool_result
  2. 每个 tool_use 都必须有一条对应的 tool_resulttool_use_id 要对上。 一次响应里有三个工具调用,就得回三条,缺一条整个请求都不合法。
  3. 顺序无所谓,配对才重要。并行执行完按任意顺序回都行,靠 id 认领。

并行调用

模型经常一次要求调多个工具(比如同时读三个文件)。这时候:

calls = [c for c in resp.content if c.type == "tool_use"]
results = await asyncio.gather(*[run(c) for c in calls])   # 并行
msgs.append({"role": "user", "content": [
    {"type": "tool_result", "tool_use_id": c.id, "content": r}
    for c, r in zip(calls, results)
]})
!并行只对只读工具安全

三个 read_file 并行没问题;两个 write_file 写同一个文件、或者一个在改文件另一个在跑测试, 结果就看运气了。区分只读与写入工具、只对只读的并行,是最省事的做法。

失败怎么回:报错就是下一轮的输入

新手最容易做错的一件事:工具挂了,于是抛异常、打日志、循环终止。

正确做法是把错误当成正常返回值喂回去

try:
    out = run_tool(call)
except Exception as e:
    out = f"错误:{e}"        # 照样作为 tool_result 回灌
    is_error = True

因为模型是靠这条反馈自我纠错的。给它一句 错误:文件不存在 src/app.ts,当前目录下有 src/App.tsx, 它下一轮就能自己改对;给它一个空字符串,它只能瞎猜,然后一模一样地重试第二遍、第三遍。

×「同一个工具连调二十次」的根因

八成是工具返回里没有可行动的信息 —— 空结果、无意义的 null、或者只有一句 failed。 错误文本要写清三件事:哪里错了、为什么错、下一步能试什么

输出裁剪:别让一次 grep 吃掉半个窗口

工具输出是上下文里最大的变量。一次 grep -r 全仓、一次 cat 大文件, 几万 token 就进去了,而模型真正需要的可能只有三行。

工具自己就该带上限:

MAX = 30000  # 字符
if len(out) > MAX:
    out = out[:MAX] + f"\n\n[输出被截断,共 {len(out)} 字符。用更精确的条件缩小范围。]"

截断提示里要说清「被截断了」以及「怎么办」—— 否则模型会以为它看到了全部。

检查点多选

关于 tool_result 的回灌,下面哪些说法是对的?

危险工具,先埋个伏笔

bashwrite_filegit push 这类工具一旦交出去,模型就有了改变真实世界的能力。 协议层面完全不管这件事 —— 它只负责把请求送到你手上。

拦不拦、怎么拦,是 harness 的事。Pi 的选择是不内置权限弹窗,让你自己定义什么叫危险 (L2 有一整节讲怎么写这道门)。现在只要记住一句:执行发生在你的代码里,所以责任也在你这里。

这一节的结论

  1. 工具 = name + description + schema,三者都进 prompt,都占钱。
  2. description 决定调用准确率,写给模型看。
  3. 每个 tool_use 配一条 tool_result,assistant 消息要原样带回。
  4. 报错原样回灌,模型才有纠错依据。
  5. 输出要有上限,并且要说清「被截断了」。

延伸资料