拆解 Claude Code SubAgent:隔离、专业化与权限设计

0 阅读36分钟

从"这东西有什么用"聊到"它底下是怎么跑的",一篇讲完。


目录

入门篇

  1. 一个比喻理解 SubAgent
  2. SubAgent 解决的三个核心问题
  3. 你可能已经在使用了
  4. 什么时候该用,什么时候不该用

实践篇

  1. 三步创建自定义 SubAgent
  2. 配置文件完全指南
  3. 前台、后台与恢复
  4. 最佳实践:Prompt 怎么写

原理篇

  1. 为什么 SubAgent 是一个微型会话
  2. 两条路径的设计取舍:专业化 vs 缓存效率
  3. 权限模型:单向棘轮原则
  4. 工具池设计:最小权限原则的实际落地
  5. 生命周期管理
  6. Agent 定义的加载策略:信任的梯度
  7. MCP Server 的隔离策略:共享 vs 专属的取舍
  8. Worktree 隔离:让子代理在自己的沙箱里改代码
  9. 从 Sub-Agent 到 Multi-Agent:架构选型的三角博弈

附录

  1. 总结一下源码里藏着的设计巧思
  2. 源码关键文件索引

入门篇


1. 一个比喻理解 SubAgent

想象你是一个项目经理(主 Agent),手下有几个专员(SubAgent)。你不会自己去翻 200 个文件找答案——你会把任务交给调研专员,让他去翻,他翻完了最终再把结论汇报给你。

这就是 SubAgent 做的事:主 Agent 把任务派给一个独立的子进程去执行,子进程干完后只把结论带回来。

比如 Claude Code 内部的工具调用:

Agent({
  SubAgent_type: "Explore",
  prompt: "搜索整个代码库,找出所有 API 端点定义"
})

这段调用会启动一个 Explore 类型的子代理,它自己去搜索、读取文件、分析代码,最后把结果摘要返回。主 Agent 只看到结论不看到过程。

一句话总结:SubAgent = 一个拥有独立上下文窗口的自治 Worker,干完活只交结论。


2. SubAgent 解决的三个核心问题

问题一:上下文污染

Claude 的上下文窗口再大也是有限的。如果让主 Agent 自己去搜 30 个文件,那些搜索结果、文件内容、中间分析全部留在主对话里,等真正要做决策时,那上下文窗口可能已经快满了。

SubAgent 的解决方案是让自己天然拥有一个独立的上下文窗口。即中间过程全都留在子代理里,主对话只看结论。也就是说子代理执行完毕后,这些中间内容就消失了。

简单判断:如果信息对当下执行是必要的,但对后续决策是噪声——用子代理。

问题二:行为不可控

主 Agent 通常拥有完整的工具权限(读文件、写文件、执行命令)。但某些任务你只想让它"看",不想让它"改"。

对于这个问题 SubAgent 的解决方案是精确的工具权限控制。即我们可以定义一个只读型子代理,只给它 ReadGrepGlob 三个工具,这样它想改也改不了了。

# 只读型子代理(代码审查)
tools: Read, Grep, Glob

# 开发型子代理(bug 修复)
tools: Read, Write, Edit, Bash

# 研究型子代理(技术调研)
tools: Read, WebFetch, WebSearch

问题三:经验无法沉淀

每次都要手动告诉 Claude "去查这个、用那个方式分析"。这些操作步骤无法复用。

针对这个问题 SubAgent 的解决方案是配置即文件。子代理的定义保存在 .md 文件中,可以放进 Git 与团队共享,好用的配置可以复制到其他项目。

所以 SubAgent 可以用三个词概括:隔离、约束、复用。那么再从更高层面看 SubAgent 的设计哲学,其实就是将一个大脑拆成多个岗位角色,每个岗位只做一件事,并且有明确的权限边界。


3. 你可能已经在使用了

Claude Code 内置了几个 SubAgent。当你在对话里说”帮我看看代码库结构”、”先规划一下怎么做”、或者 Claude 自动走验证流程的时候,这些 SubAgent 就在干活。而你可能根本没注意到。

Explore(代码库的搜索引擎)

Explore 是最常用的内置 SubAgent。它的定位很明确:快速搜索、只读分析。

当我们在对话里说比如”帮我找一下所有 API 端点的定义”或者”这个函数在哪些地方被调用了”,Claude 就会启动 Explore 去干活。它会把成百上千行的 grep 结果、文件读取、路径分析全吞进自己的上下文里,最后只给你一份干净的摘要。

搜索深度分三档:quick、medium(默认)、very thorough。这个档位是可以在 prompt 里指定的,Explore 会据此调整搜多广。这不是代码层面的硬限制,纯粹是 prompt 级别的指导:

  • quick 就是跑几条 grep 就收工,适合”某个 class 在哪个文件”这种目标明确的问题
  • medium 则会多搜几个路径、多读几个文件,适合”这个模块的结构是怎样的”
  • very thorough 会在多个目录和命名规范下反复搜,尽量不留死角——适合”梳理认证流程从入口到数据库的完整调用链”。

