从零构建 Plan-and-Solve 智能体:让大模型学会”谋定而后动”

为什么需要 Plan-and-Solve?

在上一篇文章中,我们构建了 ReAct 范式:让模型边想边做,通过 Thought → Action → Observation 循环动态调整行动。它像一个经验丰富的侦探,根据现场线索一步步推理,随时修正调查方向。

但 ReAct 并非万能的。它的核心弱点是短视——每一步只基于当前观察做局部决策,缺乏对全局路径的掌控。当我们把 ReAct 应用在多步骤、长链条的复杂任务上时,模型很容易偏离轨道:前几步还算顺利,走到中间就忘了最终目标,或者被无关信息带偏。

这个问题在更早的 Chain-of-Thought(思维链) 范式中同样存在:模型虽然能一步步推理,但每一步都在”自由发挥”,没有一份事先确定好的行动蓝图。

2023 年,Lei Wang 提出了 Plan-and-Solve Prompting,用一句话概括其核心思想:

不要边想边做,而是先想清楚”怎么做”,再动手去做。

如果说 ReAct 是侦探,那么 Plan-and-Solve 更像一位建筑师:动工之前先绘制完整蓝图,然后严格按照蓝图施工。事实上,如今主流大模型产品的 Agent 模式,几乎都融入了这种”先规划、后执行”的设计。


核心思想:解耦”思考什么”与”如何做”

ReAct 把思考与行动融合在每一步;Plan-and-Solve 则把整个流程解耦为两个清晰阶段:

阶段 职责 产物
规划阶段 (Planning) 理解完整问题,将其分解为一组逻辑连贯的子步骤 一份结构化的分步行动计划
执行阶段 (Solving) 严格按照计划,逐步骤求解,并传递中间结果 最终答案
1
2
3
4
5
6
7
8
9
10
11
12
13
14
flowchart LR
Q([用户问题]) --> PLAN["规划阶段<br/>Planner 生成行动计划 P"]
PLAN --> |"P = p₁, p₂, ..., pₙ"| SOLVE["执行阶段<br/>Executor 逐步骤求解"]

subgraph SOLVE_DETAIL ["执行阶段内部"]
S1["步骤 1"] --> S2["步骤 2"] --> S3["..."] --> SN["步骤 n"]
end

SOLVE --> FINAL([最终答案])
SOLVE_DETAIL -. "中间结果 sᵢ 作为下一步输入" .-> S2

style PLAN fill:#e3f2fd,stroke:#1565c0
style SOLVE fill:#e8f5e9,stroke:#2e7d32
style FINAL fill:#fff3e0,stroke:#ef6c00

这种”先谋后动”的策略带来一个关键收益:目标一致性。由于计划在开工前就已完整确定,智能体在漫长执行过程中始终知道”我在哪一步、下一步去哪”,不会在中途迷失方向。

形式化表达

整个流程可以写成两个公式。首先,规划模型 $\pi_{\text{plan}}$ 根据原始问题 $q$ 生成包含 $n$ 个步骤的计划:

$$
P = \pi_{\text{plan}}(q) = (p_1, p_2, \dots, p_n)
$$

随后,执行模型 $\pi_{\text{solve}}$ 逐一完成每个步骤。注意第 $i$ 步的解 $s_i$ 同时依赖三样东西:原始问题 $q$、完整计划 $P$、以及之前所有步骤的结果:

$$
s_i = \pi_{\text{solve}}(q, P, (s_1, \dots, s_{i-1}))
$$

最终答案就是最后一步的执行结果 $s_n$。这个公式揭示了 Plan-and-Solve 的信息流本质:问题提供”为什么做”,计划提供”做什么”,历史结果提供”从哪继续”。


适用场景

Plan-and-Solve 尤其擅长处理结构性强、可被清晰分解的复杂任务:

  • 多步数学应用题:先列出计算步骤,再逐一求解。
  • 需要整合多个信息源的报告撰写:先规划报告结构(引言、数据源 A、数据源 B、总结),再逐一填充内容。
  • 代码生成任务:先构思函数、类、模块的整体结构,再逐一实现。

共同特征是:任务的求解路径相对确定,难点在于”保持不迷路”,而不在于”动态探索”。


动手实现:三个组件

