8 · 多智能体协作编排

8.1 子智能体到底解决什么问题

先说清楚什么是「子智能体」:主智能体在执行任务时,可以创建一个新的、独立的智能体实例,把某个子任务派给它,等它做完拿回结果。被派出去的那个就叫子智能体(subagent)。

大多数人的第一反应是「这是为了并行加速」。但两套系统的源代码显示,真正的首要动机是上下文隔离

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

如果它自己读:这 20 个文件的完整内容全部进入主上下文,而且此后每一轮都要重发一遍(回顾第 1.1 节)。假设每个文件 2,000 token,那就是 40,000 token 永久占用,一直付费到会话结束。

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

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

8.2 Claude Code 的三种子智能体形态

形态子智能体的上下文用途
命名子智能体
调用时指定 subagent_type
全新的,只带一段任务描述 探索代码库、通用任务,或者用户自定义的智能体类型(写在 .claude/agents/*.md 文件里)
分叉子智能体
调用时省略 subagent_type
完整继承父智能体的对话历史和系统提示词 并行探索同一个问题的多个方向。比如「用三种不同思路各写一版实现,然后比较」
协调者模式的工人 受限的工具集,上下文独立 在「协调者」模式下负责实际干活的执行单元

8.3 分叉子智能体:把提示词缓存用到极致

这是 Claude Code 里最精巧的机制之一,而且它是第 1.8 节那个缓存概念的终极应用

先看清楚这里的机会

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

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

如果能让这 5 个子智能体发出的请求前缀达到「字节级完全一致」,那么第 1 个建立缓存,后面 4 个全部命中缓存。

这意味着输入 token 的成本从 5 份降到大约 1.4 份(1 份全价 + 4 份 10% 折扣价)。省下 70% 以上。

为了做到字节级一致,Claude Code 做了四件事

① 系统提示词传递「已渲染好的字节」,而不是重新生成
/**
 * 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, 指令)]
每个子智能体只有最后那个文字块不同,从而最大化缓存命中。

分叉子 #1: [...历史, 模型消息(工具调用×5), 用户消息(占位×5, "探索方向A")] 分叉子 #2: [...历史, 模型消息(工具调用×5), 用户消息(占位×5, "探索方向B")] 分叉子 #3: [...历史, 模型消息(工具调用×5), 用户消息(占位×5, "探索方向C")] └──────────── 完全相同的前缀,全部命中缓存 ───────────┘ └─唯一差异─┘

注意那个「占位工具结果」的巧妙之处:父智能体那条消息里有 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 以下。

这是一个非常具体、可量化、能在面试里讲清楚的架构决策。它体现的能力是「知道什么时候该为性能牺牲整洁度」—— 比单纯背诵设计原则有价值得多。

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,    // 不能进入计划模式
  ...(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 工具集完全呼应

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

· 有人在场、能弹确认框 → 给全量工具
· 后台跑、弹不出确认框 → 只给只读工具
· 输入来自不可信的外部 → 进一步收窄

两个独立团队从不同角度得出了同一条规则。这说明它不是巧合,是必然。

8.5 Hermes 的做法:任务委派 + 看板协作

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(应当避免权限提示)标记是同一个问题的两种解法。两家都不得不面对「后台任务无法交互」这个现实。

Hermes 独有:子智能体跑起来之后还能被实时控制

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(心跳)—— 它的存在说明这套机制是为长时间运行设计的:工人需要定期报告「我还活着,还在干这个任务」,否则协调者无法区分「它在慢慢干」和「它已经崩了」。

两种多智能体模型的本质差别
Claude Code:调用栈模型
  • 父调用子,子返回结果,栈帧弹出
  • 生命周期 = 一次工具调用的时长
  • 通信方式 = 返回值(单向
  • 状态在内存里,进程结束即消失
  • 优化目标:延迟与缓存命中率
Hermes:工作流模型
  • 任务写进看板,工人主动认领
  • 生命周期 = 跨会话、跨进程
  • 通信方式 = 看板评论 + 插话 + 心跳(双向
  • 状态在数据库里,程序重启后继续
  • 优化目标:持久性与可干预性

8.6 一个两家共同的硬约束:中断的级联

两套系统都花了不少代码处理「中断如何向下传播、结果如何向上收敛」:

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

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

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

面试追问:什么时候该用子智能体?

先纠正一个常见误解:子智能体的首要价值不是并行,是上下文隔离。

要读 20 个文件才能得出一个结论时,自己读会让那 20 个文件的内容永久占用主上下文、每一轮都重发一遍;派子智能体去读,主上下文只收到那一句结论。在长任务里,这个交易是决定性的。

然后给判据:

如果要展示深度,讲扇出时的缓存策略:N 个子智能体如果能让请求前缀达到字节级一致,后 N−1 个全是缓存命中,输入成本降到 1/3 以下。为此值得付出「给用不到的工具」「传已渲染好的系统提示词字节而不是重新生成」「用完全相同的占位符填充工具结果」「把递归守卫从接口层下移到调用层」这些看起来不优雅的代价 —— Claude Code 的 buildForkedMessages 函数就是这么做的,只让最后一个文本块携带各自的指令