拨开迷雾看本质:从零推导多 Agent 协作网络 (Flow Agent)
1. 为什么我们需要多个 Agent 协作?
在简单的场景中,一个拥有各种工具的单体 Agent(如我们在 react.md 中推导的那样)就足够了。
但当业务变得复杂时,单体 Agent 很容易出现一些系统性问题:
- Prompt 爆炸:如果你让一个 Agent 既懂写代码、又懂审查、还要会发邮件,它的 System Prompt 会长到让大模型"失焦"。
- 工具灾难:如果给它塞 50 个工具,模型根本选不过来,甚至会把查天气的工具和删数据库的工具搞混。
朴素的想法:
既然一个 Agent 搞不定,我们就把任务拆分给多个 专职 的 Agent。
假设我们手写一个极简的"多 Agent 协作"代码:
go
// 朴素的多 Agent 协作
func RunMultiAgent(input string) {
// 1. 经理分析需求
managerPlan := managerAgent.Run(input)
// 2. 如果需要写代码,就调 Coder
if strings.Contains(managerPlan, "code") {
codeResult := coderAgent.Run(managerPlan)
// 3. 代码写完,调 Reviewer
reviewResult := reviewerAgent.Run(codeResult)
fmt.Println("最终结果:", reviewResult)
}
}
看起来很清晰?但这只是在写 硬编码的脚本 ,一旦业务链路变长、需要 Agent 之间互相讨论(比如 Reviewer 觉得不行,打回给 Coder 重新写),这种用 if/else 手工调度的代码就会彻底失控。
2. 第一步演进:解决硬编码调度(Transfer 机制)
问题场景 :
我们希望 Agent 能够 自主决定 下一个任务交给谁,而不是在外部代码里用 if/else 写死。比如 Manager 发现问题很复杂,它自己决定"把任务转交(Transfer)给 Coder"。
批判性思考 :
要让 Agent 自主转交,我们需要赋予它一种"交接棒"的能力。
最自然的想法是:把"转移给另一个 Agent"做成一个 Tool 塞给当前 Agent。
引入 Transfer 机制后的本质代码:
go
// 框架层:定义一种特殊的工作流引擎
type FlowEngine struct {
agents map[string]Agent
}
func (e *FlowEngine) Run(startAgentName string, history []Message) {
currName := startAgentName
for currName != "" {
agent := e.agents[currName]
// 执行当前 Agent
resp := agent.Run(history)
history = append(history, resp)
// 检查这个 Agent 是否调用了特殊的 Transfer 工具
if resp.HasToolCall("TransferTo") {
// 解析它想转给谁
nextAgent := resp.ToolCalls[0].Args["target_agent"]
currName = nextAgent // 接力棒传下去了!
} else {
// 如果没调 Transfer,说明任务结束了
currName = ""
}
}
}
在这个模型下,Coder 和 Reviewer 可以互相 Transfer,形成一个死循环辩论,而你不需要在外部写任何一行 while 循环。
补充:Transfer 的数据契约
上面的伪码默认 Transfer 只是在"换一个 Agent 继续读同一段 history"。这能跑通,但也会自然引出一个工程问题:交接时到底传什么?
- 如果只传
history,结构化状态(例如计划、约束、工具调用的结果对象)会被迫编码进文本,形成隐式协议。 - 如果额外传
state,就需要一个明确的 Schema(强类型或至少可校验),否则状态会在多个 Agent 之间逐步漂移。
更稳妥的理解方式,是把 Transfer 的 "数据契约" 拆成两层:一层是 显式控制面 ,另一层是 隐式数据面 。
- 显式控制面:Transfer 的职责是表达 "把控制权交给谁" 。它可以额外携带少量观测信息(例如 reason / tag),用于复盘与可观测,但不应承载业务数据,否则交接会退化为 "在 Agent 之间传包",并在链路变长后迅速演化出不可维护的隐式协议。
- 隐式数据面:上下文与状态应放在一个共享的会话容器里,并由框架贯穿整个协作链路。在 Eino 的实现中,Transfer 的显式载荷非常薄(目标 Agent 名),下一个 Agent 之所以仍能接着跑,是因为它继续共享同一个 runCtx(RootInput + Session.Events + Session.Values)。因此,许多看起来像 "state" 的信息,最终更像是 "会话 KV" 或 "事件序列" 的投影,而不是 Transfer 参数本身。
若要把这套模式做得更工程化,改进方向通常也落在共享容器上,而不是往 Transfer 参数里塞更多字段:
- 为 Session.Values 引入最小 Schema:至少做到可校验(版本号 + 必需字段),并明确 key 命名空间,避免不同 Agent 互相踩踏或让状态在链路中漂移。
- 把大对象从 history 搬走:history 更适合承载决策与结论;工具大结果、结构化中间态更适合放在 Session.Values 中,只在 history 里引用一个摘要或 ID。
- 将交接做成可观测事件:即使 Transfer 只传目标名,也应记录交接原因与关键上下文摘要,方便在事后回答 "为什么会跳到这个 Agent" 。
3. 第二步演进:解决"记忆错乱"与"鸡同鸭讲"(History Rewrite)
问题场景 :
在上面的交接棒模式中,大家共享同一个 history []Message。
想象一下:
Coder写了一段代码,并在history里记录了assistant: 好的,代码写完了。- 接力棒传给了
Reviewer。 Reviewer看到历史记录里写着assistant: 好的,代码写完了。
灾难发生了!Reviewer会以为这句话是 自己 刚才说的!它会陷入精神分裂,无法区分哪句话是 Coder 说的,哪句话是自己说的。
批判性思考 :
大模型本身是没有"自我意识"的,它只认 Role(user/assistant/tool)。在多 Agent 网络中,不同 Agent 轮流扮演 assistant 会导致极其严重的身份混淆。
我们必须在交接棒时,翻译(Rewrite)历史。
引入 History Rewriter 后的本质解法:
go
// 每次交接给新 Agent 前,把之前的记录"翻译"成用户口吻
func RewriteHistory(history []Message, nextAgentName string) []Message {
var newHistory []Message
for _, msg := range history {
if msg.Role == "assistant" && msg.Author != nextAgentName {
// 关键:把别的 Agent 说的的话,伪装成 User 对当前 Agent 说的背景信息
rewritten := fmt.Sprintf("For context: [%s] said: %s", msg.Author, msg.Content)
newHistory = append(newHistory, Message{Role: "user", Content: rewritten})
} else {
newHistory = append(newHistory, msg)
}
}
return newHistory
}
通过这种转换,当 Reviewer 接手时,它看到的是 user: For context: [Coder] said: 好的,代码写完了。这样它的"自我认知"就不会错乱。
补充:History Rewrite 的工程代价
History Rewrite 是一个非常有效的 "兜底工程技巧",但它也会带来一些确定的代价:
- 上下文膨胀:每条消息都增加前缀与转述信息,链路越长越容易触发摘要/裁剪,从而改变后续 Agent 的判断依据。
- 语用漂移 :把别的 Agent 的发言改写成
user背景,会把对等协作语境悄悄变成"用户指令 + 背景材料",影响模型的服从与质疑倾向。 - 注入面扩大 :如果子 Agent 输出包含诱导性文本,改写为
user后风险更高;更稳的长期方向通常是结构化消息(保留 Author、RunPath、State)而不是纯字符串拼接。
4. 第三步演进:解决网络层级与事件风暴(Flow Agent Wrapper)
问题场景 :
随着 Agent 越来越多,扁平的"交接棒"不够用了。我们需要 层级结构(Supervisor 模式) 。
比如:MainAgent 包含子 Agent [Coder, Reviewer]。
这时候会出现两个典型痛点:
- 事件风暴:前端想要看到实时的打字机效果。但由于有多个嵌套的 Agent 轮流运行,到底该监听谁的输出?如果 Coder 和 Reviewer 在疯狂对骂,前端怎么把这些流式的事件(Event)有序地拼凑起来?
- 中断与恢复地狱 :如果
Coder在中间调用工具挂起了。系统恢复时,你怎么知道是去唤醒MainAgent还是Coder?
批判性思考 :
我们不能把多 Agent 当成简单的"平级函数互相调用"。我们需要一个 统一的包装器(Wrapper) ,把所有 Agent 包装成一样的接口。这个包装器负责:
- 拦截和聚合事件:把各个子 Agent 零散的事件打上时间戳和标签,汇聚到一根总的管子里。
- 拦截和翻译交接棒(Transfer):截获底层的 Transfer 事件,根据层级结构找到目标 Agent。
- 处理横切关注点(Cross-cutting Concerns) :在真实业务中,我们不仅要调 Agent,还要在每个 Agent 执行前后做打点追踪(Tracing)、权限校验、统一的异常捕获 。如果把这些代码塞进每个 Agent 内部,代码会极度臃肿。包装器正好可以在调用
realAgent.Run前后,插入这些"横切逻辑"。
引入 Flow Wrapper (装饰器模式) 的本质代码:
我们需要一个 统一的包装器(Wrapper) ,把所有 Agent 包装成一样的接口。这个包装器负责:
- 统一输入输出:把零散的历史记录聚合成统一的输入,把乱飞的事件打上标签聚合成统一的输出流。
- 拦截和翻译交接棒(Transfer):截获底层的 Transfer 事件,根据层级结构递归调度下一个 Agent。
- 精准路由恢复(Resume):当收到恢复信号时,根据信号里的"目的地名字",决定是自己恢复,还是把信号透传给某个子 Agent。
在这个模型里,有一个经常被忽略但很关键的隐含前提:事件流的顺序需要足够稳定。否则前端渲染、日志对齐与恢复定位都会变得很困难。最简单的方式是保持 "单线程、深度优先" 的运行模型;一旦引入并发子 Agent,就需要额外的排序键(例如时间戳 + 运行路径 + 事件序号)来重建可重放顺序。
go
// 框架层:代理包装器 (装饰器模式)
// 它包装了一个真实的 Agent,并为它处理历史翻译、事件转发、调度和恢复
type FlowAgent struct {
realAgent Agent
subAgents map[string]*FlowAgent
name string
}
// ---------------------------------------------------------
// 核心逻辑 1:如何组织输入和输出 (Run)
// ---------------------------------------------------------
func (f *FlowAgent) Run(globalHistory []Message, outChan chan Event) {
// 【输入组织】:大模型容易精神分裂,所以在真正调底层 Agent 前,先把全局历史翻译成 "For context: [XXX] said: ..."
rewrittenHistory := RewriteHistory(globalHistory, f.name)
// 创建一个拦截器 channel,用来"偷听"真实 Agent 的动静
innerChan := make(chan Event)
go f.realAgent.Run(rewrittenHistory, innerChan)
// 【输出组织】:循环处理底层冒出来的每一个事件
for event := range innerChan {
// 1. 打上当前 Agent 的水印,防止多层嵌套时前端分不清是谁说的
event.AgentName = f.name
// 2. 拦截 Transfer 事件 (解决交接棒问题)
if event.IsTransfer() {
targetName := event.TransferTarget
targetAgent := f.subAgents[targetName]
// 递归调度!把接力棒传给下一个 Agent,并把事件无缝拼接到当前的总输出流中
// 注意:因为 targetAgent 也是个 FlowAgent,所以它也会经历历史翻译、水印等过程
targetAgent.Run(event.CurrentHistory, outChan)
return // 当前 Agent 的任务被交接出去了,自己的 Run 结束
}
// 3. 普通事件,直接透传给外层的输出管子
outChan <- event
}
}
// ---------------------------------------------------------
// 核心逻辑 2:如何解决中断与恢复地狱 (Resume)
// ---------------------------------------------------------
// resumeInfo 里包含了用户当初恢复时指定的"目标组件名字"
func (f *FlowAgent) Resume(resumeInfo ResumeInfo, outChan chan Event) {
// 1. 如果中断发生在我这层(比如我自己调的某个 Tool 挂起了),那我自己恢复
if resumeInfo.TargetAgentName == f.name {
f.realAgent.Resume(resumeInfo, outChan)
return
}
// 2. 如果中断不发生在我这层,说明中断发生在我的某个子孙节点里
// 这时候我就像个路由器一样,查一下路由表(subAgents),把恢复信号原封不动地踢给对应的子节点
targetAgent := f.subAgents[resumeInfo.TargetAgentName]
if targetAgent != nil {
targetAgent.Resume(resumeInfo, outChan)
return
}
panic("找不到要恢复的 Agent!")
}
这个路由逻辑隐含了一个约束: TargetAgentName 必须稳定且唯一。层级网络里仅靠短名很容易冲突,更稳的做法通常是使用路径化的标识(例如 root.reviewer)或运行时分配的 ID,并在 checkpoint 里保存该标识以保证跨版本可恢复。
通过这个 FlowAgent 装饰器:
- 输入/输出不再混乱 :外部的调用方只需要往最外层的
FlowAgent传一次输入,监听一根outChan。内部不管互相怎么 Transfer、怎么嵌套,最终所有的水滴都会被打上AgentName的标签,从这根唯一的管子里流出来。 - 恢复不再迷路 :当外部调用
rootAgent.Resume时,恢复信号会像网络数据包一样,沿着装饰器链一层层往下传,直到精准命中当初挂起的那个真实 Agent。
5. 补充思考:Flow 与 ReAct 的关系
在理解了 Flow 的包装器本质后,我们必须回答一个关键问题:被包装在里面的那个 realAgent 到底是什么?Flow 和 ReAct 是什么关系?
答案是:Flow 是编排层(网络),ReAct 是执行层(节点)。
在绝大多数情况下,Flow 包装的 realAgent 就是一个 ReAct Agent(也就是 Eino 里的 ChatModelAgent)。
ReAct 如何支撑了 Flow?
如果在 flow.md 里我们推导的 Transfer(交接棒)机制显得过于"理想化",那是因为我们没有把它和 ReAct 结合起来看。
-
"思考然后交接"的闭环 :
Flow 只是规定了"一旦出现 Transfer 事件,我就帮你转发"。但谁来产生这个 Transfer 事件?
是底层的 ReAct Agent!ReAct Agent 的核心能力是
Reasoning + Acting。当它(模型)经过 Reasoning 发现自己搞不定,或者发现任务应该归属下一环节时,它就会选择调用一个特殊的 Tool(比如TransferToAgent)。这个 Tool 的执行结果(Acting),就是吐出一个 Transfer Action,从而触发了外层 Flow 的调度。
没有 ReAct 的智能决策,Flow 就是一个无法触发的死网络。 -
状态挂起的降维打击(宏观路由 vs 微观现场) :
在 Flow 中,我们遇到了"中断与恢复地狱"。虽然 Flow 的
Resume方法像路由器一样解决了"把信号踢给谁"的问题(Agent 层面的 Resume ),但最终那个子节点是怎么真正挂起和恢复的呢?这完全依赖于底层的 ReAct 框架!正如我们在
react.md中推导的,ReAct 底层的图引擎(Graph)和状态机(State)才是真正保存 Checkpoint 和执行断点续传的功臣(工具层面的 Resume)。- ReAct 负责"微观现场":它记住"我正在调第 3 个工具,工具叫 AskUser,参数是什么"。
- Flow 负责"宏观路由" :它记住"当前是 Coder 在干活,所以我要把唤醒信号发给 Coder,而不是 Reviewer"。
Flow 站在了 ReAct 的肩膀上,把微观节点的挂起能力,扩展成了整个宏观网络的挂起能力。
6. 映射到现实:Eino 的 adk/flow.go 到底做了什么?
当我们审视真实的 adk/flow.go 时,你会发现它的核心逻辑就是我们上面推导的 第三步(Flow Wrapper) 。
Eino 的 flowAgent 是一个非常典型的 装饰器(Decorator) 模式。
- 解决"鸡同鸭讲" :L171-L200
rewriteMessage函数,正是把其他 Agent 的assistant发言,改写成了For context: [xxx] said:的user发言,显著缓解了大模型的身份错乱问题。 - 解决"调度交接" :L468-L488 的
if destName != ""逻辑。当底层的 Agent 吐出一个Transfer动作时,flowAgent把它截获了,然后从自己的subAgents列表里找出目标 Agent,递归调用subAIter := agentToRun.Run(...)。 - 解决"事件风暴与归属" :L429-L440 的
exactRunPathMatch逻辑。因为多层嵌套,事件会在 channel 里乱飞。flowAgent通常需要通过RunPath匹配,来决定哪些事件属于"我自己这层的运行",应该记录到 Session 里,哪些事件只是子节点的噪音,应该忽略。
6.1 深入 Transfer 工具的"原生注入"机制
在前面的推导中,我们把"转移给另一个 Agent"简单地说成是"塞给当前 Agent 的一个 Tool"。在 Eino 的真实实现中,这个注入过程隐藏着极深的架构考量。
当你调用 adk.SetSubAgents(ctx, parentAgent, []Agent{childAgent}) 构建 Flow 网络时,ChatModelAgent 会在准备执行上下文时( <adk/chatmodel.go#L636-L657> prepareExecContext)原生地注入 Transfer 能力:
go
// 1. 获取所有可以 Transfer 的目标(子节点 + 父节点)
transferToAgents := a.subAgents
if a.parentAgent != nil && !a.disallowTransferToParent {
transferToAgents = append(transferToAgents, a.parentAgent)
}
// 2. 如果存在可交接的目标,原生地注入 Instruction 和 Tool
if len(transferToAgents) > 0 {
// 动态生成 Transfer 的专属提示词,大模型看到的极简版本大致如下:
// "可用的其他 Agent:
// - Coder:负责写代码
// - Reviewer:负责审查
// 决策规则:如果你自己搞不定,请调用 'transfer_to_agent' 函数,把活交出去。"
transferInstruction := genTransferToAgentInstruction(ctx, transferToAgents)
instruction = concatInstructions(instruction, transferInstruction)
// 强行把 transfer_to_agent 工具塞进列表
toolsNodeConf.Tools = append(toolsNodeConf.Tools, &transferToAgent{})
// 【核心黑科技】:标记这个工具为"立即返回"类型,用于打断 ReAct 的内部死循环
returnDirectly[TransferToAgentToolName] = true
}
为什么是硬编码原生注入,而不是用 Middleware 包装?
这揭示了 Transfer 的本质:它不是一个普通的"业务工具(Tool)",而是改变控制流的"底层汇编指令(JMP)"。
Transfer必须修改底层 ReAct 引擎的控制流:一旦大模型调用了它,Agent 必须立刻结束当前生命周期(returnDirectly),把控制权交还给外层的 Flow 引擎去唤醒下一个节点。Transfer必须感知网络的父子拓扑:它需要动态知道自己当前挂载了哪些子节点、谁是父节点,而 Middleware 是一种无状态的请求拦截器,不适合维护图拓扑。
7. 批判性总结:Flow 究竟是"标准"还是"选项"?
在阅读完上述分析后,极易产生一个误解:既然 Flow 能解决这么多问题,是不是所有的多 Agent 协作底层都在用 flowAgent?
答案是:绝对不是。
为了搞清 adk/flow.go 和各种 Prebuilt Agent(预置模式)的关系,我们必须做一次彻底的隔离辨析。
7.1 核心辨析:flowAgent 只是一种特定的网络协议,不是底层标准!
在 Eino 的世界里,真正的大一统底层标准是 adk.Agent 接口 。只要你实现了 Run 和 Resume,你就是一个合法的 Agent。
而 flowAgent(通过 SetSubAgents 和 AgentWithOptions 创建),仅仅是为了实现 Transfer(异步变轨交接) 这种特定的协作模式,而特化出来的一种"网络协议包装器"。
这就好比:Agent 接口是 TCP/IP 协议,而 flowAgent 是 HTTP 协议。你可以用 HTTP 聊天,但你完全可以绕过 HTTP,自己用 TCP 写一套专用的 RPC(比如 Deep Agent 和 PlanExecute)。
7.2 映射到 Prebuilt 模式:谁在用,谁没用?
-
Supervisor 模式:重度依赖 Flow
- 源码证明 :在 adk/prebuilt/supervisor/supervisor.go 中,核心逻辑正是调用了
adk.SetSubAgents,并用DeterministicTransferTo包装了子节点。 - 原因 :Supervisor 的灵魂就是"星型网络的 Transfer 变轨"(主管派活,小兵干完后强制 Transfer 回主管)。这种需求完美契合
flowAgent解决的痛点(交接、历史改写),因此它是 Flow 体系的直接受益者。
- 源码证明 :在 adk/prebuilt/supervisor/supervisor.go 中,核心逻辑正是调用了
-
Workflow 模式:强行复用 Flow 马甲
- 源码证明 :在 adk/workflow.go 中,框架对所有子节点调用了
toFlowAgent,但强制加上了WithDisallowTransferToParent()。 - 原因 :Workflow 其实不需要自由的 Transfer(它的顺序是写死的),但它需要复用
flowAgent强大的"事件拦截与路由"能力(用来监听 BreakLoop 和 Exit)。所以它属于"阉割版复用"。
- 源码证明 :在 adk/workflow.go 中,框架对所有子节点调用了
-
Deep Agent 模式:彻底抛弃 Flow
- 源码证明 :在 adk/prebuilt/deep/task_tool.go 中,DeepAgent 将子代理包裹时,使用的是
adk.NewAgentTool,完全没有调用SetSubAgents或使用flowAgent。 - 原因 :正如我们在
deep.md中推导的,Deep Agent 走的是 "函数调用栈(Tool Call)" 路线。主 Agent 是通过同步阻塞等待task工具返回,而不是通过Transfer异步交接。既然没有交接,就不需要flowAgent来改写历史、不需要它来接管路由。
- 源码证明 :在 adk/prebuilt/deep/task_tool.go 中,DeepAgent 将子代理包裹时,使用的是
-
PlanExecute 模式:彻底抛弃 Flow
- 源码证明 :在 adk/prebuilt/planexecute/plan_execute.go 中,它直接使用了最底层的
compose.NewChain拼接 Graph,完全没有flowAgent的影子。 - 原因:PlanExecute 是一台"强管控的白盒状态机",节点只能乖乖执行图框架按数组顺序塞过来的步骤,根本没有动态路由的自由,自然不需要 Flow。
- 源码证明 :在 adk/prebuilt/planexecute/plan_execute.go 中,它直接使用了最底层的
7.3 最终结论
adk/flow.go 的本质,是 利用"装饰器"抹平了多 Agent 协作中,基于 Transfer 异步交接带来的网络拓扑与历史错乱差异 。
在开发者眼里,无论底层是多深的网络(主管管组长,组长管小兵),最外层看起来只是一个普通的、实现了 Run 和 Resume 的 Agent。所有的"交接棒(Transfer)"、"历史记忆翻译"和"事件流合并",都被 flowAgent 这个包装器吃掉了。
它的代价是什么?
和 react.go 一样,隐含机制与约定较多。
当你的 Agent 网络出现问题(比如 A 传给 B,B 又传给 C,中间某个参数丢失了),你去查日志,会发现:
- 你的原始 Message 被
historyRewriter悄悄改写了。 - 你的事件流被一层层的
flowAgent包裹、过滤和复制。 - 如果你想实现一种并非基于
Transfer工具的交接逻辑(比如基于外部事件触发),flowAgent的这套体系会很难直接支持,因为它把交接逻辑深度绑定在了对lastAction.TransferToAgent的拦截上。
Eino 的 Flow 体系,为了提供"开箱即用的多 Agent 聊天网络",在内部做了极重的封装。它更适合构建类似 AutoGen 的聊天式 Agent 协作,但如果你的业务需要的是类似 Airflow 的 状态机工作流编排 ,用这套体系反而会感到束手束脚。
8. 进一步思考:这套模式的边界与更稳的演进
- 字符串改写是兜底,不是终局:当系统从 Demo 走向复杂链路,建议逐步迁移到结构化消息(保留 Author、RunPath、State、工具结果),把 "身份" 和 "状态" 从文本里抽出来。
- 显式 State 契约会迟早出现 :一旦链路上出现 Planner、Executor、Reviewer 等分工,纯
history很快就不够用;越早把 State Schema 明确,越少靠约定字符串解析续命。 - 并发引入前先定义可重放顺序:如果要让子 Agent 并行,先定义事件排序键与重放策略,否则流式 UI、日志复现与 resume 对齐都会变成难题。
- 外部事件驱动需要解耦 Transfer:当交接来自外部系统(队列、Webhook、定时器)而不是模型的 tool call,交接机制需要从 "拦截 lastAction" 演进为更通用的调度接口。