跳到主要内容

子代理与 Supervisor:多 agent 协作与任务交接

30 秒导读: 一个 Agent 太"全能"就什么都做不精。VoltAgent 让你把一个 supervisor(主管)Agent 和若干 专精子 Agent 组队——主管只负责"拆任务、点名、汇总",真正干活交给子代理。本章讲清楚:这个委派是怎么在运行时发生的、任务怎么交接过去、父子怎么共享上下文与追踪、结果又怎么流回来。

本章只讲运行时委派(supervisor/sub-agent)。它和第 5 章的 Workflow 引擎是两种编排:

子代理委派(本章)Workflow(第 5 章)
谁决定下一步模型在运行时临时决定点名谁写死的声明式步骤图
形态一个工具调用(delegate_task).andThen / .andAgent / .andWhen 步骤链
适合开放式、需要模型判断该找谁固定流程、要能暂停/恢复

如果你还不了解单个 Agent 一次生成的生命周期,先读 01 · Agent 运行时;委派本质上就是"在父 Agent 的一次生成里,调了一个特殊工具,而这个工具会去跑另一个 Agent 的完整生成"。


1. 这是什么(零基础也能懂)

一句话定义: 把一个 Agent 声明为另一个 Agent 的 subAgents,父 Agent 就变成 supervisor,自动获得一个 delegate_task 工具,能在对话中途把子任务甩给专精的子代理去做。

解决什么问题: 假设你在做一个客服机器人。用户既可能问"我的订单到哪了",又可能问"帮我算一下退款金额",还可能要"用英文重写这封投诉"。你可以塞一个巨型 prompt 让单个 Agent 全包——但它会顾此失彼。更好的做法:

  • 一个 物流查询 Agent(接了物流 API)
  • 一个 计算 Agent(擅长数值)
  • 一个 翻译 Agent

再放一个 supervisor 在最前面。它读懂用户意图,把子问题分发给对应的专家,收齐答案后拼成最终答复。

用起来什么样: 声明子代理只要一个 subAgents 数组(packages/core/src/agent/agent.ts:1181 构造 SubAgentManager):

// 示意,非源码:声明一个 supervisor
const supervisor = new Agent({
name: "Supervisor",
instructions: "你负责把用户问题分派给合适的专家,并汇总答复。",
model: myModel,
subAgents: [logisticsAgent, mathAgent, translatorAgent], // ← 关键
});

// 之后照常调用,委派对模型是透明的
await supervisor.streamText("我的订单到哪了?顺便把这句翻成英文:'请尽快发货'");

不需要手写"先调物流、再调翻译"的逻辑——一旦 subAgents 非空,VoltAgent 自动:

  1. 把 supervisor 的 system prompt 改写成"你是主管,手下有这些专家……"(见 §8)。
  2. 给它挂上一个 delegate_task 工具。

一句话直觉: 把 supervisor 想成一个项目经理。它自己不写代码,但知道手下每个人擅长什么;delegate_task 就是它派活的"工单系统",可以一次给多个人派活,然后等大家交活。

本节不出现底层细节。记住一件事:委派 = 父 Agent 调了一个会去跑子 Agent 的工具


2. 顶层全景(它大概怎么转)

怎么读这张图: 从上往下是一次委派的控制流。左边竖线是"用户 ↔ supervisor"的主循环,右边是委派工具触发后 SubAgentManager 把任务 fan-out(扇出)给多个子代理。

用户请求


┌──────────────────┐ subAgents 非空 → 自动挂上
│ Supervisor │─────────────────────────┐
│ (父 Agent) │ ▼
└──────────────────┘ [ delegate_task 工具 ]
▲ │ 模型决定调用
│ 汇总子代理结果, │ 传入 {task, targetAgents[]}
│ 生成最终答复 ▼
│ ┌──────────────────────┐
│ │ SubAgentManager │
└──────────────────────────────────│ handoffToMultiple │
└──────────────────────┘
│ Promise.all 并行
┌───────────────┼───────────────┐
▼ ▼ ▼
子Agent A 子Agent B 子Agent C
(跑完整生成) (跑完整生成) (跑完整生成)
│ │ │
└───── fullStream 打元数据转发回父流 ──────┘

