从零推导 Supervisor (主管) Agent
拨开迷雾看本质:从零推导 Supervisor (主管) 模式
1. 寻找“第一性原理”:最朴素的主管分发
在阅读本文之前,如果你还没有看过 flow.md,建议先去了解 Eino 中的 Flow 网络 和 Transfer(交接棒) 机制。因为 Supervisor 并不是一个全新的执行引擎,它是完全寄生在 Flow 机制之上的一种特化拓扑。
抛开 adk/prebuilt/supervisor/supervisor.go 里的 Container 和 DeterministicTransferTo,这个文件解决的最核心的业务问题是什么?
答案是:在一个原本自由交接(Transfer)的 Flow 网络中,硬生生雕刻出一种“星型”拓扑,其中一个作为中心大脑(主管),其他作为干活的手脚(小兵)。小兵只能和主管沟通,小兵之间不能直接沟通。
如果不使用任何框架,我们要实现一个“主管-小兵”模式,用最朴素的原生 Go 代码写出来,它其实就是一个无限的 switch-case 调度器:
// 第一性原理:最基本的主管分发调度
func RunSupervisor(supervisor Agent, workers map[string]Agent, input string) string {
history := []Message{{Role: "user", Content: input}}
for {
// 1. 主管思考
plan := supervisor.Run(history)
history = append(history, plan)
// 2. 如果主管觉得任务完成了,直接退出
if plan.IsDone() {
return plan.Content
}
// 3. 主管决定派活给某个小兵
targetWorkerName := plan.GetTargetWorker()
worker, ok := workers[targetWorkerName]
if !ok {
panic("主管乱派活,找不到小兵")
}
// 4. 小兵干活,然后把结果汇报给主管(直接塞进 history)
result := worker.Run(history)
history = append(history, result)
}
}
非常简单直白,主管就是一个大 Router。既然这么简单,为什么 Eino 要写一个专门的 prebuilt/supervisor 并且引入一个 Container 包装器?
2. 第一次演进:应对“脱缰的野马”(Transfer 的约束)
痛点与危机:
在我们之前的推导(如 flow.md)中,我们知道 Eino 的多 Agent 协作底层是依赖一种叫做 Transfer(交接棒) 的机制的。
这意味着,每个 Agent 只要调用 TransferToAgent 工具,就可以把任务转给网络里的任何人。
危机来了:在 Supervisor 模式下,我们不希望小兵 A 把任务直接转给小兵 B。因为这样会让网络变成“网状”,主管会被架空,整个任务的进度会失控。我们希望小兵干完活后,必须、且只能把结果还给主管。
如果小兵的模型由于幻觉,或者被恶意的 Prompt 注入,强行调用了 TransferTo(WorkerB),整个网络就崩溃了。
引入第一次抽象:强制转移的约束(Deterministic Transfer)
// 为了防止小兵乱跑,我们引入一个“强制押送员”包装器
type DeterministicTransferWrapper struct {
realWorker Agent
forceTarget string // 强制的目标(比如 Supervisor)
}
func (w *DeterministicTransferWrapper) Run(history []Message) Message {
// 1. 小兵正常干活
resp := w.realWorker.Run(history)
// 2. 无论小兵吐出什么,甚至它想 Transfer 给别人,我们都强行把它改写!
// 强行把它的 Next 目标改为主管
resp.ForceSetTransferTarget(w.forceTarget)
return resp
}
// 这样在构建网络时:
func BuildSupervisor(supervisor Agent, workers []Agent) Agent {
safeWorkers := make([]Agent, 0)
for _, w := range workers {
// 给每个小兵戴上“项圈”,干完活只能回主管那里
safeWorkers = append(safeWorkers, &DeterministicTransferWrapper{
realWorker: w,
forceTarget: supervisor.Name(),
})
}
// 把戴着项圈的小兵交给主管
return ComposeNetwork(supervisor, safeWorkers)
}
通过这一步演进,我们在不修改底层引擎、也不修改小兵内部逻辑的情况下,从架构层面保证了“星型拓扑”的绝对安全性。
3. 第二次演进:应对可观测性的割裂(Unified Tracing)
痛点与危机:
在多 Agent 系统中,可观测性(Tracing/日志打点)是极其重要的横切关注点。
假设你启动了这个 Supervisor 网络,它内部包含了 1 个主管和 3 个小兵。
当你在外层注册了一个 OnStart 和 OnEnd 回调函数,想要追踪这次运行的总耗时时,你会发现一个尴尬的问题:
你拿到的是 4 棵平行的追踪树!
因为在底层引擎看来,这是 4 个互相 Transfer 的独立 Agent。你的监控系统里会看到 Supervisor 启动了、结束了;接着 Worker A 启动了、结束了;接着 Supervisor 又启动了…
它们缺乏一个统一的 Trace Root(追踪根节点)。这在排查生产问题时是灾难性的。
引入第二次抽象:统一容器包装(The Container Wrapper)
为了让整个 Supervisor 网络在外部看起来像一个整体,我们需要在最外层再套一个盒子(Container)。这个盒子什么业务逻辑都不干,它唯一的目的就是劫持生命周期,提供统一的身份标识。
// 为了统一追踪,引入一个外层容器
type SupervisorContainer struct {
networkRoot Agent // 内部的复杂网络(主管+小兵)
name string
}
// Container 实现了标准的 Agent 接口
func (c *SupervisorContainer) Run(ctx context.Context, input string) string {
// 1. 【核心】在真正跑网络前,触发一个 Container 级别的 OnStart
// 这会在监控系统里生成一个根 Span,比如:[SpanID: 001, Name: SupervisorSystem]
ctx = StartTraceSpan(ctx, "SupervisorSystem", c.name)
defer EndTraceSpan(ctx)
// 2. 把带有统一 Trace ID 的 ctx 传给内部网络
// 内部的 Agent 收到这个 ctx 后,它们的打点都会认 Span 001 做“干爹”
return c.networkRoot.Run(ctx, input)
}
对比一下效果:
如果没有 Container(只有平级的 Transfer):
你在监控面板(如 Jaeger/Zipkin)里看到的是三棵独立的小树,它们在时间轴上是断开的:
[-] Agent Run: Supervisor_Boss (耗时 2s)
[-] Agent Run: Worker_Coder (耗时 5s)
[-] Agent Run: Supervisor_Boss (耗时 1s)
排查问题时,你很难一眼看出这三次独立的运行其实同属于“一次用户请求”。
如果有 Container(统一 Tracing):
你在监控面板里看到的是一棵完整的大树:
[-] SupervisorSystem (总耗时 8s)
├── [-] Agent Run: Supervisor_Boss (耗时 2s)
├── [-] Agent Run: Worker_Coder (耗时 5s)
└── [-] Agent Run: Supervisor_Boss (耗时 1s)
整个 Supervisor 网络在外部看起来就像是一个“单体大 Agent”,其内部复杂的派单、打回、重试,全部被收拢在了这个统一的生命周期(根 Span)之下。
这一步演进揭示了:当组件组合成更高级的模式时,为了横切关注点(如 Tracing)的完整性,必须引入物理上的边界包装器。
4. 映射到真实源码:Eino 的 supervisor.go 到底做了什么?
当我们带着这两步推导去审视真实的 adk/prebuilt/supervisor/supervisor.go 时,你会发现它完美对应了我们的两次演进:
-
第一次演进:防止小兵乱跑(Deterministic Transfer)
在New函数(adk/prebuilt/supervisor/supervisor.go#L92-L100)中,它并没有直接把conf.SubAgents传给主管。而是遍历了所有小兵,给它们套上了一个adk.AgentWithDeterministicTransferTo的包装器,并且硬编码ToAgentNames: []string{supervisorName}。
这和我们推导的DeterministicTransferWrapper如出一辙,就是给小兵戴上“干完活必须回主管”的项圈。 -
第二次演进:统一的追踪根节点(Unified Tracing Container)
在组装完网络后,它返回的不是inner(内部网络),而是一个&supervisorContainer{}(adk/prebuilt/supervisor/supervisor.go#L107-L110)。
看supervisorContainer的定义(L56-L79),它的Run和Resume几乎就是简单的透传(s.inner.Run(...))。
那它的作用是什么?看文件头部的神级注释(L20-L31):The supervisor pattern provides unified tracing support through an internal container… OnStart is invoked once at the supervisor container level… All agents within the supervisor appear as children of the same trace root.
这就是为了提供一个统一的GetType() string { return "Supervisor" }身份,让底层 Callback 系统在触发OnStart时,能够建立一棵统一的 Trace 树!
5. 补充思考:Supervisor、Workflow 与 Flow 的大一统关系
如果你连着看了 flow.md、workflow.md 和本篇 supervisor.md,你会发现 Eino ADK 的多 Agent 编排体系其实只用了一个底层核心:Flow 网络(基于 Transfer 的路由与事件拦截)。
Workflow 和 Supervisor 本质上都是对这个自由 Flow 网络的拓扑约束(Topology Constraint)。
- Flow 是最底层的“无向图”:任何节点只要拿到接力棒(TransferTo),都可以传给网络里的任何人。这种基于涌现的路由极其灵活,但也极容易失控。
- Workflow(流水线模式) 是一种“强管控”约束:它把无向图变成了单行道(Sequential)或多车道(Parallel)。
- 包装过程:当你调用
NewSequentialAgent(A, B, C)时,框架底层其实是把 A、B、C 全部包成了flowAgent,并且打上了一个补丁:WithDisallowTransferToParent()。 - 拦截原理:它通过外层的
for i := 0; i < len(agents); i++(或者wg.Wait())彻底接管了控制权。节点 A 跑完后,根本不需要吐出什么 Transfer 动作,外层的for循环会自动把 A 的结果塞给 B 启动。如果 A 真的因为模型幻觉吐出了一个TransferTo(D),外层的循环也会直接忽略或者报错(因为控制流不再由节点内部涌现,而是由外部循环写死)。
- 包装过程:当你调用
- Supervisor(星型模式) 则是另一种“半管控”约束:它把无向图雕刻成了一个星型拓扑。
- 包装过程:它允许子节点吐出 Transfer 动作,但它在每个小兵外层套了一个
DeterministicTransferTo包装器。 - 拦截原理:小兵模型思考后决定:“我要把接力棒传给 [前端组长]!”(它吐出了
TransferTo("前端组长"))。但这层包装器就像个强硬的邮局,直接把信封上的收件人涂掉,强行改写成TransferTo("Supervisor大脑")。这样就保证了无论小兵怎么折腾,接力棒永远只能回到中心节点。
- 包装过程:它允许子节点吐出 Transfer 动作,但它在每个小兵外层套了一个
这三种模式共享了同一套“事件聚合”、“历史翻译”和“中断路由(Resume)”的底层基建(都实现在核心的 flowAgent 里)。而它们之所以能表现出完全不同的协作形态,纯粹是因为在外层像搭积木一样,组合了不同的 Wrapper(包装器)和调度循环。
这种“核心逻辑下沉 + 外层轻量包装”的设计,正是 Eino 架构中最优雅、最符合“组合优于继承”哲学的一笔。
6. 批判性总结 (Critical Trade-offs)
优势:精妙的复用与边界控制
Eino 的 Supervisor 设计极其优雅。它没有写任何一行新的调度循环代码。
它纯粹通过复用底层的 Transfer 机制(flow.go 提供的能力),加上两个极其轻量的装饰器(DeterministicTransferTo 和 supervisorContainer),就硬生生地在一个无向图中,雕刻出了一个严格的星型拓扑,并且完美解决了可观测性问题。这种“拼乐高”式的设计,是高度内聚和正交的。
代价与局限:过度包装导致的心智负担
这种“一切皆 Wrapper”的哲学也有代价。
如果你在调试时步进(Step Into)一个 Supervisor 的运行,你的调用栈会深得令人发指:Runner -> supervisorContainer -> flowAgent(Supervisor) -> flowAgent(DeterministicWrapper) -> realWorker。
对于一个刚接触 Eino 的开发者来说,当他只想要一个简单的“主管分发任务”,却在日志里看到一堆莫名其妙的包装器名字时,会感到极大的困惑。
更优解的探讨:
Eino 选择用拓扑约束(Wrapper 拦截)来实现 Supervisor,是因为它底层的基石是自由的 Transfer 机制。
但如果我们在更高的维度看,Supervisor 本质上是一个有限状态机(FSM)。
如果框架能够提供一种基于配置的声明式状态机(类似 AWS Step Functions),开发者只需要声明 StateA -> StateB,框架在底层直接进行状态路由,而不是依靠层层嵌套的 Wrapper 去拦截和篡改信号,可能会让代码更扁平、调试更直观。
Eino 的做法是 Go 语言接口组合哲学发挥到极致的产物,它足够 Hacker,但也确实不够直白。
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐
所有评论(0)