# Step 17: Tool Calling 循环

一句话导读:这是整个项目的质变点——AI 从"只会说话"变成"会动手"。而实现它的,不是什么神秘 API,只是一个 while 循环加一条铁律:只要 AI 还在要工具,就喂给它结果再问一遍;它不要了,就是答完了。理解这个循环,就理解了所有 Agent 的心脏。


# 一、这一步做了什么(What)

step01–16 所有工具都得人手动 /run。step17 彻底改变模式:

  1. utils/api.ts 重写为 callWithTools(),把工具定义随请求发给模型,并从回复里解析出 tool_use 块;
  2. index.ts 重写成一个 Tool Calling 循环:调 API → 若 AI 要工具就执行 → 结果回传 → 再调 API → 直到 AI 不再要工具;
  3. 消息格式从简单 {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, /*...*/ };
}
1
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
}
1
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(总结)
1
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 把它们并行起来,并加上每个工具的计时,顺带引出"局部失败隔离"。