1. 计划-执行Agent的本质:从朴素循环到工程化实现
在构建基于大语言模型的智能系统时,计划-执行(Plan-Execute)模式是最基础也最核心的范式之一。这种模式模拟了人类处理复杂任务时的自然思维过程:先制定计划,再分步执行,根据执行结果动态调整计划。让我们从最基础的实现开始,逐步探讨其工程化演进路径。
1.1 最基础的计划-执行循环
想象你要开发一个能自动完成复杂任务的AI助手。最直观的实现方式就是一个简单的循环结构:
go复制func BasicPlanExecute(input string) string {
// 第一步:生成初始计划
plan := llm.GeneratePlan(input)
// 循环执行直到所有任务完成
for {
if plan.AllTasksDone() {
return "任务完成"
}
task := plan.NextPendingTask()
result := ExecuteTask(task)
plan = llm.UpdatePlan(plan, task, result)
}
}
这个朴素实现包含了三个关键组件:
- Planner(计划器):将用户输入分解为可执行的子任务序列
- Executor(执行器):实际执行单个子任务
- Replanner(重规划器):根据执行结果动态调整计划
提示:这种基础实现适合简单场景,但当任务复杂度增加时,很快就会遇到工程瓶颈。
1.2 基础实现的局限性
在实际工程中,上述简单循环很快会暴露出几个关键问题:
-
控制流僵化:现实任务中,我们可能需要:
- 遇到严重错误时完全终止流程(Exit)
- 将部分任务委派给其他专业Agent(Transfer)
- 暂停等待人工确认(Pending)
-
状态持久化困难:当执行过程被中断(如系统崩溃或人工干预)后,难以从断点恢复执行
-
大模型输出不可靠:LLM生成的计划可能是非结构化的自然语言,需要复杂的解析逻辑
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 第一次演进:引入状态机和图结构
2.1 从循环到状态机
为了解决控制流僵化问题,我们需要将线性的循环转换为基于状态机的图结构。核心思想是:
- 将整个流程分解为离散的节点(Node)
- 通过有向边(Edge)定义节点间的转移关系
- 使用全局状态(State)保存执行上下文
go复制type PlanExecuteState struct {
Plan *TaskPlan
CurrentTask *Task
LastResult string
Status ExecutionStatus
}
type ExecutionGraph struct {
nodes map[string]NodeFunc
edges map[string][]string
}
2.2 图结构的优势
这种设计带来了几个关键优势:
- 灵活的控制流:可以轻松实现条件分支、并行执行等复杂逻辑
- 状态持久化:在任何节点执行后都可以序列化整个状态
- 可观测性:每个节点的输入输出都清晰可见,便于调试
注意:图结构的引入虽然增加了系统复杂度,但为后续的功能扩展奠定了基础。
2.3 典型节点设计
一个完整的计划-执行图通常包含以下核心节点:
| 节点类型 | 职责 | 输出 |
|---|---|---|
| Planner | 生成初始计划 | TaskPlan结构体 |
| Executor | 执行单个任务 | 执行结果 |
| Replanner | 更新计划 | 更新后的TaskPlan |
| Validator | 验证结果 | 验证通过/失败 |
| Fallback | 异常处理 | 恢复策略 |
3. 第二次演进:处理大模型的非结构化输出
3.1 结构化解析的挑战
大语言模型的输出本质上是非结构化的自然语言,而我们的系统需要严格的结构化数据。这就需要在图结构中增加专门的格式转换节点:
go复制type FormatParserPair struct {
Formatter func(state *State) string // 状态→提示词
Parser func(output string) State // 输出→状态
}
// 示例:计划生成节点的格式化器
func planFormatter(state *State) string {
return fmt.Sprintf(`请将以下任务分解为子步骤:
任务:%s
当前进度:%v
请用JSON格式输出,包含task列表`,
state.OriginalInput, state.Plan)
}
3.2 解析策略比较
实践中常用的解析策略有:
- JSON模式:要求LLM输出严格JSON,使用schema验证
- XML模式:利用标签结构提取关键信息
- 正则表达式:从自由文本中提取结构化字段
- 多轮验证:当解析失败时要求LLM重新生成
提示:在实际工程中,通常会组合使用多种策略以提高鲁棒性。
3.3 错误处理机制
由于LLM输出的不确定性,必须设计完善的错误处理流程:
- 语法验证:检查JSON/XML格式是否正确
- 语义验证:检查必填字段是否完整
- 自动修复:尝试自动修正小错误(如缺少引号)
- 重试机制:当严重错误时重新生成响应
go复制func parseWithRetry(raw string, maxRetry int) (*Plan, error) {
for i := 0; i < maxRetry; i++ {
if plan, err := strictParser(raw); err == nil {
return plan, nil
}
// 自动修复常见错误
raw = fixCommonErrors(raw)
}
return nil, errors.New("max retry exceeded")
}
4. 工程实践:以Eino实现为例
4.1 图结构实现细节
在Eino的plan_execute.go中,图结构的构建大致遵循以下模式:
go复制func buildGraph() *Graph {
g := NewGraph()
// 添加核心节点
g.AddNode("planner", plannerNode)
g.AddNode("executor", executorNode)
g.AddNode("replanner", replannerNode)
// 添加格式转换节点
g.AddNode("planner_formatter", formatPrompt)
g.AddNode("planner_parser", parsePlan)
// 构建边关系
g.AddEdge("start", "planner_formatter")
g.AddEdge("planner_formatter", "planner")
g.AddEdge("planner", "planner_parser")
// ...其他边关系
return g
}
4.2 状态管理设计
Eino使用全局状态对象来维护执行上下文:
go复制type ExecutionState struct {
mu sync.Mutex
Plan *Plan `json:"plan"`
History []ExecutionLog `json:"history"`
Context map[string]any `json:"context"`
// ...其他字段
}
// 状态操作方法
func (s *ExecutionState) Snapshot() []byte {
s.mu.Lock()
defer s.mu.Unlock()
return json.Marshal(s)
}
这种设计支持:
- 并发安全访问
- 完整的序列化/反序列化
- 执行历史追踪
4.3 性能优化技巧
在大规模部署时,我们积累了几个关键优化点:
- 缓存机制:缓存常见任务的解析结果
- 批量处理:对多个小任务进行批量执行
- 预编译模板:提前编译提示词模板
- 异步执行:非关键路径使用goroutine
go复制// 示例:带缓存的解析器
type CachedParser struct {
cache map[string]*Plan
ttl time.Duration
}
func (p *CachedParser) Parse(raw string) (*Plan, error) {
key := hash(raw)
if plan, ok := p.cache[key]; ok {
return plan, nil
}
// ...正常解析逻辑
}
5. 对比分析与模式选择
5.1 Plan-Execute vs Supervisor模式
两种主流架构的对比:
| 维度 | Plan-Execute | Supervisor |
|---|---|---|
| 控制方式 | 显式状态机 | 隐式聊天历史 |
| 数据结构 | 强类型结构体 | 自由格式消息 |
| 可恢复性 | 强(精确状态) | 弱(依赖上下文) |
| 复杂度 | 高(需处理解析) | 低(直接传递消息) |
| 适用场景 | 精确流程控制 | 灵活对话场景 |
5.2 何时选择Plan-Execute模式
Plan-Execute特别适合以下场景:
- 需要严格保证任务完整性的工作流
- 可能被中断需要精确恢复的长周期任务
- 涉及多个专业Agent协作的复杂流程
- 需要详细执行日志的审计场景
5.3 混合架构实践
在实际工程中,我们经常采用混合架构:
go复制func HybridOrchestrator(input string) {
// 顶层使用Supervisor进行任务分配
supervisor := NewSupervisor()
// 对需要精确控制的任务使用Plan-Execute
if needsPreciseControl(input) {
pe := NewPlanExecutor()
pe.Run(input)
return
}
// 普通任务使用对话式处理
supervisor.Handle(input)
}
6. 实战经验与避坑指南
6.1 常见问题排查
在实施Plan-Execute系统时,我们遇到过这些典型问题:
-
解析失败:
- 症状:频繁出现JSON解析错误
- 解决方案:增加输出格式的严格约束,使用更鲁棒的解析库
-
无限循环:
- 症状:Replanner不断生成新任务
- 解决方案:设置最大迭代次数,添加任务去重逻辑
-
状态膨胀:
- 症状:State对象越来越大,影响性能
- 解决方案:定期清理历史记录,使用增量更新
6.2 调试技巧
-
可视化工具:
- 使用Graphviz绘制执行路径
- 开发专用的状态检查器
-
日志策略:
- 记录每个节点的完整输入输出
- 为每次执行分配唯一追踪ID
go复制// 增强的日志记录
func logNodeExecution(node string, in, out any) {
log.Printf("[%s] input: %+v", node, in)
log.Printf("[%s] output: %+v", node, out)
metrics.Increment(node)
}
6.3 性能优化实践
-
并发控制:
- 对独立任务使用worker pool模式
- 设置合理的并发上限
-
资源管理:
- 对LLM调用实现速率限制
- 使用连接池管理外部服务调用
go复制// 带限流的执行器
type ThrottledExecutor struct {
limiter *rate.Limiter
backend Executor
}
func (t *ThrottledExecutor) Execute(task Task) (Result, error) {
if err := t.limiter.Wait(context.TODO()); err != nil {
return nil, err
}
return t.backend.Execute(task)
}
7. 架构演进与未来方向
7.1 简化图结构的尝试
针对Eino实现中图结构膨胀的问题,我们探索了几种简化方案:
- 节点组合:将格式转换逻辑内聚到主节点中
- 中间件模式:在节点执行链中插入格式化/解析步骤
- DSL描述:使用声明式语言定义执行流程
go复制// 中间件示例
func WithParsing(next NodeFunc) NodeFunc {
return func(state *State) {
// 前置格式化
prompt := format(state)
// 调用主逻辑
next(state)
// 后置解析
state.Plan = parse(state.RawOutput)
}
}
7.2 增强的验证机制
为了提高系统可靠性,我们引入了多层验证:
- 静态验证:检查计划的结构完整性
- 动态验证:模拟执行预测可能的问题
- 成本验证:估算执行所需的资源消耗
7.3 自适应执行策略
最新的演进方向包括:
- 动态节点选择:根据任务特性自动选择最优实现
- 混合规划:结合符号推理和神经网络的优点
- 在线学习:从历史执行中优化策略
go复制type AdaptivePlanner struct {
fastPlanner Planner // 快速但简单的规划器
smartPlanner Planner // 复杂但耗时的规划器
}
func (a *AdaptivePlanner) Plan(input string) *Plan {
if isSimpleTask(input) {
return a.fastPlanner.Plan(input)
}
return a.smartPlanner.Plan(input)
}
在实际工程实践中,Plan-Execute模式的价值在于它提供了一种系统化的方法来管理复杂任务的执行。虽然初期实现成本较高,但随着系统复杂度的提升,这种严谨的设计往往能带来更好的长期可维护性。最关键的是要根据具体业务需求,在灵活性和可靠性之间找到合适的平衡点。
