前三篇里模型只有嘴:能说不能做。本文是「从零手写 Agent」系列第 4 篇,也是全系列的转折点——给模型一份"工具菜单",看它如何发出结构化的调用请求,你的代码如何执行并回传结果。模型的嘴与手在这一刻连通,Agent 正式诞生。
上一篇的流式解决"怎么看",本篇解决"怎么干"。工具调用同样可以全程裸 HTTP 复现——请求体多一个 tools 字段、响应里多一种内容块而已;为了把篇幅留给协议结构,本文代码用官方 SDK 展示,它省掉的只是拼请求头与解析 JSON,消息结构一分不少。本文按"心智模型 → 工具三要素 → 完整往返 → 实测细节 → 总结"组织,代码基于任意兼容 Anthropic 协议的端点均可复现。
1、心智模型:模型点菜,厨房执行
首先要立住的认知是:模型从不执行任何工具。它只做两件事——决定"用哪个工具、填什么参数",以及拿到结果后组织语言回答。中间的执行,跑代码、读文件、调接口,全部发生在你的进程里。
这个设计意味深长。模型再聪明也只是"动嘴",所有安全责任在执行侧:它可以喊出任何工具调用,但执行不执行、怎么执行,由你的代码决定。工具调用是纯数据往返,没有魔法。
这个边界也决定了安全的形态。模型的调用请求只是一行 JSON,落点在你的进程里:它喊"删除文件",你的执行函数完全可以先查一遍路径再决定拒不拒单;它填的参数再离谱,也跑不出你写的函数体。第 6 篇会把"厨房有权拒单"做成三层安全边界,本篇先立住原则——安全责任在执行侧,模型侧只有请求。
一次完整往返的参与方与消息流如下:
解读:往返里最容易翻车的是第四步——必须先把模型那条含工具调用块的消息作为助手消息原样回传,再把执行结果装进用户消息发回,两条缺一或顺序颠倒,接口直接报错。
1.1、stop_reason 的新值
带上 tools 之后,响应里最关键的变化藏在 stop_reason。系列第 1 篇的三个高频取值,在本篇有了新戏份。各取值的含义与应对如下:
| 取值 | 意思 | 客户端该做什么 |
|---|---|---|
end_turn |
模型说完了 | 取正文展示,收工 |
max_tokens |
输出预算用完 | 视情况续跑或重问 |
tool_use |
模型点了菜 | 执行工具并回传结果 |
tool_use 是本篇主角:模型停笔的原因不是"说完了",而是"在等你上菜"。客户端拿到这个值却什么都不做,对话就悬在半空——接口不会替你执行,也不会催你。把三个取值放进一次带工具的对话里看更清楚:模型要么以 tool_use 挂起等结果,要么 end_turn 正常收工,要么 max_tokens 被截断。循环调度器只需要认这几个字——第 5 篇的 Agent Loop,判断依据正是它。
2、工具三要素
定义一个工具就是写一份说明书,三要素如下:
TOOLS = [{
"name": "calculate",
"description": "精确计算两个数的四则运算。大数运算不要心算,必须调用我。",
"input_schema": {
"type": "object",
"properties": {
"a": {"type": "number", "description": "第一个操作数"},
"b": {"type": "number", "description": "第二个操作数"},
"op": {"type": "string", "enum": ["add", "subtract",
"multiply", "divide"], "description": "运算类型"},
},
"required": ["a", "b", "op"],
},
}]
| 要素 | 作用 |
|---|---|
name |
模型"喊"的名字,执行侧靠它查表找到真函数 |
description |
模型决定何时用、怎么用的唯一依据 |
input_schema |
参数的 JSON Schema 契约,模型照着填参 |
解读:description 值得逐字打磨,因为模型看不见你的函数体,对工具的全部认知就是这一段文字。一个对照实验很有说服力:把上面这段描述改成一个字"算",再问大数乘法,模型时而心算出错、时而犹豫不决,行为随机漂移;描述恢复后立刻稳定。工具的能力不在代码里,在描述里。JSON Schema 语法见官方文档。
2.1、schema 是参数契约
input_schema 用 JSON Schema 描述参数的形状,模型照着它填参。本篇用到的字段如下:
| 字段 | 作用 | 示例 |
|---|---|---|
type |
顶层固定为 object |
所有工具一致 |
properties |
每个参数的类型与描述 | a 是 number |
enum |
圈定字符串取值范围 | op 四选一 |
required |
必填参数名单 | 三个参数全必填 |
两个容易被误解的点。其一,schema 是写给模型看的,接口不做强制校验——参数不合 schema 时接口照单全收,防错责任全在执行侧,第 3 节代码里的 try 就是干这个的。其二,参数描述与工具描述同样重要:op 的描述写清"运算类型"再配上 enum,模型就几乎不会填出 "plus" 或"乘"这种约定外的值。
配套的第二个工具一并给出,它在第 4 节的实测里出场。定义如下:
TOOLS.append({
"name": "get_weekday",
"description": "获取今天星期几。任何涉及星期的问题都应调用我。",
"input_schema": {"type": "object", "properties": {}},
})
解读:无参工具的 properties 给空对象即可,required 可省。描述里"任何涉及星期的问题都应调用我"这种显式的使用边界,比含糊的"获取日期信息"可靠得多——描述是工具的唯一 UI,边界模糊的 UI 一定会被误用。
3、完整往返的代码
执行侧的核心逻辑不到二十行,要点是"说明书"与"真函数"分离,用字典做路由:
TOOL_IMPL = {"calculate": calculate} # 名字到真函数的映射表
response = client.messages.create(
model=MODEL, max_tokens=2048,
tools=TOOLS, # 说明书随请求一起发
messages=history,
)
tool_calls = [b for b in response.content if b.type == "tool_use"]
history.append({"role": "assistant", # 点菜单原样回传
"content": [b.model_dump() for b in response.content]})
results = []
for call in tool_calls:
func = TOOL_IMPL[call.name]
try:
result = func(**call.input) # 模型填的参数直接解包
except Exception as e:
result = f"工具执行出错: {e}" # 错误也转成文字
results.append({"type": "tool_result",
"tool_use_id": call.id, # 按调用 id 对号入座
"content": str(result)})
history.append({"role": "user", "content": results})
解读:四段各有讲究。stop_reason 等于 tool_use 说明模型在等结果;助手消息必须原样回传,里面的调用块携带配对用的 id;每个结果块的 tool_use_id 与调用块的 id 相等,接口靠它对号入座;工具抛异常不许让进程崩,把错误文字当结果回传,模型会自我修正。系列第 1 篇里 content 为什么是列表,此刻有了完整答案:思考块、文字块、工具调用块同框出现。
还有一条暗线值得记账:每完成一次往返,历史里就多出一条含调用块的助手消息和一条含结果块的用户消息,而它们随后每一轮都要全量重发。工具用得越欢,第 2 篇的账单膨胀越快——这是第 7 篇上下文管理要治的病,本篇先把账挂上。
3.1、调用块与结果块
往返里两种结构化块的样子值得单独看。一次真实响应里的调用块如下:
{"type": "tool_use", "id": "toolu_01XfD", "name": "calculate",
"input": {"a": 1234, "b": 567, "op": "multiply"}}
它混在 content 列表里,与 thinking、text 块并排。执行结果装进用户消息回传,回执单长这样:
{"role": "user", "content": [
{"type": "tool_result", "tool_use_id": "toolu_01XfD",
"content": "699678"}]}
解读:id 就是配对钥匙(第 3 节解读已述),这里补三个细节。裸 HTTP 时这两块就是普通 JSON,json.loads 解析即可,用法见 Python json 模块,SDK 只是把它变成带属性的对象;content 传字符串最省事,富内容(比如图片)可传块列表;工具失败时可加 "is_error": true 显式标记,模型会更明确地按失败处理。上文 b.model_dump() 一行也因此不可省——历史要原样回传,序列化时丢了 id,配对就断了。
4、实测细节
真实运行暴露的三个细节比代码更值得记。
一是并行点菜:问"现在几点?再算 1234 乘 567",模型一条回复里返回两个调用块,一次要了两道菜。规则是并行调用的结果必须装进同一条用户消息回传,拆开发送会被接口拒绝。
二是返回值要自解释:让工具返回星期几时直接返回了整数 3,模型碰巧靠另一个工具给的日期反推出是星期四,但这个解是侥幸的——裸数字的编码约定(0 还是 1 表示周一)对模型是黑盒。返回"星期四"或在描述里写明约定,才是可依赖的设计。
三是错误也是数据:把除零错误以文字形式回传,模型礼貌地解释并调整策略,对话不中断。容错的正确位置在工具执行层,不在模型层。把这条原则再推一步:工具的出错信息本身就是给模型的提示——写"除数不能为 0,请检查参数"远好于只回一句 "Error",前者模型能自我修正,后者它多半原参数再试一遍。
4.1、并行调用的回传规则
并行点菜那次实测的完整消息序列值得整个看一遍。摘录如下(口径:真实对话的脱敏摘录,id 截短显示):
user: 现在几点?再算 1234 乘 567
assistant: [tool_use get_time id=toolu_01A] [tool_use calculate id=toolu_01B]
user: [tool_result toolu_01A → "14:32"] [tool_result toolu_01B → "699678"]
assistant: 现在是 14:32;1234 × 567 = 699678
解读:两个调用块在同一条助手消息里,两个结果块在同一条用户消息里,按 id 一一配对。若把两个结果拆成两条用户消息先后发送,接口直接报错——规则是"紧跟一条、全部装下"。反过来看,模型并行是有理由的:两道菜彼此无依赖,一次往返省一轮等待。依赖与串行的取舍,在第 5 篇的依赖链实测里会看得更清楚。
5、总结
工具调用的完整心智模型两句话讲完:模型点菜(返回 tool_use 块,stop_reason 为 tool_use),厨房执行(本地跑真函数)并上菜(tool_result 按 id 配对回传);描述决定模型用不用这个工具,schema 决定参数填得对不对,而接口不替你校验——防错永远在执行侧。对于动手派,建议把 calculate 与 get_weekday 都注册起来,故意问一句"现在几点?顺便算 1234 乘 567",亲眼看一次并行点菜的消息序列,再故意让工具抛一次异常,看模型怎么接住。到此为止 Agent 的零件齐全,但一次往返只能干一步的活。下一篇把往返套进循环,让模型连续决策——那三行代码就是所有 Agent 框架的心脏。工具调用细则可查 Anthropic 工具使用文档。

