Claude Code 工具并发机制:依赖与安全边界
当模型在一条回复中返回多个 tool_use 时,客户端应该并行执行,还是严格按数组顺序串行执行?
只看 Anthropic API 的消息结构,很难得到完整答案。API 负责表达模型已经提交了哪些调用,却没有提供 depends_on、sequence 或 parallel 之类的调度字段。真正执行这些调用的 Agent Runtime,还需要结合工具副作用、输入参数和当前执行状态做第二次判断。
Claude Code 的处理方式可以概括为两层:模型负责表达语义依赖,Runtime 负责约束运行时安全。两层共同决定一次 Tool Call 最终以并发、串行还是动态交错的方式执行。
本文涉及两类信息,适用范围需要先说明:
- Anthropic API 的
tool_use/tool_result消息结构以官方 Parallel tool use 文档为准; - Claude Code 的调度细节来自本机 Claude Code 2.1.207 源码快照,基线构建提交前缀为
bc512d563325,完整哈希见文末。内部函数、调用关系和默认值都属于特定版本的实现观察,不是 Anthropic API 的长期兼容协议,后续版本可能调整。
API 表达调用,Runtime 决定执行方式
按照 Claude Code 的系统提示约定,两个互不依赖的文件读取可以出现在同一条 Assistant Message 中:
{
"role": "assistant",
"content": [
{
"type": "tool_use",
"id": "toolu_01",
"name": "Read",
"input": { "file_path": "src/a.ts" }
},
{
"type": "tool_use",
"id": "toolu_02",
"name": "Read",
"input": { "file_path": "src/b.ts" }
}
]
}
在 Claude Code 的提示约定下,这条消息表示模型认为两个调用在当前时点都已经具备提交条件。它没有进一步声明客户端必须并发执行,也没有声明二者存在先后关系。
需要区分“客户端提示约定”和“API 协议保证”。Anthropic API 允许一条消息包含多个 tool_use,官方也提供并行 Tool Use 的引导方式,但 API 不保证同一回复中的所有调用都没有业务依赖,更不会替客户端完成并发安全判断。
Anthropic API 返回的 content[] 是有序数组,但单个 tool_use 不包含以下调度信息:
{
"parallel": true,
"depends_on": "toolu_01",
"sequence": 2
}
因此,Tool Call 数组不能直接解释为串行执行计划,也不能无条件解释为并发任务列表。数组顺序是 Runtime 维护副作用边界时的重要输入,最终执行方式仍由客户端调度器决定。
如果第二个调用必须使用第一个调用的结果,依赖关系需要跨模型轮次表达:
第 1 次模型请求
└─ assistant: Read config.json
Runtime 执行 Read
└─ user: tool_result(tool_use_id = toolu_01)
第 2 次模型请求
└─ assistant: 根据配置内容调用 Bash 或 Edit
第一个 Tool 完成前,模型还没有获得其结果,也就无法可靠构造依赖该结果的后续调用。这类数据依赖不是 Runtime 可以凭 Tool 名称推断出来的。
Tool 执行完成后,结果通过 tool_use_id 与原调用对应:
{
"role": "user",
"content": [
{
"type": "tool_result",
"tool_use_id": "toolu_01",
"content": "..."
},
{
"type": "tool_result",
"tool_use_id": "toolu_02",
"content": "..."
}
]
}
这种关联不依赖 Tool 实际完成的先后顺序。即使第二个调用先返回,Runtime 仍能用 ID 将结果归还给正确的 tool_use。
并发判断分为语义层和安全层
Claude Code 会在系统提示词中要求模型区分两类调用:
- 没有数据依赖的调用,可以在同一条回复中提交;
- 后一个调用依赖前一个调用结果时,必须等待
tool_result,再在下一轮提交。
这是语义层的并发控制。模型根据当前上下文判断调用之间是否存在业务依赖。
Runtime 接收 Tool Call 后,还要判断这些工具同时执行是否会修改相同状态、破坏顺序或互相干扰。这是安全层的并发控制。
模型判断语义依赖
↓
同一轮提交一个或多个 tool_use
↓
Runtime 校验参数与工具并发属性
↓
动态队列检查并发条件与独占屏障
↓
执行并返回对应的 tool_result

