从零推导 Plan-Execute (计划-执行) Agent

拨开迷雾看本质:从零推导 Plan-Execute (计划-执行) Agent

1. 寻找"第一性原理":最朴素的计划执行

抛开 adk/prebuilt/planexecute/plan_execute.go 里的各种 Graph、State 和 Node 组合,这个文件解决的最核心的业务问题是什么?
答案是:面对一个极其复杂的任务,大模型一步到位搞不定,必须先列出一个"TODO List(计划)",然后按顺序一个个去执行(或者重新规划),直到所有任务都打上勾。

如果不使用任何框架,我们要实现一个"计划-执行"模式,用最朴素的原生 Go 代码写出来,它其实就是一个带状态机的 while 循环:

go 复制代码
// 第一性原理:最基本的 Plan-Execute 循环
func RunPlanExecute(input string) string {
    // 1. Planner:把大需求拆解成任务列表
    plan := plannerModel.GeneratePlan(input) 
    
    // 2. 循环执行任务
    for {
        // 如果计划里没有任务了,就结束
        if plan.IsAllDone() {
            return "任务全部完成"
        }
        
        // 拿出一个待办任务
        currentTask := plan.GetNextPendingTask()
        
        // 3. Executor:执行这个子任务
        result := executorModel.ExecuteTask(currentTask)
        
        // 4. Replanner:根据执行结果,更新计划(可能打勾,可能新增任务)
        plan = replannerModel.UpdatePlan(plan, currentTask, result)
    }
}

这看起来非常直观:生成计划 -> 执行一步 -> 审视并更新计划 -> 执行下一步

既然这么简单,为什么 Eino 的 plan_execute.go 会写得如此复杂,甚至引入了底层的 Graph 编排?


2. 第一次演进:应对复杂的控制流与状态持久化(The Graph & State Crisis)

痛点与危机

上述的朴素 for 循环面临两个致命问题:

  1. 控制流太僵化 :真实的业务中,Replanner 更新完计划后,不一定继续循环。它可能发现任务彻底失败,需要直接退出(Exit) ;也可能发现接下来的任务需要交给另一个外部 Agent(转移,Transfer )。原生的 for 循环很难优雅地处理这些网状的分支。
  2. 状态丢失与中断恢复(Resume Hell) :如果 Planner 拆出了 10 个任务,在执行第 5 个任务时,调用了一个耗时的 API 或者需要人类审批挂起了。原生的 for 循环内存栈直接丢失。怎么保存这个"TODO List(计划)"的中间状态?

引入第一次抽象:将循环拆解为 Graph 与全局 State

为了解决控制流分支和中断恢复,我们必须放弃写死的 for 循环,转向基于状态机的有向图(Graph)

go 复制代码
// 1. 定义全局的、可序列化的状态(存放 TODO List)
type PlanExecuteState struct {
    Plan           *Plan // 包含所有的 TODO items
    OriginalInput  string
    CurrentResult  string // 刚刚执行完的任务结果
}

// 2. 将三个核心动作拆解为独立的 Graph Node
func PlannerNode(state *PlanExecuteState) {
    state.Plan = plannerModel.GeneratePlan(state.OriginalInput)
}

func ExecutorNode(state *PlanExecuteState) {
    task := state.Plan.GetNextPendingTask()
    state.CurrentResult = executorModel.ExecuteTask(task)
}

func ReplannerNode(state *PlanExecuteState) {
    state.Plan = replannerModel.UpdatePlan(state.Plan, state.CurrentResult)
}

// 3. 组装 Graph:用边(Edge)和分支(Branch)代替 for 循环
func BuildPlanExecuteGraph() *Graph {
    g := NewGraph()
    g.AddNode("Planner", PlannerNode)
    g.AddNode("Executor", ExecutorNode)
    g.AddNode("Replanner", ReplannerNode)
    
    // 连线
    g.AddEdge("START", "Planner")
    g.AddEdge("Planner", "Executor")
    g.AddEdge("Executor", "Replanner")
    
    // 动态分支:根据 Replanner 更新后的状态决定去哪
    g.AddBranch("Replanner", func(state *PlanExecuteState) string {
        if state.Plan.IsAllDone() {
            return "END"
        }
        // 如果有特殊的 Transfer 需求,可以在这里路由
        return "Executor" // 继续循环
    })
    
    return g
}

通过这一步演进,复杂的 Plan-Execute 循环被拍平进了一张 Graph 中。框架可以在任意一个 Node 结束后保存 PlanExecuteState 的快照,从而完美支持长生命周期的挂起和恢复。


3. 第二次演进:应对大模型的幻觉与输出不确定性(The Parse & Format Crisis)

痛点与危机

在大模型的真实世界里,最大的敌人是不可靠的输出

在上面的伪代码中,plannerModel.GeneratePlan()replannerModel.UpdatePlan() 看起来返回了结构化的 *Plan 对象。

