《AI Agent 开发入门》5.2 Plan-and-Solve:先出计划再执行

ReAct 边想边做,Plan-and-Solve 则先谋后动。本节把循环拆成 Planner 与 Executor 两段:Planner 用结构化输出(JSON 数组)一次性产出子任务列表,Executor 顺序执行每一步且每步内部仍是一个完整小循环。我们用 FakeModel 演示一个三步计划,打印每步状态,并给出与 ReAct 的取舍表。

5.2 Plan-and-Solve:先出计划再执行

上一节的 ReAct 是「边想边做」:模型走一步看一步,每一步都根据最新的观察临时决定下一步。这在探索性任务里很灵活,但对「步骤本来就已知」的任务就显得笨——明明可以先想清楚整条路线,却要每走一步都停下来重新推理一遍。这一节换一种骨架:先让模型把计划一次想全,再照着执行。

本节把 minagent 推进到「两段式的规划循环」:新增 minagent/plan.py 里的 Planner 与 Executor,Planner 用结构化输出产出计划列表,Executor 顺序执行每步(每步内部仍可调工具),把「想」与「做」在时间上彻底分开。

5.2.1 先谋后动:为什么要把计划单独拿出来

ReAct 的一个隐性代价是短视:它每一轮只看得见「当前这一步」,缺少一个「全局的路线图」。对于需要多步、且步骤之间有清晰先后关系的任务(先查数据、再清洗、再计算、最后汇总),这种短视会导致两个问题——模型可能在细节上反复横跳,也可能走着走着忘了总目标。

Plan-and-Solve 的思路是把一次任务拆成两个阶段:

  1. 规划阶段(Plan):让模型先不执行任何工具,只输出一份有序的子任务列表。这一步只做一次。
  2. 求解阶段(Solve):拿到计划后,逐条执行。每条子任务可以自己调工具,也可以直接由模型作答。

这样做的好处是把「想大方向」和「做具体事」分成两个清晰的职责。计划是一份可检查的产物——你可以在执行之前先看一眼「它打算怎么干」,发现方向错了就重来,而不是等到执行一半才发现。这是 ReAct 给不了的:ReAct 的计划是隐式的,散在每一轮的 Thought 里。

5.2.2 Planner:用结构化输出吐计划

Planner 的输入是「目标 + 可用工具」,输出是一串子任务。这里有一个工程上的关键选择:计划必须结构化。如果让模型自由发挥,它可能返回带编号的自然语言段落,解析起来非常脆。正确做法是要求它输出 JSON 数组——每一项是一句话的子任务。

# minagent/plan.py(上半)
from __future__ import annotations

import json

from .types import Message, Model


class Planner:
    """一次性把目标拆成一串子任务,用结构化输出(JSON 数组)返回。"""

    PROMPT = "把目标拆成有序子任务,只输出 JSON 数组,每项是一句话。"

    def __init__(self, model: Model) -> None:
        self.model = model

    def make_plan(self, goal: str, tools: list[dict]) -> list[str]:
        resp = self.model.complete(
            [Message(role="user", content=f"{self.PROMPT}\n目标:{goal}")], tools)
        return json.loads(resp.message.content)