工具方面 Explore 能用 Glob(按文件名搜)、Grep(按内容搜)、Read(读文件)、Bash(但只能跑只读命令)。在前段时间 Claude Code 暴露的源码里使用 disallowedTools 硬性屏蔽了 Edit、Write、NotebookEdit。说明它确实改不了东西。

外部用户跑 Explore 用的是 Haiku,快且便宜。Anthropic 内部用户则会继承主 Agent 的模型。

Claude Code 暴露的源码里有个不太起眼的阈值:EXPLORE_AGENT_MIN_QUERIES = 3。这个参数的作用是,主 Agent 被告知任务只需要 1-2 次搜索就搞定的别启动 Explore,直接用 Grep/Read,只有明确需要 3 次以上查询时才值得派出去。

另外,Explore 默认省略 CLAUDE.md 和 gitStatus(能到 40KB)。只读代理不需要知道 commit 规范和 PR 流程,自己会跑 git status。这一项每周会省 5-15 Gtok。

Plan(动手之前先想清楚)

Plan 的定位是软件架构师。它不写代码,专门在动手之前把方案想透。

比如当我们跟 Claude 说”我想给系统加个支付模块”,这个时候 Claude 就会先派 Plan 去调研,Plan 会读现有代码、找已有的模式和约定、理清依赖关系、最后输出一份分步实施计划。

系统提示给 Plan 定义了四步流程:

  • 理解需求
  • 深入探索(读代码、追踪调用链、参考已有实现)
  • 设计方案(考虑取舍)
  • 输出计划(分步策略、依赖关系、可能的坑)。

输出必须以”Critical Files for Implementation”结尾,并列出最关键的 3-5 个文件,这样主 Agent 拿到这份计划就知道下一步该读什么、改什么了。

Plan 跟 Explore 一样只读——同样的使用了 disallowedTools,改不了文件。但模型不同:Plan 继承主 Agent 的模型,不会降级到 Haiku。架构设计需要更强的推理能力,用便宜模型容易翻车。

Explore 和 Plan 的分工边界是:Explore 搜完就交结果,Plan 搜完还要分析、权衡、给建议。找函数在哪用 Explore,搞清”加这个功能要改哪些文件、按什么顺序改”用 Plan。

General-purpose(什么都干的全能选手)

Explore 和 Plan 都被硬性禁止了 Edit、Write 等工具,但 General-purpose 没这个限制。tools: ['*'],即父 Agent 有什么它就能用什么,这是它跟 Explore/Plan 的根本区别。搜索和规划是只读的活儿,而 General-purpose 要真刀真枪改代码。

系统提示很短,两段话完事:

Given the user's message, you should use the tools available to complete the task. Complete the task fully — don't gold-plate, but don't leave it half-done.

意思是把活干完,别画蛇添足,但也别半途而废。

General-purpose 适合的场景是那种连贯的多步骤流程:先读代码定位问题、再改代码、再跑测试验证。比如”修复认证模块的登录 bug”这种任务。

模型字段故意留空,由 getDefaultSubagentModel() 在运行时决定,是跟着会话配置走的。

Claude Code Guide——产品文档专家

Claude Code 还有一个不太起眼的内置 SubAgent:claude-code-guide。当你问”Claude Code 怎么配 hooks?”、”Agent SDK 怎么用?”的时候,Claude 会派它去查官方文档。

它的工具是 Glob、Grep、Read、WebFetch、WebSearch。Haiku 模型,dontAsk 权限(不弹确认框)。干活流程是先抓 code.claude.complatform.claude.com 的文档索引,再定位到具体页面拿答案。

Verification——专门来挑刺的

Verification 的系统提示第一句话就说:

Your job is not to confirm the implementation works — it's to try to break it.

它不是来验证”代码能跑”的,它是来找茬的。

当主 Agent 完成一项实现任务后,Verification 被自动调用。它会跑构建、测试、lint,然后根据变更类型(前端、后端、CLI、数据库迁移等各有各的检查套路)做针对性验证,还要跑边界值测试和对抗性探测。

输出格式要求严格:每条检查必须附带实际执行的命令和输出,不能只说”看起来没问题”。最后给出 VERDICT:PASS、FAIL 或 PARTIAL。默认后台运行,模型继承主 Agent。


这五个内置 SubAgent 各管一摊:搜索、规划、执行、查文档、找茬。共同点是它们都把高噪声的工作留在子进程里,不让垃圾信息堆到主对话中。


4. 什么时候该用,什么时候不该用

其实判断标准很简单:主对话需不需要承载过程本身?

适合用 SubAgent 的场景

  1. 有高噪声输出的任务——主对话只关心结论,不关心过程。比如搜索 30 个文件找一个 API 定义。
  2. 角色边界非常明确的任务——天然需要和其他任务隔离开。比如代码审查只看不改。
  3. 可以并行执行的研究型任务——比如同时调研三个模块的实现方式。
  4. 可以拆成清晰阶段的流水线式任务——比如先调研,再规划,再实现。

不适合用 SubAgent 的场景

你想做的事该用什么
读取一个已知路径的文件Read 工具
搜索 "class Foo" 在哪Grep 工具
在 2-3 个文件里找东西Read 工具
简单的文本修改Edit 工具直接改

