0%

Agent Orchestration - 智能体编排

1. 为什么要用多智能体

多智能体不是把单个 Agent 做得更好,是用 token 换容量。

Anthropic 自建研究 Agent 时做过一组对比:以 Claude Opus 4 为主 Agent、Claude Sonnet 4 为子 Agent 的多 Agent 系统,在内部 research eval 上比单 Agent 的 Opus 4 高 90.2%。他们给的归因不是「多个 Agent 更聪明」。在 BrowseComp 评测上,token 用量单独解释了 80% 的性能方差,加上工具调用次数和模型选择,三个因素合计解释 95%。多 Agent 架构的作用在于把 token 用量扩展到单个 Agent 够不到的规模。

代价写在同一篇里:Agent 比普通对话多用约 4 倍 token,多 Agent 系统约 15 倍。原文的措辞是,多 Agent 只适用于任务本身价值足够高、付得起这个溢价的场景。

适合拆的任务有三个特征:

  • 能切成多块并行推进,各块不需要看别人的中间过程。Anthropic 的例子是找出标普 500 信息技术板块所有公司的董事会成员:拆开之后每个子 Agent 分头查一批,单 Agent 顺序搜则找不到答案。
  • 信息量超出单个上下文窗口。
  • 要对接的工具又多又杂。

反过来,需要所有 Agent 共享同一份上下文、或者 Agent 之间依赖密集的任务不适合拆。Anthropic 点名的反例是编码,多数编码任务真正可并行的部分比研究少,模型目前也不擅长实时协调和委派。

三条都不占,或者任务的价值撑不起 15 倍 token,就该用单 Agent。决定要拆之后,用哪种实现取决于要隔离什么:

1
2
3
4
5
6
7
8
9
10
11
┌─ 要隔离的东西,决定用哪种实现
│
│ 子 Agent 的推理链会污染主上下文 ──→ Agent 当 Tool
│ 主 Agent 保留控制权,子 Agent 跑完只回一个结果
│
│ 按角色分流,各角色领域不相交 ─────→ Host + Specialist
│ 可并行,各角色不需要互相等待
│
│ 全局状态要一致,还要能中断 ───────→ State Graph
│ 依赖密集、必须共享状态,代价最高
└─

2. Eino 里三种拆法

2.1 按角色拆:Host + Specialist

flow/agent/multiagent/host 包内置了这个模式。Host 理解用户输入并决定交给哪个 Specialist,Specialist 完成各自的子任务,最后由 Summarizer 合并结果。配置入口是 MultiAgentConfig 的三个字段:Host、Specialists []*Specialist、Summarizer。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
                    ┌─────────────────────────┐
│ User Query │
└────────────┬────────────┘
▼
┌─────────────────────────┐
│ Host Agent (Router) │ ──> (识别意图并路由)
└──────┬───────────┬──────┘
│ │
┌─────────────┘ └────────────────────┐
▼ ▼
┌────────────────────────┐ ┌────────────────────────┐
│ Specialist A (售后退款)│ │ Specialist B (商品推荐)│
└────────────┬───────────┘ └────────────┬───────────┘
│ │
└───────────────────┬──────────────────────────┘
▼
┌─────────────────────────┐
│ Summarizer (聚合响应) │ ──> (合并多 Agent 产出)
└─────────────────────────┘

隔离的是角色边界。每个 Specialist 有自己的 system prompt 和工具集,客服 Agent 的上下文里不会出现退款工具的定义。

两处代价写在源码注释里。

Summarizer 只在 Host 选中多个 Specialist 时才被调用。不配置的话用默认实现,而默认实现是把所有 Specialist 的输出直接拼成一条消息,注释明确写了它不支持流式。要做打字机效果就得自己实现一个。

StreamToolCallChecker 的默认实现判断首个 chunk 里有没有 tool call。注释点名这跟 Claude 配合不好,因为 Claude 通常先输出文本再输出 tool call,需要额外用 prompt 约束模型在工具调用时别产生多余文本。

2.2 按能力拆:Agent 当 Tool