部件一句话职责:

部件干什么在哪
SubAgentManager管理子代理列表、生成委派工具、执行交接packages/core/src/agent/subagent/index.ts:55
delegate_task 工具模型调用它来点名子代理派活subagent/index.ts:753 createDelegateTool
handoffTask把一个任务交接给一个子代理并跑它subagent/index.ts:319
handoffToMultiple把任务并行交接给多个子代理subagent/index.ts:715
stream-metadata-enricher给子代理的流事件贴上"这是谁产的"元数据subagent/stream-metadata-enricher.ts:38
AgentRegistry全局登记父子关系(算委派深度用)packages/core/src/registries/agent-registry.ts:91

主线走一遍(高层):

  1. 用户消息进 supervisor,它的一次生成开始(见 01 章)。
  2. 因为有子代理,工具集里多了 delegate_task(agent.ts:6312)。
  3. 模型输出一个 delegate_task 工具调用,参数是 { task, targetAgents: ["物流查询", "翻译"] }
  4. 工具 execute 按名字查到子代理配置,调 handoffToMultiple 并行跑它们(subagent/index.ts:832)。
  5. 每个子代理跑一次自己的完整生成,结果、用量、消息回收成结构化数组返给模型。
  6. supervisor 拿到各家答复,继续它的循环,最终拼出答复给用户。

3. 核心原理

3.1 delegate_task 工具是怎么"长出来"的

要解决的小问题: 委派对模型必须表现成一个普通工具——模型只会调工具,不懂"子代理"这种概念。所以框架要把"派活"这件事包装成一个 schema 清晰的工具。

思路: 只要 Agent 有子代理,就在每次准备工具时动态塞进一个 delegate_task。它的参数很简单:任务文本 + 目标代理名字列表 + 可选上下文。

真实实现: 工具在 createDelegateTool 里用 createTool 造出来,参数 schema 见 subagent/index.ts:775:

parameters: z.object({
task: z.string().describe("The task to delegate"),
targetAgents: z.array(z.string()).describe("List of agent names to delegate the task to"),
context: z.record(z.string(), z.any()).optional().describe("Additional context for the task"),
}),

按名字匹配子代理(subagent/index.ts:805)——找不到的名字会 warn 并过滤掉,一个都没匹配上才抛错。这段是容错的关键:模型偶尔会拼错代理名,框架不让整次委派崩掉。

挂载时机有两处:

  • 每次生成前:agent.ts:6312hasSubAgents() 为真时把 delegate 工具加进本次运行的工具集。
  • 动态加子代理:agent.ts:8287addSubAgent——如果这是第一个子代理,顺手把工具挂上静态工具管理器;removeSubAgent(agent.ts:8305)在子代理清空时把工具摘掉。

关键细节: 工具的 execute 结果永远返回数组(subagent/index.ts:864 "Always return array for consistent API"),即使只派给一个代理。每个元素是 { agentName, response, usage, bailed },让模型能分辨哪段答复来自谁。

3.2 一次交接的生命周期:handoffTask

要解决的小问题: "把任务交给子代理"不只是调一下它的 generateText。得处理:上下文怎么带过去、用哪种生成方法、钩子怎么触发、追踪怎么串、结果怎么规整回来。handoffTask(subagent/index.ts:319)就是这一整套。

一次交接按顺序做这些事:

handoffTask(task, targetAgent, ...)

1. onHandoff 钩子 ── 通知子代理"有人给你派活了" (index.ts:364)

2. 拼任务消息 ── 若带 context,揉进任务文本 (index.ts:369)
│ taskMessage = { role:"user", parts:[{text: taskContent}] }
│ messages = [...sharedContext, taskMessage]

