
两个工具同时启动,第二个先结束。若把先结束的结果直接塞进第一条调用的位置,模型收到的记录就会把结果认错。问题不在于并发本身,而在于把「谁先完成」和「这是谁的结果」混成了一件事。
本地 Node 测试用 slow_lookup 和 fast_lookup 两个固定函数模拟这段过程。慢函数等待 30 毫秒,快函数等待 5 毫秒,返回值分别是 A 和 B。代码没有连接模型或真实工具,验证范围只有 JavaScript 的异步调度和本地结果收集。
parallel event order: start slow_lookup -> start fast_lookup -> finish fast_lookup -> finish slow_lookup
parallel return order: call-1:A, call-2:B
sequential event order: start slow_lookup -> finish slow_lookup -> start fast_lookup -> finish fast_lookup
sequential return order: call-1:A, call-2:B
原始输出来自 tools/verify-agent-parallel-order.mjs,本次复跑与留存日志 tools/verify-agent-parallel-order.log 一致。并行分支里,快工具先完成;返回数组仍按输入位置给出 call-1:A, call-2:B。因此这个例子里,完成先后变了,关联关系没有变。这里的稳定性来自每个映射函数返回了包含 id 的对象;若只返回结果文本 A 或 B,收集器即便保留输入位置,也没有足够信息在后续排序或过滤后恢复调用身份。位置能在单个数组里帮忙,标识才能跨步骤传递。
误区一:先结束的结果就是第一项
Promise.all 收集结果时按传入的 Promise 顺序返回数组,不按完成时间重新排序。这个保证对数组有用,却不能替代调用身份。调用在收集前经过筛选、拆批或重新排序,数组里的第 0 项可能已经不对应原来的第 0 次工具请求。
更麻烦的是,日志常按时间记录事件。看见 fast_lookup 的完成记录排在前面,就容易照着日志顺序组装返回消息。日志回答的是「什么时候发生」,不回答「这条结果归哪个调用」。如果把两者当成同一种顺序,越是响应时间不稳定,越容易在偶发情况下把结果串错。
误区二:结果数组有序,就不必保存调用 ID
测试里的 call-1 和 call-2 不只是便于打印的标签。它们让每个返回值继续携带来源。实际消息协议通常要求工具结果引用相应的调用标识;具体字段名称和格式要按所用接口确认,不能从这个桩测试推断所有 SDK 都采用同一套结构。
只靠位置匹配有隐含前提:调用列表从创建到回填始终保持相同顺序,中间没有过滤、取消或部分失败。重试与分批很容易打破这个前提。若第一个调用失败后被移除,剩下结果按压缩后的数组下标回填,原调用序号就会整体偏移。保留稳定 ID,才能在顺序变化后仍确认「这条结果属于哪次请求」。
误区三:并行改成串行就能修好关联
串行版本确实让 slow_lookup 完成后才启动 fast_lookup,日志顺序也随之整齐。它降低了并发,但不等于结果契约可靠。后续若为了提速恢复并行,基于「执行次序固定」的假设仍会出问题;工具本身也可能在内部再发起异步操作。
我会让每个工作项从进入调度器时就带着调用 ID,完成时把 ID 和结果一起交还,由回填层按 ID 匹配原请求。要展示成数组时,可以按原请求顺序整理;涉及协议消息时则用 ID 核对,不用数组下标猜测。出错项也要留下它的 ID 和错误状态,不能只过滤掉失败项再把成功结果压紧排列。
本机脚本没有验证失败、取消或重复 ID 的处理,也没有调用任何模型。若接口要求特定的消息顺序,还要遵守接口约定;按 ID 关联只解决身份映射,不会自动满足协议的轮次规则。并行适用于互不依赖且允许同时执行的调用。只要结果可能回到对话历史,调用 ID 就应跟着结果走,完成时间只用于诊断,不拿来充当身份。