8 · 子智能体

tools/AgentTool/ 目录,主文件 228 KB。这是所有工具里最复杂的一个 —— 因为它的作用是递归地启动另一个完整的智能体

8.1 首要动机是上下文隔离,不是并行

场景:主智能体要找到某个函数定义在哪,可能需要读 20 个文件才能确定。

自己读:20 个文件的完整内容进入主上下文,此后每一轮都要重发一遍。每个文件 2,000 token,就是 40,000 token 永久占用,一直付费到会话结束。

派子智能体读:子智能体读完 20 个文件、得出结论、返回「在 foo.ts 第 42 行」,然后它的整个上下文被丢弃。主智能体只收到那一句,约 15 token。

并行只是副产品。子智能体的第一性原理是「用一次性的上下文,换一个结论」。

8.2 三种形态

形态上下文用途
命名子智能体
subagent_type: 'Explore'
全新,只带任务描述 内建的探索型智能体、通用型智能体,或用户自定义的(写在 .claude/agents/*.md
分叉子智能体
省略 subagent_type
完整继承父的对话历史和系统提示词 并行探索同一问题的多个方向
协调者工人
COORDINATOR_MODE
受限工具集,上下文独立 协调者模式下的执行单元

8.3 分叉:把提示词缓存用到极致

分叉的典型用法是「同时派 5 个子智能体,从不同角度探索同一个问题」。这 5 个的上下文几乎完全一样 —— 唯一区别是最后那句「你负责方向 A / B / C / D / E」。

提示词缓存是前缀匹配的。所以:

如果能让这 5 个子智能体发出的请求前缀达到字节级一致,第 1 个建立缓存,后面 4 个全部命中。输入成本从 5 份降到约 1.4 份(1 份全价 + 4 份 10% 折扣价),省下 70% 以上

为此做的四件事

① 系统提示词传「已渲染好的字节」
/**
 * The getSystemPrompt here is unused: the fork path passes
 * `override.systemPrompt` with the parent's already-rendered system prompt
 * bytes, threaded via `toolUseContext.renderedSystemPrompt`. Reconstructing
 * by re-calling getSystemPrompt() can diverge (GrowthBook cold→warm)
 * and bust the prompt cache; threading the rendered bytes is byte-exact.
 */

claude-code/src/tools/AgentTool/forkSubagent.ts

译:这里的 getSystemPrompt 是没用到的:分叉路径传递的是父已经渲染好的系统提示词字节,通过 renderedSystemPrompt 字段串下来。重新调用生成函数可能产生分歧(因为特性开关配置可能从冷缓存变成热缓存),从而毁掉提示词缓存;传递已渲染的字节是字节级精确的。

展开解释这个坑:系统提示词的内容并不完全固定,它可能包含 A/B 实验的变体。而实验配置本身有缓存 —— 父智能体生成提示词的那一刻,某个实验配置可能还是「冷缓存」状态(用默认值);几秒后子智能体重新生成时,配置已经变成「热缓存」(用真实值)。两次生成的字节不同,缓存全废。

对应的字段在 Tool.ts 里有定义:

/**
 * Parent's rendered system prompt bytes, frozen at turn start.
 * Used by fork subagents to share the parent's prompt cache — re-calling
 * getSystemPrompt() at fork-spawn time can diverge (GrowthBook cold→warm)
 * and bust the cache. See forkSubagent.ts.
 */
renderedSystemPrompt?: SystemPrompt

「frozen at turn start」(在轮次开始时冻结) —— 这是关键。

② 工具清单原样继承
/**
 * Synthetic agent definition for the fork path.
 *
 * Not registered in builtInAgents — used only when `!subagent_type` and the
 * experiment is active. `tools: ['*']` with `useExactTools` means the fork
 * child receives the parent's exact tool pool (for cache-identical API
 * prefixes). `permissionMode: 'bubble'` surfaces permission prompts to the
 * parent terminal. `model: 'inherit'` keeps the parent's model for context
 * length parity.
 */
export const FORK_AGENT = {
  tools: ['*'],
  permissionMode: 'bubble',
  model: 'inherit',
  ...
}

三个字段各有理由:

③ 消息构造:只让最后一个文本块不同
/**
 * Build the forked conversation messages for the child agent.
 *
 * For prompt cache sharing, all fork children must produce byte-identical
 * API request prefixes. This function:
 * 1. Keeps the full parent assistant message (all tool_use blocks, thinking, text)
 * 2. Builds a single user message with tool_results for every tool_use block
 *    using an identical placeholder, then appends a per-child directive text block
 *
 * Result: [...history, assistant(all_tool_uses), user(placeholder_results..., directive)]
 * Only the final text block differs per child, maximizing cache hits.
 */
export function buildForkedMessages(...)
分叉子 #1: [...历史, 模型消息(工具调用×5), 用户消息(占位×5, "探索方向A")] 分叉子 #2: [...历史, 模型消息(工具调用×5), 用户消息(占位×5, "探索方向B")] 分叉子 #3: [...历史, 模型消息(工具调用×5), 用户消息(占位×5, "探索方向C")] └──────────── 完全相同的前缀,全部命中缓存 ───────────┘ └─唯一差异─┘

那个「占位工具结果」的巧妙之处:父那条消息里有 5 个工具调用(每个对应一个分叉)。按接口规则,每个工具调用必须有配对的结果。但这 5 个分叉还没跑完,真实结果不存在。于是给每一个都填完全相同的占位内容 —— 既满足配对要求,又保证 5 个子智能体看到的这段一模一样。

源码里还有一个专门的常量:

/** Must be identical across all fork children for prompt cache sharing. */
④ 递归分叉守卫:从接口层下移到调用层
/**
 * Guard against recursive forking. Fork children keep the Agent tool in their
 * tool pool for cache-identical tool definitions, so we reject fork attempts
 * at call time by detecting the fork boilerplate tag in conversation history.
 */
export function isInForkChild(messages: MessageType[]): boolean { ... }

常规做法是「把 Agent 工具从子智能体的工具池里去掉」。但那样就改变了工具定义,破坏缓存。所以改为:工具留着,但在真正调用的那一刻检查对话历史里有没有分叉标记:

if (isInForkChild(toolUseContext.messages)) {
  throw new Error('Fork is not available inside a forked worker. '
                + 'Complete your task directly using your tools.')
}
这四个决定的共同点:为缓存牺牲代码整洁度

逐条看,每一个在常规代码评审里都会被挑刺

但对于要做扇出的智能体,这些代价值得付:5 个子智能体里 4 个走缓存,输入成本降到 1/3 以下。

这体现的能力是「知道什么时候该为性能牺牲整洁度」 —— 比单纯背诵设计原则有价值得多。

工作树隔离的额外提示

/**
 * Notice injected into fork children running in an isolated worktree.
 * Tells the child to translate paths from the inherited context, re-read
 * potentially stale files, and that its changes are isolated.
 */
export function buildWorktreeNotice(...)

如果分叉子智能体跑在隔离的 git 工作树里,它继承的历史里那些文件路径指向的是父的目录。所以要显式告诉它:路径要翻译、文件可能已经不是你看到的那个版本、你的修改是隔离的。

8.4 子智能体的工具限制

export const ALL_AGENT_DISALLOWED_TOOLS = new Set([
  TASK_OUTPUT_TOOL_NAME,        // 不能查看其他任务的输出
  EXIT_PLAN_MODE_V2_TOOL_NAME,  // 不能退出计划模式(会话级全局状态)
  ENTER_PLAN_MODE_TOOL_NAME,    // 不能进入计划模式
  // 内部用户允许嵌套子智能体,外部用户不允许
  ...(process.env.USER_TYPE === 'ant' ? [] : [AGENT_TOOL_NAME]),
  ASK_USER_QUESTION_TOOL_NAME,  // ★ 不能向用户提问
  TASK_STOP_TOOL_NAME,          // 不能停止其他任务
  // 防止在子智能体内递归执行工作流
  ...(feature('WORKFLOW_SCRIPTS') ? [WORKFLOW_TOOL_NAME] : []),
])

export const CUSTOM_AGENT_DISALLOWED_TOOLS = new Set([...ALL_AGENT_DISALLOWED_TOOLS])

claude-code/src/constants/tools.ts

原则为什么
不能修改全局状态计划模式是整场会话级的开关。子智能体改了会影响父和所有兄弟,而它们完全不知情
不能直接和用户对话子智能体跑在后台,没有界面通道,弹不出确认框。它传递信息只能通过「返回结果」
不能操作兄弟任务没有横向权限。避免子智能体互相干扰或形成意料之外的协作

后台异步智能体的白名单更严格

/*
 * Async Agent Tool Availability Status (Source of Truth)
 */
export const ASYNC_AGENT_ALLOWED_TOOLS = new Set([
  FILE_READ_TOOL_NAME,      // 读文件
  WEB_SEARCH_TOOL_NAME,     // 网络搜索
  TODO_WRITE_TOOL_NAME,     // 待办清单
  GREP_TOOL_NAME,           // 内容搜索
  WEB_FETCH_TOOL_NAME,      // 抓网页
  GLOB_TOOL_NAME,           // 文件名搜索
  ...
])

注意这里从「黑名单」变成了「白名单」 —— 而且几乎全是只读工具。

能力面必须随「交互能力」和「信任级别」同步收缩:

· 有人在场、能弹确认框 → 黑名单模式,给全量工具减去几个
· 后台跑、弹不出确认框 → 白名单模式,只给明确安全的

从黑名单切到白名单,是安全等级的一次质变:黑名单漏掉一个就是漏洞,白名单漏掉一个只是功能缺失。

8.5 子智能体的上下文构造

创建子智能体时会构造一个新的 ToolUseContext。有几个字段的处理很讲究:

/**
 * Always-shared setAppState for session-scoped infrastructure (background
 * tasks, session hooks). Unlike setAppState, which is no-op for async agents
 * (see createSubagentContext), this always reaches the root store so agents
 * at any nesting depth can register/clean up infrastructure that outlives
 * a single turn. Only set by createSubagentContext; main-thread contexts
 * fall back to setAppState.
 */
setAppStateForTasks?: (f: (prev: AppState) => AppState) => void

译:供会话级基础设施(后台任务、会话钩子)使用的「永远共享」的状态写入函数。不同于普通的 setAppState(对异步智能体是空操作),这个函数总能抵达根存储 —— 这样任意嵌套深度的智能体都能注册或清理那些生命周期超过单轮的基础设施。只有创建子智能体上下文时才设置它;主线程上下文回退到普通的 setAppState。

这里有两个正交的需求,需要两个通道
需求通道
状态隔离
子智能体不该污染主线程的界面状态
setAppState 对子智能体是空操作
基础设施注册
子智能体启动的后台进程必须能被清理
setAppStateForTasks 总是抵达根存储

如果只有一个通道,就得在「隔离」和「可清理」之间二选一。子智能体启动了一个后台进程但注册不进根存储 → 它结束后那个进程变成孤儿,永远不会被清理。

另外两个相关字段:

agentId?: AgentId      // 只有子智能体才设置;钩子用它来区分是不是子智能体调用
agentType?: string     // 子智能体的类型名

/** When true, preserve toolUseResult on messages even for subagents.
 *  Used by in-process teammates whose transcripts are viewable by the user. */
preserveToolUseResults?: boolean

最后那个字段说明:默认情况下子智能体的工具结果会被丢弃(省内存,反正用户看不到)。但「进程内队友」这种形态的子智能体,它的对话记录是用户可见的,所以要保留。

8.6 内建智能体类型

tools/AgentTool/builtInAgents.ts 定义了几个内建类型,其中最常用的是:

类型特征
Explore
探索
只读工具集。用于「扫一遍代码库找答案」这类任务。它读片段而不是整个文件,所以能定位代码,但不适合做审查
general-purpose
通用
全量工具。用于多步骤的复杂任务
Plan
架构
只读 + 规划。返回分步计划,识别关键文件,考虑架构权衡

用户还可以自定义 —— 在 .claude/agents/*.md 里写一个 Markdown 文件,头部元数据声明名字、描述、可用工具、模型。loadAgentsDir.ts 负责加载。

8.7 后台任务的几种形态

tasks/ 目录下有多种任务形态,它们的差别在于「跑在哪里」和「怎么通信」:

形态说明
LocalAgentTask本地进程内的子智能体
LocalShellTask本地后台 shell 命令(比如启动一个开发服务器)
InProcessTeammateTask进程内「队友」—— 多智能体群模式下的伙伴,对话记录用户可见
RemoteAgentTask远程执行的智能体(123 KB,最复杂)
LocalMainSessionTask主会话自身作为一个任务被追踪
DreamTask内部实验特性(KAIROS_DREAM 开关)

任务的完成通过消息队列通知回主循环 —— 就是第 3.9 节讲的那个「进程级全局队列」,每个智能体只取走发给自己的通知。

8.8 中断的级联

中止控制器构成一棵树:

主会话的 AbortController ├─ 子智能体 A 的 AbortController(父的子) │ └─ A 内部工具批次的兄弟控制器(子的子) ├─ 子智能体 B 的 AbortController └─ 后台 shell 任务的 AbortController 用户按 Ctrl+C → 拉主控制器 → 整棵树全部触发 ↓ 每一层都必须: · 为自己未完成的工具调用补齐合成结果 · 清理自己启动的子进程 · 从任务注册表里注销

这是自建智能体最容易漏的地方:派生容易,回收难。

具体的暴雷场景:派出 5 个子智能体之后用户按了 Ctrl+C。
· 没有级联中止 → 那 5 个继续跑完,继续烧钱,而且没人在看结果
· 没有补齐合成结果 → 下一轮接口调用直接报格式错误,会话再也恢复不了

两个问题都不会在开发阶段暴露(开发时你不会去按 Ctrl+C),但在生产环境每天都会发生。