4.5 并发控制
工具的并发执行遵循严格的规则:
- 并发安全的工具可并行:
isConcurrencySafe(input) === true的工具(如 FileReadTool、GrepTool、GlobTool)可以同时执行 - 非并发安全的工具串行:
isConcurrencySafe(input) === false的工具(如 FileEditTool、BashTool 写命令)必须独占执行 isReadOnly只是直觉:只读通常意味着并发安全,但二者是接口里两个独立方法——真正门控并发调度的是isConcurrencySafe,不是isReadOnly
工具编排由 src/services/tools/toolOrchestration.ts 的 runTools() 函数管理。它不是把所有只读工具一次性 filter 出来做整体 Promise.all,而是按输出顺序把连续的并发安全调用聚成一批并行执行,遇到非并发安全调用则单独成批串行:
// partitionToolCalls 的核心:按 isConcurrencySafe 分批,保持原始顺序
function partitionToolCalls(toolUses) {
return toolUses.reduce((batches, toolUse) => {
const tool = findToolByName(tools, toolUse.name)
const parsed = tool?.inputSchema.safeParse(toolUse.input)
const safe = parsed?.success
? Boolean(tool.isConcurrencySafe(parsed.data)) // 判据是并发安全,不是只读
: false
// 连续的并发安全调用并入同一批,否则新开一批
if (safe && batches.at(-1)?.isConcurrencySafe) {
batches.at(-1).blocks.push(toolUse)
} else {
batches.push({ isConcurrencySafe: safe, blocks: [toolUse] })
}
return batches
}, [])
}
// 每个并发安全批次内部并行执行(runToolsConcurrently),
// 且受并发上限 getMaxToolUseConcurrency()(默认 10)限流;非安全批次串行。StreamingToolExecutor:流式并行执行
上述静态编排策略有一个局限:它必须等待模型完整输出所有 tool_use blocks 后才开始执行。而实际上,模型的流式输出需要 5-30 秒,一个 tool_use block 可能在流式输出的前几秒就已完整——何必等到最后?
StreamingToolExecutor(src/services/tools/StreamingToolExecutor.ts,约 530 行)正是为此设计。它在模型流式输出的同时,一旦检测到完整的 tool_use block,就立即启动执行:
// 工具在 StreamingToolExecutor 中经历 4 种状态
type ToolStatus = 'queued' | 'executing' | 'completed' | 'yielded'
// 每个工具的跟踪信息
type TrackedTool = {
id: string
block: ToolUseBlock
assistantMessage: AssistantMessage
isConcurrencySafe: boolean
results?: Message[]
pendingProgress: Message[] // 进度消息即时发射,不等待最终结果
}并发控制规则直接嵌入执行器内部:
// 能否执行一个工具?
private canExecuteTool(isConcurrencySafe: boolean): boolean {
const executingTools = this.tools.filter(t => t.status === 'executing')
return (
executingTools.length === 0 ||
(isConcurrencySafe && executingTools.every(t => t.isConcurrencySafe))
)
}规则只有一条:当前没有工具在执行时,任何工具都可以启动;有工具在执行时,只有自身和所有正在执行的工具都标记为并发安全,新工具才能启动。非并发安全的工具必须独占执行。
时间线对比展示了这种优化的效果:
串行执行(等待所有 tool_use 完成后):
[==========API 流式输出==========][tool1][tool2][tool3]
流式并行执行(StreamingToolExecutor):
[==========API 流式输出==========]
[tool1] ← tool_use_1 完成后立即启动
[tool2] ← 利用流式窗口(5-30s)覆盖工具延迟
[tool3]
[======结果在 API 输出完成时已就绪======]典型场景下,工具执行延迟约 1 秒,而模型流式输出持续 5-30 秒。这意味着大部分工具执行可以完全隐藏在流式窗口内,用户感知的总延迟接近于纯 API 调用时间。
另一个关键设计是 progressAvailableResolve 唤醒信号。结果消费者(getRemainingResults())以事件驱动方式工作——当新结果或进度就绪时,执行器通过 resolve Promise 唤醒消费者,避免轮询开销。pendingProgress 消息——如 BashTool 的 stdout 流——立即发射给 UI,不必等待工具最终完成。
分区算法与并发上限
在 StreamingToolExecutor 内部,工具分两类执行:
- 并发安全工具(
isConcurrencySafe: true):可以与其他并发安全工具同时执行。典型例子是 FileReadTool、GrepTool、GlobTool——它们只读取数据,不会互相干扰。 - 非并发安全工具:必须独占执行,在它运行期间不能有其他工具同时执行。FileEditTool、BashTool 的写操作属于此类。
执行器的调度逻辑基于一个简单规则:当前没有工具在执行时,任何工具都可以启动;当有工具在执行时,新工具只能在"自身和所有正在执行的工具都是并发安全"的条件下启动。一旦遇到非并发安全工具,队列处理暂停,等待当前所有执行中的工具完成后,非并发安全工具独占运行。
并发数的硬性上限出现在静态编排路径上:toolOrchestration.ts 的 runToolsConcurrently() 在并发批次上以 getMaxToolUseConcurrency()(默认 10,可通过 CLAUDE_CODE_MAX_TOOL_USE_CONCURRENCY 环境变量覆盖)限流,防止模型一次发出 20 个 FileReadTool 调用时导致文件句柄耗尽或 I/O 竞争。而 StreamingToolExecutor 本身没有数字上限——它只靠上面 canExecuteTool() 的并发安全性门控,可以同时启动任意多个并发安全工具;并不存在名为 MAX_TOOL_USE_CONCURRENCY 的常量。
此外,StreamingToolExecutor 还实现了兄弟取消机制(siblingAbortController):当一个 Bash 工具执行出错时,它会取消同批次中其他正在执行的工具。这避免了"第一个命令失败但后续命令继续执行"的问题。非 Bash 工具(如 FileReadTool、WebFetchTool)的错误则不会级联——这类失败通常各自独立,不影响兄弟工具。
结果的发射顺序始终与工具在模型输出中的出现顺序一致(FIFO),即使后面的工具先完成。这确保了消息流的确定性和可预测性。