Hi,大家好,欢迎来到维元码簿。
本文属于 《Claude Code 源码 Deep Dive》 系列,专注于多 Agent 协作中的 上下文隔离与权限边界 板块。如果你想了解整个系列,可以先看系列开篇 | Claude Code 源码架构概览:51万行代码的模块地图。
本文讲一件事:多个 Agent 在同一进程里并发运行,系统怎么保证互不干扰、互不越权。
读完全文,你将能回答这几个问题:
- 多个 Agent 在同一进程里跑,AppState 是共享的,怎么不串数据? 答案:AsyncLocalStorage------每个异步执行链有独立的存储空间。AppState 的单一共享性是问题根源,AsyncLocalStorage 的异步链隔离是解决方案。
- 子 Agent 默认有父 Agent 的权限吗? 答案:没有。
allowedTools替换全部 allow 规则------"父权限不穿透"是写入注释的设计原则。 - 父 Agent 被取消了,子 Agent 也会停吗? 答案:不一定------异步子 Agent 有独立的 AbortController,可以后台继续跑。
本篇覆盖的源码范围
| 模块 | 核心文件 | 核心代码行 | 文件总行 | 职责 |
|---|---|---|---|---|
| Agent 上下文隔离 | src/utils/agentContext.ts |
L24-100(类型定义 + AsyncLocalStorage + 查找链) | 179 行 | SubagentContext/TeammateAgentContext 定义、getAgentContext() 查找 |
| Teammate 上下文 | src/utils/teammateContext.ts |
L23-61(TeammateContext 类型 + runWithTeammateContext) | 76 行 | 同进程队友的独立 AsyncLocalStorage |
| 权限边界 | src/tools/AgentTool/runAgent.ts |
L415-498(agentGetAppState 覆盖逻辑) | 84 行 | 权限模式覆盖、allowedTools 替换、bypass 保护 |
| Abort 隔离 | src/tools/AgentTool/runAgent.ts |
L520-528 | 9 行 | 异步独立 AbortController、同步共享 |
| 文件缓存隔离 | src/tools/AgentTool/runAgent.ts |
L375-378 | 4 行 | Fork 复制缓存、Regular 创建新缓存 |
| CLAUDE.md 裁剪 | src/tools/AgentTool/runAgent.ts |
L389-410 | 22 行 | Explore/Plan 不加载 CLAUDE.md 和 gitStatus |
前情提要:从生成到隔离
在姊妹篇[子 Agent 的生成与生命周期](./05-Claude Code深度拆解-多Agent协作 1-子Agent生成与生命周期.md)中,我们看到了 AgentTool 怎么创建子 Agent、runAgent() 怎么执行 12 步生命周期。但那些步骤背后隐含着一个更关键的问题:多个 Agent 并发运行时,它们怎么互不干扰?
答案藏在三层防线下:
- AsyncLocalStorage:防串数据------Agent A 的遥测事件不会归因到 Agent B
- 权限边界:防越权------子 Agent 默认只拥有显式授权的工具
- 独立 Abort:防误杀------父 Agent 停止不影响后台运行的子 Agent