主 Agent 把子 Agent 包装成 tool.BaseTool,需要时当一个普通工具调用。

隔离的是上下文。DeepAgent 的 prompt 用 ephemeral 形容子 Agent:只在任务期间存在,返回一个结果。子 Agent 在自己的上下文里跑完 ReAct 循环,中间过程不进主 Agent 的上下文,主 Agent 只收到一份结论。DeepAgent 的 prompt 里也写了这个模式适用的条件:任务要复杂、多步,并且能在隔离状态下完整委派。

代价随之而来。主 Agent 看不到子 Agent 的中间过程,如果子 Agent 一开始就理解错了任务,主 Agent 没有中间信号可以纠正,只能重新派一次,前面那次的 token 就白花了。

ADK 里这个模式有两种形态。SetSubAgents(ctx, agent, []Agent) 是调用返回,主 Agent 始终握着控制权;TransferToAgent 是控制权转移,主 Agent 判断进入某个专业阶段后把整个交互交出去,子任务结束再回弹。

2.3 按流程拆:State Graph

前两种靠模型自己决定路由,第三种把路由写进代码。compose.Graph 用强类型状态加条件边,每一步的输入输出都是显式的。

1
2
3
4
5
6
7
8
9
10
11
12
(Start) ──> [Node: Intent_Router]
│
┌───────────────┴───────────────┐
▼ ▼
[Node: Sales_Agent] [Node: Support_Agent]
│ │
└───────────────┬───────────────┘
▼
[Node: Medical_Critic]
│
├── (Score < 0.8 && HopCount < 3) ──> (回到 Agent 反思重写)
└── (Score >= 0.8) ──> [Node: Stream_Responder] ──> (End)

条件边要先用 compose.NewGraphBranch 包一层,再挂到节点上:

1
2
3
4
5
6
7
8
9
10
branch := compose.NewGraphBranch(
func(ctx context.Context, in *SessionState) (string, error) {
if in.HopCount >= 3 || in.CriticScore >= 0.8 {
return "Stream_Responder", nil
}
return "Sales_Agent", nil
},
map[string]bool{"Stream_Responder": true, "Sales_Agent": true},
)
graph.AddBranch("Medical_Critic", branch)

隔离的是流程控制权。走到哪一步、下一步去哪,由条件函数决定,不靠模型记住。质检分数不达标就回重写节点,循环次数超上限就强制出边,这类熔断逻辑必须是确定性的,交给模型判断不可靠。

代价是三种里最高的。状态结构要自己定义,条件边要自己写,循环要自己兜底。换来的是可控。

3. 拆开之后的工程问题

拆法决定「谁来干」。跨 Agent 的上下文、流、中断和链路,是拆完才需要单独处理的。

3.1 跨 Agent 的上下文隔离

如果每个 Agent 都能看到全部交互和所有工具的原始报文,多轮之后 token 会迅速膨胀。

节点之间传递的应该是结论,不是过程。子 Agent 包成 Tool 时天然如此,tool.BaseTool 的返回值就是主 Agent 能看到的全部内容。Graph 里更直接,状态是强类型的,每个节点声明自己消费哪些字段,没声明的字段不进模型上下文。

大块产物不该走对话历史。Anthropic 建议让子 Agent 把结构化产物写到外部存储,只回传一个轻量引用给主 Agent,避免多级传递时信息被一层层改写,也省掉重复拷贝的 token。

3.2 流式透传

多 Agent 串行或嵌套调用时,中间节点阻塞会让前端长时间白屏,TTFT 恶化。

Eino 用 schema.StreamReader[T] 处理这件事:下游节点不必等上游生成完,按 chunk 订阅即可。多个 Specialist 的输出怎么合并、背压阈值定在哪里,在《Eino 运行时分析》里展开过,这里不重复。

3.3 中断与人工接管

涉及高危操作时,Agent 执行到对应节点要停下来等人,比如发放高额优惠券、退款审批。Eino 的实现在 compose/interrupt.go、compose/checkpoint.go、compose/resume.go 三个文件里,机制细节同样见运行时那篇。