重要提醒:子代理不能再嵌套调用子代理。所有编排都必须由主对话完成,流水线的调度中心只有一个。


实践篇


5. 三步创建自定义 SubAgent

方式一:交互式(推荐新手)

在 Claude Code 中输入 /agents,按照向导操作即可。

方式二:手写配置文件(推荐进阶)

直接创建 .claude/agents/your-agent.md 文件。优势是更精细的控制、方便版本管理、可以从其他项目复制。

方式三:CLI 参数临时创建(适合 CI/CD)

通过 --agents 参数在启动时传入 JSON 格式的子代理定义。仅在当前会话中存在,不会保存到磁盘。

claude --agents '[{"name":"lint-checker","tools":["Bash","Read"]}]'

这种方式特别适合 CI/CD 自动化:在流水线中临时创建任务专用的子代理。


6. 配置文件完全指南

一个完整的子代理配置文件长这样:

---
name: code-reviewer
description: Review code for security issues and best practices. Use after code changes.
tools:
  - Read
  - Grep
  - Glob
permissionMode: plan
model: sonnet
skills:
  - chain-knowledge
  - recent-incidents
hooks:
  PreToolUse:
    - matcher: "Bash"
      hooks:
        - type: command
          command: "./scripts/validate-readonly-query.sh"
---

你是一个代码审查专家。

当被调用时:
1. 首先理解代码变更的范围
2. 检查安全问题
3. 检查代码规范
4. 提供改进建议

输出格式:
## 审查结果
- 安全问题:[列表]
- 规范问题:[列表]
- 建议:[列表]

frontmatter 字段详解

字段作用备注
name子代理的唯一标识code-reviewer
description决定 Claude 何时自动调用这个子代理说清楚做什么和什么时候用
tools工具白名单只开放必要的工具
disallowedTools工具黑名单不要和 tools 同时用
model选择模型sonnetopushaiku
permissionMode权限模式控制遇到权限操作时如何处理
skills预加载的技能列表子代理不继承主对话的 Skill,需要显式列出
hooks生命周期钩子只在子代理运行期间生效,结束后自动清理
maxTurns最大执行轮次防止无限循环
effort思考努力级别(0-1)简单任务用低值,复杂任务用高值

工具权限的最小特权原则

遵循一个原则:能用 Read 完成的任务,就不要给 Edit。

只读型(审计/检查)         研究型(信息收集)         开发型(读写改)
├── Read                    ├── Read                   ├── Read
├── Grep                    ├── Grep                   ├── Write
└── Glob                    ├── Glob                   ├── Edit
                            ├── WebFetch               ├── Bash
                            └── WebSearch              ├── Glob
                                                       └── Grep

子代理存放位置与优先级

子代理定义有六种来源,同名冲突时高优先级覆盖低优先级。从高到低:

1. Built-in agents(内置)

源码里写死的,比如 Explore、Plan、General-purpose、Verification。你不能改它们也不能删。

2. Plugin agents(插件提供)

装了插件之后,插件自带的子代理会自动注册。名字带命名空间前缀(plugin-name:agent-name),避免跟自定义代理撞名。

插件代理有个安全限制:frontmatter 里写了 permissionModehooksmcpServers 会被直接忽略。源码注释说得很直白——插件是第三方代码,这些字段会让代理的权限超出用户安装时批准的范围。如果你需要这些控制能力,得在 .claude/agents/ 里手写,那里的定义是你自己审核过的。

3. User agents(用户级)

放在 ~/.claude/agents/ 目录下(Windows 是 %USERPROFILE%\.claude\agents\)。对当前用户所有项目生效。比如你有一个通用的代码审查代理,放到这里,不管在哪个项目里都能用。

创建方式:直接往这个目录丢 .md 文件就行,或者在 Claude Code 里输入 /agents 选择"用户级"位置。

4. Project agents(项目级)

放在项目根目录的 .claude/agents/ 下。只对当前项目生效。好处是可以提交到 Git,团队共享。

your-project/
└── .claude/
    └── agents/
        ├── code-reviewer.md
        └── deploy-checker.md

5. Flag agents(CLI 参数)

通过 claude --agents 参数在启动时传入 JSON 格式定义。只存在于当前会话,关掉就没了。适合 CI/CD 流水线或者临时用一下的场景。示例:

claude --agents '[{"name":"quick-check","tools":["Read","Grep"]}]'

6. Managed agents(企业管理)

这是最低优先级,也是最少人知道的一种。源码里的 source 叫 policySettings

Managed agents 存放在系统级的管理目录里:

  • macOS: /Library/Application Support/ClaudeCode/.claude/agents/
  • Windows: C:\Program Files\ClaudeCode\.claude\agents\
  • Linux: /etc/claude-code/.claude/agents/

由 IT 管理员配置,普通用户改不了。它的设计目的是让企业管理员给团队统一下发子代理定义——比如全公司通用的安全审计代理、合规检查代理。

Managed agents 的加载路径来自 getManagedFilePath(),这个目录也存放企业级的 managed-settings.json 配置。源码里的 getManagedFilePath() 还支持一个 drop-in 目录(managed-settings.d/),里面可以放多个配置文件按字母顺序叠加上去。

