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 行会被截断」就是在提前回答模型的下一个问题。
读者是模型,不是同事。该写的是使用条件、边界、常见误用,而不是实现细节。 「返回 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 → 回到 ③
三条硬规则,违反了就报错或者行为诡异:
- assistant 那条消息要原样带回去,不能只带
tool_result。 - 每个
tool_use都必须有一条对应的tool_result,tool_use_id要对上。 一次响应里有三个工具调用,就得回三条,缺一条整个请求都不合法。 - 顺序无所谓,配对才重要。并行执行完按任意顺序回都行,靠 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 的回灌,下面哪些说法是对的?
危险工具,先埋个伏笔
bash、write_file、git push 这类工具一旦交出去,模型就有了改变真实世界的能力。
协议层面完全不管这件事 —— 它只负责把请求送到你手上。
拦不拦、怎么拦,是 harness 的事。Pi 的选择是不内置权限弹窗,让你自己定义什么叫危险 (L2 有一整节讲怎么写这道门)。现在只要记住一句:执行发生在你的代码里,所以责任也在你这里。
这一节的结论
- 工具 = name + description + schema,三者都进 prompt,都占钱。
description决定调用准确率,写给模型看。- 每个
tool_use配一条tool_result,assistant 消息要原样带回。 - 报错原样回灌,模型才有纠错依据。
- 输出要有上限,并且要说清「被截断了」。