3. 按配置选生成方法 ── streamText / generateText / streamObject / generateObject
│ (默认直接 Agent 实例 → streamText, index.ts:406)

4. 跑子代理的完整生成 ── 拿到 finalResult / usage / finalMessages

5. onHandoffComplete 钩子 ── supervisor 侧回调,可 bail 提前收尾 (index.ts:466, 见 §3.5)

└─ return { result, messages, usage, bailed? }

为什么有四种生成方法? 子代理配置是个联合类型(subagent/types.ts:80 SubAgentConfig)。你可以只丢一个 Agent 实例(默认用 streamText),也可以用 createSubagent(types.ts:116)明确指定方法和选项:

// 示意,非源码:让子代理走 generateObject,产出结构化结果
const extractor = createSubagent({
agent: myExtractorAgent,
method: "generateObject",
schema: z.object({ amount: z.number(), currency: z.string() }),
options: { temperature: 0.2 },
});

handoffTask 内部用一串类型守卫(isStreamTextConfig 等,subagent/index.ts:184 起)分派到对应分支。四个分支都把结果收敛成一个 finalResult: string(对象方法会 safeStringify,index.ts:447)——这样返给模型的格式统一。

错误不外抛: 交接里任何异常都被 catch(index.ts:541),包装成一段 Error in delegating task to X: ... 的文本结果返回。也就是说子代理失败不会炸掉 supervisor,supervisor 会看到错误文本、自行决定怎么办。

注意 VoltAgent 的铁律:全程用 safeStringify 而非 JSON.stringify(index.ts:2 导入),因为任务上下文可能含循环引用。

3.3 并行 fan-out:handoffToMultiple

要解决的小问题: supervisor 的 system prompt 明确鼓励"尽量同时联系多个代理"(subagent/index.ts:264 的默认 guideline)。所以委派工具默认走并行。

实现: handoffToMultiple(subagent/index.ts:715)就是对每个目标代理并发跑 handoffTask:

const results = await Promise.all(
targetAgents.map(async (agentConfig) => {
return await this.handoffTask({
...restOptions,
targetAgent: agentConfig,
conversationId: handoffConversationId, // ← 关键:所有子代理共用一个 conversationId
});
}),
);

关键细节: 一批并行委派共用同一个 handoffConversationId(index.ts:733)。这是下一节要讲的上下文串联的核心——同一次"派活",无论派给几个代理,都归到同一段对话里。

3.4 conversationId 与追踪如何在父子间保持

这是委派"看起来是一件事"的粘合剂。有两条线要串起来:对话上下文分布式追踪

对话线 —— conversationId: 交接时优先复用传入的 conversationId,没有才新生成(subagent/index.ts:357):

const handoffConversationId = conversationId || crypto.randomUUID();

而这个 conversationId 是 supervisor 在建委派工具时从自己的运行内存里带下来的(agent.ts:6318 conversationId: resolvedMemory.conversationId)。于是 supervisor、以及它这次派出去的所有子代理,共享同一个 conversationId——子代理的记忆读写落到同一段对话下(记忆机制见 03 · 记忆子系统)。

追踪线 —— parentSpan: 委派要在可观测性 UI 里显示成一棵树(supervisor 下面挂着各子代理)。靠的是 OpenTelemetry 的 parentSpan 一路往下传:

supervisor 生成 span
└─ delegate_task 工具 span ← agent.ts:6433 executionOptions.parentToolSpan = toolSpan
└─ handoffTask 收到 parentSpan (index.ts:403)
└─ 子代理生成 span ← 挂在上面这个 parentSpan 下

工具执行时,Agent 把当前工具的 span 塞进 executionOptions.parentToolSpan(agent.ts:6433)。delegate_taskexecute 再把它取出来,层层传给 handoffToMultiplehandoffTaskparentSpan 选项(subagent/index.ts:848):