从编排的角度,这里要定的是中断点的位置和粒度:哪些节点必须有人确认,确认的是动作本身还是动作的参数。粒度定粗了审批变成走过场,定细了每个节点都要人点一下,等于没自动化。

3.4 链路观测

拆成多 Agent 之后,一次请求的调用树从「一次模型调用」变成「主 Agent → 子 Agent → 工具 → 模型」这样一棵树。

Eino 的 Callback 是无侵入的,编译期接进 Graph,运行时自动维护父子关系,挂上 OpenTelemetry 就能看到每个 Agent 的延迟占比和 token 消耗。定位是哪个子 Agent 慢,靠的是 span 的父子结构,不是日志时间戳。

4. 中断恢复要处理的边界

中断本身不难,难在恢复的时候状态还是不是当初那个。

状态结构变了。发版改了 State 的字段,线上还有挂起的任务带着旧结构。做法是给 checkpoint 打版本号,恢复时按版本做兼容转换,或者干脆用不依赖 schema 的序列化格式。

挂太久了。配超时时间,再用定时任务扫描标记过期任务,避免 checkpoint 无限堆积。

重复唤醒。多个实例同时拿到同一个挂起任务,只能有一个抢到。用 Redis 分布式锁,更新状态时带上 CAS 校验。

敏感数据落盘。checkpoint 里可能有用户信息、订单号,落盘前要加密或者脱敏。

恢复的完整流程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
┌─ 触发与挂起
│ Agent 节点调用 adk.Interrupt(ctx, info)
│ │
│ ▼
│ Checkpointer 抽取当前状态
│ ├── 超过大小阈值 ──→ 传对象存储,只留引用
│ ├── 写关系库 ──────→ 状态置为 SUSPENDED,记录挂起节点
│ └── 写缓存 ────────→ 供恢复时快速读取
│ │
│ ▼
│ 释放 Worker 协程,发待办事件给审批方
│
│ ▸ 人工审批 / 修改参数
│
│ 恢复时把 resume 信息注入 context,重新进图
│ 已完成的节点从 checkpoint 复用,从中断点的下游继续
└─

入口是 compose.Resume(ctx, interruptIDs...),需要带数据时用 compose.ResumeWithData(ctx, interruptID, data)。两者都是往 context 里注入 resume 信息,然后重新进图;定位恢复目标用的是 interrupt ID。ADK 层另有 adk.Interrupt(ctx, info),返回的是 *AgentEvent。

5. 系统分层

把上面这些放进一个完整的系统里,大致是六层。其中只有第 2 层是 Eino 提供的,其余是外围组件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
+-----------------------------------------------------------------------------------+
| 1. 接入层 (API Gateway) |
| 电商 App / 商家后台 / 客服工作台 (多端接入) | SSE/WebSocket 流式传输 | 鉴权 & 限流 |
+-----------------------------------------------------------------------------------+
│
+----------------------------------------▼------------------------------------------+
| 2. 编排与调度中枢 (Agent Orchestration & Engine) |
| ┌──────────────────┐ ┌─────────────────────────┐ ┌──────────────────────────┐ |
| │ 意图识别/Router │ │ 状态机 (compose.Graph) │ │ Planning & Reflection │ |
| └─────────┬────────┘ └───────────┬─────────────┘ └────────────┬─────────────┘ |
| │ │ │ |
| ┌─────────▼───────────────────────▼─────────────────────────────▼─────────────┐ |
| │ Multi-Agent 协作层: 客服 Agent / 售后 Agent / 商家 Copilot / 推荐 Agent │ |
| └─────────────────────────────────────────────────────────────────────────────┘ |
+-----------------------------------------------------------------------------------+
│ │
+-------------------▼------------------+ +-------------------▼-----------------+
| 3. 记忆与上下文引擎 (Memory Engine) | | 4. 能力与连接层 (Skills & MCP) |
| • 短时窗口管理 (Sliding Window/Pruning)| | • MCP Client / Server 标准接入协议 |
| • 长期记忆 (Vector DB / User Profile) | | • 业务 API: 订单/物流/退款/商品接口 |
| • 会话状态持久化 (Redis / MySQL) | | • RAG Pipeline: 向量检索 + 重排/分块 |
+--------------------------------------+ +-------------------------------------+
│ │
+-------------------▼---------------------------------------------▼-----------------+
| 5. 模型与推理网关 (Model & Infra Gateway) |
| 模型路由 (Claude / GPT / 自研开源模型) | Prompt 管理 | 语义缓存 & Prompt Cache |
+-----------------------------------------------------------------------------------+
│
+----------------------------------------▼------------------------------------------+
| 6. 评测、安全与可观测 (Eval & Observability) |
| 安全护栏 (Guardrails/注入防御) | Trace 链路追踪 (Callbacks/OTel) | 自动化评测 (Eval) |
+-----------------------------------------------------------------------------------+

