5.2 Plan-and-Solve:先出计划再执行
上一节的 ReAct 是「边想边做」:模型走一步看一步,每一步都根据最新的观察临时决定下一步。这在探索性任务里很灵活,但对「步骤本来就已知」的任务就显得笨——明明可以先想清楚整条路线,却要每走一步都停下来重新推理一遍。这一节换一种骨架:先让模型把计划一次想全,再照着执行。
本节把 minagent 推进到「两段式的规划循环」:新增
minagent/plan.py里的Planner与Executor,Planner 用结构化输出产出计划列表,Executor 顺序执行每步(每步内部仍可调工具),把「想」与「做」在时间上彻底分开。
5.2.1 先谋后动:为什么要把计划单独拿出来
ReAct 的一个隐性代价是短视:它每一轮只看得见「当前这一步」,缺少一个「全局的路线图」。对于需要多步、且步骤之间有清晰先后关系的任务(先查数据、再清洗、再计算、最后汇总),这种短视会导致两个问题——模型可能在细节上反复横跳,也可能走着走着忘了总目标。
Plan-and-Solve 的思路是把一次任务拆成两个阶段:
- 规划阶段(Plan):让模型先不执行任何工具,只输出一份有序的子任务列表。这一步只做一次。
- 求解阶段(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:失败后的自我修正 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。