因为优先级最低,如果用户或项目里有同名的代理,企业下发的版本会被覆盖。这是有意为之:让本地自定义优先于企业默认。

正文部分(子代理的系统提示词)

--- 之间的 frontmatter 是配置,下面的 markdown 正文是子代理的系统提示词。子代理只会收到这段系统提示词和基本环境信息,不会继承主对话的完整系统提示词。


7. 前台、后台与恢复

前台模式(Foreground)

子代理在执行期间阻塞主对话。权限弹窗和问题会实时传递给用户。适用于需要人工审批、人工交互的任务。

后台模式(Background)

子代理并行执行,用户可以继续在主对话中工作,适合独立的探索或分析任务。

Claude 会根据任务自动选择前台或后台。也可以手动控制:

  • 对 Claude 说 "run this in the background"
  • 正在运行的前台子代理可以按 Ctrl+B 切换到后台

切换到后台时,Claude Code 会预先请求子代理可能需要的所有权限,因为后台运行时无法弹出交互式确认。

恢复(Resume)

每个子代理执行完成后,Claude 会自动获得它的 agent ID。你可以让 Claude 在之前的基础上继续:

code-reviewer 子代理审查认证模块
[子代理完成]

继续刚才的审查,再看一下授权逻辑
[Claude 恢复之前的子代理,保留完整上下文]

恢复会保留之前的对话历史,让它从上次停下的地方继续,而不是重新开始。

但注意:Explore 和 Plan 是一次性代理,执行完毕后不能通过 SendMessage 继续对话。


8. 最佳实践:Prompt 怎么写

核心原则

源码里有一段系统提示,是 Claude Code 告诉自己怎么写子代理 prompt 的:

Brief the agent like a smart colleague who just walked into the room — it hasn't seen this conversation, doesn't know what you've tried, doesn't understand why this task matters.

因为 Fresh Agent(你指定了 subagent_type 的那种)从零开始,没有父 Agent 的任何对话历史。所以 prompt 里必须包含子代理完成任务所需的全部信息。

写得差的 prompt 长什么样

查一下认证模块

这种 prompt 的问题:子代理不知道"认证模块"指的是哪部分代码,不知道你已经看过什么,不知道你查完之后要干嘛。它会瞎逛一圈,大概率找出一堆不相关的东西。

写得好的 prompt 长什么样

我需要了解这个项目中用户认证的完整流程。具体来说:

1. 项目是一个 Next.js 应用,认证相关代码可能在 src/auth/ 或 src/middleware/ 目录下
2. 我已经知道用了 NextAuth.js,但不确定具体配置在哪个文件
3. 我需要找到:登录入口、session 管理、权限校验中间件
4. 每个模块用了什么文件、关键函数名叫什么

最后给我一个调用链的总结,从用户点击登录到请求被校验通过,中间经过了哪些函数。

差别在哪?背景信息(Next.js、NextAuth.js)、已知信息(已经知道用了 NextAuth)、明确目标(找调用链)、输出格式(总结调用链)。子代理拿到这些,就能精准行动。

五条规则

给背景。 你在做什么项目、用了什么技术栈、为什么需要这个信息。不要假设子代理知道任何上下文。

说目标,别说步骤。 告诉它你要什么结果,让它自己决定怎么搜。"找出认证流程的调用链"比"先搜 auth 相关文件,再读每个文件,再找出函数调用"好得多。后者是把你的猜测当成了搜索方案,万一前提错了就白费。

交代已知信息。"我已经看过 src/auth/login.ts,排除了 cookie 方案"。这样子代理不会重复你已经做过的工作。

指定输出格式。"200 字以内"、"列出每个模块对应的文件路径和关键函数名"。没有格式约束的输出要么太长要么太短。

不要甩锅。"基于你的发现,修复 bug"——反面教材。子代理跑完调研,你拿到结果,你自己判断怎么修。让它既调研又修复,等于把决策外包了。

源码里的原话:

Never delegate understanding. Don't write "based on your findings, fix the bug." Those phrases push synthesis onto the agent instead of doing it yourself. Write prompts that prove you understood: include file paths, line numbers, what specifically to change.

并行和串行

并行和串行是 Claude Code 内部的调度策略,了解它可以帮助你更有效地给 Claude 下指令。

并行:如果你跟 Claude 说"同时帮我调研三个模块的实现方式",Claude 会在同一条消息里发出多个子代理调用。这些子代理同时启动、同时跑、各自独立返回结果。

适合的场景:多个互相不依赖的调研任务。比如"帮我同时看一下前端路由、后端 API、数据库 schema 分别怎么设计的"。

串行:后一个任务依赖前一个的结果。比如先调研认证模块的结构,再基于调研结果决定怎么加一个新功能。这时候 Claude 会先跑第一个子代理,等结果回来再决定下一步。

适合的场景:有依赖关系的流水线任务。

你该怎么利用这点?在对话里说清楚任务之间的关系就行:

  • "同时帮我查 A 和 B" → Claude 会并行派两个 Explore
  • "先帮我查 A,查完再基于结果做 B" → Claude 会串行执行
  • "帮我查 A、B、C,它们之间没有依赖" → Claude 会并行派三个