第 2 层是前面几节讲的编排,第 3、4 层解决记忆和工具接入,第 5 层是模型,第 6 层是兜底。

6. Agent 之间怎么共享记忆

前面几节的前提都是「各块不需要看别人的中间过程」。真到了必须共享的时候,工程上要多付几笔成本。

写和读分开。多个 Agent 同时写同一份记忆会冲突,所以写入走追加日志保吞吐,读取走双轨:热数据从 Redis 读,冷数据从向量库或图库读。代价是读到的未必是最新的,需要写后立刻读的场景得走热路径。

冲突要仲裁。两个 Agent 对同一件事得出矛盾结论时,先比 slot 和向量时钟,能确定因果顺序的直接覆盖;分不出来的用小模型判断是不是逻辑冲突;还分不出来的才交给大模型裁决。三级漏斗的意义在于把大多数冲突挡在便宜的那两级。

权限要下推。向量检索不能先搜完再按权限过滤,那样会搜出一批再被砍掉,召回率虚低,而且数据已经越界拿到了。做法是把权限条件编进索引,检索时直接带掩码。

记忆要衰减。全量保存碎片化轨迹既贵又没用。按时间分层,旧轨迹定期压缩成结论,越久远的存得越粗。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
[ Multi-Agents: Perception & Action ]
│ ▲
│ 1. Append (毫秒级 ACK) │ 4. Hybrid Read (双轨读)
▼ │
┌──────────────────────────────┐ ┌────────────────────────────────────────────────────────┐
│ Ingestion Gateway (WAL Log) │ │ Query Engine │
│ (Kafka / Raft Append-Only) │ │ ├─ Hot Path: Redis (Session/Working Memory) [强一致] │
└──────────────┬───────────────┘ │ └─ Cold Path: Hybrid Vector + Graph [最终一致] │
│ └───────────────────────────▲────────────────────────────┘
▼ 2. Async Dispatch │
┌─────────────────────────────────────────────────────────┐ │ 3. Materialized View Update
│ Background Async Reconciliation & Compaction Pipeline │──────┘
│ ├─ Fast Path: Key-Slot Hash & Vector Clock (确定性覆盖) │
│ ├─ Mid Path: NLI 轻量模型 (逻辑冲突/蕴含检测) │
│ ├─ Slow Path: LLM Semantic Merge (复杂因果仲裁) │
│ ├─ ABAC Pushdown Indexer (构建带权限掩码的物理索引) │
│ └─ Knowledge Compaction Engine (LSM-style 碎片聚合) │
└─────────────────────────────────────────────────────────┘

这四件事都是共享带来的成本,不共享就不用付。

7. 参考

  • Anthropic Engineering、中译 · 90.2% 提升、token 用量解释 80% 性能方差、4 倍与 15 倍 token 开销、多 Agent 不适用的场景
  • cloudwego/eino · Host Multi-Agent 与 Summarizer 配置、ADK 的 SubAgents 与 TransferToAgent、compose.Graph 条件边、中断与恢复实现

本文的包名、函数名与源码注释均以 eino main 分支 2026-09-01 的 9d983b3 为准。