模型无法知道本地文件锁、权限确认和 Tool 内部状态;Runtime 也无法从两个只读调用中还原模型层面的业务依赖。将两层职责拆开,避免了任何一侧承担它无法可靠完成的判断。
isConcurrencySafe 是运行时判定入口
在这份源码快照中,每个 Tool 可以实现本地方法:
isConcurrencySafe(input): boolean
这个方法不会作为 Tool Schema 发送给模型。API 侧主要看到 Tool 的名称、描述和 input_schema;Claude Code Runtime 才持有只读属性、副作用、权限和并发能力等本地信息。
默认策略是 fail-closed:无法证明调用安全,就按不可并发处理。
isConcurrencySafe: () => false
以下情况会让已解析 Tool 落入保守路径:
- Tool 没有声明并发安全;
- Tool Input Schema 校验失败;
isConcurrencySafe()执行时抛出异常。
未知 Tool 不属于这个分类。在正常执行器中,无法解析的 Tool 会直接进入错误处理,不会先作为 unsafe 调用排入执行队列。
保守策略的重点不是尽可能扩大并发,而是让无法确认安全的已知调用形成顺序边界。对于能够修改文件、配置或会话状态的 Agent Runtime,这比乐观执行更可控。
动态队列产生并发区间与独占屏障
Runtime 不会先拿到整条 Assistant Message,再预先切出一组固定批次。正常执行路径维护一个动态队列:完整的 tool_use 到达后,调度器根据当前正在执行的调用和新调用的 isConcurrencySafe 结果决定是否立即启动。
从最终执行效果看,可以用“连续安全区间 + 不安全屏障”理解这条队列,但这只是概念模型,不是预切批次的实现描述。
假设模型依次提交:
Read A safe
Grep B safe
Edit C unsafe
Read C safe
Bash git diff safe
Write D unsafe
对应的等价执行边界是:
[Read A, Grep B] 可以重叠执行
↓ 等待在途调用完成
[Edit C] 独占启动
↓ 完成
[Read C, Bash git diff] 可以重叠执行
↓ 等待在途调用完成
[Write D] 独占启动
队列行为包含四个约束:
- 当前在途调用全部安全时,后续安全 Tool 可以立即启动;
- 不安全 Tool 需要等待此前在途调用结束,再独占启动;
- 不安全 Tool 执行期间,后续调用需要等待;
- 后续安全 Tool 不能越过此前的屏障。
这种动态屏障保留了数组顺序中的副作用边界。例如:
Read old file
Edit file
Read new file
第二个 Read 本身具备并发安全性,但不能越过中间的 Edit。否则它可能在修改完成前读取旧内容。Runtime 不需要理解三个调用的业务目标,只需保证安全 Tool 不跨越不安全 Tool 重排。
只读属性与并发安全不是同一分类
将规则简化为“读 Tool 并发、写 Tool 串行”并不准确。Claude Code 调度器使用的是 isConcurrencySafe(input),不是统一检查 isReadOnly(input)。
在 2.1.207 源码快照中,一些修改状态的 Tool 也声明为并发安全,例如:
TaskUpdate修改任务状态;Config的部分操作修改配置;ExitPlanMode改变会话状态并读写 Plan 信息。
这些 Tool 可能通过独立资源、内部同步或特定状态管理满足并发条件。反过来,一个逻辑上偏读取的 Tool,如果没有声明并发安全,也会走串行路径。
这份版本中常见的固定并发安全 Tool 包括:
| 类型 | 示例 |
|---|---|
| 文件读取与搜索 | Read、Grep、Glob、LSP |
| 网络读取 | WebSearch、WebFetch |
| Agent 调度 | Agent |
| 任务管理 | TaskGet、TaskUpdate、TaskList |
| 工具发现 | ToolSearch |
| 用户交互 | AskUserQuestion |
| MCP 资源读取 | ListMcpResources、ReadMcpResource |
| Plan Mode | EnterPlanMode、ExitPlanMode |
常见的独占 Tool 包括:
Edit
Write
NotebookEdit
TaskCreate
旧版 TodoWrite
Worktree 创建与退出
修改型 Bash
MCP 鉴权
未声明并发安全的 Tool
参数无效的已知 Tool
即使两个 Edit 指向不同文件,在该版本的单个 Agent 执行器中也会串行。当前调度层没有按文件路径进一步建立资源级锁,因此不会因为目标文件不同而放宽执行限制。
Bash 根据命令内容动态分类
Bash 不能使用固定属性判断。它是否支持并发取决于本次输入中的具体命令:
isConcurrencySafe(input) {
return this.isReadOnly(input)
}
通常可以并发的命令包括:
git status
git diff
git log
pwd
ls
cat file
rg pattern
通常需要串行的命令包括:
git commit
npm install
mkdir build
rm file
cp a b
echo value > file
复合命令只有在所有子命令都能被判定为只读时,才进入并发路径。出现重定向、文件修改或无法可靠分类的语法时,Runtime 会保守地将它作为独占调用。
动态分类说明 isConcurrencySafe 的输入不能被忽略。同一个 Tool 名称可能因为参数不同而进入完全不同的调度路径。
MCP 的只读提示会进入版本实现判断
MCP Server 可以在 tools/list 返回的 Tool 定义中提供只读注解:
{
"annotations": {
"readOnlyHint": true
}
}
readOnlyHint 在 MCP 协议中只是服务端提供的行为提示,不是权限边界,也不是客户端可以独立验证的安全保证。在 Claude Code 2.1.207 这份版本实现中,它被映射为类似以下判断:
isConcurrencySafe() {
return tool.annotations?.readOnlyHint ?? false
}
对应关系是:
readOnlyHint: true:该版本允许调用进入并发路径;- 未提供或值为
false:按不安全 Tool 串行执行; - 传输使用 stdio 还是 Streamable HTTP,不影响这个判断。
这里存在明确的信任边界。如果 MCP Server 将修改型 Tool 错误标记为只读,Claude Code 2.1.207 可能据此允许并发。这个映射是客户端的版本实现,不应反向解释为 MCP 协议对 Tool 安全性的承诺。
二进制中的默认值不等于主路径全局上限
在正常主路径中,一个完整的 tool_use 从模型流中生成后,就可以进入动态执行队列,不必等待整条 Assistant Message 结束。
启动条件可以表述为:
当前没有 Tool 正在执行
或者
新 Tool 并发安全,并且所有正在执行的 Tool 也都并发安全
Claude Code 2.1.207 的二进制中确实包含一个读取并发参数的辅助函数,默认值为 10,并可通过环境变量调整:
CLAUDE_CODE_MAX_TOOL_USE_CONCURRENCY=10
但从当前可见调用关系看,这个辅助函数的调用点不属于正常 Agent Tool 执行主路径。不能据此推导“Claude Code 的 Tool 默认最多并发 10 个”,也不能把它视为所有执行路径共享的全局上限。
主路径的实际并发规模仍会受到单轮 Tool Call 数量、操作系统资源、HTTP 连接池、MCP Server 限制以及 Tool 内部锁的约束。基于当前源码快照,只能确认动态队列的安全门控,不能给正常主路径写出一个可靠的固定并发数字。
Subagent 扩大了调度边界,也引入跨 Agent 冲突
Agent Tool 本身声明为并发安全,主 Agent 可以在同一轮启动多个 Subagent:
Agent:分析数据库层
Agent:分析 API 层
Agent:分析测试体系
每个 Subagent 都有独立的 Agent Loop 和 Tool 调度器。由此产生一个重要边界:
同一个 Agent 内,两个 Edit 不并发
不等于:
两个不同 Subagent 的 Edit 不并发
单个 Agent 执行器的屏障不能自动扩展为跨 Agent 的全局文件锁。多个 Subagent 同时修改共享工作区时,冲突仍需通过任务拆分、Worktree 隔离、Tool 内部锁以及 Git 冲突检测处理。
因此,并发启动 Subagent 适合边界清晰的独立调查或独立文件域;如果多个任务写入同一资源,仅依赖 Tool 调度器不足以保证一致性。
错误结果仍按 Tool Call 独立关联
Tool 调用成功、失败或在执行前被拒绝,结果都需要与原始 tool_use_id 对应。未知 Tool 会由正常执行器直接进入错误路径,而不是先伪装成一个不安全 Tool 获取独占执行机会。
设计自定义 Runtime 时,错误传播策略不能只交给一个通用 Promise 池。至少需要区分:
- 单个调用失败是否影响其他在途调用;
- 已经启动的调用是否支持取消;
- 被取消调用是否仍需生成
tool_result; - 结果顺序是否依赖完成时间,还是始终依赖
tool_use_id对应关系。
在自定义 Agent Runtime 中复用这套边界
Claude Code 的实现不需要原样复制,但它揭示了几个可复用的调度约束。
语义依赖应由模型轮次表达。依赖前序结果的调用,不应和前序调用放入同一轮,再期待 Runtime 自动推断一张不存在的依赖图。
执行安全应由 Tool 自己声明或计算。尤其是 Bash、数据库操作和远程 Tool,并发能力往往与本次输入相关,静态的“只读/写入”标签不够精确。
调度器应保留保守默认值和动态屏障。连续安全调用可以并发,但不应越过副作用调用重排。已知 Tool 的安全判断返回 false 或抛出异常时应回落到串行路径;未知 Tool 和无法执行的输入则应进入明确的错误路径。
最后,需要明确调度器的作用域。单 Agent 内的安全不等于跨 Agent 安全;本地 Tool 的并发安全也不等于远端服务具备幂等性。越过这些边界后,应由资源锁、幂等键、事务或隔离工作区继续承担一致性责任。
本文对应的源码观察入口包括:
src/constants/prompts.ts
src/Tool.ts
src/services/tools/toolOrchestration.ts
src/services/tools/StreamingToolExecutor.ts
src/utils/generators.ts
src/tools/BashTool/BashTool.tsx
src/services/mcp/client.ts
这些路径和函数名用于记录 Claude Code 2.1.207、基线构建提交 bc512d56332530b2be3f5079e29ec17aa20b8553 的实现位置,不应作为第三方集成依赖。