原理篇

读源码不只是看它做了什么,更重要的是为什么这么做。基于 v2.1.88 源码聊下 Claude Code SubAgent 系统背后的设计决策。


9. 为什么 SubAgent 是一个微型会话

Claude Code 团队没有把 SubAgent 当成一个"轻量级的任务派发"。他们把它当成一个完整的、独立的 Claude Code 会话的微缩版本

每次启动一个子代理,系统会:

  1. 从磁盘或内存找到对应的 AgentDefinition
  2. 为它组装一套独立的工具池
  3. 构建一段独立的系统提示
  4. 创建一个隔离的 ToolUseContext(权限、文件状态、拒绝追踪全是新的)
  5. 可选地为它初始化专属的 MCP Server
  6. 启动一个独立的 query() 循环
  7. 跑完后在 finally 块里做十项清理

这个流程跟启动一个新的 Claude Code 会话几乎没有区别,只是它跑在父进程内部、生命周期由父 Agent 管理

为什么要做得这么重?轻量级的做法是共享父 Agent 的上下文和状态,只在工具层面做点过滤就行了。但 Claude Code 选了隔离路线。原因在于一个核心判断:在 LLM 系统里,上下文污染比上下文缺失更危险。

共享上下文意味着子代理的中间输出会回溢到父对话里。一个 Explore 代理读 30 个文件产生的中间内容,如果留在主对话里,后面做决策时有效信息就被稀释了。相比之下,子代理从零开始需要你在 prompt 里多写几句背景信息——这是可控的成本。上下文污染是不可控的风险。

这就是为什么源码里 Fresh Agent 路径(标准路径)选择了零上下文继承。这是经过权衡的设计决策。


10. 两条路径的设计取舍:专业化 vs 缓存效率

SubAgent 有两条执行路径:Fresh Agent 和 Fork。

Fresh Agent:为专业化做的选择

当指定 subagent_type 时,系统走 Fresh 路径。它的四个特征:

  • 零上下文继承
  • 专用系统提示
  • 独立工具池
  • 独立权限模式

全部服务于同一个目标:让每个子代理成为某个领域的专家

Explore 只有搜索工具、Plan 继承父模型做架构分析、Verification 默认跑在后台专门找茬。工具池、系统提示、模型、运行模式,全都围绕这个代理的职责量身定制。

这种专业化是有代价的。因为每个子代理有独立的系统提示和工具池,它跟父 Agent 的 API 请求前缀不同,没法共享 Prompt Cache。每次启动一个 Fresh Agent,基本等于一次全新的 API 调用。源码里还特意把普通子代理的 thinkingConfig 设成 { type: 'disabled' },省输出 token,但进一步确保了缓存不可能命中。

设计取舍很清晰:Fresh Agent 用缓存效率换专业化。只要你指定了 subagent_type,你就选择了"让这个代理在自己的领域内做最好",而不是"让它尽量便宜"。

Fork:为缓存效率做的选择

Fork 是反过来的取舍。它不追求专业化,追求的是让并行子代理尽量便宜

看 Fork 的定义:

export const FORK_AGENT = {
  tools: ['*'],            // 不过滤工具——保持跟父一致
  model: 'inherit',        // 不换模型——保持跟父一致
  getSystemPrompt: () => '',  // 不生成新提示——保持跟父一致
  permissionMode: 'bubble',
}

每一行都在做同一件事:保持跟父 Agent 的 API 请求前缀字节级一致。因为 Anthropic API 的 Prompt Cache 要求前缀完全匹配。系统提示、工具定义、模型、消息前缀、思考配置五个维度全部一致,缓存才能命中。

Fork 通过 buildForkedMessages() 克隆父 Agent 的完整对话历史,给每个 tool_use 塞一个占位符 result,然后附上各自的指令文本。所有 Fork 子代理的前 N 条消息完全相同,只有最后一个文本块不同。

Agent 的请求:
  [system][tools][msg_1]...[msg_N][assistant_tool_uses]

Fork #1:
  [system][tools][msg_1]...[msg_N][assistant_tool_uses] ← 缓存命中
  [user: "调查模块A"]

Fork #2:
  [system][tools][msg_1]...[msg_N][assistant_tool_uses] ← 缓存命中
  [user: "调查模块B"]

派两个 Fork 子代理并行调研,理论上只有各自最后的那个指令文本是新的 token。相比派两个 Fresh Agent,成本可以低一个数量级。

但 Fork 的限制也来自这个设计:

  • 不能换模型(换了缓存就废了)
  • 不能自定义工具池(过滤了工具定义就变了)
  • 不能有独立的系统提示(换了前缀就不同了)。

它是一个"跟父 Agent 一模一样,只是干不同的活"的并行执行单元。

还有一个设计约束:Fork 不能嵌套。isInForkChild() 通过扫描 <fork-boilerplate> 标签来阻止 Fork 套 Fork。原因也很实际,如果 Fork 可以嵌套,内层 Fork 会继承外层 Fork 已经被污染的上下文,隔离性就保不住了。

为什么不让 Fresh Agent 也共享缓存

