多智能体编排:Stack、委派与 A2A
30 秒导读: 一个 Yao agent 在跑的过程中可以再去"叫"别的 agent。本章讲清两件事: (1)这些嵌套/并行的调用如何被一根 Stack(调用栈) 串成一棵树、彼此隔离又能整体追踪; (2)"叫别人"有两种性质完全不同的姿势——委派(delegation) 把活整个交出去、仍算同一场对话; A2A fork 借别的 agent 当工具、结果拿回来但不许污染主对话历史。
本章只讲"多 agent 如何组织与隔离"。一次请求的主管道生命周期见 02-pipeline.md;Hook 与 JSAPI 的 V8 桥机制见 03-hooks-jsapi.md;工具循环见 04-toolloop-mcp.md。
1. 这是什么(零基础也能懂)
一句话定义: 在处理一个用户请求的过程中,主 agent 可以再启动别的 agent;Yao 用一根 "调用栈"把这些嵌套/并行的 agent 调用记成一棵树,并规定谁的输出进历史、谁负责收尾。
它解决什么问题? 想象你问主 agent 一句话,它自己答不了,于是:
- 把问题整个转交给一个更专的 agent 来答(比如路由到"客服 agent");或
- 同时派三个 agent 去查三个数据源,谁先成功用谁的结果。
这时麻烦来了——它们跑在同一个请求里,共享同一个输出通道、同一段聊天历史、同一份追踪。 如果不管,就会出现三种事故:
| 事故 | 后果 |
|---|---|
每个子 agent 都发一遍 stream_start/stream_end | 前端收到多组"开始/结束",UI 错乱 |
| 子 agent 把自己的中间对话也写进聊天历史 | 主对话历史被"内心独白"污染 |
| 三个并行 agent 同时改同一个 Stack / Logger | 数据竞争(race),追踪树错乱 |
Yao 的答卷: 一个扁平的 Stack 树 + 一条铁律——"只有 root 栈才发流首尾、才存历史、才关输出;
子栈只借 ThreadID 在 UI 上分个道"。
一句话直觉: 把 Stack 想成一叠"报账单",每叫一个 agent 就往上摞一张子单,单子记着
"我是谁的孩子、在第几层、花了多久";请求结束时按 TraceID 把整叠单子捞出来,就是完整的调用树。
2. 顶层全景(它大概怎么转)
2.1 "叫别的 agent"的两种性质
Yao 里让 agent 调 agent,底下其实分成两条语义完全不同的路,别混:
| 路子 | 白话 | 上下文 | 进聊天历史吗 | Referer 标记 | 并行安全 |
|---|---|---|---|---|---|
| 委派 Delegation | 把活整个交出去,交出去就不管了 | 共享同一个 Context | 进(算主对话流) | agent | 串行,无需 |
| A2A fork | 借别的 agent 当工具,要结果 | fork 出独立子 Context | 不进(怕污染) | agent_fork | 并行,靠 fork 保证 |
判断口诀:"我不干了、你接着答用户" = 委派;"你帮我算一下、我拿结果继续" = A2A fork。
2.2 一棵请求内的 agent 树
下面这张图是一次请求里可能长出的调用树。怎么读:根在最上,每往下一层是一次"叫别的 agent", 方括号里是这条边的性质。
[root stack · depth 0] 主 agent (Referer=api)
│ ← 只有它:发 stream_start/end、InitBuffer/FlushBuffer、CloseOutput
│
┌────┴───────────────────────────────┐
│ 委派 (delegate) │ A2A fork (ctx.agent.All)
│ 同一个 Context │ 各自 fork 出独立 Context
▼ ▼
[depth 1] 客服 agent [depth 1] 查库A 查库B 查库C ← 三个并行子栈
Referer=agent Referer=agent_fork(每个)
进主历史、共享 Buffer 不进历史、各自 ThreadID 分道
Stack 只是"账本",不是"执行器"。 真正干活的是每个 agent 自己的 Stream()
(见第 2 章)。Stack 做的只是:在 Stream() 一进门就往栈上摞一张单、出门时结账并恢复父栈。
所以要理解多 agent 编排,先看这张单子(§3),再看两种"往栈上摞单"的入口(§4 委派、§5 fork)。
2.3 部件与文件
| 部件 | 干什么 | 在哪 |
|---|---|---|
Stack 结构 + 生命周期 | 记录单次 agent 调用的身份/父子/耗时 | agent/context/stack.go、agent/context/types.go:366 |
EnterStack | 在 Stream() 开头进栈、返回 done() 收尾闭包 | agent/context/stack.go:200 |
handleDelegation | 委派入口:同 Context 调目标 agent | agent/assistant/next.go:43 |
ctx.agent.* JSAPI | A2A 入口:Call/All/Any/Race | agent/context/jsapi_agent.go |
Orchestrator | A2A 实际执行:fork 上下文、并行编排 | agent/caller/orchestrator.go |
| root 栈闸门 | 只在 root 栈发流首尾/存历史/关输出 | agent/assistant/agent.go、agent/assistant/chat.go |
3. 核心机制一:Stack 数据结构与生命周期
3.1 它要解决的小问题
要把一棵 agent 调用树追踪清楚,每个节点至少得知道:我是谁、我属于哪次追踪、我父亲是谁、
我在第几层、我从根到我的完整路径、我跑了多久、成没成功。 Stack 就是这么一张扁平的记录。
为什么"扁平"? 注释说得很直白:用扁平结构避免循环引用和内存开销——节点不持有父节点指针,
父子关系只靠 ParentID 字符串和 Path 数组表达(agent/context/types.go:364-366)。
3.2 Stack 的字段
| 字段 | 类型 | 含义 |
|---|---|---|
ID | string | 本次调用的唯一 ID |
TraceID | string | 整棵 树共享,从 root 继承——按它能捞出全树 |
AssistantID | string | 处理本次调用的 agent |
Referer | string | 调用来源:api / agent(委派) / agent_fork(A2A) 等 |
Depth | int | 深度,root = 0 |
ParentID | string | 父栈 ID,root 为空(IsRoot() 就靠它判断) |
Path | []string | 从根到本节点的完整路径 [root_id, ..., this_id] |
Status | string | pending/running/completed/failed/timeout |
CreatedAt / CompletedAt / DurationMs | int64 | 时间戳与耗时(毫秒) |
依据:agent/context/types.go:366-394(type Stack struct)。
3.3 三种构造函数 + EnterStack 的三条岔路
栈节点有三种造法,对应三种"我是怎么被叫起来的":
| 构造函数 | 用于 | 关键行为 |
|---|---|---|
NewStack | root(请求入口) | Depth=0、ParentID=""、Path=[stackID];traceID 为空则新生成 |
NewChildStack | 嵌套调用(委派 / 单次 Call) | 继承父 TraceID,Depth+1,ParentID=父.ID,Path=父.Path+新ID |
NewChildStackFromForkParent | fork 出来的子上下文(A2A 并行) | 从 ForkParentInfo 取父信息建子栈,不共享真实 Stack 引用以免并行 race |
依据:agent/context/stack.go:12(NewStack)、:35(NewChildStack)、:61
(NewChildStackFromForkParent)。
这三条岔路由 EnterStack 统一分流。它是每个 Stream() 一进门就调的第一件正事
(agent/assistant/agent.go:63)。怎么读下图:看 ctx 进来时的两个状态位——
有没有现成的 ctx.Stack、有没有 ctx.ForkParent——决定走哪条造栈路。
EnterStack(ctx, assistantID, opts)
│
ctx.Stack == nil ?
│
┌────┴─────────────────────────┐
是(全新上下文) 否(上下文里已有栈)
│ │
ctx.ForkParent != nil ? └─► NewChildStack ← 委派 / 单次 Call
│ (同 ctx 内嵌套,父子相连)
┌─┴──────────────┐
是 否
│ │
NewChildStack- NewStack ← 请求真正入口(API 调用)
FromForkParent (root, 新 traceID)
(A2A fork 子栈,
继承父 traceID)
依据:agent/context/stack.go:200-235(EnterStack 的三分支)。
3.4 进栈、结账、恢复父栈——都在 done() 里
EnterStack 造完栈会做两件事:把栈存进 ctx.Stacks[stack.ID](供事后追踪),然后返回一个
done() 闭包给调用方 defer。这个闭包就是"结 账 + 恢复现场":
// 真实源码,agent/context/stack.go:246-256(EnterStack 返回的 done 闭包)
done := func() {
// 没 panic 的话,标记完成(算耗时)
if !stack.IsCompleted() {
stack.Complete()
}
// 恢复父栈:把 ctx.Stack 换回进来时的那个
if parentStack != nil {
ctx.Stack = parentStack
}
}
配合 Stream() 里的 stack, traceID, done := context.EnterStack(...); defer done()
(agent/assistant/agent.go:63-64),形成对称的进出栈:
- 进:
ctx.Stack被换成新子栈,后续这段执行"看到的当前栈"就是它。 - 出:
defer done()触发,Complete()盖上耗时,再把ctx.Stack换回父栈—— 于是父 agent 继续跑时,当前栈又回到自己。
Complete()/Fail(err)/Timeout() 三个收尾方法都盖 CompletedAt、算 DurationMs、
置对应 Status(agent/context/stack.go:85-112)。注意 done() 只在没被显式 Fail 过
(!IsCompleted())时才补 Complete(),所以中途 Fail 的栈不会被覆盖成成功。
3.5 事后检索:按 ID、按 TraceID、找 root
请求跑完,所有栈都躺在 ctx.Stacks 这张 map 里。Context 上挂了一组检索方法,给追踪日志用:
| 方法 | 返回 | 用途 |
|---|---|---|
GetAllStacks() | 全部栈 | 请求结束后一把捞出做 trace |
GetStackByID(id) | 单个栈 | 精确查某次调用 |
GetStacksByTraceID(traceID) | 同一 trace 的所有栈 | 拼出完整调用树 |
GetRootStack() | IsRoot() 那个 | 找树根 |
依据:agent/context/stack.go:263-312。辅助:GetPathString() 把 Path
渲染成 root -> parent -> current 便于调试(:132)。
3.6 为什么"仅 root 栈"才收尾?——本章最该记住的一条
一棵树里有很多栈,但面向用户的那一层只有一个:root。所以凡是"每个请求只该发生一次"的事,
Yao 都用 ctx.Stack.IsRoot()(即 ParentID == "",agent/context/stack.go:115)当闸门,
只让 root 栈做:
| 只在 root 栈做的事 | 若不 gate 会怎样 | 代码 |
|---|---|---|
发 stream_start | 嵌套/多次 LLM 调用各发一遍,前端收到多个"开始" | sendAgentStreamStart agent/assistant/agent.go:706 |
发 stream_end | 同上,多个"结束" | sendAgentStreamEnd agent/assistant/agent.go:728 |
InitBuffer / FlushBuffer | 子 agent 重复建/刷聊天缓冲,历史错乱;Buffer 只属于 root | agent/assistant/chat.go:100、:201 |
BufferUserInput | 委派的子 agent 会把用户输入重复写一遍 | agent/assistant/chat.go:142 |
CloseOutput | 子 agent 提前关掉共享的输出通道 | agent/assistant/agent.go:262、:604 |
sendAgentStreamStart 的守卫就是一行,很能说明问题:
// 真实源码,agent/assistant/agent.go:705-708
func (ast *Assistant) sendAgentStreamStart(ctx *context.Context, handler message.StreamFunc, startTime time.Time) {
if ctx.Stack == nil || !ctx.Stack.IsRoot() || handler == nil {
return // 不是 root 栈,直接不发
}
// ... 只有 root 才 Marshal 并 handler(ChunkStreamStart, ...)
}
那子栈的输出怎么办?靠 ThreadID。 非 root 栈发消息时,若消息没带 ThreadID,
就自动填成本栈的 ID,让前端把嵌套/并行 agent 的输出分到不同"线程道"上显示:
// 真实源码,agent/context/output.go:86-88
// 为非 root 栈(嵌套 agent 调用)自动设置 ThreadID
if msg.ThreadID == "" && ctx.Stack != nil && !ctx.Stack.IsRoot() {
msg.ThreadID = ctx.Stack.ID
}
同样的逻辑也出现在 agent/assistant/handlers/stream.go:116(stream_start 的 ThreadID)与
:407(缓冲最终消息时取 ThreadID)。一句话:root 管"整场"的首尾与落库,子栈只管在 UI 上分个道。
4. 核心机制二:委派 Delegation
4.1 思路:把活整个交出去,仍算同一场对话
委派的语义是"我这个 agent 不直接答了,换 X 来答用户"。所以它复用同一个 Context——
同一个 ID、Space、Writer、Buffer 全不变,只是往栈上再摞一层子栈。因为是"同一场对话的延续",
所以委派的子 agent 的输出要进聊天历史。
4.2 三个委派入口
Yao 有三处会触发委派,但它们最终都汇到同一个 handleDelegation:
| 入口 | 时机 | 白话 | 代码 |
|---|---|---|---|
| Create hook 委派 | LLM 调用之前 | 提前路由:不花一次模型调用就把请求转给子 agent | agent/assistant/agent.go:246-251 |
| Next hook 委派 | 一轮回答之后 | Next hook 决定"下一步交给谁" | agent/assistant/next.go:19-20 |
| loop_fallback 委派 | 工具循环失败/打满时 | 兜底:把上下文打包成 Markdown 丢给 __yao.loop_fallback | agent/assistant/agent.go:564-567 |
Create hook 委派的价值在"早"——注释直说是 "early routing to sub-agents without LLM call"
(agent/assistant/agent.go:245),即无需先花一次模型调用就能路由。
loop_fallback 则由 buildLoopFallbackDelegate 造出一个指向 __yao.loop_fallback 的
DelegateConfig,把对话与工具结果格式化成一条 user 消息(agent/assistant/loop.go:204-218)。
4.3 handleDelegation:同 Context 调目标 agent
三个入口的共同落点。它做的事极简:
// 真实源码,agent/assistant/next.go:48-66(handleDelegation 主体,已节选)
targetAssistant, err := Get(delegate.AgentID) // 1. 加载目标 agent
// ...
ctx.Referer = agentContext.RefererAgent // 2. 标记为 agent 委派来源
delegateOpts := agentContext.OptionsFromMap(delegate.Options)
return targetAssistant.Stream(ctx, delegate.Messages, delegateOpts) // 3. 用同一个 ctx 调它
关键在第 3 步传的是同一个 ctx。于是目标 agent 的 Stream() 进门调 EnterStack 时,
ctx.Stack != nil,走 NewChildStack 那条岔路(§3.3),自动挂成"父 agent → 委派 agent"的父子栈。
ctx.Referer = RefererAgent(值为 "agent",agent/context/types.go:68)这一步很要紧:
它让 shouldSkipHistory() 判定为"这仍是主对话流,要存历史"——注释原话:
"Delegate calls (RefererAgent) still save history as they are part of the main conversation flow"
(agent/assistant/agent.go:68)。这正是委派与下面 A2A fork 的分水岭。
DelegateConfig 结构本身很小:目标 AgentID、要发的 Messages、可选 Options
(agent/context/types.go:487-491)。
5. 核心机制三:A2A fork(ctx.agent.Call/All/Any/Race)
5.1 思路:借别的 agent 当"工具",结果拿回来但不许污染历史
A2A(agent-to-agent)是在 Hook 脚本里用 ctx.agent.* 主动"叫"子 agent 当工具用。
四个方法对标 JS 的 Promise:
| JSAPI | 语义 | 对标 |
|---|---|---|
ctx.agent.Call(id, msgs, opts?) | 单个调用 | 直接 await |
ctx.agent.All(reqs) | 全部完成才返回 | Promise.all |
ctx.agent.Any(reqs) | 任一成功即返回 | Promise.any |
ctx.agent.Race(reqs) | 任一完成即返回 | Promise.race |
接口定义:agent/context/jsapi_agent.go:12-24(AgentAPI)。它们和委派的根本区别:
子 agent 的中间对话不能进主聊天历史。