首页 > 基础资料 博客日记
拆解 Claude Code SubAgent:隔离、专业化与权限设计
2026-04-09 10:30:01基础资料围观1次
从"这东西有什么用"聊到"它底下是怎么跑的",一篇讲完。
目录
入门篇
实践篇
原理篇
- 为什么 SubAgent 是一个微型会话
- 两条路径的设计取舍:专业化 vs 缓存效率
- 权限模型:单向棘轮原则
- 工具池设计:最小权限原则的实际落地
- 生命周期管理
- Agent 定义的加载策略:信任的梯度
- MCP Server 的隔离策略:共享 vs 专属的取舍
- Worktree 隔离:让子代理在自己的沙箱里改代码
- 从 Sub-Agent 到 Multi-Agent:架构选型的三角博弈
附录
入门篇
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 的解决方案是精确的工具权限控制。即我们可以定义一个只读型子代理,只给它 Read、Grep、Glob 三个工具,这样它想改也改不了了。
# 只读型子代理(代码审查)
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.com 和 platform.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 的场景
- 有高噪声输出的任务——主对话只关心结论,不关心过程。比如搜索 30 个文件找一个 API 定义。
- 角色边界非常明确的任务——天然需要和其他任务隔离开。比如代码审查只看不改。
- 可以并行执行的研究型任务——比如同时调研三个模块的实现方式。
- 可以拆成清晰阶段的流水线式任务——比如先调研,再规划,再实现。
不适合用 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 |
选择模型 | sonnet、opus、haiku 等 |
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 里写了 permissionMode、hooks、mcpServers 会被直接忽略。源码注释说得很直白——插件是第三方代码,这些字段会让代理的权限超出用户安装时批准的范围。如果你需要这些控制能力,得在 .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 会话的微缩版本。
每次启动一个子代理,系统会:
- 从磁盘或内存找到对应的 AgentDefinition
- 为它组装一套独立的工具池
- 构建一段独立的系统提示
- 创建一个隔离的 ToolUseContext(权限、文件状态、拒绝追踪全是新的)
- 可选地为它初始化专属的 MCP Server
- 启动一个独立的 query() 循环
- 跑完后在 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 的 bypassPermissions、acceptEdits 和 auto 模式永远优先,子代理降级不了。
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 里的 permissionMode、hooks、mcpServers 会被直接忽略。源码注释说得很直白——插件是第三方代码,这些字段能让代理的权限超出用户安装时批准的范围。
这个限制说明了一个设计原则:安装时信任边界和运行时信任边界要一致。 用户安装插件时批准了一组能力,运行时不能通过 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_SUBAGENT、tengu_agent_list_attach 等)就是渐进式发布的工具。
同步瓶颈。 当前大多数 Sub-Agent 是同步执行的——父 Agent 阻塞等待子代理完成。未来的方向是异步执行加上 Agent 间消息通道。源码里已经为这个做了准备:registerAsyncAgent、enqueueAgentNotification、独立的 AbortController。
架构选型归根结底是三个维度的博弈:性能、成本和可控性。纯 Sub-Agent 模式隔离性最好(可控性强),但 token 消耗大约 15 倍(成本高),研究时间最多缩短 90%(性能好)。你需要根据实际情况决定怎么取舍。
典型的演进路径:
第一步:单 Agent + Tools
第二步:单 Agent + Skills
第三步:Supervisor + Sub-Agents
第四步:混合架构(Router 分类 + Sub-Agent 并行 + Handoff 顺序流程)
几条经验:从单 Agent 开始,碰到瓶颈再升级。先加工具再加 Agent。选对模型比堆 token 管用。多 Agent 的第一价值是隔离,不是并行。
附录
18. 总结一下源码里藏着的设计巧思
-
CLAUDE.md 瘦身。 Explore 和 Plan 默认省略 CLAUDE.md 和 gitStatus(能到 40KB)。它们是只读代理,不需要 commit/PR/lint 规则,自己会跑
git status拿最新数据。每周省 5-15 Gtok。这不是优化技巧——这是"只加载必要信息"的设计原则的体现。 -
验证提示。 子代理连续完成 3+ 个任务没验证结果的话,系统会注入提醒。这反映了一个判断:LLM 在执行模式中倾向于"完成任务"而不是"验证质量"。系统需要主动干预来纠正这个倾向。
-
一次性代理。 Explore 和 Plan 被标记为一次性代理,跑完不能通过 SendMessage 继续。原因是这两个代理的职责是"搜索和规划",不需要多轮交互。如果允许继续,会增加复杂度但不会增加多少价值。不为不存在的场景做设计。
-
后台摘要。 长运行的子代理,系统每 30 秒跑一次后台摘要。这是在"让用户了解进度"和"不要产生太多输出"之间找平衡。
-
Bash 禁止 cd。 子代理上下文里 Bash 工具不让切目录。一个在预期路径之外游荡的子代理是不可调试的。
-
Agent 列表缓存优化。 Agent 列表从工具描述挪到了 system-reminder 消息里注入。因为 MCP/插件/权限一变列表就变,放在工具描述里会频繁打碎 Prompt Cache。把经常变的东西从缓存关键路径上移走。
-
Fork 的"不偷看"规则。 系统提示写死了:主 Agent 不能读 Fork 子代理 output_file,除非用户明确要求。原因:读子代理的中间输出会把噪声拉回主对话,违背了 Fork 的设计初衷——把过程隔离在子代理里。
-
跑完全面清理。 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 的行为不可预测,权限边界是最后一道防线。
本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:jacktools123@163.com进行投诉反馈,一经查实,立即删除!
标签:
相关文章
最新发布
- AI开发-python-langchain框架(3-21-Structured Chat ReAct 智能体构建及对比 ZeroShot ReAct)
- 从零学习Kafka:位移与高水位
- 为什么给 new 设计一个 realloc 是必要的
- RFC 9535:JSONPath 的标准化之路
- 拆解 Claude Code SubAgent:隔离、专业化与权限设计
- 【从0到1构建一个ClaudeAgent】工具与执行-Agent循环
- ClawHub 24 小时热门 Top 10 | 2026 年 04 月 09 日
- 在 Web 界面直接编辑 DESIGN.md:从思路到实现
- 电子小白之集成电路
- Anthropic 推出 Claude Mythos:一个“强到不敢公开”的前沿模型