因为 Fresh Agent 的系统提示不同、工具池被过滤、思考配置被禁用、模型可能不同。四个维度的差异导致缓存键完全不同。这是 Fresh Agent 选择专业化的必然代价。

两条路径的设计本质上是一个光谱的两端:

专业化 <──────────────────> 缓存效率
   Fresh Agent              Fork
   独立提示+工具+模型        继承一切
   不共享缓存                字节级共享
   适合特定职责              适合并行调研

11. 权限模型:单向棘轮原则

源码里有一个关于权限的硬性规则,看着简单,背后的设计思路值得细想:

父 Agent 的 bypassPermissionsacceptEditsauto 模式永远优先,子代理降级不了。

if (
  agentPermissionMode &&
  state.toolPermissionContext.mode !== 'bypassPermissions' &&
  state.toolPermissionContext.mode !== 'acceptEdits' &&
  !(feature('TRANSCRIPT_CLASSIFIER') && state.toolPermissionContext.mode === 'auto')
) {
  toolPermissionContext = { ...toolPermissionContext, mode: agentPermissionMode }
}

这是一个单向棘轮(ratchet)设计。权限只能往更严格的方向调,不能往更宽松的方向调。

考虑一个场景:你用 --allowedTools 参数限制了子代理只能用 Read 和 Grep,结果子代理定义里写了 tools: ['*']。如果子代理的权限覆盖了你的限制,你花心思设的工具白名单就白费了。这违背了"调用者控制安全边界"的原则。

类似的棘轮设计还有好几个:

  • allowedTools 参数替换所有会话级规则,但保留 CLI 参数级规则。会话级规则是你运行时手动批准的,CLI 参数级规则是你启动时明确指定的。后者优先级更高,因为它是更早、更明确的安全决策。

  • 异步 SubAgent 有独立的拒绝计数器localDenialTracking),不影响父 Agent。因为异步代理跑在后台,它的权限拒绝不应该污染父 Agent 的交互体验。

  • 异步代理强制设置 shouldAvoidPermissionPrompts = true即不弹确认框,未授权操作直接拒绝。这同样是为了保安全边界:后台跑着的代理没法跟你交互确认,所以宁可直接拒绝也不默认放行。

所有这些设计的共同点是:**宁可让子代理功能受限,也不让安全边界被突破。**这在 LLM 系统里尤其重要,因为 LLM 的行为不可预测。权限边界的严谨性是最后一道防线。


12. 工具池设计:最小权限原则的实际落地

工具池组装的逻辑看代码就几行,但设计思路值得琢磨。

resolveAgentTools() 支持白名单(tools)和黑名单(disallowedTools)两种模式。白名单模式是**"只给这些",黑名单是"除了这些都给"**。两者同时存在时,白名单先过一遍,黑名单再过一遍。

比如有这么一个场景:你想让子代理能用大部分工具,但不能写文件也不能执行危险命令。用纯白名单你要列十几二十个工具名,漏了一个就出问题。用纯黑名单只列两个,但你要确保未来新增的工具默认是被允许的。

两阶段过滤给了你第三种选择:先用白名单框一个大致范围,再用黑名单精确排除。这在实践中更灵活。

tools: ['*'] 的处理也值得注意。它表示"用父的完整工具池",不做任何过滤。Fork 子代理和 General-purpose 代理用这个。设计意图是:当你信任这个子代理、不需要限制它的工具时,不要人为缩小它的能力范围。工具限制是为了约束你不信任的代理,不是为了约束所有代理。

MCP 工具的合并用了 uniqBy(..., 'name'),父工具优先。这也遵循了同样的安全逻辑:如果父 Agent 已经有一个叫 search 的 MCP 工具,子代理想加一个同名的,父的优先——子代理不能覆盖父的工具。


13. 生命周期管理

子代理的 runAgent() 函数在 finally 块里做了十项清理。这是从实际生产事故里学来的。

finally {
  await mcpCleanup()                           // 1. MCP 服务器
  clearSessionHooks(rootSetAppState, agentId)   // 2. 会话钩子
  cleanupAgentTracking(agentId)                 // 3. 缓存追踪
  agentToolUseContext.readFileState.clear()     // 4. 文件状态缓存
  initialMessages.length = 0                    // 5. 消息数组
  unregisterPerfettoAgent(agentId)              // 6. 性能追踪
  clearAgentTranscriptSubdir(agentId)           // 7. 转录目录
  rootSetAppState(prev => {                     // 8. todos
    const { [agentId]: _removed, ...todos } = prev.todos
    return { ...prev, todos }
  })
  killShellTasksForAgent(agentId, ...)          // 9. 后台 shell 任务
  // 10. 还有内存释放等隐式清理
}

清理的这么彻底是因为子代理是一个长期运行的进程,它可能打开 MCP 连接、注册钩子、创建后台 shell 任务、写入文件状态缓存。如果不清理:

  • MCP 连接泄漏——服务器进程不退出,资源浪费
  • 钩子残留——下一个子代理可能意外触发前一个的钩子
  • 文件状态缓存过期——下一个子代理可能读到脏数据
  • 后台 shell 任务失控——子代理已经结束,但它启动的 npm run dev 还在跑
  • 内存泄漏——消息数组、转录文件不被释放