这三层是独立的------外层的失败不会影响内层的隔离保证。接下来我们逐层拆解。
AsyncLocalStorage:为什么 AppState 不够用
这是多 Agent 系统中最容易被忽视、但破坏性最强的设计问题。 当多个 Agent 在同一进程中并发运行时,AppState 是单一的共享状态------Agent A 写入的 agentId 可能在事件循环的下一个 tick 被 Agent B 覆写,导致 A 的遥测事件错误地归因到 B。
问题场景:一个并发 Bug 的解剖
想象这个场景:主 Agent 启动了两个 Fork Agent,A 在搜索代码,B 在写文件。它们都在同一个 Node.js 进程中异步运行。AppState 里有一个 currentAgentId 字段用来追踪"当前是哪个 Agent"。
时间线:
t=0ms: Agent A 开始执行 → 设置 AppState.currentAgentId = "A-123"
t=5ms: Agent A 发起 API 调用 → await(事件循环切换)
t=6ms: Agent B 开始执行 → 设置 AppState.currentAgentId = "B-456"(覆盖!)
t=10ms: Agent A 的 API 响应返回 → 记录遥测事件
问题:currentAgentId 现在是 "B-456",A 的事件被归因到 B!
这不是理论上的 bug------这是在 Claude Code 的早期版本中真实存在的问题。解决方案:AsyncLocalStorage。
AsyncLocalStorage 的原理:每个异步链独立的存储
AsyncLocalStorage 来自 Node.js 的 async_hooks 模块。它的核心语义是:每个异步执行链(async/await 链)维护独立的存储,即使事件循环在它们之间切换。
typescript
// src/utils/agentContext.ts L93-100
const agentContextStorage = new AsyncLocalStorage<AgentContext>()
// 在 Agent 上下文中运行一个函数
export function runWithAgentContext<T>(context: AgentContext, fn: () => T): T {
return agentContextStorage.run(context, fn)
}
// 获取当前 Agent 上下文(没有参数穿透)
export function getAgentContext(): AgentContext | undefined {
return agentContextStorage.getStore()
}
关键调用是 agentContextStorage.run(context, fn)------在 fn 执行期间(包括 await 之后的所有继续执行),getStore() 都会返回 context。即使事件循环在 t=6ms 切换到 Agent B,Agent A 的异步链在 t=10ms 恢复时,getStore() 仍然返回 A 的 context。
两种 Agent Context:Subagent vs Teammate
先说两个概念的出处,免得后文混淆。Subagent 和 Teammate 并不是文档里为了对比而造的词,而是源码里显式定义的两种不同的 Agent 角色:
| 概念 | 来源于 | 触发路径 | 本质语义 |
|---|---|---|---|
| Subagent | src/utils/agentContext.ts 的 SubagentContext 类型 |
AgentTool 调用 → runAgent()(包含 Regular、Fork) |
主 Agent 内部临时 spawn 的"任务执行者",任务完毕即销毁 |
| Teammate | src/utils/agentContext.ts 的 TeammateAgentContext + 独立的 src/utils/teammateContext.ts |
Swarm 机制的 spawnTeammate()(team_name + name 触发) |
Team File 中注册的"持续性队友",有名字、有团队、跨任务存活 |
一句话概括:Subagent 是"干完就走"的临工,Teammate 是"长期在队里"的正式员工 。在 AgentTool 的三条路由里(见姊妹篇 1),Regular/Fork 分支产出 Subagent,Swarm 分支产出 Teammate------两者从诞生的那一刻起就是不同的物种。
基于这两种角色,Claude Code 定义了两种 Agent 上下文类型:
typescript
// SubagentContext(子 Agent)------ src/utils/agentContext.ts L32-54
export type SubagentContext = {
agentId: string // 子 Agent 的 UUID
agentType: 'subagent' // 类型标签
subagentName?: string // 如 "Explore", "code-reviewer"
isBuiltIn?: boolean // 内置 vs 用户自定义
invokingRequestId?: string// 触发此 Agent 的父请求 ID
invocationKind?: 'spawn' | 'resume' // 首次启动 vs 恢复
}
// TeammateAgentContext(同进程队友)------ L60-85
export type TeammateAgentContext = {
agentId: string // 如 "researcher@my-team"
agentName: string // 显示名
teamName: string // 所属团队
agentColor?: string // UI 颜色
planModeRequired: boolean // 是否需要 Plan Mode
isTeamLead: boolean // 是否为团队 Leader
agentType: 'teammate'
// ...
}
Subagent 和 Teammate 共享同一个 agentContextStorage,通过 agentType 字段区分。身份查找有三级优先级链:
① AsyncLocalStorage(agentContextStorage.getStore())
→ 进程内并发 Agent 的黄金标准,每个异步链独立
② dynamicTeamContext(通过 CLI 参数注入)
→ tmux 产生的进程级 Agent
③ process.env.CLAUDE_CODE_AGENT_ID
→ 独立子进程的最后兜底
不止一种 ALS:Teammate 有自己独立的存储
同进程 Teammate 还有一个独立的 teammateContextStorage:
typescript
// src/utils/teammateContext.ts L30
const teammateContextStorage = new AsyncLocalStorage<TeammateContext>()
export function isInProcessTeammate(): boolean {
return teammateContextStorage.getStore() !== undefined
}
两个 ALS 独立运行------agentContextStorage 负责身份追踪和遥测归因,teammateContextStorage 负责 Teammate 特有的生命周期管理。它们在各自的异步链中互不干扰。
权限不穿透:子 Agent 的安全底线
直觉上很多人会认为"子 Agent 是父 Agent 的延伸,应该有父 Agent 的权限"。但 Claude Code 的设计恰恰相反------子 Agent 默认只有显式授权的工具。
allowedTools:替换而非继承
runAgent() 里的注释是整段源码中最有态度的一句话:
typescript
// src/tools/AgentTool/runAgent.ts L297-300
/** Tool permission rules to add to the agent's session allow rules.
* When provided, replaces ALL allow rules so the agent only has what's
* explicitly listed (parent approvals don't leak through). */
allowedTools?: string[]
注意用词:"replaces ALL"、"don't leak through"------这是工程师在源码中表达的明确意图。不是"合并"、不是"添加"、不是"在父权限基础上过滤"------是"全部替换"。
实际生效逻辑在 agentGetAppState() 中的权限处理:
typescript
// src/tools/AgentTool/runAgent.ts L465-478
if (allowedTools !== undefined) {
toolPermissionContext = {
...toolPermissionContext,
alwaysAllowRules: {
cliArg: state.toolPermissionContext.alwaysAllowRules.cliArg, // 保留 SDK 级权限
session: [...allowedTools], // 使用提供的白名单(替换父的 session 规则)
},
}
}
唯一保留的是 cliArg 级别的权限------这些是 SDK 消费者通过 --allowedTools 显式传入的全局权限,应该对所有 Agent 生效。
权限模式的三层优先级
子 Agent 的权限模式不是简单的"继承或覆盖"------它是一个三层决策:
typescript
// src/tools/AgentTool/runAgent.ts L420-434(简化)
if (
agentPermissionMode &&
state.toolPermissionContext.mode !== 'bypassPermissions' && // 第一优先级
state.toolPermissionContext.mode !== 'acceptEdits' && // 第一优先级
!(feature('TRANSCRIPT_CLASSIFIER') &&
state.toolPermissionContext.mode === 'auto') // 第二优先级
) {
toolPermissionContext = {
...toolPermissionContext,
mode: agentPermissionMode, // 第三优先级:子 Agent 的权限模式生效
}
}
| 优先级 | 条件 | 行为 |
|---|---|---|
| 1(最高) | 父是 bypassPermissions 或 acceptEdits | 子 Agent 不能降级------保护用户明确意图 |
| 2 | 父是 auto 模式(YOLO 分类器) | 子 Agent 不能覆盖------分类器状态不应被改变 |
| 3(最低) | 其他情况 | 子 Agent 的 permissionMode 生效 |
说人话:子 Agent 可以比父更严格,但不能比父更宽松。 如果用户对父 Agent 说"随便干"(bypass),子 Agent 不能说"不,我要保守点"。反之,如果父 Agent 是默认的"每步都问我",子 Agent 可以设为 acceptEdits(自动接受编辑)。
异步 Agent 的特殊权限行为
异步 Agent(后台运行)还有额外的权限行为调整:
- shouldAvoidPermissionPrompts = true:不弹权限对话框------因为用户在终端上看不到,弹了也没用
- awaitAutomatedChecksBeforeDialog = true:等自动化检查(分类器、权限 Hooks)完成后再决定是否需要弹框
- isNonInteractiveSession = true:标记为非交互式会话
这三个标志组合起来实现了"后台 Agent 尽量不打扰用户"的目标。