为了凸显结构化推理的优势,我们不用任何外部工具,仅通过提示词设计来完成一个多步推理任务:

一个水果店周一卖出了 15 个苹果。周二卖出的苹果数量是周一的两倍。周三卖出的数量比周二少了 5 个。请问这三天总共卖出了多少个苹果?

这个问题无法一次查询得出,必须分解为:计算周二销量 → 计算周三销量 → 计算总销量。我们将实现三个组件:Planner(规划器)、Executor(执行器)、PlanAndSolveAgent(协调者)。

1
2
3
4
5
6
7
8
9
10
flowchart TB
AGENT["PlanAndSolveAgent<br/>(协调者 Orchestrator)"]
AGENT --> P["Planner<br/>生成行动计划"]
AGENT --> E["Executor<br/>逐步执行 + 状态管理"]
P -->|"plan: list[str]"| E
E -->|"最终答案"| AGENT

style AGENT fill:#ede7f6,stroke:#5e35b1
style P fill:#e3f2fd,stroke:#1565c0
style E fill:#e8f5e9,stroke:#2e7d32

组件一:Planner(规划器)

规划器的唯一任务:接收原始问题,输出一份结构化的分步计划。为了让代码能稳定解析,提示词要求模型输出 Python 列表格式的字符串:

1
2
3
4
5
6
7
8
9
10
PLANNER_PROMPT_TEMPLATE = """
你是一个顶级的AI规划专家。你的任务是将用户提出的复杂问题分解成一个由多个简单步骤组成的行动计划。
请确保计划中的每个步骤都是一个独立的、可执行的子任务,并且严格按照逻辑顺序排列。
你的输出必须是一个Python列表,其中每个元素都是一个描述子任务的字符串。

问题: {question}

请严格按照以下格式输出你的计划,```python与```作为前后缀是必要的:
```python
["步骤1", "步骤2", "步骤3", ...]

“””

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43

这个提示词通过三个设计点保障输出质量:

- **角色设定**:"顶级的AI规划专家",激发模型的专业能力。
- **任务描述**:清晰定义"分解问题"这一目标。
- **格式约束**:强制输出 Python 列表,后续用 `ast.literal_eval` 解析,比解析自然语言稳定得多。

封装成 `Planner` 类:

```python
class Planner:
def __init__(self, llm_client):
self.llm_client = llm_client

def plan(self, question: str) -> list[str]:
"""
根据用户问题生成一个行动计划。
"""
prompt = PLANNER_PROMPT_TEMPLATE.format(question=question)

# 为了生成计划,我们构建一个简单的消息列表
messages = [{"role": "user", "content": prompt}]

print("--- 正在生成计划 ---")
# 使用流式输出来获取完整的计划
response_text = self.llm_client.think(messages=messages) or ""

print(f"✅ 计划已生成:\n{response_text}")

# 解析LLM输出的列表字符串
try:
# 找到```python和```之间的内容
plan_str = response_text.split("```python")[1].split("```")[0].strip()
# 使用ast.literal_eval来安全地执行字符串,将其转换为Python列表
plan = ast.literal_eval(plan_str)
return plan if isinstance(plan, list) else []
except (ValueError, SyntaxError, IndexError) as e:
print(f"❌ 解析计划时出错: {e}")
print(f"原始响应: {response_text}")
return []
except Exception as e:
print(f"❌ 解析计划时发生未知错误: {e}")
return []

组件二:Executor(执行器与状态管理)

执行器的目标不是分解问题,而是在已有上下文的基础上,专注解决当前这一个步骤。因此它的提示词需要包含四类关键信息:

  1. 原始问题——确保模型始终了解最终目标;
  2. 完整计划——让模型知道当前步骤在整个任务中的位置;
  3. 历史步骤与结果——提供截至目前已完成的工作,作为当前步骤的直接输入;
  4. 当前步骤——明确指示现在要解决哪一个具体任务。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
EXECUTOR_PROMPT_TEMPLATE = """
你是一位顶级的AI执行专家。你的任务是严格按照给定的计划,一步步地解决问题。
你将收到原始问题、完整的计划、以及到目前为止已经完成的步骤和结果。
请你专注于解决"当前步骤",并仅输出该步骤的最终答案,不要输出任何额外的解释或对话。

# 原始问题:
{question}

# 完整计划:
{plan}

