数据截至 (上游 commit 1eb2bb1ec895)
多智能体编排: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)。