拨开迷雾看本质:从零推导 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 循环面临两个致命问题:
- 控制流太僵化 :真实的业务中,Replanner 更新完计划后,不一定继续循环。它可能发现任务彻底失败,需要直接退出(Exit) ;也可能发现接下来的任务需要交给另一个外部 Agent(转移,Transfer )。原生的
for循环很难优雅地处理这些网状的分支。 - 状态丢失与中断恢复(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 混杂文本) 。如果你直接把这个大文本喂给 Executor,Executor 根本不知道自己到底该执行哪一步。
我们需要一种极其严密的机制,把大模型的自然语言,强制转换、解析(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 不确定性而写的"数据转换脏活"。
-
图的拓扑组装(完全对应第一次演进) :
在
buildGraph函数(<adk/prebuilt/planexecute/plan_execute.go#L265-L316>)中,你可以清晰地看到我们推导的图结构:AddLambdaNode("planner_node")AddLambdaNode("executor_node")AddLambdaNode("replanner_node")- 以及那条至关重要的回环分支
AddBranch("replanner_node", branchFunc),如果发现没做完,就回到executor_node继续。
-
全局状态机(State)的设计 :
对应 <adk/prebuilt/planexecute/plan_execute.go#L169-L191> 的
state结构体。Eino 使用了compose.StatePreHandler机制,在每个节点执行前后,把数据读写到这个共享的 State 中,从而支持了断点恢复。 -
极其繁重的 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_converter和parse_converter。
正是这些为了应对 LLM 文本而加的"胶水节点",让原本只有 3 个核心节点的图,膨胀成了 7-8 个节点的巨型拓扑。
5. 补充思考:为什么只有 Plan-Execute 看起来这么"脏"?(对比 Supervisor)
在看过了 flow 和 supervisor 之后,你可能会产生一个巨大的疑问:为什么别的 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 膨胀与泛型地狱
- 胶水代码泛滥 :为了在强类型的 Go 图引擎中传递数据,代码中充斥着大量的类型转换闭包(如
compose.TransformableLambda)。一半以上的代码都在做A -> B的数据格式化。 - 调试难度极高 :当大模型输出的 JSON 哪怕少了一个括号,导致
planner_parse_converter报错时,错误栈会穿透整个图引擎。由于图的边和节点都是在运行时动态编译的,开发者很难在 IDE 里直接点进去看是在哪一步挂的。
更优解的探讨 :
Eino 把 Parser 和 Formatter 当成独立的图节点 画在图里,这是导致图膨胀的元凶。
更符合直觉的设计 ,应该是把 Parser/Formatter 作为节点的内部属性 (或者 Middleware),隐藏在 PlannerNode 内部。就像我们在第二次演进的伪代码里写的 StructuredNode 一样。
外层编排的图,应该只展示业务逻辑的流转 (Planner -> Executor -> Replanner),而不应该展示数据格式的转换(Format -> Model -> Parse)。将数据转换逻辑下沉到节点内部,能让整个系统的认知负荷大幅降低。