# 历史步骤与结果:
{history}

# 当前步骤:
{current_step}

请仅输出针对"当前步骤"的回答:
"""

这里隐藏着 Plan-and-Solve 最容易忽视、却至关重要的设计点——状态管理。执行器必须记录每一步的结果,并将其作为上下文喂给后续步骤,保证信息在整条任务链中顺畅流动。如果状态丢失,步骤 2 就不知道步骤 1 算出了 15 个苹果,整个链条立即断裂。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
class Executor:
def __init__(self, llm_client):
self.llm_client = llm_client

def execute(self, question: str, plan: list[str]) -> str:
"""
根据计划,逐步执行并解决问题。
"""
history = "" # 用于存储历史步骤和结果的字符串

print("\n--- 正在执行计划 ---")

for i, step in enumerate(plan):
print(f"\n-> 正在执行步骤 {i+1}/{len(plan)}: {step}")

prompt = EXECUTOR_PROMPT_TEMPLATE.format(
question=question,
plan=plan,
history=history if history else "无", # 如果是第一步,则历史为空
current_step=step
)

messages = [{"role": "user", "content": prompt}]

response_text = self.llm_client.think(messages=messages) or ""

# 更新历史记录,为下一步做准备
history += f"步骤 {i+1}: {step}\n结果: {response_text}\n\n"

print(f"✅ 步骤 {i+1} 已完成,结果: {response_text}")

# 循环结束后,最后一步的响应就是最终答案
final_answer = response_text
return final_answer

组件三:PlanAndSolveAgent(协调者)

最后,将两个组件整合进一个统一的智能体。它的职责非常清晰:接收 LLM 客户端,初始化内部的规划器和执行器,提供 run 方法启动整个流程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
class PlanAndSolveAgent:
def __init__(self, llm_client):
"""
初始化智能体,同时创建规划器和执行器实例。
"""
self.llm_client = llm_client
self.planner = Planner(self.llm_client)
self.executor = Executor(self.llm_client)

def run(self, question: str):
"""
运行智能体的完整流程:先规划,后执行。
"""
print(f"\n--- 开始处理问题 ---\n问题: {question}")

# 1. 调用规划器生成计划
plan = self.planner.plan(question)

# 检查计划是否成功生成
if not plan:
print("\n--- 任务终止 --- \n无法生成有效的行动计划。")
return

# 2. 调用执行器执行计划
final_answer = self.executor.execute(question, plan)

print(f"\n--- 任务完成 ---\n最终答案: {final_answer}")

注意这个类的设计原则:组合优于继承。PlanAndSolveAgent 本身不包含复杂逻辑,它只是一个协调者(Orchestrator),把”规划”和”执行”两个职责清晰地委托给内部组件。这样的结构让每个组件可以独立测试、独立替换,也方便日后演进(例如给执行器加工具、给规划器加反思)。


运行实例与分析

用水果店问题跑一遍,观察完整流程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
--- 开始处理问题 ---
问题: 一个水果店周一卖出了15个苹果。周二卖出的苹果数量是周一的两倍。周三卖出的数量比周二少了5个。请问这三天总共卖出了多少个苹果?
--- 正在生成计划 ---
✅ 计划已生成:
["计算周一卖出的苹果数量: 15个", "计算周二卖出的苹果数量: 周一数量 × 2 = 15 × 2 = 30个", "计算周三卖出的苹果数量: 周二数量 - 5 = 30 - 5 = 25个", "计算三天总销量: 周一 + 周二 + 周三 = 15 + 30 + 25 = 70个"]

--- 正在执行计划 ---
-> 正在执行步骤 1/4: 计算周一卖出的苹果数量: 15个
✅ 步骤 1 已完成,结果: 15
-> 正在执行步骤 2/4: 计算周二卖出的苹果数量: 周一数量 × 2 = 15 × 2 = 30个
✅ 步骤 2 已完成,结果: 30
-> 正在执行步骤 3/4: 计算周三卖出的苹果数量: 周二数量 - 5 = 30 - 5 = 25个
✅ 步骤 3 已完成,结果: 25
-> 正在执行步骤 4/4: 计算三天总销量: 周一 + 周二 + 周三 = 15 + 30 + 25 = 70个
✅ 步骤 4 已完成,结果: 70

--- 任务完成 ---
最终答案: 70

从日志中可以提炼出 Plan-and-Solve 的三个关键行为:

  1. 规划先行:Planner 一次调用就把应用题分解成 4 个逻辑步骤的结构化列表,为执行奠定了清晰的轨道。
  2. 状态传递:Executor 每步都将历史结果注入上下文——步骤 2 正确使用了步骤 1 的结果”15”,步骤 3 正确使用了步骤 2 的结果”30”,信息链从未断裂。
  3. 按图施工:执行过程不偏离、不发挥,严格沿着计划推进,最终准确得出”70”。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
sequenceDiagram
participant User as 用户
participant Agent as PlanAndSolveAgent
participant Planner as Planner
participant Executor as Executor
participant LLM as LLM

User->>Agent: run("水果店问题")
Agent->>Planner: plan(question)
Planner->>LLM: 规划提示词
LLM-->>Planner: [4个步骤的Python列表]
Planner-->>Agent: plan = [...]
Agent->>Executor: execute(question, plan)

loop 每个步骤 step in plan
Executor->>LLM: 执行提示词(问题+计划+历史+当前步)
LLM-->>Executor: 当前步结果
Executor->>Executor: 更新 history 状态
end

Executor-->>Agent: 最终答案 "70"
Agent-->>User: 最终答案: 70

ReAct vs Plan-and-Solve:两种思维模式的对比

两个范式都试图解决”让大模型可靠地完成复杂任务”,但哲学截然不同:

维度 ReAct Plan-and-Solve
类比 侦探:边查线索边调整方向 建筑师:先画蓝图再施工
决策方式 每步基于当前观察做局部决策 开工前一次性制定全局计划
与外部交互 强依赖(每步都可调用工具) 弱依赖(可纯靠内部推理)
核心优势 动态适应、信息实时获取 目标一致、步骤结构清晰
主要风险 中途偏离轨道、陷入死循环 计划本身出错则全盘皆错
最适合 需要外部知识/实时信息的任务 逻辑路径确定、推理密集的任务
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
flowchart LR
subgraph REACT ["ReAct: 边想边做"]
R1["Thought"] --> R2["Action"] --> R3["Observation"]
R3 -->|"未解决"| R1
R3 -->|"已解决"| R4["Finish"]
end

subgraph PAS ["Plan-and-Solve: 先谋后动"]
P1["Plan 一次性生成完整计划"] --> P2["Solve 步骤1"]
P2 --> P3["Solve 步骤2"] --> P4["..."] --> P5["Solve 步骤n"]
P5 --> P6["Finish"]
end

style R4 fill:#e8f5e9,stroke:#2e7d32
style P6 fill:#e8f5e9,stroke:#2e7d32

一个直观的选型建议:如果任务是”查询华为最新手机并总结卖点”,需要实时搜索,选 ReAct;如果任务是”解一道多步数学题”或”写一份结构固定的报告”,求解路径确定,选 Plan-and-Solve。

局限性

Plan-and-Solve 并非银弹,它有两个与生俱来的短板:

  1. 计划质量决定上限:计划一旦生成便不再调整。如果规划阶段就对任务理解有误,后面每一步执行都是在错误的轨道上越走越远——没有回头路可走。
  2. 缺乏外部反馈:纯推理模式下无法感知环境变化,遇到需要实时信息或动态修正的任务会力不从心。

这也正是下一个范式 Reflection 存在的意义:在 Plan-and-Solve(或 ReAct)生成初稿之后,引入”执行 → 反思 → 优化”的自我校正循环,让智能体审视并修正自己的输出。


总结

Plan-and-Solve 用一句话可以概括:把”想”和”做”分开,让想发生在做之前,并且只想一次。

它的工程实现也给了我们三个可迁移的设计经验:

  • 结构化输出是可靠的接口:用 Python 列表格式约束 LLM 输出,比解析自然语言稳定得多;
  • 状态管理是链条的命脉:每一步的结果必须显式传递给下一步,信息流断裂 = 任务失败;
  • 组合优于继承:让 PlanAndSolveAgent 做协调者,Planner 与 Executor 各司其职,系统才能清晰、可测、可演进。

当任务逻辑路径清晰、但步骤繁多容易迷路时,不妨试试”先谋后动”——让智能体先画好蓝图,再按图施工。