niuzj

Claude Code 工具并发机制:依赖与安全边界

当模型在一条回复中返回多个 tool_use 时,客户端应该并行执行,还是严格按数组顺序串行执行?

只看 Anthropic API 的消息结构,很难得到完整答案。API 负责表达模型已经提交了哪些调用,却没有提供 depends_onsequenceparallel 之类的调度字段。真正执行这些调用的 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

Claude Code Runtime 通过动态队列形成安全调用的并发区间与不安全调用的顺序屏障

模型无法知道本地文件锁、权限确认和 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]                 独占启动

队列行为包含四个约束:

  1. 当前在途调用全部安全时,后续安全 Tool 可以立即启动;
  2. 不安全 Tool 需要等待此前在途调用结束,再独占启动;
  3. 不安全 Tool 执行期间,后续调用需要等待;
  4. 后续安全 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 包括:

类型 示例
文件读取与搜索 ReadGrepGlobLSP
网络读取 WebSearchWebFetch
Agent 调度 Agent
任务管理 TaskGetTaskUpdateTaskList
工具发现 ToolSearch
用户交互 AskUserQuestion
MCP 资源读取 ListMcpResourcesReadMcpResource
Plan Mode EnterPlanModeExitPlanMode

常见的独占 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 的实现位置,不应作为第三方集成依赖。