作者:互联网 时间: 2026-07-21 18:02:02
一个 Agent 接入多个工具后,模型一次回复可能不只返回一个 tool_call。

例如,用户要求同时分析两个文件,模型可能返回:
read_file(a.js)read_file(b.js)
这两个文件如果每个读取 2 秒,顺序执行大约需要 4 秒,并行执行则可能只需要 2 秒左右。
看起来,直接把所有工具都放进 Promise.all() 就能提高性能。
但把任务换成下面这样:
创建项目安装依赖启动开发服务器
如果三步同时执行,安装依赖时项目可能还没有创建,启动服务时依赖也可能尚未完成。
因此,多 Tool Call 的关键问题不是“会不会使用 Promise.all()”,而是:
这些调用之间是否存在依赖关系?
本文对比项目里的两种真实 Agent Loop:
tool.mjs 使用 Promise.all() 并行执行mini-cursor.mjs 和多 MCP Demo 使用 for...of + await 顺序执行看清它们分别适合什么任务,以及工具结果怎样准确对应回原来的 tool_call_id。
模型决定使用工具时,会在 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 可以选择:
顺序执行:一个完成后再执行下一个并行执行:同时启动,等待全部完成
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 任务,例如:
这些任务不需要等待前一个结果,也不会修改同一份共享状态。
不会。
即使第二个工具最先执行完成,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 进入 rejected 状态,外层等待就会立即 reject。
await Promise.all([taskA(),taskB(), // rejectedtaskC(),]);
需要注意,外层提前失败并不会自动取消已经启动的其他异步任务。taskA 和 taskC 仍可能继续执行,只是当前代码不再等待它们的完整结果。
项目里的处理方式是在每个工具内部捕获错误:
try {return await tool.invoke(toolCall.args);} catch (err) {return `错误:${err.message}`;}
工具失败后返回的是错误字符串,而不是继续抛出异常。
这样 Promise.all() 仍然可以拿到完整结果数组:
['文件 A 的内容','错误:文件不存在','文件 C 的内容']
模型在下一轮既能看到成功结果,也能看到失败原因,再决定是否重试或换一种方式处理。
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 轮:模型根据快照决定点击哪个元素
所以需要区分两种“顺序”:
同一轮内按顺序执行:只保证副作用发生的先后顺序跨模型轮次执行:后一步可以真正依据前一步结果重新规划
两个工具同时写入同一个文件,最终结果可能取决于谁最后完成:
write_file(app.js, 内容 A)write_file(app.js, 内容 B)
这会产生竞态条件,不能简单放进 Promise.all()。
点击、切换标签页、输入和截图依赖当前页面状态。
如果同时点击两个元素或在页面加载前截图,结果会变得不可预测。
下面三步有明确依赖:
pnpm installpnpm buildpnpm preview
构建依赖安装结果,预览又依赖构建产物。
同时执行扣库存、创建订单和更新支付状态,可能产生数据一致性问题。
是否允许并发,应由业务事务和幂等设计决定,不能只由模型判断。
通常满足下面条件的任务更适合并行:
例如:
读取 a.js、b.js、c.js查询北京、上海、广州天气检查三份独立配置文件获取多个互不关联的公开接口数据
并行前仍要考虑接口限流、连接数量和机器资源。
如果模型一次返回几十个调用,直接全部启动也可能触发 API 限流或占满文件句柄。实际项目通常还要增加并发上限,而不是无限制使用 Promise.all()。
不能把“并行还是顺序”完全交给 LLM 的自然语言推理。
更稳妥的做法是让 Runtime 根据工具属性和资源冲突做判断。
可以从四个问题开始:
简单决策可以整理成:
| 场景 | 建议方式 |
|---|---|
| 多个独立只读查询 | 并行执行 |
| 修改同一份状态 | 顺序执行 |
| 后一步需要前一步结果 | 跨 Agent 轮次执行 |
| 浏览器点击、等待、截图 | 顺序并分轮执行 |
| 大量独立调用 | 限制并发数量 |
无论工具怎样调度,最终都要把每个结果放回 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 从“能调用工具”走向“稳定完成复杂任务”时必须补上的工程能力。