
两个工具调用同时发出,快的先完成,慢的后完成。日志看起来像顺序反了;如果把返回值按完成时间直接拼回对话,工具结果就可能挂到错误的调用上。这个边界容易被按 Promise 输入顺序收集掩盖:执行启动的次序,与任务完成和结果收集的次序,并不相同。
本机用 Node 写了两个固定桩工具,一个等 30 毫秒,一个等 5 毫秒。没有模型、没有网络,也没有真实工具副作用;这里只测 Promise 的调度和返回顺序。运行脚本是 tools/verify-agent-parallel-order.mjs,逐字输出保存在同目录的 verify-agent-parallel-order.log。
完成顺序和数组顺序是两条线
并行分支用 Promise.all(calls.map(...)) 启动。map 会按 calls 的顺序产生 Promise,Promise.all 收集这些 Promise 的结果时,也按输入顺序兑现数组;哪个 Promise 先完成,不会把它挪到数组前面。实测输出是:
parallel event order: start slow_lookup -> start fast_lookup -> finish fast_lookup -> finish slow_lookup
parallel return order: call-1:A, call-2:B
先结束的是 fast_lookup,返回数组的第一项却仍然是 call-1:A。这反映的是收集器保留输入位置。用 Promise.all 的位置关联调用与结果,依赖的是每个映射项都返回自己的标识与结果,不能只收集一串无名文本。
在 Agent 里,一项工具响应通常还需要带着对应调用标识回到消息历史。即便数组顺序恰好稳定,回填时也应使用标识对应,而不是凭"第一个完成的先放第一位"。如果调用集合经过过滤、排序或拆批,输入位置可能已经不是原始调用位置。把 id 留在每个结果项里,后续步骤才能核对映射关系。
串行会改变的不止输出时间
同一份调用列表改成 for...of 加 await,第二个工具要等第一个结束才启动。日志变成:
sequential event order: start slow_lookup -> finish slow_lookup -> start fast_lookup -> finish fast_lookup
sequential return order: call-1:A, call-2:B
两种写法的返回顺序一样,执行轨迹不同。并行适合彼此独立、允许同时运行的读取操作;如果工具之间有先后依赖,或者会争用同一份可变状态,就不能为了省等待时间一把 Promise.all。串行不会自动让业务正确,只是把"前一项完成后再开始下一项"变成明确约束。
还有个容易漏的细节:并发执行时,副作用和外部请求的先后都可能按完成节奏交错。数组按顺序收好,不能倒推副作用也按顺序发生。需要稳定副作用顺序时,应串行执行,或者把操作交给具备排序语义的队列;单靠最后整理数组补不回来。
失败时别把"全体完成"当成"全体成功"
这份脚本只测成功分支。真实工具调用还可能拒绝 Promise。Promise.all 会在任一成员拒绝时整体拒绝,但其他已经启动的任务不会因此自动取消;它们仍可能继续产生副作用。若需要部分成功结果,得为每项单独捕获错误,或改用 Promise.allSettled,再按调用标识逐项处理。取消能力也得由工具本身支持,不能把 Promise 聚合器当成中止开关。
这一点我没实测:不同模型或 SDK 在同一轮返回多个工具调用时,是否允许并发、是否要求按特定顺序执行、失败后怎样续接,都取决于具体接口约定。文中的桩只说明 JavaScript 本地调度行为,不证明模型会生成这类调用,也不证明某个 Agent 框架会怎样回填结果。
判断标准很实际:工作彼此独立且顺序无关,可以并行,同时保留调用标识;有依赖或顺序本身有业务意义,就串行。数组顺序可靠,但它不替系统安排执行顺序。