独立 AbortController:父子生命周期的分离
这个设计只有 9 行代码,但背后是面向"用户体验"的深思熟虑。
typescript
// src/tools/AgentTool/runAgent.ts L520-528
const agentAbortController = override?.abortController
? override.abortController // Override 优先
: isAsync
? new AbortController() // 异步 Agent:全新独立生命周期
: toolUseContext.abortController // 同步 Agent:共享父生命周期
- 同步 Agent:共享父的 AbortController → 父被取消(如用户按 Ctrl+C),子也停。因为它是在"等待结果才能继续"的同步任务,父停了子再跑完也没意义。
- 异步 Agent:独立 AbortController → 父被取消,子继续跑。因为它是"后台任务",用户把主查询后台化了,子任务应该继续执行。
- Fork Agent(也是异步):独立 Abort,但 resume 时可以复用 Abort------保持生命周期的连续性。
文件缓存与 CLAUDE.md 的智能裁剪
文件读取缓存隔离
Fork Agent 复制父的 readFileState 缓存(因为上下文已共享,文件缓存也应该一致)。Regular Agent 创建新的空缓存(独立探索,不应该被父的缓存干扰)。
typescript
// src/tools/AgentTool/runAgent.ts L375-378
const agentReadFileState =
forkContextMessages !== undefined
? cloneFileStateCache(toolUseContext.readFileState) // Fork:复制
: createFileStateCacheWithSizeLimit(READ_FILE_STATE_CACHE_SIZE) // Regular:新建
Explore/Plan Agent 的上下文瘦身
这两个只读 Agent 有两个精心设计的优化:
① 不加载 CLAUDE.md。 Explore Agent 的 omitClaudeMd = true 标志告诉系统:子 Agent 不需要项目的 CLAUDE.md 规则(如 commit 规范、PR 模板、lint 规则),因为它只做搜索,主 Agent 负责理解上下文。
typescript
// src/tools/AgentTool/built-in/exploreAgent.ts L81
omitClaudeMd: true,
节省量?源码注释给出了数字:5-15 Gtok/week(每周 50-150 亿 token)。这不是小数字------对于大规模使用来说,这是真实的基础设施成本。
② 不加载 gitStatus。 Explore 和 Plan Agent 的 System Context 中去掉了 gitStatus 字段(最多 40KB)。如果子 Agent 需要 Git 信息,它可以自己跑 git status 获取最新数据------而不是接受父 Agent 启动时那一份"已标记为过时"的快照。
typescript
// src/tools/AgentTool/runAgent.ts L404-410
const { gitStatus: _omittedGitStatus, ...systemContextNoGit } = baseSystemContext
const resolvedSystemContext =
agentDefinition.agentType === 'Explore' ||
agentDefinition.agentType === 'Plan'
? systemContextNoGit
: baseSystemContext
子 Agent 的独立 MCP 连接
子 Agent 可以有自己声明的 MCP Server(在 Agent 定义中通过 mcpServers 字段声明)。这些是 additive to parent 的------子 Agent 既继承父的 MCP 连接,又有自己的专属 MCP。合并后按名称去重。
本章小结
- AsyncLocalStorage 是同进程并发 Agent 的隔离基础------不是"更好的 AppState",而是"完全不同的并发模型"。每个异步链独立存储,归因永不串线。
- 权限不穿透是安全底线 ------
allowedTools的注释"parent approvals don't leak through"是理解整个多 Agent 安全设计的关键句子 - AbortController 的独立/共享决策反映了同步/异步的本质差异------同步 Agent 随父而停,异步 Agent 独立存活
- Explore/Plan Agent 的上下文瘦身是精细的 token 工程------不加载 CLAUDE.md、不传 gitStatus,积少成多节省巨大成本
系列导航:
本文属于 《Claude Code 源码 Deep Dive》 系列中「多 Agent 协作」命题的子篇章,专注于 上下文隔离与权限边界。
姊妹篇(可独立阅读):
- Claude Code 深度拆解:多 Agent 协作 1 --- 子 Agent 生成与生命周期
- Claude Code 深度拆解:多 Agent 协作 3 --- 任务系统与 Agent 间通信
- Claude Code 深度拆解:多 Agent 协作 4 --- 团队协作与编排
如果这篇文章对你有帮助,欢迎点赞收藏 支持一下。如果你对 Claude Code 源码感兴趣,欢迎关注本系列 后续更新。有任何想法或疑问,欢迎评论区留言讨论 👋