但在现实中,大模型吐出的永远是一串字符串(Markdown 或 JSON 混杂文本) 。如果你直接把这个大文本喂给 ExecutorExecutor 根本不知道自己到底该执行哪一步。

我们需要一种极其严密的机制,把大模型的自然语言,强制转换、解析(Parse)成计算机能懂的 TODO List 数据结构。

引入第二次抽象:提示词模板与强制结构化解析器(Formatter & Parser)

go 复制代码
// 引入结构化的输入输出管道
type StructuredNode struct {
    Model  LLM
    Prompt Template // 负责把 State 里的数据,拼成大模型能懂的 Prompt
    Parser Parser   // 负责把大模型吐出的文本,抠出 JSON 并转成结构体
}

func (n *StructuredNode) Run(state *PlanExecuteState) {
    // 1. 格式化:把当前的 TODO List 渲染成 Markdown 喂给模型
    prompt := n.Prompt.Render(state)
    
    // 2. 调用模型
    rawText := n.Model.Generate(prompt)
    
    // 3. 解析:用正则或 JSON Schema 把 rawText 抠出我们需要的 Plan 结构
    parsedPlan := n.Parser.Parse(rawText)
    
    // 4. 更新状态
    state.Plan = parsedPlan
}

这一步演进揭示了:在基于 LLM 的复杂 Agent 中,核心业务逻辑往往被"数据的组装(Format)"和"文本的解析(Parse)"所淹没


4. 映射到真实源码:Eino 的 plan_execute.go 到底做了什么?

