Claude Code 深度拆解:多 Agent 协作 2 — 上下文隔离与权限边界

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.tsSubagentContext 类型 AgentTool 调用 → runAgent()(包含 Regular、Fork) 主 Agent 内部临时 spawn 的"任务执行者",任务完毕即销毁
Teammate src/utils/agentContext.tsTeammateAgentContext + 独立的 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 源码感兴趣,欢迎关注本系列 后续更新。有任何想法或疑问,欢迎评论区留言讨论 👋

相关推荐
笨蛋©3 小时前
2026制造业实战:ISO 9001认证体系下的检验计划数字化与图纸识别流程
ai·cad·质量管理·制造业·图纸识别
少许极端4 小时前
AI修炼记2-MCP
人工智能·ai·mcp
运维开发王义杰4 小时前
初探 LangGraph:用状态机重塑 Agent 开发,附极简 Demo
agent
深海鱼在掘金4 小时前
深入浅出 LangChain —— 第五章:工具系统
人工智能·langchain·agent
深海鱼在掘金4 小时前
深入浅出 LangChain —— 第四章:提示词工程
人工智能·langchain·agent
hixiong1234 小时前
C# OpenvinoSharp部署INSID3
开发语言·人工智能·ai·c#·openvinosharp
可视化运维管理爱好者4 小时前
pi mono操作开发指南
运维·网络·ai
无籽西瓜a5 小时前
RAG 中的幻觉是什么?原因分析与防范措施
人工智能·ai·rag
星浩AI5 小时前
OpenAI 大神 Karpathy 开源:用 Obsidian 实现 LLM Wiki 知识库管理方法
后端·openai·agent