parentSpan:
(executeOptions?.parentToolSpan as Span | undefined) ||
parentToolSpan ||
(effectiveOperationContext?.systemContext?.get("parentToolSpan") as Span | undefined),

三级 fallback 保证无论从哪条路径调进来都能拿到父 span,子代理生成就正确挂到工具 span 之下。可观测性全貌见 06 · 可观测性与 VoltOps

委派深度 —— calculateDelegationDepth: 子代理还能再有子代理,于是有"委派深度"。它不靠传参,而是查全局注册表的父子关系反推(agent.ts:4418):

private calculateDelegationDepth(parentAgentId: string | undefined): number {
if (!parentAgentId) return 0;
let depth = 1;
let currentParentId = parentAgentId;
const visited = new Set<string>(); // ← 防环
while (currentParentId) {
if (visited.has(currentParentId)) break;
visited.add(currentParentId);
const parentIds = AgentRegistry.getInstance().getParentAgentIds(currentParentId);
if (parentIds.length > 0) { depth++; currentParentId = parentIds[0]; }
else break;
}
return depth;
}

父子关系在 addSubAgent 时登记进 AgentRegistry.registerSubAgent(subagent/index.ts:103agent-registry.ts:91)。深度算出来主要用于日志:子代理的 logger 会带上 delegationDepth 字段(agent.ts:4408),方便在嵌套委派里看清"这条日志来自第几层"。visited 集合防止父子关系成环时死循环。

3.5 把子代理的流转发回父流(stream-metadata-enricher)

要解决的小问题: 子代理在后台跑生成,但用户盯着的是 supervisor 的流。如果子代理调了工具、产了文本,用户什么都看不到——体验像卡住了。所以要把子代理的 fullStream 事件转发进父流,同时标注"这是哪个子代理产的"。

思路: 子代理 streamText 的结果有个 fullStream。父 Agent 的运行上下文里存了两个 writer(subagent/index.ts:594 getStreamWriters):一个 UI 流 writer、一个 fullStream writer。转发时,对每个子代理事件贴上元数据(谁产的、agentPath 等),再写进父流。

两条转发路径(forwardStreamEvents,subagent/index.ts:603):

目标 writer做法事件类型过滤
uiStreamWriter子流转成 UI 消息流,createMetadataEnrichedStream 包一层元数据后 merge默认只转 tool-call / tool-result / input-guardrail-blocked
fullStreamWriter逐事件遍历,过滤后贴元数据直接 write(writeFullStream index.ts:646)同上,可经 supervisorConfig

元数据长什么样(buildForwardingMetadata,subagent/index.ts:923):记录子代理 id/name、执行代理 id/name、父代理 id/name,以及一条 agentPath(从 supervisor 到执行者的名字链)。消费端据此知道每个事件的来源与层级。

enricher 的核心技巧(stream-metadata-enricher.ts:38):它不是简单打标,而是把带元数据的子事件重新包成一个 data-subagent-stream 自定义事件(stream-metadata-enricher.ts:56):

const dataEvent = {
type: SUBAGENT_DATA_EVENT_TYPE, // "data-subagent-stream"
// 故意不带 id —— 防止 AI SDK 把多个 delta 合并成同一个 part
data: { ...chunkObj, ...metadata, originalType: chunkObj.type },
};

注释点出两个坑:不放 id(否则 AI SDK 会把连续 delta 当成同一个 part 覆盖掉);保留 originalType(让下游还能还原原始事件类型)。过滤由 shouldForwardChunk(stream-metadata-enricher.ts:107)负责——allowedTypes 为空则全放行,否则只放行白名单内的类型。

注意: fullStream 转发是"后台开跑、不 await"的(index.ts:685 writeSubagentFullStream() 直接调不等待),错误只 log 不外抛——转发失败绝不能拖垮主流程。

3.6 supervisor 提前收尾:bail