当我们带着上述推导去审视真实的 adk/prebuilt/planexecute/plan_execute.go 时,会发现它的核心就是一个极其重型的 Graph 组装器,并且充满了为了应对 LLM 不确定性而写的"数据转换脏活"。

  1. 图的拓扑组装(完全对应第一次演进)

    buildGraph 函数(<adk/prebuilt/planexecute/plan_execute.go#L265-L316>)中,你可以清晰地看到我们推导的图结构:

    • AddLambdaNode("planner_node")
    • AddLambdaNode("executor_node")
    • AddLambdaNode("replanner_node")
    • 以及那条至关重要的回环分支 AddBranch("replanner_node", branchFunc),如果发现没做完,就回到 executor_node 继续。
  2. 全局状态机(State)的设计

    对应 <adk/prebuilt/planexecute/plan_execute.go#L169-L191> 的 state 结构体。Eino 使用了 compose.StatePreHandler 机制,在每个节点执行前后,把数据读写到这个共享的 State 中,从而支持了断点恢复。

  3. 极其繁重的 Format 与 Parse 脏活(完全对应第二次演进)

    这也是这个文件看起来最复杂、最冗长的地方。Eino 没有像我们伪代码里那样把 Parse 藏在底层,而是把它显式地暴露成了 Graph 的节点

    为了把大模型的输出转成结构化计划,Eino 在图里塞了一堆转换节点(Converter):

    • AddLambdaNode("planner_format_converter"):把输入转成模型能吃的消息。
    • AddLambdaNode("planner_parse_converter"):用 schema.StreamReaderWithConvert(<adk/prebuilt/planexecute/plan_execute.go#L419-L440>)把模型吐出的文本流,硬抠成 *Plan 结构。
    • Replanner 同理,也有一对 format_converterparse_converter

    正是这些为了应对 LLM 文本而加的"胶水节点",让原本只有 3 个核心节点的图,膨胀成了 7-8 个节点的巨型拓扑。

5. 补充思考:为什么只有 Plan-Execute 看起来这么"脏"?(对比 Supervisor)

在看过了 flowsupervisor 之后,你可能会产生一个巨大的疑问:为什么别的 Agent 框架不需要处理大模型输出不规范的问题,而 plan_execute.go 却塞满了恶心的 Parser 和 Converter?

Supervisor 和 Plan-Execute 在"宏观语义"上非常相似:都是一个"大脑"负责思考,然后把任务派发给"手脚"去执行。但它们在工程实现上走向了两个完全不同的极端。

差异 1:工具调用 (Tool Call) vs 自然语言解析 (Prompt Parsing)

  • Supervisor 的大脑是怎么派活的?

    Supervisor 底层是一个 ReAct Agent 。它依赖于现代大模型的 Tool Call (Function Calling) 能力。它不需要自己去 Parse 文本,因为大模型(如 GPT-4)在 API 层面直接支持返回结构化的 JSON Tool 参数。Eino 的底层框架直接把这个 JSON 映射成了 Transfer 动作。
    结论:Supervisor 把"结构化输出"的脏活,丢给了 OpenAI/Anthropic 的底层模型 API 和框架的内置反序列化去解决。

  • Plan-Execute 的大脑是怎么派活的?

    Plan-Execute 是一个更古老、更通用的范式。它的 Planner 往往只是一个纯文本生成模型 。它吐出的不是标准的 Tool Call,而是一段形如 <plan><task>查天气</task></plan> 的 Markdown 或 XML 文本。

    因为没有底层 API 的强制约束,Eino 必须在业务层手写一堆 schema.StreamReaderWithConvert 来把文本硬抠成 *Plan 结构体。

差异 2:状态载体:隐式上下文 vs 显式数据结构

  • Supervisor 的状态载体是"聊天记录 (History)"

    Supervisor 把所有的计划、进度、中间结果,全部塞进了 []Message(聊天历史)里。小兵干完活,就把结果当成一条 User 消息塞回给主管。主管靠着"阅读上下文"来决定下一步干嘛。
    优势 :代码极其干净,全是通用的 Message 传递。
    劣势:随着任务变多,上下文会爆炸,模型会产生"幻觉"和"遗忘"。

  • Plan-Execute 的状态载体是"白盒数据结构 (State Struct)"

    Plan-Execute 把大模型的思考结果,强制固化成了 PlanExecuteState 结构体(包含明确的 TODO List 数组)。
    优势 :极其精准。10 个任务就是 10 个数组元素,哪怕挂起一个月,恢复时也绝不会遗漏任何一个子任务。它能做到像流水线一样的确定性。
    劣势:为了在强类型的 Go 语言里维护这个白盒数据结构,你必须写无数的胶水代码,把非结构化的模型文本和结构化的 Go Struct 互相转换。

总结

Supervisor 是一种 "黑盒、基于聊天" 的轻量级派发,它把复杂性隐藏在了大模型自身的上下文理解能力里。

Plan-Execute 是一种 "白盒、基于状态机" 的重型编排,它不信任大模型的记忆,强行用代码接管了 TODO 列表,所以它必须承受解析文本的工程代价。


6. 批判性总结 (Critical Trade-offs)

优势:工程级的鲁棒性与高度定制化

Eino 的 PlanExecute 设计极其严谨。它没有采用黑盒的 ReAct 循环,而是把"生成计划"、"执行"和"重新审视"拆成了物理隔离的图节点

这种设计带来了巨大的收益:你可以在生成计划和执行之间,插入人工审批节点;你可以单独替换 Planner 的模型(比如用 o1 思考)而 Executor 用更快的模型(如 GPT-4o-mini)。它把一个"思想模型"彻底工程化、白盒化了。

代价与局限:Graph 膨胀与泛型地狱

  1. 胶水代码泛滥 :为了在强类型的 Go 图引擎中传递数据,代码中充斥着大量的类型转换闭包(如 compose.TransformableLambda)。一半以上的代码都在做 A -> B 的数据格式化。
  2. 调试难度极高 :当大模型输出的 JSON 哪怕少了一个括号,导致 planner_parse_converter 报错时,错误栈会穿透整个图引擎。由于图的边和节点都是在运行时动态编译的,开发者很难在 IDE 里直接点进去看是在哪一步挂的。

更优解的探讨

Eino 把 Parser 和 Formatter 当成独立的图节点 画在图里,这是导致图膨胀的元凶。
更符合直觉的设计 ,应该是把 Parser/Formatter 作为节点的内部属性 (或者 Middleware),隐藏在 PlannerNode 内部。就像我们在第二次演进的伪代码里写的 StructuredNode 一样。

外层编排的图,应该只展示业务逻辑的流转 (Planner -> Executor -> Replanner),而不应该展示数据格式的转换(Format -> Model -> Parse)。将数据转换逻辑下沉到节点内部,能让整个系统的认知负荷大幅降低。

相关推荐
开开心心就好2 小时前
免费自媒体多功能工具箱,图片音视频处理
人工智能·pdf·ocr·excel·音视频·语音识别·媒体
昨夜见军贴06162 小时前
AI审核守护透析安全:IACheck助力透析微生物检测报告精准合规
大数据·人工智能·安全
东方不败之鸭梨的测试笔记2 小时前
如何对AI测试用例生成方案进行评估?
人工智能·测试用例
新新学长搞科研2 小时前
【高届数会议征稿】第十二届传感云和边缘计算系统国际会议(SCECS 2026)
大数据·人工智能·生成对抗网络·边缘计算·传感器·学术会议
一只大袋鼠2 小时前
CNN 图像特征提取完整流程
人工智能·计算机视觉·cnn
码以致用2 小时前
GPT架构详解:从Transformer到大型语言模型
人工智能·深度学习·transformer
LDG_AGI2 小时前
【人工智能】OpenClaw(一):MacOS极简安装OpenClaw之Docker版
运维·人工智能·深度学习·机器学习·docker·容器·推荐算法
一水鉴天2 小时前
智能代理体系 之2 20260325 (腾讯元宝)
人工智能·重构·架构·自动化
Monster丶6262 小时前
Docker 部署 Ollama 全流程指南:支持 CPU/GPU、生产环境可用的工程化实践
运维·人工智能·docker·容器