三点值得注意:

  • 规划调用不传工具,或者传了也不执行。这一阶段模型只「想」,产出的是文本(JSON),不产生任何 tool_calls。工具 schema 传进去是为了让计划「知道有哪些能力可用」,但规划阶段一次工具都不会真跑。
  • 用 JSON 而不是 Markdown 列表。json.loads 是确定性的,只要模型输出合法 JSON,解析就稳;Markdown 列表要靠正则,编号、缩进一变就崩。第 6 章讲结构化输出时会把这条做成一等公民,这里先用最朴素的 json.loads。
  • 解析失败要有兜底。真实模型偶尔会输出带 ```json 围栏或前后缀说明的文字,直接 json.loads 会抛异常。本节为了聚焦主干没有包,但生产代码必须在这里加「剥离围栏 + 重试」的逻辑。

为什么规划阶段要单独一次调用,而不是在执行到一半时顺手让模型「顺便想想后面几步」?因为规划需要的是全局视野,而执行时需要的是局部专注。这两件事对提示词的要求正好相反:规划希望模型「别急着动手,先把整条路线想全」,执行希望模型「别想太多,专心把这一步做好」。把两段拆开,才能给每一段配最合适的提示词。混在一起,模型往往既想不深也想不专。

还有一点容易被忽略:规划阶段是纯「想」,不该产生副作用。如果我们允许 Planner 在执行前就调工具,那它可能一边规划一边改了数据,等到真正执行时环境已经变了。所以 Planner 拿到 tools 只是为了「知道有哪些能力可用」,规划阶段一次工具都不执行。把「想」与「做」的边界守死,是 Plan-and-Solve 保持可预测的前提。

5.2.3 Executor:逐步执行,每步内部仍是一个循环

Executor 拿到计划列表后,逐条执行。关键在于:每一条子任务不是一次模型调用,而是一个完整的迷你循环——它内部可以调工具、可以多轮,直到这一步收敛。于是「外层循环跑计划,内层循环跑单步」形成了两层嵌套。

# minagent/plan.py(下半)
from .loop import run
from .tools import ToolRegistry


class Executor:
    """顺序执行计划里的每一步,每步内部仍是一个完整的小循环。"""

    def __init__(self, model: Model, registry: ToolRegistry) -> None:
        self.model = model
        self.registry = registry

    def execute(self, plan: list[str]) -> list[tuple[str, str, str]]:
        done: list[tuple[str, str, str]] = []
        context = ""
        for i, task in enumerate(plan, 1):
            prompt = task if not context else f"{task}\n已知前序结果:{context}"
            r = run(self.model, self.registry, prompt, max_steps=4)
            done.append((f"step {i}", task, r.final))
            context += f"{task} => {r.final}; "
        return done

这里有两个设计决定值得展开:

其一,前序结果要显式拼进下一步的提示词。 计划是「死的」,它不会自动知道上一步算出了什么。上面用 context 字符串把「子任务 => 结果」累积起来,喂给下一步,这样第 3 步才看得到第 1、2 步的产出。真实系统里这一步通常做得更细(只塞相关的结果、或把结果存进结构化变量),但本质都是「把执行状态回灌给规划的执行者」。这正是 Plan-and-Solve 最容易翻车的地方:计划一旦脱离执行上下文,就会变成「照着昨天的地图找今天的路」。

其二,每步复用第 3 章的 run()。 没有新写循环——Executor 只是「按计划多次调用 run()」。这再次印证了卷一的主线:推理模式是同一引擎的不同编排,不是各写一套。第 11 章会把这里的「顺序执行」升级成「带依赖的 DAG 执行器」,但骨架就是这个 for task in plan。

这个「外层计划、内层单步」的两层结构,也顺带回答了「一步失败了怎么办」这个问题。上面 execute 的写法是最朴素的:某一步 run() 返回空(撞上 max_steps)也照样往下走,把错误默默带进后续步骤。真实系统里通常有两种更稳的处理:

失败处理做法代价
中止任一步失败就整体停,抛给上层简单,但一个错毁全局
重试对失败步重跑,最多 K 次可能白等,需限次
跳过标记该步失败,继续下一步后续步可能因缺输入而连锁失败
重规划停下来改计划再继续最强,但需要额外一轮规划

本节不展开这些策略——它们本质上是「错误处理」而非「规划」问题,第 14 章会系统讲错误分类与重试。这里只需记住:Plan-and-Solve 把「哪一步出错」变得非常清楚,因为每一步都是独立的一次 run()、都有独立的 final 与 steps。这比 ReAct 里「一整条轨迹从头看到尾」更容易定位问题,也是它在大流程任务里受欢迎的原因之一。

5.2.4 跑一遍:三步计划

让 Planner 规划「计算 (2+3)*4-5」,Executor 顺序执行三步,每步调一个算术工具。假模型的脚本按「先规划、再逐步执行」的顺序铺开:

# demo_plan.py
import json
from minagent import FakeModel, Tool, ToolCall, ToolRegistry
from minagent.plan import Executor, Planner

reg = ToolRegistry()
for name, fn in [("add", lambda a, b: a + b),
                 ("multiply", lambda a, b: a * b),
                 ("subtract", lambda a, b: a - b)]:
    reg.add(Tool(name=name, description=f"{name} 两个整数",
                 parameters={"type": "object",
                             "properties": {"a": {"type": "integer"},
                                            "b": {"type": "integer"}},
                             "required": ["a", "b"]},
                 func=fn))

PLAN = json.dumps(["计算 2+3 的值", "把上一步结果乘以 4", "把上一步结果减去 5"],
                  ensure_ascii=False)

model = FakeModel([
    PLAN,                                              # 0: 规划
    [ToolCall("s1", "add", {"a": 2, "b": 3})],         # 1: 执行 step1
    "5",                                               # 2: step1 收敛
    [ToolCall("s2", "multiply", {"a": 5, "b": 4})],    # 3: step2
    "20",                                              # 4: step2 收敛
    [ToolCall("s3", "subtract", {"a": 20, "b": 5})],   # 5: step3
    "15",                                              # 6: step3 收敛
])

plan = Planner(model).make_plan("计算 (2+3)*4-5 的结果", reg.schemas())
print("计划:")
for i, t in enumerate(plan, 1):
    print(f"  {i}. {t}")

print("执行:")
for label, task, result in Executor(model, reg).execute(plan):
    print(f"  {label} {task} -> {result}")

本机实测输出(Python 3.14.6):

计划:
  1. 计算 2+3 的值
  2. 把上一步结果乘以 4
  3. 把上一步结果减去 5
执行:
  step 1 计算 2+3 的值 -> 5
  step 2 把上一步结果乘以 4 -> 20
  step 3 把上一步结果减去 5 -> 15

注意轨迹的节奏:规划阶段只有一次模型调用(脚本第 0 项),产出三句话的计划;执行阶段则是三次独立的 run(),每次消耗脚本里的「工具调用 + 收敛」两项。整个任务共消耗 7 个脚本项、4 次模型调用——比 ReAct 多一次(那次规划),但每一步都短小、职责单一。

这份计划还是一份可审计的产物:你可以在 print("计划:") 那几行先看到它打算怎么干,再决定要不要放行。如果计划写的是「先乘以 4 再减去 5」,你一眼就知道对;如果它写反了顺序,你在执行之前就能拦下来。「先看计划再执行」这个能力,本身就是 Plan-and-Solve 相对 ReAct 的最大卖点。

再往深想一层:计划既然是一份独立的数据,它就能被程序化处理,而不只是打印给人看。你可以对它做很多 ReAct 做不到的事:

  • 校验:检查计划里提到的工具是否都已注册,避免执行到一半才发现「第 4 步要用的工具根本没定义」。
  • 改写:把计划里的某一步替换成更便宜的实现,或者插入一步「人工确认」。
  • 并行化:分析步骤之间的依赖,把互不依赖的几步并发跑(第 11 章的 DAG 执行器就干这个)。
  • 缓存:同一个子任务之前跑过,直接复用结果。

这些能力全都建立在「计划是结构化数据」这个前提上。ReAct 的计划散在每一轮的 Thought 文本里,程序几乎没法稳定地拿它做上述任何一件事——这正是「先谋后动」在工程上比「边想边做」更好管理的地方。代价是灵活性:计划一旦定死,中途改主意的成本就高了。

5.2.5 与 ReAct 的取舍

两种模式没有绝对优劣,只看任务形态。把差异列成一张表:

维度ReAct(边想边做)Plan-and-Solve(先谋后动)
模型调用次数每步一轮规划一次 + 每步至少一轮
全局视野弱,只看当前步强,计划一次看全
计划可审计否,散在 Thought 里是,独立一份列表
中途改主意容易,下一步即可调整难,计划已定,需重规划
适合任务探索性、路径未知步骤已知、多步有序
失败成本低,走错一步影响小高,计划错则全盘错
token 成本每轮多一段 Thought多一次规划调用

一句话选型:任务路径是否大致已知。如果「该干什么」在执行前基本能说清(算数、数据处理、固定流程),用 Plan-and-Solve,它的全局视野和可审计性更值钱;如果「下一步取决于上一步看到了什么」(调试、检索、探索),用 ReAct,它的临场调整能力更值钱。

值得强调的是,这两者不是对立的。工业界常见的做法是外层用 Plan-and-Solve 定大方向,内层每一步用 ReAct 做细活——本节的 Executor 里「每步内部仍是一个 run() 循环」正是这个结构的雏形。第 11 章讲任务分解与重规划时,会把「计划中途发现不对怎么办」这个短板补上。

把两者嵌起来看,整个系统其实长这样:

Plan-and-Solve(外层)
  计划:[任务1, 任务2, 任务3]
    │
    ├─ 任务1 ──► ReAct 内层循环(想—做—看,可调工具)
    ├─ 任务2 ──► ReAct 内层循环
    └─ 任务3 ──► ReAct 内层循环

外层负责「先干什么后干什么」的全局编排,内层负责「这一步具体怎么做」的局部探索。两者共享同一套 Message 与 ToolRegistry,所以嵌起来毫无摩擦。这个「外编排、内循环」的结构,在第 12 章的工作流编排里会进一步一般化成「图引擎」——节点是内层循环,边是外层编排。

反过来说,不要为了「显得高级」而强行上 Plan-and-Solve。如果一个任务只有一两步,或者下一步完全取决于上一步看到了什么,硬拆成「先规划再执行」只会多一次模型调用、多一层出错的地方。选型的朴素原则始终是:任务形态决定编排方式,而不是反过来。

最后给一个务实的建议:先写朴素循环,跑不动了再加规划。很多任务用最朴素的 ReAct 就能跑对,加上 Planner 反而引入了「计划可能错」这个新风险。只有当你在实践中反复看到「模型走一步看一步会迷路」时,才值得把规划单独拆出来。编排的复杂度应该由任务倒逼出来,而不是预先堆上去。

5.2.6 真实模型下会怎样

假模型的计划是我们写死在 PLAN 里的,而且写得工工整整、步骤数和参数都对。真实模型产出的计划,问题要多得多:

  • 计划质量不稳定。同一个目标,真实模型可能给出 3 步,也可能给出 7 步;可能把「乘以 4」和「减去 5」的顺序写反;也可能漏掉一步。假模型里 json.loads 永远成功,真实世界里你得先处理「输出不是合法 JSON」这件常事。
  • 计划与执行会脱节。假模型的 Executor 里,context 只是拼进提示词的字符串,反正模型(脚本)也不看。真实模型会真的读这段上下文,于是「上一步的结果描述得清不清楚」直接决定下一步对不对——这正是 Plan-and-Solve 最脆弱的一环。
  • 需要重规划。真实任务里,第 2 步常常会暴露「第 1 步的假设是错的」。ReAct 此时自然调整,Plan-and-Solve 则必须显式地「停下来、改计划、再继续」。本节没有实现重规划,这是留给第 11 章的作业。

所以假模型证明的是「Planner 与 Executor 的接口与编排对」;真实模型暴露的是「计划本身可能错、且需要被修订」。你在生产里用 Plan-and-Solve,一半的工程量都花在「怎么让计划更可靠、错了怎么改」上,而不是在这个两段式骨架本身。

5.2.7 小结

  • Plan-and-Solve = 先规划、再执行:Planner 一次性产出计划列表,Executor 顺序执行。
  • 计划必须是结构化输出(本节用 JSON 数组),才能被程序稳定解析与审计。
  • Executor 的每步内部仍是第 3 章的 run()——外层跑计划、内层跑单步,两层嵌套。
  • 前序结果要显式回灌给下一步,否则计划会脱离执行上下文,这是最易翻车处。
  • 选型看「任务路径是否已知」:已知用 Plan-and-Solve,未知用 ReAct;两者可嵌套。

Plan-and-Solve 解决了「看全路线」,但它和 ReAct 有一个共同弱点:做完就完了,不会回头反省。下一节 5.3 Reflexion:失败后的自我修正 会在循环外面再套一层——失败时生成反思,带着反思重试。想回看三元组的记录方式,见 5.1 ReAct:把「想—做—看」写进循环 。

阅读导航:上一节:5.1 ReAct:把「想—做—看」写进循环 · 下一节:5.3 Reflexion:失败后的自我修正 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「agent」更多文章

  1. 《AI Agent 高级编程》目录
  2. 《AI Agent 高级编程》11.3 全景回顾:从零到框架的完整路径与展望
  3. 《AI Agent 高级编程》11.2 真实 API 冒烟:假模型 vs 真模型量化差距