# Step 17: Tool Calling 循环
一句话导读:这是整个项目的质变点——AI 从"只会说话"变成"会动手"。而实现它的,不是什么神秘 API,只是一个 while 循环加一条铁律:只要 AI 还在要工具,就喂给它结果再问一遍;它不要了,就是答完了。理解这个循环,就理解了所有 Agent 的心脏。
# 一、这一步做了什么(What)
step01–16 所有工具都得人手动 /run。step17 彻底改变模式:
utils/api.ts重写为callWithTools(),把工具定义随请求发给模型,并从回复里解析出tool_use块;index.ts重写成一个 Tool Calling 循环:调 API → 若 AI 要工具就执行 → 结果回传 → 再调 API → 直到 AI 不再要工具;- 消息格式从简单
{role, content}升级为 Anthropic 的MessageParam(因为要塞tool_use/tool_result这种结构化块)。
从此 AI 自己决定"要不要用工具、用哪个、传什么参数",人只提问。这就是 Claude Code QueryEngine 的核心,只是简化到约 180 行。
# 二、面试官视角:为什么要做?(Why)
面试题:Tool Calling 到底是不是一个特殊的"API 功能"?模型是怎么"执行"工具的?请说清楚控制权在谁手里。
这题能一句话筛掉一半人。标准误区是以为"模型自己会调工具"。真相是:
模型永远不执行任何东西。它只会输出文本和一种叫 tool_use 的结构化消息块——本质是一句"我想调 Glob,参数是这个"的请求。真正执行工具的,是你写的那段代码(我们的 index.ts 循环)。
所以控制权始终在你手里。这带来三个关键认知:
| 认知 | 含义 |
|---|---|
tool_use 是消息类型 | 不是魔法 API,就是 message content 里的一种 block,和 text 平级 |
| 执行方是宿主程序 | 模型请求 → 你的循环拦截 → 你决定执行/拒绝/改写 → 你把结果塞回去 |
| 循环是你搭的 | 模型没有"记忆"上一轮,是你每轮把完整 messages 重新发过去 |
为什么必须搭这个循环? 因为一次 API 调用是无状态的、一次性的。模型说"我要调 Glob"之后就停了,它看不到结果。是循环负责:执行工具 → 把结果作为新消息追加 → 带着更长的历史再问一次。Agent 的"自主性",本质是这个"问-做-再问"循环制造出来的错觉——每一轮模型都是全新的,只是它每次都能看到之前所有的来往。
# 三、原理:它是怎么工作的(How)
# 第一半:API 层如何解析工具请求
callWithTools 做两件事:把工具定义发出去,把 tool_use 块收回来。
export async function callWithTools(messages, tools, onChunk, sys) {
const stream = getClient().messages.stream({
model: process.env['MODEL'] || 'claude-sonnet-4-20250514',
max_tokens: 4096,
system: sys,
messages,
tools: tools.length > 0 ? tools : undefined, // ★ 工具定义随请求发出
});
let fullText = '';
stream.on('text', (d) => { fullText += d; onChunk(d); }); // 流式显示文字部分
const final = await stream.finalMessage();
const toolCalls = [];
for (const b of final.content) {
if (b.type === 'tool_use') { // ★ 从回复里挑出工具请求
toolCalls.push({ id: b.id, name: b.name, input: b.input });
}
}
return { text: fullText, contentBlocks: final.content, toolCalls, /*...*/ };
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
关键点:模型的一次回复 final.content 是个混合数组——里面可能既有 text 块(它一边说话),又有 tool_use 块(一边动手)。我们把 tool_use 全挑出来交给循环,同时原封不动保留整个 contentBlocks(下一节说明为什么这条不能偷懒)。
# 第二半:index.ts 的主循环
messages.push({ role: 'user', content: input });
let loopCount = 0;
while (loopCount < 10) { // ★ 有上限,防死循环
loopCount++;
const result = await callWithTools(messages, registry.toAPI(), writeChunk, SYS);
// ★ 必须把 AI 的完整回复(含 tool_use)存进历史
messages.push({ role: 'assistant', content: result.contentBlocks });
if (result.toolCalls.length === 0) {
break; // ★ 终止条件:AI 不再要工具 = 最终答案
}
for (const tc of result.toolCalls) {
const tool = registry.get(tc.name);
const tr = await tool.execute(tc.input);
const text = tr.content.map(c => c.text).join('\n');
// ★ 结果必须用 tool_use_id 对应回去,且身份是 user
messages.push({
role: 'user',
content: [{ type: 'tool_result', tool_use_id: tc.id, content: text, is_error: tr.isError }],
});
}
// 回到循环顶,带着"工具结果"再问一次 AI
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
# 数据流:一次"读 package.json 并总结"
messages = [ {user:"读下 package.json 讲讲依赖"} ]
│
├─round1─ callWithTools → AI 回复: [text:"好的,我读一下", tool_use{Read, {path:"package.json"}}]
│ push assistant(含 tool_use)
│ toolCalls≠0 → 执行 Read → push user(tool_result: 文件内容)
│
├─round2─ callWithTools(messages 已含结果) → AI 回复: [text:"这个项目依赖 fast-glob..."]
│ push assistant
│ toolCalls==0 → break ★最终答案
▼
messages 现在有 4 条:user问 / assistant(要Read) / user(Read结果) / assistant(总结)
2
3
4
5
6
7
8
9
10
11
# 三条不能违反的铁律
| 铁律 | 违反后果 |
|---|---|
assistant 回复必须完整存回历史(含 tool_use 块,不能只存 text) | 下一轮 API 报错:tool_result 找不到对应的 tool_use |
tool_result 的 tool_use_id 必须精确匹配对应的 tool_use.id | 模型不知道这个结果是哪次调用的,配对错乱 |
tool_result 的 role 是 user | 协议规定工具结果以"用户身份"回传,写成 assistant 会被拒 |
这三条是 Tool Calling 协议的硬约束,也是新手最容易踩的坑——尤其第一条,很多人只 push 了 result.text,把 tool_use 块丢了,然后困惑于"为什么报 400"。
# 四、深入追问(面试常见 follow-up)
Q:循环的终止条件为什么是"AI 不再调用工具",而不是某个显式的"完成"信号?
A:因为"不再要工具"本身就是最自然的完成信号。模型的行为逻辑是:需要外部信息 → 发 tool_use;信息够了、可以直接回答 → 只发 text。所以"这一轮没有 tool_use"精确对应"它认为不需要再做事、可以给结论了"。不需要额外的 done 标记,协议的空缺本身就是信号。
Q:那个 while (loopCount < 10) 的上限为什么必须有?去掉会怎样?
A:防失控。模型可能陷入病态循环——反复调同一个工具、或工具总失败它总重试。没有上限,一次提问可能烧掉无限 token / 无限时间。10 是"够复杂任务用、又不至于失控"的教学折中。真实 QueryEngine 的上限更高(几十轮),且轮次耗尽时会广播一个明确事件告诉用户"被截断了,任务可能没做完",而不是静默 break——静默终止是最坏的体验。
Q:为什么每轮都要把整个 messages 数组重新发一遍?不能只发增量吗?
A:因为 Claude API 是无状态的——服务端不记得你上一次发了什么。模型的"记忆"完全靠你每轮把完整对话历史重发。这也是为什么长对话越来越贵(输入 token 随历史线性增长),进而引出后续的上下文压缩和 prompt caching(缓存历史前缀,避免重复计费)。这一条是理解 Agent 成本模型的钥匙。
Q:如果模型一次回复里同时要了 3 个工具,这个 step17 实现怎么处理?有什么问题?
A:step17 用一个 for 循环串行执行三个,一个跑完再跑下一个。问题是慢——三个独立的读文件本可以同时做。这正是 step18 要解决的:把 for 换成 Promise.all 并行执行。但要注意:同一轮的多个工具结果,必须全部收齐后一起作为下一轮输入,不能执行一个就回一次 API。
Q:tool_use 里的 input 是模型生成的 JSON,万一它生成的参数不符合 schema(比如少了必填字段)怎么办?
A:step17 是乐观执行——直接把 input 丢给 tool.execute,工具内部自己校验(比如 Glob 会检查 pattern 为空)并返回 isError。这个错误再作为 tool_result 回传,模型看到后通常会自我纠正、重发正确参数。把参数校验的失败也变成一次可学习的反馈,是让 Agent 自愈的关键设计。
# 五、踩坑 / 设计权衡
- 最容易漏的一行:
messages.push({ role:'assistant', content: result.contentBlocks })必须存整个 contentBlocks。只存result.text会丢掉tool_use块,导致下一轮的tool_result变成"孤儿",API 直接 400。 tool_result的 role 反直觉:明明是"程序执行的结果",却要用user角色回传。这是协议约定:对模型而言,工具结果是"外部世界给它的输入",归为 user 侧。- 串行是暂时的:step17 为了讲清楚循环骨架,故意用串行;性能优化留给 step18,避免一次引入太多变量。
# 六、与真实源码的对照
| 我们的实现 | Claude Code 源码 |
|---|---|
index.ts 的 while 循环 ~180 行 | src/QueryEngine.ts 约 1296 行 |
callWithTools ~70 行 | src/services/api/(约 20 个文件) |
loopCount < 10 硬上限,静默 break | 更高上限 + 轮次耗尽广播 max_rounds 事件 |
| 串行执行多工具 | Promise.all 并行(我们 step18 补上) |
| 无压缩、无缓存 | 上下文压缩 + prompt caching + 预算控制 |
| 工具报错原样回传 | 错误分类、超时、权限拒绝(step19/20 补) |
核心那句"循环直到 AI 不再要工具",真实版和我们一模一样。1296 行里的绝大多数,都是围绕这个骨架处理边界情况。
# 七、一句话总结
Tool Calling 循环 = 无状态 API + 一个有状态的 while 循环:模型只会"请求"工具(发 tool_use 块),执行永远由你的宿主代码完成;你负责每轮把完整历史重发、把工具结果以 user 身份精确配对回传,并在"AI 不再要工具"时收尾。Agent 的自主性不是模型自带的魔法,而是这个"问-做-再问"循环制造出来的。掌握这一步,Claude Code 的心脏就在你手里了。
# 下一节预告
循环通了,但一次要 3 个工具还在傻乎乎地串行等。下一步 step18 用 Promise.all 把它们并行起来,并加上每个工具的计时,顺带引出"局部失败隔离"。