源码里对子代理的每一步清理都对应一个可能出问题的场景。这是一种防御性编程的思路:不假设子代理会正常结束,而是假设它随时可能失败或被取消,确保不管怎么结束都不留垃圾。

生命周期管理的另一个设计是同步转异步的自动机制。**同步执行的子代理如果超时,会自动被切到后台。**源码里用一个竞速实现:

for await (const message of runAgent({ ... })) {
  // 正常处理
  // 同时有个计时器在跑
  // 计时器先到 → 切后台,返回 "launched in background"
}

这么做主要是因为子代理的执行时间不可预测。一个 Plan 代理分析大型代码库可能需要几分钟,直接超时报错会让用户白等。切到后台让用户可以继续干别的,子代理跑完了再通知。这样会有更好的用户体验。


14. Agent 定义的加载策略:信任的梯度

六种 Agent 来源的优先级:Built-in > Plugin > User > Project > Flag > Managed;这不是一个随意的排序。它反映了 Claude Code 对**"谁更可信"的判断梯度**。

代码里硬编码的 Built-in agents 可信度最高,因为它们经过 Anthropic 团队测试。插件其次,因为安装时用户批准了插件的权限清单。用户级和项目级是用户自己写的,可信度再低一档——不是因为用户不靠谱,而是因为项目级配置可能被 Git 提交者篡改(源码注释里明确提到了这个安全考虑)。Flag 是临时传的,生命周期最短Managed 是 IT 管理员下发的,优先级最低,因为企业管理员不知道你的项目具体需要什么。

Plugin agents 有一个额外的安全限制:frontmatter 里的 permissionModehooksmcpServers 会被直接忽略。源码注释说得很直白——插件是第三方代码,这些字段能让代理的权限超出用户安装时批准的范围。

这个限制说明了一个设计原则:安装时信任边界和运行时信任边界要一致。 用户安装插件时批准了一组能力,运行时不能通过 frontmatter 悄悄增加新能力。如果你需要这些控制,得在 .claude/agents/ 里手写——因为那里的定义是你自己审核过的。

Agent 定义从 .md 文件解析的逻辑也值得一看。parseAgentFromMarkdown() 把文件拆成 frontmatter(YAML 配置)和正文(系统提示)。这个设计的妙处是:它让 Agent 定义既能被人直接阅读和编辑(就是 markdown 文件),又能被程序精确解析(YAML 是结构化的)。不需要额外的 schema 文件或编译步骤。这种"配置即文档"的思路贯穿了整个 Claude Code 的设计。


15. MCP Server 的隔离策略:共享 vs 专属的取舍

子代理可以在 frontmatter 里声明 MCP 服务器。源码里分了两种处理方式:引用(字符串)和内联(对象)。

引用方式使用全局配置里的 MCP 连接,子代理结束时不断开。内联方式创建专属连接,跑完就清理。

分两种是因为 MCP 服务器的启动成本不低。有些服务器(比如数据库连接、浏览器实例)初始化要好几秒。如果每个子代理都重新连一遍,并行跑三个子代理就要等三遍启动。引用方式通过共享连接避免了这个问题。

但共享连接有隐患:**如果子代理修改了 MCP 服务器的状态(比如改了数据库里的数据),下一个用同一个连接的子代理会看到脏状态。**内联方式通过"跑完就清理"来隔离——每个子代理拿到的是全新的 MCP 实例。

引用方式快但不隔离,内联方式隔离但慢。源码让你根据场景选。

还有一个安全限制:当 MCP 被锁定为"仅限插件"时,用户自定义的 Agent 不能声明 frontmatter MCP。这又是一个棘轮设计——企业管理员锁了 MCP 策略,用户级代理不能绕过。


16. Worktree 隔离:让子代理在自己的沙箱里改代码

Worktree 隔离的用途很直接:让子代理在一个独立的 Git Worktree 里修改文件,不影响你的工作目录。

设计上有个细节值得注意:如果子代理跑完没有产生任何文件改动,Worktree 和分支会被自动清理。如果有改动,则返回路径和分支名,由用户决定怎么处理。

并行跑多个子代理时,每个都会创建一个 Worktree。如果都保留下来,仓库里会堆满废弃的 Worktree。自动清理降低了运维成本。

Fork 子代理在 Worktree 里跑时还会收到一个路径翻译通知。这解决了 Fork 继承上下文带来的一个实际问题:Fork 继承了父 Agent 的对话历史,历史里的文件路径指向父的工作目录。但子代理现在在 Worktree 里,路径不同了。如果不做翻译,子代理会去父目录操作,Worktree 隔离就形同虚设。

这个细节说明了一个重要的思路:隔离不是一次性动作,而是需要在整个生命周期中持续维护的属性。 创建 Worktree 只是第一步,路径翻译、权限隔离、状态隔离、资源清理,每一层都要考虑隔离的一致性。


17. 从 Sub-Agent 到 Multi-Agent:架构选型的三角博弈

把视线从 Claude Code 的源码里拔出来,看看更大图景。

Sub-Agent 是多 Agent 系统的基础形态。但在实际工程中,纯 Supervisor + Sub-Agent 模式会遇到四个挑战:

