从零推导 Deep Agent 模式

拨开迷雾看本质:从零推导 Deep Agent 模式

1. 寻找"第一性原理":最朴素的深度任务编排

抛开 adk/prebuilt/deep 包里的各种 Middleware、task_toolwrite_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 包写得这么绕,到处都是 MiddlewareTool


2. 第一次演进:应对工具抽象与能力注入(The Tool & Middleware Crisis)

痛点与危机

在朴素的实现里,我们期望大模型能吐出一个 action(比如包含 TargetParams 的 JSON),然后我们在外部用 switch-case 去解析这个 JSON,并调用对应的 subAgent

但这里有一个致命的悖论:大模型怎么知道有哪些 subAgent 可以用?它怎么知道传什么参数?

你有两种选择告诉大模型:

  1. 纯文本上下文(Prompt) :你在 System Prompt 里写死:"你可以调用前端组长,参数格式是 xxx;你可以调用后端组长,参数格式是 yyy。请在回答里输出你选的人和参数。" 然后你在代码里写 switch-case 去解析大模型的回答。
    • 代价:大模型经常不按格式输出,你的解析代码会经常崩溃。
  2. 工具调用(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)来实现派发。这就完美解决了工具数量爆炸的问题。

批判性思考:为什么必须是"切面注入",而不是让用户自己配置?

你可能会问:既然 taskwrite_todos 本质上都是 Tool,那为什么不让用户像配置普通工具(比如"查天气"、"搜网页")一样,直接把这两个工具写在 Config.Tools 列表里?为什么要搞一个 Middleware 在框架底层偷偷注入?

这就是 Deep Agent 的核心设计哲学:把"架构级能力"和"业务级工具"强行隔离。

如果让用户自己配置:

  1. 心智负担太重 :用户只想说"我有一个主 Agent 和 3 个子 Agent,你帮我跑起来"。如果让他自己配置,他必须自己手写一个极其复杂的 task 工具,在里面手写路由子 Agent 的逻辑,还要自己写一个很长的 Prompt 告诉模型怎么用。
  2. 极易被破坏 :如果用户不小心在 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 工具的描述里只有 CoderReviewer 等专业选手,大脑发现都不匹配。如果退回到大脑自己去处理,会极大消耗大脑的上下文(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) :在 newTaskTooltask_tool.go#L61-L123 )中,框架并没有把 SubAgent 变成 Tool。相反,它做了一次字符串拼接和字典映射。它遍历用户传入的 SubAgent 列表,把它们的名字和描述拼接到 task Tool 的 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) 的内存路由。
  • 步骤二:强行注入说明书(Middleware 切面)

    • 过程(How)types.go 里的 appendPromptTooltypes.go#L52-L65 )充当了切面拦截器。它在 BeforeAgent 阶段,强行把 write_todostask 工具塞进 runCtx 的工具列表里,并把这两个工具自带的冗长说明书(Prompt)拼接到系统提示词中。在 deep.gonewWriteTodos()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 实现,必须硬编码在引擎初始化中。
  • 步骤三:组装超级大脑(主 Agent 实例化)

    • 过程(How) :在 deep.goNew 函数( deep.go#L105-L153 )中,你会发现 DeepAgent 最终调用的依然是 adk.NewChatModelAgent
    • 原理(Why)DeepAgent 根本不是一个新类型的执行引擎。它本质上就是一个挂载了特化 Middleware 的普通 ReAct Agent。它复用了底层 ReAct 的所有能力(流式、挂起、重试),仅仅通过中间件改变了它的上下文视野。

5. 批判性总结与架构选型 (Critical Trade-offs & Selection)

5.1 优势:极致的关注点分离与能力复用

DeepAgent 的设计可以说是 Eino 中间件哲学的集大成者。它没有写任何一行新的执行循环 ,它所有的"魔法(Todo 记忆、任务下发、通用兜底)"全部是通过 ChatModelAgentMiddlewareTool 组合出来的。

这种设计让它能够完美复用底层 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)只看到老板在"通话中",根本不知道员工在干活。
  • Supervisor/Flow(异步交接的图节点路由)

    • 现象(How) :主节点决定让子节点干活时,它不调用工具,而是 return 一个特殊的指令(TransferTo)。随后主节点彻底结束当前生命周期,进入休眠 。底层 Graph 框架接管指令,去独立唤醒子节点
    • 代码证明 :在 <adk/chatmodel.go> 的 prepareExecContext 中,框架把 Transfer 工具标记为 returnDirectly=true。这意味着一旦模型选了它,底层的执行引擎就会跳出死循环,直接返回给外层的图引擎去调度下一个节点。
    • 本质(Why):在底层 Graph 的监控大屏上,一切是白盒的。框架清楚地看到"主节点已休眠,子节点正在运行"。这实现了完美的断点恢复:如果子节点崩溃,重启时框架直接唤醒子节点接着干,主节点无需重跑。
    • 动作类比 :老板填了一张工单,递给调度中心(Graph 框架),然后老板下班回家睡觉了。调度中心把工单派给员工。公司打卡系统清晰地看到"老板已下班,员工正在加班"。
  • PlanExecute(状态机驱动的流水线)

    • 现象(How) :控制权 完全交给了底层 Graph 框架。各个节点(Plan/Execute)只能乖乖处理框架按数组顺序塞过来的当前步骤。
    • 本质(Why):被彻底剥夺了动态路由权,用绝对的死板换取绝对的可控。