要解决的小问题: 有时子代理已经给出了最终答案,supervisor 没必要再多绕一圈"我来润色一下再回复用户"——那既费一次 LLM 调用,又可能把答案改坏(system prompt 甚至明令"不要总结子代理的答复")。于是需要一个"就用它的答案,别再处理了,直接收尾"的出口。这就是 bail

机制: onHandoffComplete 钩子(在 supervisor 侧,hooks/index.ts:33)收到一个 bail 函数(hooks/index.ts:51):

bail: (transformedResult?: string) => void;

在钩子里调 bail(),handoffTask 就会带 bailed: true 直接返回;传字符串则用你给的替换结果(subagent/index.ts:471):

// 示意,非源码:子代理答案够好就直接收尾
const supervisor = new Agent({
name: "Supervisor",
subAgents: [mathAgent],
hooks: {
onHandoffComplete: ({ result, bail }) => {
if (result.startsWith("Final answer")) bail(result); // 直接用,不再让 supervisor 加工
},
},
});

bail 时框架做了什么(subagent/index.ts:494):

  1. 打一条 Supervisor bailed after handoff 日志。
  2. 在当前 span 打 bailed / bail.supervisor / bail.transformed 属性,并把最终输出写进 output 属性(index.ts:507)——好让可观测性 UI 显示"这里提前收尾了"。
  3. 在 supervisor 根 span 上也标 bailed(index.ts:517)。
  4. 返回 { result: 转换后或原始, bailed: true }

并行下的 bail: 多代理并行时,只要有一个 bail,那条结果就带 bailed: true,其余照常返回(见 bail.spec.ts:99 "should return bailed result when one agent bails in parallel execution")。所以 bail 是逐结果的标记,不是"炸停全部"。行为契约由 bail.spec.ts 锁定,想改动先看它。


4. supervisor 的 system prompt 是怎么造的

一个 Agent 一旦有子代理,它的 system message 会被 generateSupervisorSystemMessage(subagent/index.ts:236)重写。这解释了"为什么声明 subAgents 后模型就会自己派活"。

默认模板长这样(subagent/index.ts:292):

You are a supervisor agent that coordinates between specialized agents:
<specialized_agents>
- 物流查询: <该代理的 purpose 或 instructions>
- 翻译: ...
</specialized_agents>
<instructions> ...你原本的 instructions... </instructions>
<guidelines> ...一长串行为守则... </guidelines>
<agents_memory> ...之前子代理交互的记忆... </agents_memory>

子代理清单由 extractAgentName + extractAgentPurpose(subagent/index.ts:257)拼出——purpose 优先取 agent.purpose,没有才退回 instructions(index.ts:163)。这样主管才知道"手下每个专家擅长什么"。

内置守则(部分,subagent/index.ts:261) 值得留意,因为它们直接塑造委派行为:

  • "尽量同时联系多个代理"——鼓励并行 fan-out。
  • "不要在最终答复里提代理的名字"——对用户隐藏内部编排。
  • "不要总结子代理的答复"——配合 §3.6 的 bail,倾向于原样透传。
  • "代理之间互不知道彼此存在,你是唯一中介"。

可定制点(SupervisorConfig,agent/types.ts:344):

字段作用
systemMessage完全替换默认模板
customGuidelines在默认守则后追加自定义条目(index.ts:283)
includeAgentsMemory是否附上 <agents_memory>(默认 true)
fullStreamEventForwarding.types覆盖 §3.5 的转发白名单