状态复杂度。 一个 Sub-Agent 的轻微错误可能级联放大。应对方法是在每个 Agent 输出端设检查点。源码里的 Verification 代理干的就是这个。

非确定性调试。 LLM 的输出不固定,出问题时需要完整链路追踪。源码里的侧链转录(recordSidechainTranscript)和链式 UUID 就是为此设计的。

部署复杂度。 多 Agent 系统不能简单"停机更新",得渐进式部署。源码里的 Feature Flag 门控(FORK_SUBAGENTtengu_agent_list_attach 等)就是渐进式发布的工具。

同步瓶颈。 当前大多数 Sub-Agent 是同步执行的——父 Agent 阻塞等待子代理完成。未来的方向是异步执行加上 Agent 间消息通道。源码里已经为这个做了准备:registerAsyncAgentenqueueAgentNotification、独立的 AbortController。

架构选型归根结底是三个维度的博弈:性能、成本和可控性。纯 Sub-Agent 模式隔离性最好(可控性强),但 token 消耗大约 15 倍(成本高),研究时间最多缩短 90%(性能好)。你需要根据实际情况决定怎么取舍。

典型的演进路径:

第一步:单 Agent + Tools
第二步:单 Agent + Skills
第三步:Supervisor + Sub-Agents
第四步:混合架构(Router 分类 + Sub-Agent 并行 + Handoff 顺序流程)

几条经验:从单 Agent 开始,碰到瓶颈再升级。先加工具再加 Agent。选对模型比堆 token 管用。多 Agent 的第一价值是隔离,不是并行。


附录


18. 总结一下源码里藏着的设计巧思

  1. CLAUDE.md 瘦身。 Explore 和 Plan 默认省略 CLAUDE.md 和 gitStatus(能到 40KB)。它们是只读代理,不需要 commit/PR/lint 规则,自己会跑 git status 拿最新数据。每周省 5-15 Gtok。这不是优化技巧——这是"只加载必要信息"的设计原则的体现。

  2. 验证提示。 子代理连续完成 3+ 个任务没验证结果的话,系统会注入提醒。这反映了一个判断:LLM 在执行模式中倾向于"完成任务"而不是"验证质量"。系统需要主动干预来纠正这个倾向。

  3. 一次性代理。 Explore 和 Plan 被标记为一次性代理,跑完不能通过 SendMessage 继续。原因是这两个代理的职责是"搜索和规划",不需要多轮交互。如果允许继续,会增加复杂度但不会增加多少价值。不为不存在的场景做设计。

  4. 后台摘要。 长运行的子代理,系统每 30 秒跑一次后台摘要。这是在"让用户了解进度"和"不要产生太多输出"之间找平衡。

  5. Bash 禁止 cd。 子代理上下文里 Bash 工具不让切目录。一个在预期路径之外游荡的子代理是不可调试的。

  6. Agent 列表缓存优化。 Agent 列表从工具描述挪到了 system-reminder 消息里注入。因为 MCP/插件/权限一变列表就变,放在工具描述里会频繁打碎 Prompt Cache。把经常变的东西从缓存关键路径上移走。

  7. Fork 的"不偷看"规则。 系统提示写死了:主 Agent 不能读 Fork 子代理 output_file,除非用户明确要求。原因:读子代理的中间输出会把噪声拉回主对话,违背了 Fork 的设计初衷——把过程隔离在子代理里。

  8. 跑完全面清理。 finally 块清掉十项资源。子代理跑完不留垃圾。这不是代码洁癖,这是从生产事故里学来的教训。


19. 源码关键文件索引

文件行数核心设计职责
src/tools/AgentTool/AgentTool.tsx~1387路由决策,生命周期管理
src/tools/AgentTool/runAgent.ts~974执行引擎,资源管理,防御性清理
src/tools/AgentTool/forkSubAgent.ts~211缓存一致性,递归防护
src/tools/AgentTool/prompt.ts~287双路径提示策略
src/tools/AgentTool/loadAgentsDir.ts~756信任梯度加载
src/tools/AgentTool/agentToolUtils.ts-最小权限工具过滤
src/utils/forkedAgent.ts~690缓存共享,上下文隔离

写在最后

Claude Code 的 SubAgent 系统不是"派个任务出去"这么简单,他有一些自己的核心判断:

隔离比共享安全。 子代理从零开始、独立的工具池和权限、彻底的 finally 清理——每一层都在避免父 Agent 跟子代理互相干扰。上下文污染是不可控的风险,多写几句 prompt 背景是可控的成本。

专业化跟缓存效率是一对矛盾。 Fresh Agent 选了专业化,Fork 选了缓存效率。两条路径的存在不是因为技术上的巧合,而是因为这两种需求都是真实的。在写自定义 Agent 时也需要做同样的取舍: Agent 是要高度专业化(窄工具、专用提示),还是要尽量便宜(宽工具、继承上下文)?

安全边界是单向的。 权限只能降不能升、插件不能声明 hooks 和 MCP、企业管理员锁了策略用户就绕不过去。这些棘轮设计背后的逻辑是:LLM 的行为不可预测,权限边界是最后一道防线。