维度二:记忆与进度管理 (How to track progress?)
  • Deep Agent(动态的自我备忘录) :依赖隐式注入的 write_todos 工具。大模型需要自己"有意识"地调用它来更新进度。极度灵活,但极易崩溃(一旦模型忘了调用,记忆就会错乱)。
  • Supervisor(隐式的对话历史) :没有显式的 Todo 列表,纯靠主节点阅读不断累积的 Message History 来判断进度。符合直觉,但长周期下上下文极易超载。
  • PlanExecute(白盒的结构化状态) :最稳定也最死板。框架维护严格的 []Task 数组,大模型无法逃避或跳过步骤,框架在外部死死盯着进度。

5.3 选型指南与最终结论

基于上述第一性原理,业务选型可以遵循以下法则:

  1. 如果业务容错率极低,需要明确的进度条和人工干预选 PlanExecute。它的白盒状态机让你随时可以暂停图、修改任务数组、然后继续。
  2. 如果业务是确定的几个专家协同(如:产品 -> 开发 -> 测试),且需要清晰的分布式日志追踪选 Supervisor。真实的节点流转(Transfer)让你可以清晰看到每个专家的耗时和输入输出。
  3. 如果任务极其开放、高度未知(如:"调研五个开源项目并对比"),需要模型边做边想选 Deep Agent 。它用 write_todos 实现了自我挂起,用 general-purpose 实现了无限的兜底,是目前上限最高、但也最吃模型智力(如 GPT-4o / Claude 3.5)的模式。

DeepAgent 代表了当前 Agent 架构的最高级形态:从"硬编码的图"走向"大模型自我驱动的动态图" 。但它的局限在于过度依赖 Prompt Engineering,一旦大模型规划出错,系统极难从外部干预。

更符合工业界未来的演进方向,可能是Deep Agent 与 PlanExecute 的结合:保留 DeepAgent 用工具管理 Todo 和派发任务的灵活性,但把它塞进一个类似 PlanExecute 的宏观状态机中。由大模型做微观决策,由状态机做宏观的兜底(超时强制中断、任务强制熔断回滚),从而在"智能"和"可控"之间找到更好的平衡。

相关推荐
XMAIPC_Robot2 小时前
基于RK3588 ARM+FPGA的电火花数控系统设计与测试(三)
运维·arm开发·人工智能·fpga开发·边缘计算
前端架构师2 小时前
我不是狐狸,我是那Harness Engineering
人工智能
俞凡2 小时前
CLAUDE.md 完全指南
人工智能
码路高手2 小时前
Trae-Agent中的设计模式应用
人工智能·架构
百慕大三角2 小时前
pi-mono sdk中文文档
人工智能·ai编程
码路高手2 小时前
Trae-Agent中的Evaluation架构分析
人工智能·架构
lifallen2 小时前
从零推导 Plan-Execute (计划-执行) Agent
人工智能·语言模型
开开心心就好2 小时前
免费自媒体多功能工具箱,图片音视频处理
人工智能·pdf·ocr·excel·音视频·语音识别·媒体
昨夜见军贴06162 小时前
AI审核守护透析安全:IACheck助力透析微生物检测报告精准合规
大数据·人工智能·安全