位置:首页 > 进阶教程 > Agent一次返回多个Tool Call应该并行还是顺序执行?

Agent一次返回多个Tool Call应该并行还是顺序执行?

时间:2026-07-22  |  作者:宇宙开黑者  |  阅读:0

一个 Agent 接入了多个工具后,模型在一次回复中可能返回不止一个 tool_call。

比如,用户要求同时分析两个文件。模型可能给出这样的调用:

read_file(a.js)
read_file(b.js)

这两个文件如果每个读取 2 秒,顺序执行大约需要 4 秒。

并行执行则可能只需要 2 秒左右。看起来,直接把所有工具都放进 Promise.all() 就能提高性能。

Agent 一次返回多个 Tool Call,应该并行还是顺序执行?

但换个场景:

创建项目
安装依赖
启动开发服务器

如果三步同时执行,安装依赖时项目可能还没创建。

启动服务时,依赖也可能尚未完成。

所以,多 Tool Call 的关键问题不是“会不会用 Promise.all()”。

而是:这些调用之间是否存在依赖关系?

本文就来对比项目里的两种真实 Agent Loop。

tool.mjs 使用 Promise.all() 并行执行。

mini-cursor.mjs 和多 MCP Demo 则用 for...of + await 顺序执行。

看看它们分别适合什么任务,以及工具结果如何准确对应回原来的 tool_call_id。

模型返回的是 Tool Call 数组

当模型决定使用工具时,会在 AIMessage 中返回 tool_calls。

也就是 response.tool_calls。每个调用通常包含这样的结构:

{
  id: 'call_xxx',
  name: 'read_file',
  args: { filePath: './src/app.js' }
}

一次回复如果需要多个工具,tool_calls 就是一个数组:

[
  { id: 'call_1', name: 'read_file', args: { filePath: './src/app.js' } },
  { id: 'call_2', name: 'read_file', args: { filePath: './src/config.js' } }
]

模型只负责生成调用意图和参数。

真正决定怎样调度这些任务的是 Runtime。

Runtime 可以选择顺序执行,一个完成后再执行下一个。

也可以选择并行执行,同时启动,等待全部完成。

方案一:使用 Promise.all() 并行执行

并行执行的工作原理

在 tool.mjs 中,使用了 Promise.all() 处理一轮里的全部 Tool Call:

const toolResults = await Promise.all(
  response.tool_calls.map(async (toolCall) => {
    const tool = tools.find(item => item.name === toolCall.name);
    if (!tool) {
      return `错误:找不到工具 ${toolCall.name}`;
    }
    try {
      return await tool.invoke(toolCall.args);
    } catch (err) {
      return `错误:${err.message}`;
    }
  })
);

map() 把每个 Tool Call 转换成一个 Promise。

Promise.all() 同时等待这些异步任务。执行过程可以理解为:

tool_call 1 ──开始────────完成
tool_call 2 ──开始────完成
tool_call 3 ──开始──────────完成
           ↓ 所有任务完成后继续

适用场景:独立 I/O 任务

它最适合彼此独立的 I/O 任务,例如:

  • 同时读取多个互不相关的文件
  • 查询多个城市的天气
  • 请求多个独立数据接口
  • 对多份内容执行互不影响的检查

这些任务不需要等待前一个结果。

它们也不会修改同一份共享状态。

Promise.all() 的结果顺序会乱吗?

不会。

即使第二个工具最先执行完成,Promise.all() 返回的结果数组仍然按照传入 Promise 的顺序排列。

比如:

输入顺序:call_1、call_2、call_3
完成顺序:call_2、call_1、call_3
结果数组:result_1、result_2、result_3

因此,项目里可以通过相同的 index 把结果对应回原来的调用:

response.tool_calls.forEach((toolCall, index) => {
  messages.push(new ToolMessage({
    content: toolResults[index],
    tool_call_id: toolCall.id,
  }));
});

这里有两个对应关系。

toolResults[index] response.tool_calls[index]。

tool_call_id toolCall.id。

数组下标帮助 Runtime 取到正确结果。

tool_call_id 帮助模型确认结果属于哪一次调用。

并行执行不代表可以省略 tool_call_id。

Promise.all() 为什么经常“一错全错”?

标准 Promise.all() 有一个重要特点。

只要其中一个 Promise 进入 rejected 状态,外层等待就会立即 reject。

await Promise.all([
  taskA(),
  taskB(), // rejected
  taskC(),
]);

需要注意,外层提前失败并不会自动取消已经启动的其他异步任务。

taskA 和 taskC 仍可能继续执行。

只是当前代码不再等待它们的完整结果。

项目里的处理方式是在每个工具内部捕获错误:

try {
  return await tool.invoke(toolCall.args);
} catch (err) {
  return `错误:${err.message}`;
}

工具失败后返回的是错误字符串,而不是继续抛出异常。

这样 Promise.all() 仍然可以拿到完整结果数组:

['文件 A 的内容', '错误:文件不存在', '文件 C 的内容']

模型在下一轮既能看到成功结果,也能看到失败原因。

模型再决定是否重试,或换一种方式处理。

方案二:使用 for...of 顺序执行

顺序执行的工作原理

mini-cursor.mjs 中采用了另一种方式:

for (const toolCall of response.tool_calls) {
  const foundTool = tools.find(tool => tool.name === toolCall.name);
  if (foundTool) {
    const toolResult = await foundTool.invoke(toolCall.args);
    messages.push(new ToolMessage({
      content: toolResult,
      tool_call_id: toolCall.id,
    }));
  }
}

