拨开迷雾看本质:从零推导 Deep Agent 模式
1. 寻找"第一性原理":最朴素的深度任务编排
抛开 adk/prebuilt/deep 包里的各种 Middleware、task_tool 和 write_todos,这个包解决的最核心、最原初的业务问题是什么?
答案是:面对一个高度复杂、需要多个专业子领域协同的长周期任务,提供一个具备"记忆管理(Todo List)"和"任务下发(Task Delegation)"能力的超级大脑。
如果不使用任何框架,我们要实现一个带有 Todo 管理和子节点调度的超级大脑,用最朴素的原生 Go 代码写出来,它大概是这样的:
go
// 第一性原理:最基本的深度任务分发
func RunDeepAgent(input string) string {
history := []Message{{Role: "user", Content: input}}
todoList := []Todo{} // 大脑的短期记忆:记录还要做啥
for {
// 1. 超级大脑思考(必须把当前的 todoList 塞进 prompt 里)
prompt := buildPrompt(history, todoList)
action := llm.Generate(prompt)
// 2. 根据大脑的决策执行动作
switch action.Type {
case "UpdateTodo":
// 大脑自己更新记忆
todoList = action.NewTodos
case "CallSubAgent":
// 大脑把子任务派发给某个专业小兵
targetAgent := subAgents[action.Target]
result := targetAgent.Run(action.Params)
history = append(history, result)
case "Finish":
return action.FinalAnswer
}
}
}
非常直白:大脑靠 todoList 记住进度,靠 CallSubAgent 借用他人的能力。既然这么简单,为什么 Eino 要把 deep 包写得这么绕,到处都是 Middleware 和 Tool?
2. 第一次演进:应对工具抽象与能力注入(The Tool & Middleware Crisis)
痛点与危机 :
在朴素的实现里,我们期望大模型能吐出一个 action(比如包含 Target 和 Params 的 JSON),然后我们在外部用 switch-case 去解析这个 JSON,并调用对应的 subAgent。
但这里有一个致命的悖论:大模型怎么知道有哪些 subAgent 可以用?它怎么知道传什么参数?
你有两种选择告诉大模型:
- 纯文本上下文(Prompt) :你在 System Prompt 里写死:"你可以调用前端组长,参数格式是 xxx;你可以调用后端组长,参数格式是 yyy。请在回答里输出你选的人和参数。" 然后你在代码里写
switch-case去解析大模型的回答。- 代价:大模型经常不按格式输出,你的解析代码会经常崩溃。
- 工具调用(Tool Call) :你把每个
subAgent都注册成一个标准的 Tool,大模型通过内置的 Function Calling 能力来调用它们。- 代价 :你需要把每一个
subAgent都包成一个 Tool。如果有 50 个小兵,模型就会看到 50 个工具,极易发生"工具幻觉(选错工具)"。
- 代价 :你需要把每一个
引入第一次抽象:收敛入口(单一 task 工具包裹所有 SubAgent)
这里很容易产生概念混淆:"任务(Task)"是一项具体的工作,而 task 是 Eino 注入的一个真实 Tool 的名字。
为了解决上面的困境,Eino 选择了一种巧妙的"收敛"设计:不把每个 SubAgent 当作独立的 Tool,而是统一提供一个名叫 task 的 Tool。
大模型在工具列表里只会看到一个工具:task。
那大模型怎么知道有哪些 SubAgent 可用呢?
秘密在于 task 这个 Tool 的**描述(Description)**是动态生成的。框架会遍历所有传入的 SubAgent,提取它们的名字和作用描述,然后把这些信息拼接到 task Tool 的 Description 里。
大模型看到的 task Tool 描述大致是这样的:
"你可以使用本工具来把具体工作(任务)派发给专业的子代理(SubAgent)。
当前可用的子代理有:
- Coder:负责写代码
- Reviewer:负责审查代码
请在调用本工具时,在参数target中填入你要选的子代理名字,在query中填入具体的工作要求。"
这样一来,SubAgent 并不是 Tool,task 才是 Tool。 主 Agent 是通过阅读 task Tool 的说明书,了解到底下有哪些 SubAgent,然后通过调用 task Tool(传入 target=Coder)来实现派发。这就完美解决了工具数量爆炸的问题。
批判性思考:为什么必须是"切面注入",而不是让用户自己配置?
你可能会问:既然 task 和 write_todos 本质上都是 Tool,那为什么不让用户像配置普通工具(比如"查天气"、"搜网页")一样,直接把这两个工具写在 Config.Tools 列表里?为什么要搞一个 Middleware 在框架底层偷偷注入?
这就是 Deep Agent 的核心设计哲学:把"架构级能力"和"业务级工具"强行隔离。
如果让用户自己配置:
- 心智负担太重 :用户只想说"我有一个主 Agent 和 3 个子 Agent,你帮我跑起来"。如果让他自己配置,他必须自己手写一个极其复杂的
task工具,在里面手写路由子 Agent 的逻辑,还要自己写一个很长的 Prompt 告诉模型怎么用。 - 极易被破坏 :如果用户不小心在
Tools列表里漏传了write_todos,或者修改了系统内置的 Prompt,整个"记忆+分发"的深度编排架构就彻底崩溃了。
因此,框架选择了一种**"包办婚姻"的做法:
用户 不需要(也不应该)感知**底层是怎么实现任务分发和记忆管理的。用户只需要在 Config 里声明 SubAgents: []Agent{A, B, C}。
框架会在 Agent 启动的最后一刻,通过 Middleware 切面,强制把这套体系(特化的 Prompt 和内部 Tool)注入进去。
引入第一次抽象:基于 Middleware 的隐式能力注入
go
// 用户的视角极其简单:只需要声明"我要哪些小兵"
func BuildDeepAgent() Agent {
return NewDeepAgent(Config{
SubAgents: []Agent{Coder, Reviewer},
})
}
// 框架层的视角:通过中间件"暗度陈仓"
// 这是一个包装器,专门用来往大模型里塞内置 Tool 和强制的说明书
type AppendPromptToolMiddleware struct {
tool Tool
prompt string // 框架硬编码的、不容篡改的"说明书"
}
func (m *AppendPromptToolMiddleware) BeforeAgent(ctx, runCtx) {
// 1. 强制把框架的说明书拼接到用户自己写的 System Prompt 后面
runCtx.Instruction += m.prompt
// 2. 强制把内置工具(task, write_todos)塞进可用工具列表里
runCtx.Tools = append(runCtx.Tools, m.tool)
return ctx, runCtx
}
通过这一步演进,Deep Agent 实现了能力的无感注入。它向外暴露了一个极其简单的配置接口,向内则用 Middleware 把自己变成了一个复杂的组装车间。
3. 第二次演进:应对"子节点也需要思考"的递归危机(The General Agent Crisis)
痛点与危机 :
通过单一的 task 工具,大脑(主 Agent)可以把具体工作派发给专业的 SubAgent(比如专写前端的、专写 SQL 的)。
但这会遇到一个致命的边界情况:如果大脑发现当前要解决的具体工作(比如"帮我读一下这篇很长的文档并总结"),没有任何一个专业的 SubAgent 能处理 ,怎么办?
因为 task 工具的描述里只有 Coder 和 Reviewer 等专业选手,大脑发现都不匹配。如果退回到大脑自己去处理,会极大消耗大脑的上下文(Context Window),甚至导致原本宏大的主线任务跑偏。
引入第二次抽象:兜底的通用子代理(General SubAgent)
我们必须为 task 工具兜底:当没有专业的人能干这事时,框架自动在 task 工具的可选列表里,悄悄塞入一个名叫 general-purpose 的"通用分身"。这个分身和大脑拥有相同的配置(除了没法再调 task 工具去无限套娃),专门用来干脏活累活。
go
// Task 工具的本质代码
type TaskTool struct {
subAgents map[string]Agent
}
// 在构建 Task 工具时,我们偷偷塞进去一个"通用兜底人"
func BuildTaskTool(professionalAgents []Agent, baseConfig Config) *TaskTool {
tool := &TaskTool{subAgents: make(map[string]Agent)}
// 1. 注册专业小兵
for _, pa := range professionalAgents {
tool.subAgents[pa.Name] = pa
}
// 2. 【核心】克隆一个没有 task 工具的基础 Agent 作为兜底
generalAgent := NewChatModelAgent(baseConfig) // 拥有文件读写等基础能力,但不能再往下派活
tool.subAgents["general-purpose"] = generalAgent
return tool
}
func (t *TaskTool) Run(args string) string {
targetName := ParseTarget(args) // 大模型决定给谁
agent := t.subAgents[targetName]
return agent.Run(args) // 把任务丢给它,拿到结果返回给大脑
}
这一步演进极其精妙:它解决了一个极度复杂的任务编排系统的伸缩性问题。大脑只需要负责"分发",脏活累活要么交给专家,要么交给自己的"通用分身"。
4. 映射到真实源码:Eino 的 adk/prebuilt/deep 到底做了什么?
当我们带着前面的纠偏去审视真实的 adk/prebuilt/deep 源码时,你会发现它的核心逻辑可以完美拆解为三个步骤。这也彻底推翻了"每个 Agent 都是 Tool"的直觉误区:
-
步骤一:构建"花名册"与动态路由(
task工具的本质)- 过程(How) :在
newTaskTool( task_tool.go#L61-L123 )中,框架并没有把SubAgent变成 Tool。相反,它做了一次字符串拼接和字典映射。它遍历用户传入的SubAgent列表,把它们的名字和描述拼接到taskTool 的Description中,同时在内存里建了一个map[string]Agent。兜底逻辑( task_tool.go#L85-L111 )则是在!withoutGeneralSubAgent时,凭空捏造出一个名叫general-purpose的通用子代理,并仅仅作为字典的一个普通 Value 塞进t.subAgents映射表中。 - 原理(Why) :这就是"收敛入口"的代码实现。通过动态生成工具描述,大模型像查字典一样选中 Target;框架在
task工具被触发时,直接根据 Target 去map里提取真正的 Agent 并执行。复杂度从 O(N) 个 Tool 降维成了 1 个 Tool + O(1) 的内存路由。
- 过程(How) :在
-
步骤二:强行注入说明书(Middleware 切面)
- 过程(How) :
types.go里的appendPromptTool( types.go#L52-L65 )充当了切面拦截器。它在BeforeAgent阶段,强行把write_todos和task工具塞进runCtx的工具列表里,并把这两个工具自带的冗长说明书(Prompt)拼接到系统提示词中。在deep.go的newWriteTodos()( deep.go#L202-L225 )也复用了这个逻辑。 - 原理(Why) :贯彻 "包办婚姻" 的设计哲学。用户只需配置 SubAgent 数组,框架在最后一刻利用中间件完成依赖注入,保证了 架构级能力 对业务开发者的完全透明。
- 与 Flow Agent
Transfer注入的对比 :
虽然 Deep 的task和 Flow 的Transfer都是通过向大模型注入 Tool 来实现的,但它们在"谁来掌控生命周期"上有着本质的区别:- Deep
task(同步阻塞调用) :task是一个普通工具。当大模型调用task派发任务时,大模型不能下班 。它必须在原地挂起(阻塞),死死等待子 Agent 执行完毕并把结果return出来,然后大模型继续思考。既然它不改变底层的运行循环,用 Middleware 注入就是最轻量的解法。 - Flow
Transfer(异步交接控制权) :Transfer是一个强制中断指令 。当大模型调用Transfer把活交出去后,大模型必须立刻下班(结束生命周期) 。控制权被交还给底层的 Flow 引擎,由引擎去唤醒下一个 Agent。为了实现这种"打断当前运行循环"的霸道行为,它需要设置returnDirectly=true这种只有核心引擎(ChatModelAgent)才能操作的特权配置。因此,它无法用简单的 Middleware 实现,必须硬编码在引擎初始化中。
- Deep
- 过程(How) :
-
步骤三:组装超级大脑(主 Agent 实例化)
- 过程(How) :在
deep.go的New函数( deep.go#L105-L153 )中,你会发现DeepAgent最终调用的依然是adk.NewChatModelAgent。 - 原理(Why) :
DeepAgent根本不是一个新类型的执行引擎。它本质上就是一个挂载了特化 Middleware 的普通 ReAct Agent。它复用了底层 ReAct 的所有能力(流式、挂起、重试),仅仅通过中间件改变了它的上下文视野。
- 过程(How) :在
5. 批判性总结与架构选型 (Critical Trade-offs & Selection)
5.1 优势:极致的关注点分离与能力复用
DeepAgent 的设计可以说是 Eino 中间件哲学的集大成者。它没有写任何一行新的执行循环 ,它所有的"魔法(Todo 记忆、任务下发、通用兜底)"全部是通过 ChatModelAgentMiddleware 和 Tool 组合出来的。
这种设计让它能够完美复用底层 ReAct 引擎的所有能力(比如流式输出、挂起恢复、错误重试)。当你需要一个高度复杂的 Orchestrator(编排者)时,这种设计极其优雅。
5.2 核心差异:Deep Agent vs Supervisor vs PlanExecute
为了在实际业务中做出正确的架构选型,我们需要看透这三种"复杂任务编排模式"的本质差异。
如果用一句话概括它们的灵魂:
- Deep Agent 是一场 "大模型完全自治的独角戏"(把一切路由和记忆包装成工具)。
- Supervisor 是一个 "依靠流转指令的星型网络"(节点间发生真实的控制权交接)。
- PlanExecute 是一台 "框架强管控的白盒状态机"(被剥夺路由权的流水线作业)。
维度一:控制权归属与生命周期 (Who controls the flow?)
这里有一个极其反直觉的陷阱:虽然在物理层面,无论是哪种模式,子 Agent 都在消耗 CPU 独立执行,但在"图框架(Graph Engine)"眼里,它们的可见性完全不同。
-
Deep Agent(同步阻塞的函数调用栈):
- 现象(How) :主 Agent 决定调用
task工具,随后主 Agent 陷入同步阻塞(Blocking) ,死死等待task函数返回。task在内部悄悄启动并运行子 Agent。 - 代码证明 :在 <adk/agent_tool.go#L172-L205> 的
InvokableRun方法中,框架用一个死循环for { event, ok := iter.Next() }强行阻塞住了当前 Goroutine。只要内部 Agent 不结束,外部的 Tool 就无法return。 - 本质(Why) :在底层 Graph 框架的监控大屏上,子 Agent 是不存在的。框架只看到"主 Agent 正在执行一个极其耗时的 Tool"。这导致了断点恢复(Resume)灾难:如果子 Agent 跑到一半崩溃,重启时框架只能让主 Agent 从头再调一次工具。
- 动作类比 :老板(主 Agent)打电话给员工(子 Agent)安排活,老板拿着话筒一直等(不挂电话)。公司打卡系统(Graph)只看到老板在"通话中",根本不知道员工在干活。
- 现象(How) :主 Agent 决定调用
-
Supervisor/Flow(异步交接的图节点路由):
- 现象(How) :主节点决定让子节点干活时,它不调用工具,而是
return一个特殊的指令(TransferTo)。随后主节点彻底结束当前生命周期,进入休眠 。底层 Graph 框架接管指令,去独立唤醒子节点。 - 代码证明 :在 <adk/chatmodel.go> 的
prepareExecContext中,框架把 Transfer 工具标记为returnDirectly=true。这意味着一旦模型选了它,底层的执行引擎就会跳出死循环,直接返回给外层的图引擎去调度下一个节点。 - 本质(Why):在底层 Graph 的监控大屏上,一切是白盒的。框架清楚地看到"主节点已休眠,子节点正在运行"。这实现了完美的断点恢复:如果子节点崩溃,重启时框架直接唤醒子节点接着干,主节点无需重跑。
- 动作类比 :老板填了一张工单,递给调度中心(Graph 框架),然后老板下班回家睡觉了。调度中心把工单派给员工。公司打卡系统清晰地看到"老板已下班,员工正在加班"。
- 现象(How) :主节点决定让子节点干活时,它不调用工具,而是
-
PlanExecute(状态机驱动的流水线):
- 现象(How) :控制权 完全交给了底层 Graph 框架。各个节点(Plan/Execute)只能乖乖处理框架按数组顺序塞过来的当前步骤。
- 本质(Why):被彻底剥夺了动态路由权,用绝对的死板换取绝对的可控。
维度二:记忆与进度管理 (How to track progress?)
- Deep Agent(动态的自我备忘录) :依赖隐式注入的
write_todos工具。大模型需要自己"有意识"地调用它来更新进度。极度灵活,但极易崩溃(一旦模型忘了调用,记忆就会错乱)。 - Supervisor(隐式的对话历史) :没有显式的 Todo 列表,纯靠主节点阅读不断累积的
Message History来判断进度。符合直觉,但长周期下上下文极易超载。 - PlanExecute(白盒的结构化状态) :最稳定也最死板。框架维护严格的
[]Task数组,大模型无法逃避或跳过步骤,框架在外部死死盯着进度。
5.3 选型指南与最终结论
基于上述第一性原理,业务选型可以遵循以下法则:
- 如果业务容错率极低,需要明确的进度条和人工干预 :选 PlanExecute。它的白盒状态机让你随时可以暂停图、修改任务数组、然后继续。
- 如果业务是确定的几个专家协同(如:产品 -> 开发 -> 测试),且需要清晰的分布式日志追踪 :选 Supervisor。真实的节点流转(Transfer)让你可以清晰看到每个专家的耗时和输入输出。
- 如果任务极其开放、高度未知(如:"调研五个开源项目并对比"),需要模型边做边想 :选 Deep Agent 。它用
write_todos实现了自我挂起,用general-purpose实现了无限的兜底,是目前上限最高、但也最吃模型智力(如 GPT-4o / Claude 3.5)的模式。
DeepAgent 代表了当前 Agent 架构的最高级形态:从"硬编码的图"走向"大模型自我驱动的动态图" 。但它的局限在于过度依赖 Prompt Engineering,一旦大模型规划出错,系统极难从外部干预。
更符合工业界未来的演进方向,可能是Deep Agent 与 PlanExecute 的结合:保留 DeepAgent 用工具管理 Todo 和派发任务的灵活性,但把它塞进一个类似 PlanExecute 的宏观状态机中。由大模型做微观决策,由状态机做宏观的兜底(超时强制中断、任务强制熔断回滚),从而在"智能"和"可控"之间找到更好的平衡。