连载10-Sub-agents 深度解析:从源码理解 Claude Code 的分身术
![]()
Sub-agents 深度解析:从源码理解 Claude Code 的分身术
AI Coding 系列第 09 篇 · 多 Agent 编排
这篇文章讲到最后只有一句话:Sub-agent 不是一个人,是一套机械规则。 你在后面三节遇到的每个"为什么它会这样做",答案都不在 prompt 工程里——在 runAgent.ts 的几行 if 里。带着这句话往下读,这篇 9000 字的源码分析会变成它的验证过程。
你已经会用 Claude Code 完成单轮任务,也了解 Skill 和 Hook 如何定义知识和自动化规则。但当你面对一个需要"同时做三件事"的复杂项目——比如一边重构后端接口、一边更新文档、一边跑测试——你本能地想让 Claude"分身"去并行处理。
这篇文章从源码层面拆解 Sub-agents 的运行机制。不是教你"可以并行"这种显而易见的话,而是帮你理解:Claude Code 内部是怎么 fork 一个 Agent 的,Agent 之间的上下文隔离到底意味着什么,以及 worktree 隔离、权限继承、生命周期管理这些你在官方文档里找不到细节的东西。
一、先建立正确的心智模型
很多人把 Sub-agent 理解成"多线程"。这个类比有误导性。
更准确的说法是:Sub-agent 是一个独立的 LLM 会话,拥有自己的消息历史、工具集、中止控制器和文件状态缓存,但与父 Agent 共享同一个 AppState 状态树。
看一眼 runAgent.ts 中创建子 Agent 上下文的核心代码:
📂 展开源码:createSubagentContext 调用
// src/tools/AgentTool/runAgent.ts
const agentToolUseContext = createSubagentContext(toolUseContext, {
options: agentOptions,
agentId,
agentType: agentDefinition.agentType,
messages: initialMessages,
readFileState: agentReadFileState,
abortController: agentAbortController,
getAppState: agentGetAppState,
shareSetAppState: !isAsync, // 同步 Agent 与父共享状态写入
shareSetResponseLength: true,
})
关键细节:
-
readFileState是从父 Agent 克隆的,不是共享引用。子 Agent 读文件时走自己的缓存,不会污染父 Agent 的文件视图。 -
abortController对异步 Agent 是全新的(new AbortController()),对同步 Agent 是共享父 Agent 的。这意味着你 Ctrl+C 取消父 Agent 时,同步子 Agent 也会被取消,但后台运行的异步 Agent 不会。 -
shareSetAppState: !isAsync这行很关键——同步 Agent 能直接写入父 Agent 的状态树,异步 Agent 则完全隔离。
这不是多线程,更像是 Unix 的 fork():创建时复制父进程的内存快照,之后各走各路。
理解这个模型之后,你就能回答一个更根本的问题——为什么必须用 Sub-agent 而不是在主对话里多写几个 prompt?
答案不在"聪明"或"更强",而在结构。主对话的上下文是线性追加的,500 行测试日志、200 行 grep 结果、一堆中间推理——这些信息对"当下执行"是必要的,但对"后续决策"是噪声。Claude Code 不会自动区分临时执行数据和长期决策记忆,默认全当作重要信息存着。
而 Sub-agent 是 Claude Code 里唯一一个结构上允许"执行完即丢弃"的东西。它的上下文窗口独立——噪声进去,结论出来,窗口销毁。主对话永远看不到中间过程。不是优化,是架构层面的隔离。
![]()
二、何时用 Sub-agent:四个问题搞定决策
你不需要读完剩余 700 行源码分析再做决定。问自己四个问题就够了。
![]()
问题一:主对话真的需要这些中间过程吗?
如果任务的输出超过 50 行,而你只关心里面不到 10 行的内容——用子代理。
- 跑测试:300 行日志 → 你只需要"通过/失败,哪个挂了" → 信噪比 1%
- 代码搜索:
grep返回 50 个匹配 → 你需要 3 个关键文件 → 信噪比 6% - 日志分析:1000 行错误 → 你需要 1 条根因判断 → 信噪比 0.5%
噪声留在主对话的不是"看着乱",是 token 膨胀。一次 npm test 输出 300 行 ≈ 4500 tokens——后续每轮对话都要重新发送这些噪声。子代理把这些吞下去,吐回 200 tokens 的精炼摘要。从 8800 tokens 压到 3700 tokens,主对话瘦身 58%。
记一条规则:如果你想让主对话记住什么,就别让不重要的东西进入它的上下文。 这就是 Sub-agent 唯一不可替代的价值——结构上允许"执行完即丢弃"。
问题二:子 Agent 需要继承父 Agent 的上下文吗?
-
需要 → 用 Fork。省略
subagent_type,子 Agent 继承父 Agent 的对话历史和系统提示。共享 prompt cache,便宜。适合"帮我分担当前任务的一部分"。 -
不需要 → 用 Named Agent。指定
subagent_type,子 Agent 从零开始,只看你写在 prompt 里的信息。适合"帮我做一件独立的事"。
Fork 和 Named Agent 不是高级/低级的区别,是两种通信模式。Fork 是"你继续做这个,我分个身帮你分担";Named Agent 是"你去把这件事做了,我不管你之前干了什么"。
问题三:子 Agent 的修改会污染我当前的编辑工作吗?
-
会 → 加
isolation: "worktree"。子 Agent 在独立的 git worktree 里工作,修改不碰你的文件。完成后无变更则自动清理,有变更则保留分支让你 review 后合并。 - 不会(只读/搜索/分析等不写代码的任务)→ 不需要。
Worktree 的附加代价:每个 worktree 借用一个 git branch;node_modules 等大目录通过 symlink 共享(但如果子 Agent npm install 了新包,注意不要污染主仓库的依赖)。详见第五节。
问题四:这笔账划得来吗?
每个 Sub-agent 启动有 1-3 秒开销:克隆文件缓存、构建系统提示、加载 Skills、连接 MCP。同时,它的上下文隔离帮你省下几千到几万 tokens 的噪声搬运。
结论不是"Sub-agent 很贵"也不是"很值",而是——值不值取决于任务信噪比。低信噪比任务(跑测试、搜代码、分析日志)用 Sub-agent 绝对划算;高信噪比任务(直接的对话互动)不需要画蛇添足。
经验法则:
| 子任务预计耗时 | 决策 |
|---|---|
| > 3 分钟 | 启动成本忽略不计,大胆用 |
| 30 秒 ~ 3 分钟 | 信噪比判断决定 |
| < 30 秒 | 不值得,主对话直接做完 |
实战:怎么在对话中调用子 Agent
讲的都是"什么时候用",现在说"怎么用"。Claude Code CLI 里只能输入自然语言。 文章中出现的 Agent({subagent_type: "...", ...}) 是 Claude 内部的工具调用格式,不是让读者直接在终端敲的——Claude 读自然语言,帮读者生成这些调用。
你说自然语言 → Claude 解析意图 → Claude 内部生成 Agent() 工具调用 → 子 Agent 干活 → 结果展示给你。
# 触发内置 Explore Agent
帮我找一下项目中所有和 JWT token 验证相关的代码
# 触发你定义的自定义 Agent(如果 .claude/agents/ 里有 code-reviewer.md)
用 code-reviewer 审查 src/auth/ 的安全问题
# 触发 Fork(Claude 判断需要继承上下文时)
重构好了,帮我顺便写一下这三个函数的单元测试
# 流水线(Claude 顺序执行多个 Agent)
用 bug-locator 找到 token 验证失败的原因,然后让 bug-analyzer 分析根因
Claude 内部做的事:匹配你提到的名字(如 code-reviewer)到 .claude/agents/ 或内置 Agent → 生成 Agent() 工具调用(参数是 Claude 自己根据你的描述推断的)→ 子 Agent 启动 → 完成后直接把结果展示给你。你不会看到中间的 Agent({...}) 调用,只看到最终的文字回复。
如果想约束子 Agent 的行为(工具、权限、模型),要么在 .claude/agents/ 里预先配好(推荐),要么在自然语言里说清楚。话说得越具体,Claude 生成的调用参数越精确:
# 粗粒度(Claude 自己判断一切)
帮我审查代码
# 细粒度(你指定 Agent、范围、关注点)
用 code-reviewer 审查 src/auth/tokenValidator.ts,
重点关注硬编码密钥、缺少输入校验、auth 绕过风险,
用 sonnet 模型,最多调 20 轮工具
# 带 worktree 隔离(并行修改不要互相污染)
用 bug-fixer 修复 tokenValidator.ts:42-68 的竞态条件,
用 worktree 隔离,改完跑测试验证
Claude 会把"用 sonnet 模型"翻译成 model: "sonnet","最多调 20 轮工具"翻译成 maxTurns: 20。不是说自然语言万能——有些参数 Claude 可能理解偏差。关键的权限边界和工具白名单建议在 .claude/agents/ 配置里锁死,不要依赖自然语言。
常见疑问:如果同时有 code-review Skill 和 code-reviewer Sub-agent,Claude 选哪个?
源码里没有硬编码优先级。Claude 根据自己的判断二选一——它从系统提示里同时看到"可用 Skill 列表"和"可用 Agent 列表",靠任务特征自行裁量。简单规则性任务(如格式化输出)倾向 Skill;复杂多步骤、需要上下文隔离的任务倾向 Sub-agent。
但有一个非显而易见的耦合:Skill 的 frontmatter 里可以设
context: fork。 这种情况下,Skill 的实际执行会被路由到 Sub-agent——Claude 表面在"调用 Skill",底层却启动了一个独立上下文的子 Agent。从这个角度看,Skill 和 Sub-agent 不是互斥选项——context: fork的 Skill 就是用 Sub-agent 跑的 Skill。
调用后,后台的流程是透明的:
- Claude 把自然语言翻译成
Agent()工具调用 → 源码根据subagent_type(Claude 判断的)路由到对应 Agent 定义 - 创建独立的 LLM 会话——克隆文件缓存、构建系统提示、加载 Skills、连接 MCP
- 子 Agent 执行任务,它的工具调用和思考过程不会出现在你的对话里
- 完成后,只把最终的文字回复展示在你的对话中
你感知到的:子 Agent 执行期间终端可能显示它的工具调用(如果你开了详细输出),但它返回给你对话的内容只有最终的文字结果。500 行 grep 输出被子 Agent 吞掉了,你只看到"找到了 3 个相关文件,路径如下"。
(本文中的 Agent({...}) 代码示例展示的是 Claude 内部的工具调用格式,方便你理解参数含义。你不是在 CLI 里敲这些代码——这些是 Claude 在你的自然语言指令下生成的。)
下面走进源码,看这些机制具体怎么实现。
三、四种内置 Agent 类型:各有各的活法
![]()
Claude Code 不是只有一种 Sub-agent。打开 builtInAgents.ts,你会看到内置 Agent 的注册逻辑:
📂 展开源码:内置 Agent 注册
// src/tools/AgentTool/builtInAgents.ts
const agents: AgentDefinition[] = [
GENERAL_PURPOSE_AGENT,
STATUSLINE_SETUP_AGENT,
]
if (areExplorePlanAgentsEnabled()) {
agents.push(EXPLORE_AGENT, PLAN_AGENT)
}
这段代码展示了本文重点关注的四种 Agent。完整源码中还有
CLAUDE_CODE_GUIDE_AGENT(回答 Claude Code 使用问题)和VERIFICATION_AGENT(feature gate 控制的验证 Agent),以及 Coordinator Mode 下的动态 Agent 编排分支——它们各有专门的场景,不影响对核心四种的理解。
Explore Agent——只读搜索专家
Explore Agent 的系统提示开头就是一堵墙:
📂 展开源码:Explore Agent 的系统提示(只读限制)
=== CRITICAL: READ-ONLY MODE - NO FILE MODIFICATIONS ===
This is a READ-ONLY exploration task. You are STRICTLY PROHIBITED from:
- Creating new files (no Write, touch, or file creation of any kind)
- Modifying existing files (no Edit operations)
...
它的 disallowedTools 直接禁掉了 Agent、FileEdit、FileWrite、NotebookEdit、ExitPlanMode。这不是"建议"只读,而是工具级别的硬限制——Explore Agent 连尝试写文件的机会都没有。
注意这里的设计原则:安全感不是靠 prompt 劝说 Agent "请不要修改文件",而是从工具层把 Write 和 Edit 物理删掉。Agent 没有"自觉性"——唯一可依赖的,是它能调用哪些函数。这不是信任问题,是机械限制。
有两个值得注意的源码细节:
1. 它不带 CLAUDE.md:
📂 展开源码:omitClaudeMd 检查逻辑
// src/tools/AgentTool/runAgent.ts
const shouldOmitClaudeMd =
agentDefinition.omitClaudeMd &&
!override?.userContext
Explore Agent 设了 omitClaudeMd: true。原因是 Explore 只做搜索,commit 规范、PR 模板、lint 规则这些 CLAUDE.md 里的指令对它毫无意义。Anthropic 在代码注释里说这个优化"saves ~5-15 Gtok/week across 34M+ Explore spawns"——每周节省约 5-15 Gtok,覆盖 3400 万次以上 Explore 调用,每次省几千 token,累计节约量级惊人。
2. 它也不带 gitStatus:
📂 展开源码:gitStatus 省略逻辑
const { gitStatus: _omittedGitStatus, ...systemContextNoGit } =
baseSystemContext
const resolvedSystemContext =
agentDefinition.agentType === 'Explore' ||
agentDefinition.agentType === 'Plan'
? systemContextNoGit
: baseSystemContext
Explore 和 Plan 不需要会话开始时的 git status 快照(最多 40KB)。如果它们需要 git 信息,会自己跑 git status 获取实时数据,而不是依赖可能已经过期的快照。
Plan Agent——只读架构师
Plan Agent 和 Explore 共享同一套只读限制,但角色定位不同。它的系统提示要求输出结构化的实施方案:
End your response with:
### Critical Files for Implementation
List 3-5 files most critical for implementing this plan
Plan Agent 用 model: 'inherit',继承父 Agent 的模型。Explore 对外部用户默认用 Haiku(追求速度),Plan 则需要和父 Agent 一样强的推理能力。
General-Purpose Agent——全能型选手
tools: ['*'] 意味着它能使用所有工具(写代码、跑测试、执行 bash 命令),是真正的"全能分身"。注意:当 fork 功能开启时,省略 subagent_type 触发的是 Fork 而非 General-Purpose(见下文 Fork 小节)。
📂 展开源码:General-Purpose Agent 定义
export const GENERAL_PURPOSE_AGENT: BuiltInAgentDefinition = {
agentType: 'general-purpose',
tools: ['*'], // 全部工具
source: 'built-in',
baseDir: 'built-in',
// model is intentionally omitted - uses getDefaultSubagentModel()
getSystemPrompt: getGeneralPurposeSystemPrompt,
}
Fork Agent——上下文继承的分身
Fork 的触发机制不是语义分析——是参数缺失时的默认路由:当 Claude 生成 Agent() 调用但省略了 subagent_type,且 fork feature gate 开启时,自动走 Fork 路径(AgentTool.tsx:322)。它的特征是继承父对话的完整上下文——你不需要在 prompt 里重复"我刚才重构了哪些文件",Fork 子 Agent 从对话历史里自己知道。
和 Named Agent 的区别一目了然:
你:重构 src/auth/ 的 token 验证逻辑,改了三个函数
Claude:完成
你:顺便帮我写一下这三个函数的单元测试吧
→ Claude 判断:子 Agent 需要知道"刚才重构了哪些函数"
→ 触发 Fork:子 Agent 从对话中直接知道,不需要你重复说
你:检查 src/auth/ 有没有安全问题
→ Claude 判断:审查独立于之前的对话
→ 使用 code-reviewer(Named Agent):从零开始,只看你给的信息
Fork 不能嵌套——Fork 子 Agent 不能再创建自己的子 Agent。多层编排的工作由主对话负责。
📂 展开源码:Fork 的定义、触发路由、消息构建和防递归机制
// src/tools/AgentTool/forkSubagent.ts
// Not registered in builtInAgents — used only when !subagent_type
export const FORK_AGENT = {
agentType: 'fork',
tools: ['*'],
maxTurns: 200,
model: 'inherit',
permissionMode: 'bubble',
getSystemPrompt: () => '', // 继承父 Agent 的系统提示
}
触发路由(AgentTool.tsx:322):
const effectiveType = subagent_type ??
(isForkSubagentEnabled() ? undefined : 'general-purpose')
// subagent_type 缺失 + fork 门开启 → Fork 路径
Fork 子 Agent 产生字节级相同的 API 请求前缀,共享 prompt cache 所以比新建 Agent 便宜。防递归靠 isInForkChild() 检测对话中的 <fork_boilerplate> 标记直接拒绝。
四种 Agent 的分工很清楚:Explore 找,Plan 想,General-Purpose 做。Fork 省略 subagent_type 即可触发——需要继承上下文时不加字段走 fork,独立任务时指定类型走 Named Agent。不能在 .claude/agents/ 里定义 fork 类型的自定义 Agent。
四、.claude/agents/ 自定义 Agent:你只需要关注四个字段
除了内置 Agent,你可以在 .claude/agents/ 目录下用 Markdown + YAML frontmatter 定义自己的 Agent。
先说不该做的事:把 Zod Schema 里所有 14 个字段背下来。你真正每次都要认真设计的只有四个——description(何时触发)、tools / disallowedTools(权限边界)、model(成本决策)。其余字段有需要时再查。
一个接近实战的配置示例(不是模板,是设计思路的载体):
---
name: code-reviewer
description: "Reviews code changes for quality, security, and consistency with project conventions. Use when you want a second opinion on code before committing."
model: inherit
permissionMode: dontAsk
tools:
- Read
- Glob
- Grep
- Bash
disallowedTools:
- Write
- Edit
- NotebookEdit
skills:
- code-review
hooks:
SubagentStop:
- hooks:
- type: command
command: "echo 'Code review completed at $(date)' >> .claude/review-log.txt"
maxTurns: 30
---
You are a code review specialist. Your job is to analyze code changes and provide actionable feedback.
Focus on:
- Security vulnerabilities (injection, auth bypass, data exposure)
- Performance issues (N+1 queries, unnecessary allocations, blocking calls)
- Error handling gaps (missing try-catch, unhandled promise rejections)
- Consistency with existing patterns in the codebase
Be specific: cite file paths and line numbers. Don't flag style issues unless they affect readability.
四个需要认真设计的字段:
-
description:不是你写给人看的注释——是 Claude 判断"何时自动调用这个 Agent"的唯一依据。写清楚做什么和什么时候用。关键词proactively会鼓励 Claude 在合适的时机主动委派。 -
toolsvsdisallowedTools:白名单黑名单二选一,不要同时用。只读审查用tools: [Read, Grep, Glob];需要大部分工具但排除个别危险的用disallowedTools: [Write, Edit]。原则:最小权限——能用 Read 完成的就不要给 Edit。 -
model:不是越强越好。代码审查/分析推理 → sonnet;执行固定流程/模式匹配 → haiku;需要和主对话同等推理 → inherit。选错模型比选错工具更贵——Anthropic 的研究表明,升级模型的性能提升往往超过翻倍 token 预算的效果。 -
skills:Agent 不会自动继承主对话的 Skill。如果子 Agent 需要某个 Skill 的知识(如链路的 SLA 约束、历史事故记录),必须在skills字段显式列出,Skill 内容会在 Agent 启动时注入为isMeta: true的系统消息。
其余字段说明(有需要再看):
| 字段 | 用途 | 何时需要考虑 |
|---|---|---|
permissionMode |
覆盖权限确认行为 | 异步 Agent 建议设 dontAsk
|
maxTurns |
限制工具调用轮数 | 防止跑飞,建议 20-50 |
background |
强制后台运行 | 长时间任务的非阻塞执行 |
isolation |
worktree 隔离 | 并行修改不同模块时启用 |
mcpServers |
Agent 专属 MCP 连接 | 需要访问特定外部服务 |
hooks |
Agent 生命周期的自动动作 | SubagentStop 写日志等 |
initialPrompt |
首轮额外注入的提示 | 给 Agent 额外的任务约束 |
memory |
记忆作用域 | 跨会话共享知识 |
Agent 来源优先级
同名 Agent,后加载的覆盖先加载的:
// 覆盖链:内置 → 插件 → 用户级(~/.claude/agents/) → 项目级(.claude/agents/) → 企业管理策略
const agentMap = new Map<string, AgentDefinition>()
for (const agents of [builtIn, plugin, user, project, flag, managed]) {
for (const agent of agents) {
agentMap.set(agent.agentType, agent) // 后写入覆盖先写入
}
}
这意味着:你在 .claude/agents/ 里定义的 general-purpose Agent 会替换内置的通用 Agent。企业管理员可以通过策略设置强制覆盖所有 Agent 定义。
所以你应该怎么做:配置完 Agent 后,用一个简单任务测试 description 是否能正确触发。如果 Claude 该用的时候不用,大概率是 description 写得像"自我介绍"而不是"使用条件"。
五、Worktree 隔离:给 Agent 一个独立的代码沙箱
当你设置 isolation: "worktree" 时,子 Agent 会在一个独立的 git worktree 中工作。先理解概念:git worktree 让你在同一个仓库里同时 checkout 出多个分支到不同目录——每个目录像一个独立的仓库副本,有各自的 HEAD,但共享同一个 .git 目录。你不必为了在新分支上工作而 stash 当前修改。
![]()
本质上就是 fork() + chroot():共享同一个 .git,但每个 Agent 看到的文件系统是独立的隔离视图。
创建流程
📂 展开源码:Worktree 创建流程 (createAgentWorktree)
// src/utils/worktree.ts - createAgentWorktree
export async function createAgentWorktree(slug: string): Promise<{
worktreePath: string
worktreeBranch?: string
headCommit?: string
gitRoot?: string
}> {
validateWorktreeSlug(slug)
// 关键:使用 findCanonicalGitRoot 而不是 findGitRoot
// 确保 Agent worktree 总是创建在主仓库的 .claude/worktrees/ 下
// 而不是嵌套在某个会话 worktree 的 .claude/worktrees/ 里
const gitRoot = findCanonicalGitRoot(getCwd())
const { worktreePath, worktreeBranch, headCommit, existed } =
await getOrCreateWorktree(gitRoot, slug)
if (!existed) {
await performPostCreationSetup(gitRoot, worktreePath)
}
return { worktreePath, worktreeBranch, headCommit, gitRoot }
}
创建后的自动化设置
performPostCreationSetup 做了一系列你手动操作很容易遗漏的事:
-
复制
settings.local.json:本地设置可能包含敏感配置,需要传播到 worktree -
配置 git hooks 路径:让 worktree 复用主仓库的
.husky或.git/hooks,避免 pre-commit hook 失效 -
符号链接大目录:根据
settings.worktree.symlinkDirectories配置,symlinknode_modules等目录避免磁盘膨胀 -
复制
.worktreeinclude指定的文件:gitignore 的文件(如.env、build 产物)不在 worktree 中,但可以通过.worktreeinclude声明需要哪些
Worktree 的生命周期管理
Agent worktree 有一个优雅的"按需保留"机制:
📂 展开源码:Worktree 变更检查 (hasWorktreeChanges)
// 检查 worktree 是否有变更
export async function hasWorktreeChanges(
worktreePath: string,
headCommit: string,
): Promise<boolean> {
// 检查 1: 有没有未提交的改动
const status = await execFileNoThrowWithCwd(
gitExe(), ['status', '--porcelain'], { cwd: worktreePath })
if (statusOutput.trim().length > 0) return true
// 检查 2: 有没有新的 commit
const revList = await execFileNoThrowWithCwd(
gitExe(), ['rev-list', '--count', `${headCommit}..HEAD`], { cwd: worktreePath })
if (parseInt(revListOutput.trim(), 10) > 0) return true
return false
}
如果子 Agent 完成后没有任何变更,worktree 会被自动清理。如果有变更(新 commit 或未提交的修改),worktree 和分支会保留,返回路径和分支名让你后续处理。
还有一个后台清理机制,定期扫描过期的临时 worktree:
📂 展开源码:临时 Worktree 清理模式 (EPHEMERAL_WORKTREE_PATTERNS)
const EPHEMERAL_WORKTREE_PATTERNS = [
/^agent-a[0-9a-f]{7}$/, // AgentTool 创建的
/^wf_[0-9a-f]{8}-[0-9a-f]{3}-\d+$/, // WorkflowTool 创建的
/^bridge-[A-Za-z0-9_]+(-[A-Za-z0-9_]+)*$/, // Bridge 创建的
]
只有匹配这些模式的 worktree 才会被自动清理——你手动通过 EnterWorktree 创建的 worktree(比如 feature-redesign)永远不会被误删。
Fork + Worktree 的组合
当 Fork 子 Agent 在 worktree 中运行时,会收到一条特殊的上下文通知:
📂 展开源码:Worktree 上下文通知 (buildWorktreeNotice)
// src/tools/AgentTool/forkSubagent.ts
export function buildWorktreeNotice(
parentCwd: string, worktreeCwd: string,
): string {
return `You've inherited the conversation context above from a parent
agent working in ${parentCwd}. You are operating in an isolated git
worktree at ${worktreeCwd} — same repository, same relative file
structure, separate working copy. Paths in the inherited context refer
to the parent's working directory; translate them to your worktree root.
Re-read files before editing if the parent may have modified them...`
}
这段提示告诉 Fork 子 Agent:你继承的上下文里的文件路径指向父 Agent 的工作目录,你需要把路径"翻译"到自己的 worktree 里。这是一个容易被忽略但非常关键的细节。
并行修改不同模块时启用 worktree,每个模块独立分支互不干扰。只读探索不需要。如果子 Agent 要在 worktree 里 npm install 新依赖,记得在 .worktreeinclude 里声明 .env 等被 gitignore 的关键文件。
六、权限模型:谁能做什么
Sub-agent 的权限控制是分层的,不是简单的"继承父 Agent 权限"。
权限模式覆盖
📂 展开源码:权限模式覆盖逻辑
// src/tools/AgentTool/runAgent.ts
const agentGetAppState = () => {
const state = toolUseContext.getAppState()
let toolPermissionContext = state.toolPermissionContext
// Agent 定义的权限模式可以覆盖父 Agent 的
// 但 bypassPermissions 和 acceptEdits 模式永远不会被覆盖
if (
agentPermissionMode &&
state.toolPermissionContext.mode !== 'bypassPermissions' &&
state.toolPermissionContext.mode !== 'acceptEdits'
) {
toolPermissionContext = {
...toolPermissionContext,
mode: agentPermissionMode,
}
}
// 异步 Agent 不能显示权限弹窗——自动拒绝需要确认的操作
if (shouldAvoidPrompts) {
toolPermissionContext = {
...toolPermissionContext,
shouldAvoidPermissionPrompts: true,
}
}
}
几条规则:
-
bypassPermissions(SDK 模式)和acceptEdits永远优先——子 Agent 不能收窄这两种宽松模式 - 异步 Agent 设置
shouldAvoidPermissionPrompts: true,遇到需要用户确认的操作会自动拒绝 -
permissionMode: 'bubble'是 Fork 的默认模式,权限请求会"冒泡"到父 Agent 的终端
工具过滤
📂 展开源码:工具过滤器 (filterToolsForAgent)
// src/tools/AgentTool/agentToolUtils.ts
export function filterToolsForAgent({ tools, isBuiltIn, isAsync }): Tools {
return tools.filter(tool => {
if (tool.name.startsWith('mcp__')) return true // MCP 工具不受限
if (ALL_AGENT_DISALLOWED_TOOLS.has(tool.name)) return false
if (!isBuiltIn && CUSTOM_AGENT_DISALLOWED_TOOLS.has(tool.name)) return false
if (isAsync && !ASYNC_AGENT_ALLOWED_TOOLS.has(tool.name)) return false
return true
})
}
三层过滤:
-
所有 Agent 禁用的工具(
ALL_AGENT_DISALLOWED_TOOLS):比如ExitPlanMode——子 Agent 不应该改变父 Agent 的计划模式 -
自定义 Agent 额外禁用的(
CUSTOM_AGENT_DISALLOWED_TOOLS):用户定义的 Agent 比内置 Agent 受限更多 - 异步 Agent 的白名单:后台运行的 Agent 只能使用一个限定的工具子集
MCP 工具(mcp__ 前缀)不受这些限制,始终可用。
allowedTools 的权限隔离
📂 展开源码:allowedTools 权限隔离
// 父 Agent 的 session-level 权限不会泄露到子 Agent
if (allowedTools !== undefined) {
toolPermissionContext = {
...toolPermissionContext,
alwaysAllowRules: {
cliArg: state.toolPermissionContext.alwaysAllowRules.cliArg, // 保留 SDK 级权限
session: [...allowedTools], // 替换为子 Agent 自己的权限
},
}
}
注意这里的 cliArg 和 session 的区分:SDK 通过 --allowedTools 传入的权限(cliArg)是全局的,所有 Agent 都继承;而会话级别的权限(session)在子 Agent 创建时会被重置,防止父 Agent 运行时积累的权限无意间泄露给子 Agent。
自定义 Agent 首选白名单(tools),明确列出允许的工具,而不是依赖 disallowedTools 排除。异步 Agent 必须配合 permissionMode: 'dontAsk' 或 bubble——否则需要确认的操作被静默拒绝,Agent 不知道原因就反复重试,看起来像卡住了。
七、Agent 的完整生命周期与 Hook 联动
Hook 不是独立于生命周期的外挂——SubagentStart 和 SubagentStop 本身就是生命周期的两个关卡。先看 Hook 怎么嵌入,再看完整流程。
Hook 如何在生命周期中触发
在 Hooks 篇里讲过 SubagentStart 和 SubagentStop 事件,这里从 Agent 源码看触发机制。
SubagentStart:启动前的注入
📂 展开源码:SubagentStart Hook 注入
// src/tools/AgentTool/runAgent.ts
// 执行 SubagentStart hooks 并收集额外上下文
const additionalContexts: string[] = []
for await (const hookResult of executeSubagentStartHooks(
agentId, agentDefinition.agentType, agentAbortController.signal,
)) {
if (hookResult.additionalContexts?.length > 0) {
additionalContexts.push(...hookResult.additionalContexts)
}
}
// 把 Hook 注入的上下文作为用户消息添加到初始对话中
if (additionalContexts.length > 0) {
const contextMessage = createAttachmentMessage({
type: 'hook_additional_context',
content: additionalContexts,
hookName: 'SubagentStart',
...
})
initialMessages.push(contextMessage)
}
SubagentStart Hook 可以向子 Agent 注入额外的上下文信息——比如团队编码规范的摘要、当前 Sprint 的约束条件、或者从 CI 系统拉取的最新构建状态。
Agent 自带的 Hooks
Agent 定义的 frontmatter 可以声明自己的 hooks,这些 hooks 会在 Agent 启动时注册为 session hooks,Agent 结束时自动清理:
📂 展开源码:Agent 专属 Hooks 注册/清理
// 注册 Agent frontmatter 中的 hooks
// isAgent=true 会把 Stop hooks 转换为 SubagentStop
if (agentDefinition.hooks && hooksAllowedForThisAgent) {
registerFrontmatterHooks(
rootSetAppState,
agentId,
agentDefinition.hooks,
`agent '${agentDefinition.agentType}'`,
true, // isAgent - converts Stop to SubagentStop
)
}
// ... Agent 运行 ...
// 清理
finally {
if (agentDefinition.hooks) {
clearSessionHooks(rootSetAppState, agentId)
}
}
isAgent = true 这个参数把 Agent frontmatter 里声明的 Stop hooks 自动转换成 SubagentStop hooks。因为子 Agent 完成时触发的不是 Stop(那是主会话结束时的事件),而是 SubagentStop。
完整流程:从创建到销毁
一个 Sub-agent 从创建到销毁经历的完整流程:
启动阶段:
- 生成唯一的
agentId(createAgentId()) - 解析模型选择(Agent 定义 → 父 Agent 模型 → 默认模型)
- 如果启用了 Perfetto tracing,在追踪树中注册
- 克隆父 Agent 的
readFileState(文件缓存隔离) - 构建上下文:Explore/Plan 去掉 CLAUDE.md 和 gitStatus
- 执行 SubagentStart hooks,收集额外上下文
- 注册 frontmatter hooks(Stop → SubagentStop 转换)
- 预加载 frontmatter 中声明的 Skills
- 初始化 Agent 专属的 MCP Servers
- 记录初始消息到 sidechain transcript
运行阶段:
📂 展开源码:生命周期:运行阶段 (query loop)
for await (const message of query({
messages: initialMessages,
systemPrompt: agentSystemPrompt,
canUseTool: hasPermissionsToUseTool,
toolUseContext: agentToolUseContext,
querySource,
maxTurns: maxTurns ?? agentDefinition.maxTurns,
})) {
// 转发 API metrics 到父 Agent 的显示
// 记录每条消息到 sidechain transcript
// 检测 max_turns_reached 信号
yield message // 流式输出给父 Agent
}
清理阶段(finally 块):
📂 展开源码:生命周期:清理阶段 (finally cleanup)
finally {
await mcpCleanup() // 清理 Agent 专属 MCP 连接
clearSessionHooks(rootSetAppState, agentId) // 清理 session hooks
cleanupAgentTracking(agentId) // 清理 prompt cache 追踪
agentToolUseContext.readFileState.clear() // 释放文件缓存内存
initialMessages.length = 0 // 释放 fork 上下文消息
unregisterPerfettoAgent(agentId) // 释放 Perfetto 注册
clearAgentTranscriptSubdir(agentId) // 释放 transcript 映射
// 清理 AppState.todos 中的孤儿条目
// 杀死 Agent 启动的后台 bash 任务
// 杀死 Agent 启动的 Monitor 任务
}
每一步清理都有明确的必要性。比如最后两步——如果不杀死 Agent 启动的后台 shell 循环(run_in_background 的任务),这些进程的父进程退出后会被 init 进程(PID=1)接管,变成"僵尸进程",在主会话退出后依然残留运行。
八、异步 Agent vs 同步 Agent:不只是"后台运行"
这不是简单的"加个 run_in_background: true"的区别。两种模式在架构上有本质不同。
用 fork() 的术语来说:同步 Agent 是 fork() + waitpid()——父进程阻塞等子进程结束;异步 Agent 是 fork() + detach——子进程独立运行,父进程继续干活。
![]()
| 维度 | 同步 Agent | 异步 Agent |
|---|---|---|
| AbortController | 共享父 Agent 的 | 独立的新实例 |
| setAppState | 共享父 Agent 的 | 隔离(通过 rootSetAppState 间接写入) |
| 权限弹窗 | 可以显示 | 自动拒绝(shouldAvoidPermissionPrompts) |
| 工具集 | 完整(经过过滤) |
ASYNC_AGENT_ALLOWED_TOOLS 白名单 |
| 非交互模式 | 继承父 Agent | 强制 isNonInteractiveSession: true
|
| thinking | 禁用 | 禁用 |
| 完成通知 | 直接返回结果 | 通过 enqueueAgentNotification
|
一个容易踩坑的点:异步 Agent 用 bubble 权限模式时,权限请求会冒泡到父 Agent 的终端,看起来像是同步的权限请求,但其实来自一个后台 Agent。这在同时运行多个异步 Agent 时可能造成困惑。
还有一个更隐蔽的坑:Agent 被静默拒绝后,不会告诉你"我卡在权限上了"。它只知道"操作失败了",然后用同样的方式重试。
所以你应该怎么做:短任务用同步(能直接看输出),长任务(>2 分钟)用异步(不阻塞主对话)。异步 Agent 启动前确保配了
permissionMode: 'dontAsk'或bubble,并限定工具白名单——否则背景 Agent 会因权限不足静默失败,反复重试你也不知道为什么。
九、实战案例:基于源码理解的正确用法
本节中的
Agent({...})示例是 Claude 内部生成的工具调用格式,展示参数含义。CLI 中实际输入的是自然语言——Claude 帮你翻译成这些调用。
下面四个案例从简单到复杂递进:单一 Agent 审查 → 探索+实现串行流水线 → 多 Agent worktree 并行重构 → 影响面分析的事前拦截。
案例 1:并行代码审查
最基础的用法——四个完全独立的只读任务,并行执行。你要审查一个大 PR,涉及四个模块。每个模块的审查完全独立——审查 auth 的结果不影响审查 payment 的判断。
# 你在 CLI 里说:
用 code-reviewer 同时审查 src/auth/、src/payment/、src/order/、src/user/
四个模块的最新改动,每个模块独立审查,汇总成一份安全报告。
Claude 内部会把这一句话拆成四个并行的 Agent({subagent_type: "code-reviewer", ...}) 调用,四个审查 Agent 同时启动,各自只读分析自己负责的模块。
为什么这里必须用 Named Agent 而不是 Fork?因为审查 Agent 不需要知道你之前和 Claude 聊了什么——它只需要知道"去读哪几个文件"。Named Agent 从零开始,干净;Fork 继承你的对话历史,多余。
案例 2:探索 + 实现的流水线
案例 1 是"四个任务互不依赖"的并行模式。但现实中有很多任务是串行依赖的——先探索再实现,后一步需要前一步的输出。
# ❌ 错误:你说"帮我把 auth token 验证改成用 JWT,同时探索一下现在怎么实现的"
# → Claude 可能并行启动搜索和实现 → 实现 Agent 不知道搜索的发现
# ✅ 正确:
# 第一步:先搜索
用 Explore 找到项目中所有和 auth token 验证相关的实现,返回文件路径和函数名。
# 第二步:拿到搜索结果后,基于结果去改
# Claude 返回:tokenValidator.ts:42 用自定义 HMAC,session.ts:18 管理令牌生命周期
基于刚才 Explore 的结果,用 general-purpose Agent 把 tokenValidator.ts:42
的 HMAC 验证改成 JWT,同时更新 session.ts:18 的令牌生命周期逻辑。
# 注意这里 Claude 继承了对话上下文(Fork),知道 Explore 返回了什么
案例 3:Worktree 隔离的并行重构
案例 2 是串行流水线。现在回到并行——但这次每个 Agent 都会改文件,不再是只读。四模块重构可以并行,但需要各自独立的分支,互不污染。
# 你在 CLI 里说:
用四个 Agent 并行重构 user、product、order、payment 模块,
都改成 repository 模式。每个 Agent 用 worktree 隔离,
在自己的 git 分支上改。完成后告诉我各自的分支名。
Claude 内部给四个 Agent 各加 isolation: "worktree"。完成后每个 Agent 的改动在各自的临时分支上——你可以逐个 git diff 审查,不满意的直接删分支。四个重构互不干扰,也不用 stash 你当前的工作。
案例 4:影响面分析——堵住"正确代码、错误后果"的漏洞
前三个案例关注的是"怎么做"。案例 4 关注的是"该不该做"——用 Agent 在代码动工之前完成安全检查。
一个真实线上事故:开发者让 AI 对存量系统做功能迭代。代码本身没 bug,逻辑完全正确。上线后用户端 7 秒拿不到返回结果——新加的数据库查询增加了约 200ms 延迟,压垮了一个只剩 500ms 余量的 SLA 链路。
根因不是代码质量——是设计阶段缺少影响面分析。
# 你说:
我准备重构 src/auth/tokenValidator.ts 的令牌验证逻辑。
先用 impact-analyzer 检查这个改动会影响哪些调用链,有没有 SLA 风险。
Claude 启动 impact-analyzer——这个 Agent 通过 skills: ["chain-knowledge"] 预加载了链路拓扑和 SLA 约束,能追踪每一层调用关系。它返回的分析报告会告诉你:这个改动会影响订单服务和支付回调链,SLA 余量只剩 300ms,你的改动可能让端到端延迟超限。
只有当影响面分析通过后,才启动修改 Agent。 这个流程把 Sub-agent 从一个"事后审查"的辅助角色,升级成了"事前拦截"的工程防线——不是代码写好后再检查,而是代码还没写就先堵漏洞。
流水线中的交接契约
案例 2 展示了一条串行流水线——Explore 找 → General-Purpose 改。当流水线拉长到三四个阶段时,上下游之间需要交接契约(Handoff Contract):上游为下游准备的结构化信息,让下游不需要重复任何搜索就能开始自己的分析。
反面教材:Bug Locator 输出"bug 可能在 auth 模块里" → Analyzer 收到后不得不自己又搜了一遍 → 流水线形同虚设。合格交接至少包含:具体文件路径、函数名、行号范围、搜索证据(搜过什么、排除了什么)、为什么怀疑这个位置。
扩展视角:从子代理到 Agent Teams
本文的 Sub-agent 有一个硬约束:子代理只能向主对话汇报,不能互相通信。 打破这个限制的是 Claude Code 的实验性功能 Agent Teams——下篇详解。
十、常见失败模式与源码级诊断
每个失败模式背后都有一个被源码证实了的心理误判。知道"为什么掉坑"比知道"坑在哪"更有用。
失败模式 1:Agent 消耗 token 却不返回有用结果
症状:Agent 运行了很久,做了很多工具调用,最终报告里信息很少——像是做了一大堆工作但没有总结。
心理根因:你以为 Agent 会"自然地"在最后做总结。LLM 没有总结本能——它只是在生成下一个 token。如果最后一轮恰好是工具调用,它不会"觉得"自己需要再补一段文字总结。
源码级原因:finalizeAgentTool 优先提取最后一条 assistant 消息中的 text block(agentToolUtils.ts:301-303)。如果为空,会反向遍历所有历史 assistant 消息找第一个有 text 的(agentToolUtils.ts:307-315)——这个 fallback 能兜住一部分情况,但当 fallback 命中的是一条中间过程的思考而不是最终总结时,仍然拿不到有用的结果。根源还是 LLM 本身没有总结本能,以工具调用结束时不会自觉补一段文字。
解决方案:在 Agent 的 prompt 里明确要求"最后一条消息必须是文字总结,不要以工具调用结束"。不是你提示写得不够好——是提取逻辑本身只看最后一条消息。
失败模式 2:异步 Agent 被权限请求卡住
症状:异步 Agent 看起来卡住了,没有错误信息,没有进度,就像"死掉了"。
心理根因:你以为"后台运行 = 自动获得所有权限"。实际上后台运行的真相是"不能弹窗问你 → 自动拒绝 → Agent 不理解为什么被拒 → 重试 → 再次被拒 → 无限循环"。Agent 不会告诉你"我被权限卡住了",因为它的上下文里只有"操作失败了"。
源码级原因:当 shouldAvoidPermissionPrompts 为 true 时,需要权限确认的操作会被自动拒绝。Agent 不理解"拒绝"和"失败"的区别,继续用相同方式重试。
解决方案:
- 给异步 Agent 配置
permissionMode: 'dontAsk'加上明确的allowedTools(治本) - 或者用
permissionMode: 'bubble'让权限请求冒泡到你的终端,但多 Agent 并行时一堆弹窗会让你困惑(治标)
失败模式 3:Fork 子 Agent 试图再 fork
症状:Fork 子 Agent 的对话突然终止,没有输出,也没有错误提示。
心理根因:你以为 Fork 就是"一个普通的 Agent,可以再调 Agent"。但 Fork 的本质是"克隆了主对话的上下文,带上防递归标记"。它的设计意图就是"只执行,不分发"——分发的责任在主对话。
源码级原因:isInForkChild() 检测到对话中的 <fork_boilerplate> 标记,拒绝了 fork 请求。这不是 bug——所有编排必须由主对话完成,子 Agent 不能嵌套。
解决方案:Fork 子 Agent 收到的 boilerplate 里已经说了"Do NOT spawn sub-agents; execute directly"。如果你的任务确实需要多层 Agent 协作,用 Named Agent 而不是 Fork——让主对话作为唯一的编排者逐阶段调用。
这三个失败模式的共同根因:你把 Agent 当成了人,但源码里它是一套机械规则。它不会"觉得该总结了"、"理解权限为什么被拒"、"知道不该再 fork"。每当 Agent 的行为不如预期,第一反应不是改进 prompt,而是去查对应的源码逻辑——通常答案就在几行代码里。
本篇实践任务
任务 1:解剖你项目中的 Agent 调用
在一个中等复杂度的项目上,让 Claude Code 做一个涉及搜索 + 修改的任务(比如"找到所有硬编码的 API URL 并替换为环境变量")。观察它是否主动使用了 Sub-agent,用的是哪种类型,prompt 是怎么写的。对比它的选择和你的直觉。
任务 2:写一个自定义 Agent 配置
在 .claude/agents/ 下创建一个只读的代码审查 Agent,配置 disallowedTools、permissionMode、maxTurns。然后用它审查你最近的一次 commit,观察它的行为是否被配置正确约束了。
任务 3:测试 Worktree 隔离
对一个有测试的项目,启动两个 isolation: "worktree" 的 Agent 并行修改不同模块。完成后检查:各自的 worktree 分支是否独立?git log 是否只包含各自模块的修改?合并时是否有冲突?
下篇预告
第 10 篇:Agent Teams——当子 Agent 开始互相说话
本文讲的 Sub-agent 有一个硬约束:子 Agent 只能向主对话汇报,不能互相通信。而 Claude Code 的实验性功能 Agent Teams 打破了这个限制——Teammates 可以直接发消息、互相挑战结论、共享发现。下一篇讲 Agent Teams 的源码实现和四种核心协作模式:竞争假设、分层评审、模块化开发、规划审批。
AI Coding 系列持续更新。Sub-agent 不是让 Claude 做更多,而是让它记更少——噪声隔离有边界,编排决策有框架。