5. 巧妙之处(可借鉴)

  • 委派伪装成普通工具。 模型只需理解"调一个工具",不需要任何"子代理"概念。schema 简单到只有 task + targetAgents + context(subagent/index.ts:775)。复杂的编排全藏在工具 execute 背后。

  • 按名字匹配 + 容错过滤。 模型拼错代理名不会让委派崩溃,只 warn 并过滤;全错才抛(subagent/index.ts:805-826)。这是"和不可靠的模型输出打交道"的实用姿势。

  • 委派深度靠注册表反推,而非传参。 参数会漏传、会伪造;查全局父子关系反推更可靠,还顺手用 visited 防环(agent.ts:4418)。

  • 流转发故意"不带 id"。 一个反直觉但关键的细节(stream-metadata-enricher.ts:60):给转发事件带 id 会让 AI SDK 误合并连续 delta,所以特意省略。踩过坑才写得出这条注释。

  • bail 让 supervisor 能"闭嘴收尾"。 子代理答案够好时,一次多余的 LLM 加工既费钱又可能改坏答案。bail 给了逐结果的提前退出口,并在 span 上留痕(subagent/index.ts:494)。


6. 边界与局限

  • 委派靠模型自觉。 派不派、派给谁,完全由模型根据 system prompt 判断。模型选错代理、或该并行却串行,框架不纠正(只在完全匹配不上时报错)。

  • 子代理失败被"吞"成文本。 交接里的异常被 catch 成错误字符串返回(subagent/index.ts:541),不外抛。好处是 supervisor 不崩;代价是错误可能被模型忽略。要"错误即中断"得改用 SupervisorConfig 里的相关开关(agent/types.ts:371 附近的 stream 错误抛出选项)。

  • fullStream 转发是尽力而为。 后台开跑、不 await、错误只 log(subagent/index.ts:677)。极端情况下父流可能丢子事件,但绝不会因此拖垮主流程。

  • 深度无硬上限。 calculateDelegationDepth测量深度(给日志用),并不限制它。子代理嵌套子代理没有内建的深度上限,深层嵌套的成本/延迟要自己把控。

  • 这不是 Workflow。 需要固定流程、可暂停/恢复、可回放的多步编排,应该用 Workflow 引擎,而不是让 supervisor 每次即兴决定。


7. 代码地图(导航索引)

主题文件符号
子代理管理器(总入口)packages/core/src/agent/subagent/index.ts:55SubAgentManager
登记子代理 + 注册父子关系packages/core/src/agent/subagent/index.ts:96addSubAgent
生成委派工具packages/core/src/agent/subagent/index.ts:753createDelegateTool
单个交接的生命周期packages/core/src/agent/subagent/index.ts:319handoffTask
并行 fan-outpackages/core/src/agent/subagent/index.ts:715handoffToMultiple
bail 提前收尾逻辑packages/core/src/agent/subagent/index.ts:466handoffTask(onHandoffComplete 分支)
supervisor system promptpackages/core/src/agent/subagent/index.ts:236generateSupervisorSystemMessage
流转发到父流packages/core/src/agent/subagent/index.ts:603forwardStreamEvents / writeFullStream
转发元数据构造packages/core/src/agent/subagent/index.ts:923buildForwardingMetadata
流事件贴元数据packages/core/src/agent/subagent/stream-metadata-enricher.ts:38createMetadataEnrichedStream
事件类型过滤packages/core/src/agent/subagent/stream-metadata-enricher.ts:107shouldForwardChunk
子代理配置类型 + 工厂packages/core/src/agent/subagent/types.ts:80SubAgentConfig / createSubagent
Agent 侧挂委派工具packages/core/src/agent/agent.ts:6312(prepareTools 内)
工具 span → parentToolSpanpackages/core/src/agent/agent.ts:6433executionOptions.parentToolSpan
委派深度计算packages/core/src/agent/agent.ts:4418calculateDelegationDepth
父子关系注册表packages/core/src/registries/agent-registry.ts:91registerSubAgent / getParentAgentIds
onHandoffComplete + bail 签名packages/core/src/agent/hooks/index.ts:33OnHandoffCompleteHookArgs
supervisor 配置packages/core/src/agent/types.ts:344SupervisorConfig
bail 行为契约(测试)packages/core/src/agent/subagent/bail.spec.ts:5SubAgentManager - Bail System

相邻章节: index · 01 运行时 · 02 工具与 MCP · 03 记忆 · 05 Workflow 引擎 · 06 可观测性