第 3 篇把工具的"定义"讲完了:tools 参数声明工具名、描述与参数模式,tool_choice 控制模型用不用它。但那一步结束时,模型只是"开口要了工具"——真正把工具跑起来、把结果送回去、让对话继续往下走的部分,一步都还没发生。工具调用的完整形态是一个循环:模型请求调用,你的代码负责执行,执行结果回传给模型,模型再决定是继续要工具,还是直接作答。这个来回滚起来,就是 Agent 的最小骨架。
本文是《Anthropic SDK 从入门到实战》第 4 篇,把循环的每一环拆开讲:先看五步全景与停止条件,再解析响应里的 tool_use 块、回传 tool_result 的正确层级,然后落地成 while 循环骨架,处理多工具并行,最后组装一个文件查询助手。全文按"全景 → 解析 → 回传 → 循环 → 并行 → 实战 → 总结"组织。
1、工具调用循环全景
先立一个前提:模型没有任何执行能力,它能做的只是发出调用请求。工具的执行永远发生在你的代码里——模型决定"调什么、传什么参数",你的程序负责真正去查天气、读文件、跑脚本,再把结果递回去。这个协作关系固定成五步:
- 发起带 tools 定义的请求
- 检查 stop_reason 是否为 tool_use
- 在本地执行模型点名的工具
- 把结果作为 tool_result 回传
- 重复,直到 stop_reason 变为 end_turn
这个来回用流程图看更直观:
解读:图里唯一的选择点是 stop_reason——等于 tool_use 就沿着左侧走一圈回到起点,等于 end_turn 就从右侧出循环。循环期间真正要盯的就是这一个字段,它的全部取值与应对方式如下:
| 取值 | 含义 | 循环应对 |
|---|---|---|
end_turn |
自然说完,回合结束 | 取文本作答,正常出口 |
tool_use |
模型请求调用工具 | 执行并回传,继续循环 |
max_tokens |
输出被上限截断 | 提高上限重试,不算正常出口 |
stop_sequence |
命中自定义停止序列 | 同 end_turn,回合结束 |
pause_turn |
长回合被服务端暂停 | 续传暂停消息后继续 |
refusal |
安全原因拒答 | 终止并检查 stop_details |
六种取值里需要动手处理的只有 tool_use 一种,其余都是"收尾"或"异常处理"分支——这也是官方工具调用文档用一个条件判断就能驱动整个循环的原因,取值语义详见停止原因处理。另外记住一个贯穿全文的事实:Messages API 无状态,每轮请求都携带全量历史,循环能一环扣一环,靠的是 messages 数组不断"滚雪球"。
2、解析响应中的 tool_use 块
第 1 篇的内容块家族里,tool_use 只是露了个面,现在它是主角。模型决定调工具时,response.content 里会出现 tool_use 块,前面可能还跟着一句"我查一下"的 text 块。提取写法与第 1 篇取文本如出一辙——判型遍历:
tool_calls = [b for b in response.content if b.type == "tool_use"]
call = tool_calls[0]
print(call.name) # 工具名,如 get_weather
print(call.input) # 参数,SDK 已解析成字典
print(call.id) # 本次调用的唯一编号
把这个块序列化成 JSON,线上传输的原始形状如下:
{
"type": "tool_use",
"id": "toolu_01XFDUDYJgACzvnptvVoYEL",
"name": "get_weather",
"input": { "location": "旧金山", "unit": "celsius" }
}
解读:三个字段各司其职。name 与工具定义里的名字对应,告诉你要执行哪个函数;input 是模型按 input_schema 填好的参数——注意它已经是 Python 字典,直接取键使用,不需要再做一次 JSON 解析;id 是这次调用的唯一编号,回传结果时必须原样带回,它是模型把"请求"与"结果"配对的唯一凭据。字段完整定义见Messages API 参考。另外两个不要假设:tool_use 不一定排在第 0 个位置,也不一定只有一个——一次回复携带多个 tool_use 块是并行调用的常态,第 5 节展开。
3、执行工具与回传 tool_result
执行环节没有黑魔法:按 name 分发到本地函数、把 input 当参数传进去——查天气就调天气 API,读文件就 open,模型到此已经交完接力棒。真正决定循环能不能续上的,是把结果送回去的姿势。回传代码如下:
output = execute_tool(block.name, block.input) # 本地执行
messages.append({"role": "assistant", "content": response.content})
messages.append({
"role": "user", # role 必须是 user
"content": [
{
"type": "tool_result",
"tool_use_id": block.id, # 与 tool_use 块的 id 对应
"content": str(output), # 结果以文本回传
}
],
})
解读:两处 append 缺一不可。第一处把模型上一轮的完整回复(含 tool_use 块)落进历史——不带它,模型不知道自己刚才要过什么;第二处才是结果本身:tool_result 块必须作为 user 消息 content 数组的元素出现,role 是 user,不是 assistant,更不是一条独立的顶层消息。官方排错指南还要求 tool_result 块排在那条 user 消息 content 的最前面,附加的普通文本块只能跟在后面。
注意:新手最常见的 400 都出在层级上——把 tool_result 放进 assistant 消息、当成顶层消息、或 tool_use_id 对不上。规矩只有一条:每个 tool_use 块恰好对应一个 tool_result,id 一一对应。
tool_result 块的字段一览如下:
| 字段 | 必填 | 说明 |
|---|---|---|
| type | 是 | 固定值 tool_result |
| tool_use_id | 是 | 对应 tool_use 块的 id |
| content | 否 | 结果文本,或内容块数组 |
| is_error | 否 | 置 true 标记执行失败 |
content 最常用字符串,要回传图片等富内容时改用内容块数组。is_error 值得单独强调:工具执行失败也要生成 tool_result——带上可读的错误描述、把 is_error 置 true,模型会据此换参数重试或改走别的路;把异常直接吞掉不回传,下一轮请求立刻因为缺少结果而 400。让模型知道工具失败了,比假装没发生好得多。
4、while 循环骨架
一个来回手写两段代码足够,但真实任务里模型往往连续调工具——先列目录、再开文件、再查引用,每个动作一个来回。把来回套进 while,就是官方工具使用总览给出的循环骨架。完整实现如下:
client = anthropic.Anthropic()
tools = [...] # 第 3 篇的工具定义
messages = [{"role": "user", "content": "旧金山现在多少度?"}]
while True:
response = client.messages.create(
model="claude-sonnet-5-5",
max_tokens=1024,
tools=tools, # 每轮必带,不能只在第一轮带
messages=messages, # 滚雪球的全量历史
)
if response.stop_reason != "tool_use": # 出口:不再要工具就停
break
messages.append({"role": "assistant", "content": response.content})
results = [] # 收集本轮全部工具结果
for block in response.content:
if block.type == "tool_use":
output = execute_tool(block.name, block.input)
results.append({
"type": "tool_result",
"tool_use_id": block.id, # 与 tool_use 一一对应
"content": str(output),
})
messages.append({"role": "user", "content": results})
for block in response.content: # 循环外的最终回答
if block.type == "text":
print(block.text)
解读:四个要点。出口条件写成"不是 tool_use 就退",比"等于 end_turn 才退"更稳——max_tokens 截断、refusal 拒答同样不该继续跑循环;tools 与 messages 每一轮都要带全,API 无状态,哪轮少了 tools,模型哪轮就"没有工具可用";append 进历史的是整个 content 而不是只挑 tool_use 块,text 块同样是上下文;采样参数 temperature、top_p、top_k 已在 2026-10-21 API 版本移除,工具调用请求同样一个都不要带。这个骨架配合第 3 节的层级规则,就是一切"自动工具调用"的内核——后面无论换什么框架,背后转的都是这一个 while。
5、多工具与并行调用
"多工具"有两个维度。定义侧:tools 列表本来就能装任意多个工具,模型按 name 自行挑选。响应侧:一次回复的 content 里可能同时出现多个 tool_use 块——并行调用默认开启,比如一句话问三个城市的天气,模型一口气发出三个 get_weather。处理规矩只有一条:逐个执行(可以并发),全部结果装进同一条 user 消息回传。代码如下:
results = []
for call in [b for b in response.content if b.type == "tool_use"]:
try:
output = execute_tool(call.name, call.input)
results.append({"type": "tool_result",
"tool_use_id": call.id, "content": str(output)})
except Exception as e: # 失败也要回传,不能跳过
results.append({"type": "tool_result", "tool_use_id": call.id,
"content": f"执行失败:{e}", "is_error": True})
messages.append({"role": "user", "content": results}) # 一条消息装下全部
解读:失败的调用照样生成 tool_result——跳过它就破坏了一一对应,下一轮直接 400;带上 is_error 与可读的错误信息,模型还有机会自己修正。多个结果也绝不能拆成多条 user 消息分两次发送:官方文档明确指出,拆开发送会让模型逐渐放弃并行调用,行为悄悄退化而性能毫无收益。真想提速,是在本地用线程池并发跑 execute_tool,回传这一步永远合并成一条消息。第 4 节骨架里的 for 循环天然满足这条规矩——它本来就是收集完再一次性 append。
6、实战:文件查询助手
零件齐了,组装一个能回答"我的笔记里写了什么"的助手:两个工具 list_files 与 read_file,加上第 4 节的循环。工具定义如下:
tools = [
{
"name": "list_files",
"description": "列出指定目录下的文件与子目录名。",
"input_schema": {
"type": "object",
"properties": {
"path": {"type": "string", "description": "相对笔记目录的路径"}
},
"required": ["path"],
},
},
{
"name": "read_file",
"description": "读取一个文本文件的内容。",
"input_schema": {
"type": "object",
"properties": {
"path": {"type": "string", "description": "相对笔记目录的路径"}
},
"required": ["path"],
},
},
]
解读:两个工具共用一个 path 参数,粒度刻意做细——工具越小、参数越少,模型选对、填对的概率越高;description 写清"什么时候该用我",是模型挑工具的主要依据。执行侧是一个分发函数加安全底线:
import os
ROOT = os.path.abspath("notes") # 只允许访问 notes 目录
def execute_tool(name, args):
path = os.path.abspath(os.path.join(ROOT, args["path"]))
if not path.startswith(ROOT): # 防路径穿越,越界一律拒绝
return "错误:路径越界"
if name == "list_files":
return "\n".join(os.listdir(path)) or "(空目录)"
if name == "read_file":
with open(path, encoding="utf-8") as f:
return f.read()
return f"错误:未知工具 {name}"
解读:工具一旦声明,模型就能要求读任何路径。所以第一步先用 abspath 把参数规整成绝对路径,再检查它必须落在白名单目录 ROOT 之内,越界返回错误文本而不抛异常——这段防护与模型能力无关,是把文件系统交给模型的程序必须守住的底线。主循环直接复用第 4 节骨架,加上 system 提示:
messages = [{"role": "user",
"content": "notes 目录里有哪些文件?挑最长的一篇总结成三句话。"}]
while True:
response = client.messages.create(
model="claude-sonnet-5-5", max_tokens=1024,
tools=tools, messages=messages,
system="你是文件助理,先看清目录再读文件,不要臆测内容。",
)
if response.stop_reason != "tool_use":
break
messages.append({"role": "assistant", "content": response.content})
results = [{"type": "tool_result", "tool_use_id": b.id,
"content": execute_tool(b.name, b.input)}
for b in response.content if b.type == "tool_use"]
messages.append({"role": "user", "content": results})
print(next(b.text for b in response.content if b.type == "text"))
解读:system 里那句"先看清目录再读文件",能明显减少模型跳过 list_files 直接猜文件名的行为。运行起来循环一般转两到三轮:第一轮 stop_reason 为 tool_use,调 list_files 拿到目录;第二轮挑一篇笔记调 read_file;第三轮 stop_reason 变 end_turn,content 里只剩 text 块——那就是最终回答。给循环加一行日志、把每轮的工具名打出来,完整的调用轨迹一目了然,也是日后排查"模型为什么不调工具"的第一手段。
7、总结
本篇把工具调用从"定义"推进到"循环":五步循环由 stop_reason 驱动,tool_use 块给出 id、name、input 三要素,执行结果以 tool_result 块回到 user 消息的 content 数组、与 tool_use_id 一一对应,while 骨架把来回滚到 end_turn 为止;并行调用的全部结果合并进同一条消息,失败用 is_error 如实回传。这套"请求——执行——回传——循环"的机制就是 Agent 一词的具体内核,任何框架的自动工具调用都是它的封装。
留两个练习:其一,给第 6 节的助手加一个 write_file 工具——schema 增加 content 参数、execute_tool 增加分支并沿用路径防护,然后让它"把这篇笔记的总结存成 summary.md",观察模型何时决定动笔;其二,故意把回传的 role 改成 assistant、或删掉一个 tool_use_id,跑一次亲眼看 400 报错长什么样,再改回来——层级关系亲手修对一次,就再也不会包错。对于想让 Claude 从"会说话"变成"能动手"的开发者,建议今天就把这个循环跑起来——工具调用的体感,跑一次胜过读十篇文档。