await 位于循环内部。

当前工具完成后,程序才会执行下一个工具。执行过程:

tool_call 1:开始 → 完成
tool_call 2:         开始 → 完成
tool_call 3:                  开始 → 完成

适用场景:有副作用或共享状态的任务

顺序执行耗时可能更长。

但它更适合会产生副作用或共享状态的任务:

  • 写入同一个文件
  • 创建项目后再安装依赖
  • 安装依赖后再启动服务
  • 打开网页后再获取页面快照
  • 获取快照后再根据页面内容点击元素
  • 点击进入详情页后再截图

这些任务不能只看“有几个 Tool Call”。

还要看先后关系。

顺序执行就能解决所有依赖吗?

不能。

同一个 AIMessage 里的多个 Tool Call,是模型在看到工具结果之前一次性规划出来的。

即使 Runtime 按顺序执行,后一个调用的参数也已经提前确定:

模型同时生成 call_1 和 call_2
→ Runtime 执行 call_1
→ Runtime 执行 call_2

执行 call_1 后,模型没有机会根据真实结果重新生成 call_2 的参数。

例如浏览器任务:

打开搜索页 → 获取页面快照 → 根据快照找到酒店按钮 → 点击按钮

模型必须先看到页面快照。

然后才能知道应该点击哪个元素。

这种真正依赖上一步结果的任务,更适合拆成多个 Agent Loop:

第 1 轮:模型调用打开网页 → ToolMessage 返回页面状态
第 2 轮:模型根据状态调用页面快照 → ToolMessage 返回快照内容
第 3 轮:模型根据快照决定点击哪个元素

所以需要区分两种“顺序”:

  • 同一轮内按顺序执行:只保证副作用发生的先后顺序
  • 跨模型轮次执行:后一步可以真正依据前一步结果重新规划

哪些工具不能轻易并行?

1. 修改同一个资源

两个工具同时写入同一个文件,最终结果可能取决于谁最后完成:

write_file(app.js, 内容 A)
write_file(app.js, 内容 B)

这会产生竞态条件。

不能简单放进 Promise.all()。

2. 浏览器操作

点击、切换标签页、输入和截图依赖当前页面状态。

如果同时点击两个元素或在页面加载前截图,结果会变得不可预测。

3. 命令行任务

下面三步有明确依赖:

pnpm install
pnpm build
pnpm preview

构建依赖安装结果。

预览又依赖构建产物。

4. 共享数据库状态

同时执行扣库存、创建订单和更新支付状态,可能产生数据一致性问题。

是否允许并发,应由业务事务和幂等设计决定。

不能只由模型判断。

哪些工具适合并行?

通常满足下面条件的任务更适合并行:

  • 彼此不依赖返回结果
  • 不修改同一资源
  • 失败不会破坏其他任务
  • 调用顺序不影响最终结果
  • 服务端允许相应并发量

例如:

读取 a.js、b.js、c.js
查询北京、上海、广州天气
检查三份独立配置文件
获取多个互不关联的公开接口数据

并行前仍要考虑接口限流、连接数量和机器资源。

如果模型一次返回几十个调用,直接全部启动也可能触发 API 限流或占满文件句柄。

实际项目通常还要增加并发上限。

而不是无限制使用 Promise.all()。

Runtime 应该怎样决定?

不能把“并行还是顺序”完全交给 LLM 的自然语言推理。

更稳妥的做法是让 Runtime 根据工具属性和资源冲突做判断。

可以从四个问题开始:

  1. 后一个调用是否需要前一个调用的返回值?
  2. 多个调用是否会修改同一个文件、页面或数据库记录?
  3. 工具失败后是否可以安全重试?
  4. 外部服务是否有并发与频率限制?

简单决策可以整理成:

场景建议方式
多个独立只读查询并行执行
修改同一份状态顺序执行
后一步需要前一步结果跨 Agent 轮次执行
浏览器点击、等待、截图顺序并分轮执行
大量独立调用限制并发数量

ToolMessage 的顺序也要保持稳定

无论工具怎样调度,最终都要把每个结果放回 messages:

AIMessage(tool_calls)
→ Runtime 执行工具
→ ToolMessage(tool_call_id)
→ 再次调用模型

并行执行时,不能按照“哪个工具先完成就先随意塞进去”的方式。

这样做会丢失对应关系。

项目使用 Promise.all() 的结果顺序和原始 tool_calls 下标完成映射。

再为每条消息保留 tool_call_id。

这是并行工具执行能够稳定工作的关键。

总结

Agent 一次返回多个 Tool Call,并不意味着所有工具都应该并行。

并行解决的是等待时间
顺序保证的是状态和依赖
跨轮次解决的是根据真实结果重新规划

可以把选择原则记成:

独立、只读、无共享状态 → Promise.all()
存在副作用或资源冲突 → for...of + await
后一步依赖前一步结果 → 返回 ToolMessage,让模型进入下一轮

模型负责提出调用计划。

Runtime 才应该负责并发控制、错误隔离和执行顺序。

这也是 Agent 从“能调用工具”走向“稳定完成复杂任务”时必须补上的工程能力。

免责声明:文中图文均来自网络,如有侵权请联系删除,心愿游戏发布此文仅为传递信息,不代表心愿游戏认同其观点或证实其描述。

相关文章

更多

精选合集

更多

大家都在玩

热门话题

大家都在看

更多