返回《Claude Code 架构设计指南》

工具执行,流式与并发分区

IT周瑜

模型一轮回复可以带多个 tool_use,怎么执行这批调用,主循环里有两条路。一条叫 runTools,老老实实等流式输出全部结束,拿到完整的工具列表再统一调度。另一条走 StreamingToolExecutor,模型还在往外吐字,第一个工具块一到就先跑起来。用哪条由一个配置门控决定,两条路的工具最终都汇到同一个执行入口 runToolUse。

先看 runTools 的分区策略,函数 partitionToolCalls 把一串 tool_use 切成批次。规则只有两条,相邻的并发安全工具攒成一批,非并发安全的工具各自单独成批。切完之后,安全批用 runToolsConcurrently 并发执行,并发上限默认十,环境变量可调。非安全批用 runToolsSerially 串行执行,一个跑完再跑下一个。这里消费的正是上一章契约里的元数据,工具说并发安全,调度器就真信,所以那个标记的实现必须谨慎,代码里也注释了,isConcurrencySafe 抛异常时按不安全处理,宁可慢不可错。

路径一 runTools 批量执行 模型流式输出 T1 T2 T3 只读批并发跑 写工具串行 路径二 StreamingToolExecutor 边流边执行 输出 T1 T1 立即执行 同时输出 T2 T3 T2 T3 接着跑 时间 → 分区规则 相邻的并发安全工具攒成一批并发执行 上限默认 10 非并发安全工具单独成批 一个一个串行执行 重试或降级时旧执行器整体 discard 防止孤儿 tool_result 泄漏进新一轮
图 5-1 上半是批量路径,下半是流式路径,时间从左往右

流式路径省的是等待。模型生成一整个回合要十几秒,首批工具往往几秒内就到齐了。StreamingToolExecutor 挂在流式回调里,每到一个 tool_use 块就立刻启动执行,模型的剩余输出和工具的执行并行推进。等流式结束,主循环调用它的 getRemainingResults 收尾。省下来的时间就是首批工具的执行和剩余输出的重叠部分,对读文件这类快工具,一个回合能省好几秒。

并发执行还有一个上下文传播问题。串行世界里,前一个工具改一改共享状态,后一个自然看得到。并发世界里同一批工具各自拿到的是进入批次前的上下文快照,某个工具确实更新了状态时,它产出一个 contextModifier,调度器把改动排队暂存,批次收尾时统一应用,保证改动不会凭空消失,也不会中途可见。这个设计让并发批内的工具互相独立,批与批之间仍然严格有序。调度器全程维护一个进行中调用的集合,工具开跑时登记,收尾时移除,界面靠它显示当前有几个工具在飞。

两条路径都要处理同一类麻烦,正在执行时出了岔子怎么办。请求过长触发重试,或流式中途降级,此时旧执行器手里可能还压着没收割的结果。源码的处理是整体丢弃,query.ts 里两处都写着先调用 discard 再 new 一个全新的执行器,注释说明这是防止带旧 tool_use_id 的孤儿 tool_result 泄漏进重试轮次。结合第 3 章的配对约束看,孤儿结果比没有结果更糟,id 对不上的结果会让下一次请求直接被 API 拒掉。

执行入口 runToolUse 内部还有一层固定次序,先跑 PreToolUse hook,再走权限检查,都过了才真正调 tool.call。hook 能改入参也能否决,权限检查裁决放行与否,这两道闸门的先后与协作,分别在第九和第六章展开。执行完的结果包装成 tool_result 消息 yield 回主循环,拼进消息列表,一轮工具执行到此走完。收尾处还会跑 PostToolUse hook,让用户的脚本能观察到每一次执行的结果,批与批之间的衔接靠它完成审计一类的工作。