先说清楚什么是「子智能体」:主智能体在执行任务时,可以创建一个新的、独立的智能体实例,把某个子任务派给它,等它做完拿回结果。被派出去的那个就叫子智能体(subagent)。
大多数人的第一反应是「这是为了并行加速」。但两套系统的源代码显示,真正的首要动机是上下文隔离:
设想一个场景:主智能体要找到某个函数定义在哪个文件里,可能需要读 20 个文件才能确定。
如果它自己读:这 20 个文件的完整内容全部进入主上下文,而且此后每一轮都要重发一遍(回顾第 1.1 节)。假设每个文件 2,000 token,那就是 40,000 token 永久占用,一直付费到会话结束。
如果派一个子智能体去读:子智能体的上下文用完即弃 —— 它读完 20 个文件、得出结论、返回一句「在 foo.ts 第 42 行」,然后它的整个上下文被丢弃。主智能体只收到那一句话,大约 15 个 token。
并行只是副产品。子智能体的第一性原理是「用一次性的上下文,换一个结论」。
| 形态 | 子智能体的上下文 | 用途 |
|---|---|---|
| 命名子智能体 调用时指定 subagent_type |
全新的,只带一段任务描述 | 探索代码库、通用任务,或者用户自定义的智能体类型(写在 .claude/agents/*.md 文件里) |
| 分叉子智能体 调用时省略 subagent_type |
完整继承父智能体的对话历史和系统提示词 | 并行探索同一个问题的多个方向。比如「用三种不同思路各写一版实现,然后比较」 |
| 协调者模式的工人 | 受限的工具集,上下文独立 | 在「协调者」模式下负责实际干活的执行单元 |
这是 Claude Code 里最精巧的机制之一,而且它是第 1.8 节那个缓存概念的终极应用。
分叉的典型用法是「同时派 5 个子智能体,从不同角度探索同一个问题」。这 5 个子智能体的上下文几乎完全一样 —— 都继承了父智能体的全部历史,唯一的区别是最后那一句「你负责探索方向 A / B / C / D / E」。
而提示词缓存是前缀匹配的。所以:
如果能让这 5 个子智能体发出的请求前缀达到「字节级完全一致」,那么第 1 个建立缓存,后面 4 个全部命中缓存。
这意味着输入 token 的成本从 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 这个字段串下来。重新调用 getSystemPrompt() 来构造可能产生分歧(因为特性开关配置可能从冷缓存变成热缓存),从而毁掉提示词缓存;而传递已渲染的字节是字节级精确的。
展开解释这个坑:系统提示词的内容并不是完全固定的,它可能包含 A/B 实验的变体。而实验配置本身是有缓存的 —— 父智能体生成系统提示词的那一刻,某个实验配置可能还是「冷缓存」状态(用默认值);几秒钟后子智能体重新生成时,配置已经变成「热缓存」(用真实值)。结果是两次生成的字节不同,缓存全废。
解法:父智能体在轮次开始时就把渲染好的字节冻结下来,分叉时原样传递。
export const FORK_AGENT = {
tools: ['*'], // 配合 useExactTools:继承父的"精确"工具集
permissionMode: 'bubble', // 权限确认冒泡到父智能体所在的终端
model: 'inherit', // 继承父的模型(保证上下文窗口大小一致)
...
}
子智能体其实用不到父的全部工具。但工具定义是请求前缀的一部分(回顾第 5.3 节),改了就没缓存了。所以宁可给它一堆用不到的工具。
/**
* 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. 完整保留父智能体那条消息(包含所有工具调用块、思考块、文字)
2. 构造一条用户消息,为每一个工具调用块都配一个完全相同的占位工具结果,然后在末尾追加各自不同的一小段指令文字
结果形状是:[...历史, 模型消息(所有工具调用), 用户消息(占位结果×N, 指令)]
每个子智能体只有最后那个文字块不同,从而最大化缓存命中。
注意那个「占位工具结果」的巧妙之处:父智能体那条消息里有 5 个工具调用(每个对应一个分叉)。按 API 规则,每个工具调用必须有配对的结果(回顾第 4.4 节)。但这 5 个分叉还没跑完,真实结果不存在。于是给每一个都填一个完全相同的占位内容 —— 既满足了配对要求,又保证了 5 个子智能体看到的这段内容一模一样。
/**
* 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 工具,所以我们改为在调用时拒绝 —— 方法是检测对话历史里有没有分叉的样板标记。
常规做法是「把 Agent 工具从子智能体的工具池里去掉」。但那样就改变了工具定义,破坏缓存。所以 Claude Code 选择:工具留着,但在真正调用的那一刻检查对话历史里有没有分叉标记,有就拒绝:
throw new Error('Fork is not available inside a forked worker. '
+ 'Complete your task directly using your tools.')
// 译:分叉功能在分叉出的工人内部不可用。请直接用你手上的工具完成任务。
逐条看这四个决定,每一个都在常规评审里会被挑刺:
但如果你的智能体要做扇出(一次派多个子智能体),这些代价值得付:5 个子智能体里 4 个走缓存,输入 token 成本降到 1/3 以下。
这是一个非常具体、可量化、能在面试里讲清楚的架构决策。它体现的能力是「知道什么时候该为性能牺牲整洁度」—— 比单纯背诵设计原则有价值得多。
export const ALL_AGENT_DISALLOWED_TOOLS = new Set([
TASK_OUTPUT_TOOL_NAME, // 不能查看其他任务的输出
EXIT_PLAN_MODE_V2_TOOL_NAME, // 不能退出计划模式(那是会话级的全局状态)
ENTER_PLAN_MODE_TOOL_NAME, // 不能进入计划模式
...(USER_TYPE === 'ant' ? [] : [AGENT_TOOL_NAME]), // 默认禁止嵌套创建子智能体
ASK_USER_QUESTION_TOOL_NAME, // ★ 不能向用户提问
TASK_STOP_TOOL_NAME, // 不能停止其他任务
...(feature('WORKFLOW_SCRIPTS') ? [WORKFLOW_TOOL_NAME] : []), // 不能递归执行工作流
])
claude-code/src/constants/tools.ts
三条原则清晰可见:
| 原则 | 为什么 |
|---|---|
| 不能修改全局状态 | 计划模式是整场会话级别的开关。子智能体改了它,会影响父智能体和所有兄弟智能体,而它们完全不知道发生了什么。 |
| 不能直接和用户对话 | 子智能体没有界面通道 —— 它跑在后台,弹不出确认框。它想传递信息只能通过「返回结果」这一条路。 |
| 不能操作兄弟任务 | 没有横向权限。子智能体之间互相不可见,避免它们互相干扰或形成意料之外的协作。 |
另外还有一个更严格的白名单 ASYNC_AGENT_ALLOWED_TOOLS(异步智能体允许的工具),只包含 Read、WebSearch、TodoWrite、Grep、WebFetch、Glob 等基本只读工具。因为后台异步运行的智能体完全无法弹出权限确认框,所以它的工具面必须进一步收窄到只读。
这一点和第 3.2 节的 Hermes webhook 工具集完全呼应:
能力面必须随着「交互能力」和「信任级别」同步收缩。
· 有人在场、能弹确认框 → 给全量工具
· 后台跑、弹不出确认框 → 只给只读工具
· 输入来自不可信的外部 → 进一步收窄
两个独立团队从不同角度得出了同一条规则。这说明它不是巧合,是必然。
Hermes 的子智能体通过一个叫 delegate_task(委派任务)的工具创建,实现在 tools/delegate_tool.py,共 5,071 行。
MAX_DEPTH = 1 # 扁平结构:父(第0层) -> 子(第1层);
# 孙子级会被拒绝,除非显式调高 max_spawn_depth 配置
_MIN_SPAWN_DEPTH = 1
_DEFAULT_MAX_CONCURRENT_CHILDREN = 10 # 最多同时跑 10 个子智能体
_RECENT_SUBAGENTS_CAP = 200 # 最近子智能体记录保留 200 条
DELEGATE_BLOCKED_TOOLS = frozenset(...) # 子智能体禁用的工具清单
def _subagent_auto_deny(command, description, **kwargs) -> str: ...
def _subagent_auto_approve(command, description, **kwargs) -> str: ...
def _get_subagent_approval_callback(): ...
hermes-agent/tools/delegate_tool.py
那两个 _subagent_auto_deny(子智能体自动拒绝)和 _subagent_auto_approve(子智能体自动批准)函数很关键:
子智能体没有终端,弹不出确认框。所以它的审批回调函数必须被替换成一个「自动决策」函数 —— 要么自动拒绝,要么自动批准,不能等人。
这和 Claude Code 的 shouldAvoidPermissionPrompts(应当避免权限提示)标记是同一个问题的两种解法。两家都不得不面对「后台任务无法交互」这个现实。
def interrupt_subagent(subagent_id: str) -> bool: ... # 中止某个子智能体
def steer_subagent(subagent_id, ...) -> ...: ... # ★ 中途插话纠偏
def list_active_subagents() -> List[Dict]: ... # 列出当前活跃的子智能体
def set_spawn_paused(paused: bool) -> bool: ... # ★ 全局暂停派生新的
def _is_descendant_of(child_agent, parent_agent, max_hops=8) -> bool: ...
# 检查谱系关系,防环
_CONTROL_ACTIONS = frozenset({"list", "steer", "stop"})
子智能体启动之后,父智能体(或用户)还可以:列出它们、给某个插话纠偏、中止某个、甚至全局暂停派生新的。
Claude Code 的子智能体一旦派出去,只能等它完成或者全部中止,没有中间地带。
这个差别源于定位不同:
那个 _is_descendant_of(..., max_hops=8) 也值得注意:它说明 Hermes 在架构上允许更深的谱系(虽然默认配置是 1 层),并且要防止「A 是 B 的子、B 又是 A 的子」这种循环 —— 所以设了 8 跳的遍历上限。
Hermes 还有一套 Claude Code 完全没有的东西 —— 看板(Kanban)作为多个智能体的共享协作界面:
kanban_show kanban_list kanban_create kanban_link
kanban_complete kanban_block kanban_unblock kanban_comment
kanban_request_review kanban_request_changes
kanban_heartbeat ★ 心跳:证明自己还活着
kanban_attach kanban_attach_url kanban_attachments
hermes-agent/toolsets.py · 核心工具清单
这些工具只在两种情况下才会出现在模型的工具清单里:智能体是作为「看板工人」被派生的(通过环境变量 HERMES_KANBAN_TASK 判断),或者当前身份配置显式启用了看板工具集。
注意那个 kanban_heartbeat(心跳)—— 它的存在说明这套机制是为长时间运行设计的:工人需要定期报告「我还活着,还在干这个任务」,否则协调者无法区分「它在慢慢干」和「它已经崩了」。
两套系统都花了不少代码处理「中断如何向下传播、结果如何向上收敛」:
interrupt_subagent(id) 显式向下级联,加上 _close_subagent_steering() 清理插话通道、_unregister_subagent() 从注册表注销。这是自建智能体最容易漏的地方:派生容易,回收难。
具体的暴雷场景:派出 5 个子智能体之后用户按了 Ctrl+C。
· 如果没有级联中止 → 那 5 个进程继续跑完,继续烧钱,而且没人在看它们的结果
· 如果没有补齐合成的工具结果 → 下一轮 API 调用直接报格式错误,这场会话再也恢复不了
两个问题都不会在开发阶段暴露(开发时你不会去按 Ctrl+C),但在生产环境每天都会发生。
先纠正一个常见误解:子智能体的首要价值不是并行,是上下文隔离。
要读 20 个文件才能得出一个结论时,自己读会让那 20 个文件的内容永久占用主上下文、每一轮都重发一遍;派子智能体去读,主上下文只收到那一句结论。在长任务里,这个交易是决定性的。
然后给判据:
如果要展示深度,讲扇出时的缓存策略:N 个子智能体如果能让请求前缀达到字节级一致,后 N−1 个全是缓存命中,输入成本降到 1/3 以下。为此值得付出「给用不到的工具」「传已渲染好的系统提示词字节而不是重新生成」「用完全相同的占位符填充工具结果」「把递归守卫从接口层下移到调用层」这些看起来不优雅的代价 —— Claude Code 的 buildForkedMessages 函数就是这么做的,只让最后一个文本块携带各自的指令。