# Step 18: 多轮工具调用(并行执行 + 计时)

一句话导读:step17 的循环里,同一轮的多个工具还在排队串行等。这一步把 for 换成 Promise.all——一行改动,却牵出三个真问题:哪些工具能并行、一个失败会不会连累其他、以及"同一轮结果必须一起回传"这条不能破的规矩。


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

在 step17(Tool Calling 循环已通)基础上做四点优化:

  1. 并行执行:同一轮 AI 要的多个工具,从串行 for 改为 Promise.all 同时启动;
  2. 每轮计时:记录每个工具的耗时(42ms)和整轮总耗时,方便看瓶颈;
  3. 工具组显示:一轮里调了哪几个工具,先打印 calling: Bash + Glob;
  4. 错误隔离:一个工具抛错,用 try/catch 包在每个任务内部,不影响同轮其他工具。

# 二、面试官视角:为什么要做?(Why)

面试题:把 for 改成 Promise.all 就能并行——但这是不是无脑加速?什么情况下同一轮的工具绝对不能并行?

考的是你懂不懂并行的前提条件。答案:并行的前提是工具之间相互独立、无副作用依赖。

模型在同一轮里一次性发出的多个 tool_use,隐含了一个语义——它认为这几件事不依赖彼此的结果(否则它会分成两轮,先做 A 拿到结果再决定 B)。所以"同一轮多工具"天然适合并行:

场景 能否并行 原因
Read a.ts + Read b.ts + Glob ✅ 能 三个只读操作,互不依赖
Read 配置 → 再根据内容 Write ❌ 不能 Write 依赖 Read 结果,模型会分两轮
Write 同一个文件两次 ⚠️ 危险 写同一资源,并行有竞态

关键洞察:是模型的"分轮"行为帮我们做了依赖判断。同一轮 = 模型担保独立,可以放心 Promise.all;有依赖的操作,模型自然会拆到不同轮,串行天然成立。所以并行是安全的——但前提是你信任"同一轮即独立"这个约定,并且工具本身没有隐藏的共享副作用(比如都写同一个文件)。

性能收益也直观:三个各 300ms 的独立工具,串行要 900ms,并行只要 ~300ms(取最慢那个)。


# 三、原理:它是怎么工作的(How)

# 串行 → 并行的核心改动

// step17:串行,一个等完再下一个
for (const tc of result.toolCalls) {
  const tr = await tool.execute(tc.input);   // 阻塞,最慢的×N
  messages.push({ role:'user', content:[{ type:'tool_result', ... }] });
}

// step18:并行,全部同时启动,一起等
const toolResults = await Promise.all(
  result.toolCalls.map(async (tc) => {
    const tool = registry.get(tc.name);
    if (!tool) return { tc, error: 'tool not found', elapsed: 0 };
    const t0 = Date.now();
    try {
      const tr = await tool.execute(tc.input);
      return { tc, tr, error: null, elapsed: Date.now() - t0 };   // ★ 每个任务自己计时
    } catch (e) {
      return { tc, tr: null, error: e.message, elapsed: Date.now() - t0 };  // ★ 错误就地捕获
    }
  })
);
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

# 两个魔鬼细节

细节一:try/catch 必须包在 map 内部,不能包在 Promise.all 外面。

这是错误隔离的命脉。Promise.all 有个致命特性——只要有一个 promise reject,它立刻整体 reject,其余结果全丢。如果把 try/catch 放在外层:

Bash(成功) + Glob(抛错) + Read(成功)
   → Promise.all 因 Glob reject 而整体失败
   → Bash 和 Read 的成功结果全部作废!
1
2
3

而把 try/catch 放进每个 map 任务里,就把"抛出的异常"转成了"正常 resolve 的错误对象"({error: '...'})。这样每个 promise 都成功 resolve,Promise.all 永不整体失败,每个工具的成败被独立记录。这是"局部失败隔离"落地的具体手法。

细节二:结果必须收齐后一起回传。

const resultBlocks: any[] = [];
for (const r of toolResults) {
  if (r.error) resultBlocks.push({ type:'tool_result', tool_use_id:r.tc.id, content:r.error, is_error:true });
  else         resultBlocks.push({ type:'tool_result', tool_use_id:r.tc.id, content:text, is_error:r.tr.isError });
}
if (resultBlocks.length > 0) {
  messages.push({ role:'user', content: resultBlocks });   // ★ 一条 user 消息装全部结果
}
1
2
3
4
5
6
7
8

同一轮 N 个工具的结果,要放进同一条 user 消息的 content 数组里一次性回传。协议要求:一批 tool_use 必须由一批 tool_result 一次性应答完整。并行只是加速了"执行",回传的"打包完整性"半点不能变。

# 数据流

AI 一轮里要: Bash + Glob + Read
   ↓ info("round #1 calling: Bash + Glob + Read")
Promise.all 同时启动三个 execute
   ├ Bash  42ms ✓
   ├ Glob   8ms ✓        ← 各自计时,各自 try/catch
   └ Read  15ms ✗(报错)
   ↓ 全部 resolve(错误已转为对象,无一 reject)
收齐 3 个结果 → 打包进 1 条 user 消息
   ↓ info("round #1 completed in 45ms")  ← 总耗时≈最慢的,非三者之和
带着 3 个结果回到循环顶,再问 AI
1
2
3
4
5
6
7
8
9
10

# 四、深入追问(面试常见 follow-up)

Q:Promise.all 会不会因为某个工具报错而丢掉其他工具的结果?你是怎么防的? A:会——Promise.all 遇到任一 reject 就整体 reject。防法是把 try/catch 放进每个 map 任务内部,把异常转成正常 resolve 的 {error} 对象,让每个 promise 都成功。这样 Promise.all 永不整体失败。也可以用 Promise.allSettled(它天然不因单个失败而整体失败),效果等价,本质都是"把失败降级为一种正常结果"。

Q:既然要容错,为什么不直接用 Promise.allSettled,而是手动 try/catch? A:两者都对。手动 try/catch 的好处是能在 catch 里顺手做额外处理——计时、打印错误、构造给 AI 的恢复消息(step20 就在这里加了错误分类)。allSettled 返回的是标准 {status, value/reason},还得再转一层。教学版用 try/catch 是为了让"错误就地变成结果对象"这个过程显式可见。

Q:计时是打印出来给人看的,对 AI 的行为有影响吗? A:当前实现里,elapsed 只用于终端显示,没有回传给 AI(tool_result 里只有内容不含耗时)。它的价值是给开发者调优——一眼看出哪个工具是瓶颈(比如某个 Bash 命令 5 秒,其他都是毫秒级)。真实系统会把耗时纳入更完整的遥测,但也一般不塞进模型上下文,避免浪费 token。

Q:并行度需要限制吗?如果 AI 一轮要了 50 个工具会怎样? A:教学版没限流,50 个 execute 会全部同时启动——可能瞬间打开 50 个文件句柄 / 50 个子进程,压垮系统。生产实现会加并发上限(如信号量控制同时最多 N 个),或对危险工具(Bash)串行、安全工具(Read)并行。而且别忘 step15/16 用的是同步 fg.sync/readFileSync——它们其实会阻塞事件循环,让"并行"名不副实。真正的并行需要工具本身也是异步非阻塞的。

Q:并行执行下,工具的执行顺序不确定,会不会导致结果回传给 AI 的顺序乱掉、影响 AI 理解? A:不会。虽然完成顺序不定,但我们遍历的是 toolResults 数组,它的顺序由 result.toolCalls 决定、是稳定的;更重要的是每个 tool_result 都带 tool_use_id,模型靠 id 而非顺序来配对。id 配对让顺序无关紧要——这也是协议设计带 id 的深意。


# 五、踩坑 / 设计权衡

  • try/catch 的位置是唯一正确解:放外层就前功尽弃,这是最容易写错的地方。
  • "同一轮独立"是个信任假设:它依赖模型正确分轮。绝大多数情况成立,但如果模型误判、把两个有依赖的操作放进同一轮,并行可能产生竞态。生产系统对写操作会更保守。
  • 同步工具拖累并行:step15/16 的 sync 实现让并行打了折扣,是遗留的性能债。
  • 无并发上限:教学够用,生产必须限流。

# 六、与真实源码的对照

特性 真实 QueryEngine 我们的实现
工具并行 Promise.all 同左
错误隔离 每任务独立捕获 try/catch in map
并发上限 有(信号量/分类限流) 无
超时控制 AbortController + timeout 无(step20 补简化版)
进度回调 ToolProgress 事件流 仅终端计时打印
重试 categorizeRetryableAPIError 无(step20 补)

# 七、一句话总结

并行工具 = Promise.all + 每任务内 try/catch + 结果打包一起回传:一行 for→Promise.all 的背后是三条纪律——同一轮才可并行(依赖判断已被模型的分轮行为代劳)、异常必须就地转成结果对象以隔离局部失败、N 个结果靠 tool_use_id 配对并打包进同一条消息一次性回传。加速的是执行,不变的是协议的完整性。

# 下一节预告

AI 现在能自主、并行地调工具了——包括 rm -rf。下一步 step19 给它套上缰绳:执行前先问用户 y/Y/n/a,权限系统登场。