Agent系列(三):Plan-and-Solve——先想清楚,再动手

ReAct 在哪里会撞墙?

上一篇我们说 ReAct 的贪心策略------每一步只看当前状态,决定下一个行动。大多数情况下这很好用,但有一类任务会让它步履蹒跚。

想象你要 Agent 完成这个任务:

搜索 Python、Java、Go 三种语言的发布年份,按时间从早到晚排序,然后计算 Python 和 Go 相差多少年。

用 ReAct 执行,可能的轨迹:

css 复制代码
Action: web_search("Python发布年份")
Action: web_search("Java发布年份")
Action: web_search("Go发布年份")
Action: calculator("...")
(有时候会多走几步,甚至重复搜索)

这并不离谱,但有一个潜在问题:ReAct 在行动之前没有"全局规划"。它不知道任务总共需要多少步,不知道哪一步依赖哪一步,也不知道当前走到哪里了。每一步都是"当前最优解",不是"整体最优路径"。

对于有明确依赖关系的多步任务,这就像不看地图边走边问路------能到,但弯路不少。

Plan-and-Solve 的思路是:先用 LLM 制定完整的行动计划,然后按计划逐步执行。


Plan-and-Solve 的两阶段架构

这个范式来自 2023 年的论文 Plan-and-Solve Prompting,核心思想分两步:

Phase 1 --- Plan(规划):让 LLM 以上帝视角分析整个任务,输出一个有序的步骤列表。这一阶段不执行任何操作,只是"想清楚怎么做"。

Phase 2 --- Solve(执行):按照计划,逐步执行每个步骤。每步可以调用工具,上一步的结果会注入到下一步的上下文里。

加上生产环境必备的容错机制,完整的架构是:

css 复制代码
任务
 │
 ▼
[Plan 节点]     ← LLM 生成 3-7 步计划(不执行,只规划)
 │
 ▼
[Execute 节点]  ← 执行当前步骤(内嵌 ReAct,可调工具)
 │
 ├─ 步骤失败? ─→ [Replan 节点] ← 根据已完成进度重新规划剩余步骤
 │                    │
 │                    └──────────────┐
 │                                   ▼
 ├─ 还有步骤? ─→ 回到 Execute      Execute(继续)
 │
 └─ 全部完成? ─→ [Finalize 节点] ← 输出最终答案
                        │
                        ▼
                      END

和 ReAct 的关键区别:ReAct 是一个无边界的循环,Plan-and-Solve 是一个有终点的序列


用 LangGraph 实现:State + Graph

LangGraph 是实现这个架构的理想工具------它把 Agent 建模成一个状态机(StateGraph),状态在节点之间流转。

状态设计

python 复制代码
from typing import TypedDict

class PlanSolveState(TypedDict):
    task: str                    # 用户原始任务
    plan: list[str]              # 当前计划(步骤列表)
    completed_steps: list[str]   # 已完成步骤(含结果摘要)
    current_step_index: int      # 当前执行到第几步(0-based)
    step_result: str             # 本步骤的执行结果
    replan_count: int            # 已重新规划的次数
    final_answer: str            # 最终答案

状态是整个图的"血液"------所有节点都从这里读取输入,向这里写入输出。设计好状态,架构就成功了一半。

Plan 节点

python 复制代码
def plan_node(state: PlanSolveState) -> dict:
    messages = [
        SystemMessage(content=PLANNER_SYSTEM),  # 规划专家 prompt
        HumanMessage(content=f"任务:{state['task']}"),
    ]
    response = llm.invoke(messages)
    plan = parse_plan(response.content)  # 解析 "1. xxx\n2. xxx" 格式

    return {
        "plan": plan,
        "current_step_index": 0,
        "completed_steps": [],
    }

Planner 的系统提示很关键:

python 复制代码
PLANNER_SYSTEM = """你是一个任务规划专家。
规则:
1. 将任务分解为 3-7 个独立的步骤
2. 每个步骤必须是具体的、可操作的
3. 步骤之间有明确的依赖关系
4. 最后一步通常是"整合所有信息,给出最终答案"

输出格式(只输出步骤列表):
1. [步骤描述]
2. [步骤描述]
...
"""

Execute 节点(内嵌 ReAct 子 Agent)

python 复制代码
def execute_node(state: PlanSolveState) -> dict:
    idx = state["current_step_index"]
    current_step = state["plan"][idx]

    # 构建执行上下文(包含已完成步骤的结果)
    system_prompt = EXECUTOR_SYSTEM.format(
        completed_steps=format_completed_steps(state["completed_steps"]),
        current_step=current_step,
    )

    # 用 ReAct 子 Agent 执行单个步骤(可能需要工具)
    sub_agent = create_react_agent(model=llm, tools=[calculator, web_search])
    result = sub_agent.invoke(
        {"messages": [
            SystemMessage(content=system_prompt),
            HumanMessage(content=f"请执行这个步骤:{current_step}"),
        ]},
        config={"recursion_limit": 8},
    )

    step_result = result["messages"][-1].content
    new_completed = state["completed_steps"] + [
        f"{current_step} → {step_result[:100]}"
    ]

    return {
        "step_result": step_result,
        "completed_steps": new_completed,
        "current_step_index": idx + 1,
    }

这里有个重要设计选择:Execute 节点内嵌了一个 ReAct 子 Agent。这意味着 Plan-and-Solve 和 ReAct 并不是互斥的------Plan-and-Solve 提供全局结构,ReAct 负责每个步骤内的工具调用。

路由函数

python 复制代码
MAX_REPLAN = 2

def should_continue(state) -> Literal["execute", "replan", "finalize"]:
    idx = state["current_step_index"]
    total = len(state["plan"])

    if idx >= total:
        return "finalize"  # 所有步骤完成

    # 检测步骤失败
    result = state.get("step_result", "")
    failed = any(kw in result for kw in ["计算错误", "搜索请求失败", "Error"])

    if failed and state["replan_count"] < MAX_REPLAN:
        return "replan"  # 失败且还有重试机会

    return "execute"  # 继续执行

构建图

python 复制代码
from langgraph.graph import END, START, StateGraph

graph = StateGraph(PlanSolveState)

graph.add_node("plan", plan_node)
graph.add_node("execute", execute_node)
graph.add_node("replan", replan_node)
graph.add_node("finalize", finalize_node)

graph.add_edge(START, "plan")
graph.add_edge("plan", "execute")
graph.add_conditional_edges(
    "execute",
    should_continue,
    {"execute": "execute", "replan": "replan", "finalize": "finalize"},
)
graph.add_conditional_edges(
    "replan", after_replan,
    {"execute": "execute", "finalize": "finalize"},
)
graph.add_edge("finalize", END)

agent = graph.compile()

完整代码:agent-02-plan-and-solve/plan_and_solve_agent.py


真实运行:看计划是怎么生成的

Demo 1:多国人口数据收集与计算

任务:搜索中国、美国、印度三国人口,计算总和和中国的占比。

Planner 生成的计划

markdown 复制代码
1. 使用互联网搜索引擎,分别搜索"中国人口数量"、
   "美国人口数量"和"印度人口数量",获取最新的人口数据。
2. 记录下中国、美国和印度的人口数量。
3. 将中国、美国和印度的人口数量相加,得到三国人口总和。
4. 计算中国人口数量占三国人口总和的百分比。
5. 整合所有信息,给出最终答案。

执行轨迹

scss 复制代码
[步骤 1] 调用 web_search("中国人口数量") → 14.0489亿
         调用 web_search("美国人口数量")  → 3.41亿
         调用 web_search("印度人口数量")  → 14.51亿

[步骤 2] 记录(无工具调用,模型整合信息)
         → 中国 14.0489亿,美国 3.41亿,印度:暂无数据 ← ⚠️

[步骤 3] 调用 calculator("14048900000.0 + 3400000000.0")
         → 17448900000 ← ⚠️ 少了印度!

[步骤 4] 调用 calculator("14.0489 / 17.4489 * 100")
         → 80.5145%

[最终答案] 三国人口总和 17.4489亿,中国占 80.5145%

等等,出了什么问题?

步骤 1 成功搜到了印度的人口(14.51亿),但步骤 2 模型却说"印度暂无数据"。步骤 3 的计算只加了中国和美国,漏掉了印度。

这是 Plan-and-Solve 最典型的陷阱之一:信息在步骤间传递时丢失。

步骤 1 的搜索结果被写入了 completed_steps,但摘要被截断(只保留了 100 个字符),关键数字可能没有完整保留。步骤 2 没有工具调用,完全依靠模型从上下文里"记住"步骤 1 的结果------而模型幻觉了"暂无数据"。

这不是 bug,是设计决策带来的必然代价:当信息链很长时,摘要式传递会导致信息损耗。解决方案见文末。

Demo 2:依赖链任务(iPhone 折合人民币)

任务:搜索最新 iPhone 价格(美元),搜索汇率,换算成人民币。

Planner 生成了 7 步计划 ------实际上 3 步就够了(搜价格、搜汇率、计算)。这展示了 Planner 的另一个特性:会对简单任务过度规划,把每个小步骤都拆开。

步骤 6 出现了有趣的工具失败:

vbscript 复制代码
[步骤 6] 需要四舍五入 8836.45
  → 调用 calculator("round(8836.45)")
  → 工具返回:计算错误:不支持的 AST 节点:Call
  → 调用 calculator("round(8836.45, 0)")
  → 工具返回:计算错误:不支持的 AST 节点:Call
  → 步骤结果:Sorry, need more steps to process this request.

我们的计算器只支持四则运算,不支持函数调用(这是故意的,防止注入)。模型尝试了两次 round(),都失败了,最终放弃并给出了一个"我不知道怎么处理"的回复。

但在步骤 7(最终整合),模型聪明地绕过了这个问题:

yaml 复制代码
1299美元 × 6.8025 = 8836.45人民币
四舍五入约为 8836元

它用自然语言做了"四舍五入",没有依赖工具。这说明:工具失败不一定是终点,模型本身的能力可以作为 fallback。

Demo 3:简单任务的规划

任务:计算 2^10 + 3^5。

Planner 生成了 4 步计划

markdown 复制代码
1. 计算 2 的 10 次方
2. 计算 3 的 5 次方
3. 将步骤 1 和步骤 2 的结果相加
4. 整合所有信息,给出最终答案

对比 ReAct 的处理:直接一步 calculator("2**10 + 3**5") 就解决了。

Plan-and-Solve 在这里显然"用力过猛"------把一个单步计算拆成了 4 步。这正是本文要重点讨论的权衡之一。


五个关键发现

运行这个 Demo 后,总结出 5 个在实际工程中非常重要的观察:

发现 1:Planner 倾向于过度规划

对于简单任务,LLM 会把每一个小动作都拆成独立步骤。这会增加执行轮次和 Token 消耗,反而更慢。好的 Planner Prompt 应该明确规定:简单任务不超过 3 步,只有真正有依赖关系的步骤才需要拆开。

发现 2:信息在步骤间的传递需要精心设计

每步执行完,结果以摘要形式存入 completed_steps。如果摘要太短,关键数字可能被截掉(Demo 1 的印度人口丢失就是这个原因)。解决方案:用结构化格式(JSON 或键值对)存储步骤结果,而不是截断的自然语言。

发现 3:工具失败不等于步骤失败

模型在工具调用失败后,可以用自身知识作为 fallback(Demo 2 的四舍五入)。这说明"工具失败"不应该直接触发 Replan------应该先让 Execute 节点自行处理,只有模型完全无法给出合理结果时才 Replan。

发现 4:Replan 机制是双刃剑

Replan 给了系统容错能力,但也引入了不确定性:重新规划的计划和原计划可能有逻辑冲突,或者绕过了某些必要步骤。生产环境中建议:Replan 次数不超过 2 次,超过后直接降级处理(告知用户任务无法完成)。

发现 5:Plan-and-Solve 和 ReAct 不是对立的

在我们的实现里,每个 Execute 步骤内部用的是一个 ReAct 子 Agent。Plan-and-Solve 提供"战略规划",ReAct 提供"战术执行"。这种组合在实际工程中非常常见,也是 LangGraph 设计的精髓之一。


何时选 ReAct,何时选 Plan-and-Solve?

这是本文最核心的工程判断:

markdown 复制代码
任务分析
│
├─ 步骤数少于 3?
│   └─ 直接用 ReAct(轻量、快速)
│
├─ 步骤之间有强依赖关系?
│   (后一步需要精确使用前一步的结果)
│   └─ 选 Plan-and-Solve(显式计划保证依赖顺序)
│
├─ 任务边界清晰、步骤可枚举?
│   └─ 可以用 Plan-and-Solve 甚至 Workflow
│
├─ 任务是开放式的、边界模糊?
│   └─ 用 ReAct(灵活应对未知情况)
│
└─ 任务需要长期规划(10+ 步)?
    └─ 考虑多 Agent 架构(见后续文章)

实际场景对照

场景 推荐方案 理由
搜索一个事实并回答 ReAct 单步,不需要规划
多数据源对比分析 Plan-and-Solve 数据收集有依赖顺序
自动写代码并测试 Plan-and-Solve 步骤明确:写→运行→修复
开放式竞品研究 ReAct 边搜边决定查什么
数据清洗流水线 Workflow-Driven 步骤完全固定
复杂故障排查 ReAct + Plan 混合:先规划调查路径,再动态执行

信息传递问题的改进方案

Demo 1 中印度人口丢失的问题,有几种修复思路:

方案 A:结构化存储步骤结果

python 复制代码
# 不要存自然语言摘要
completed_steps.append(f"搜索中国人口 → {step_result[:100]}")

# 改为存结构化数据
step_data = {
    "step_index": idx,
    "description": current_step,
    "result": step_result,  # 存完整结果,不截断
    "extracted_values": {},  # 让模型提取关键数值
}

方案 B:引入专门的"数据收集"状态槽

python 复制代码
class PlanSolveState(TypedDict):
    # ... 其他字段 ...
    collected_data: dict[str, Any]  # 专门存储收集到的数据

每个 Execute 步骤不仅写 completed_steps,还把提取到的关键数据写入 collected_data。后续步骤直接读这个字典,不依赖模型从自然语言中"回忆"。

方案 C:让 Planner 在计划里明确数据传递方式

在 Planner 的 Prompt 里要求它为每一步指定:

  • "输入:依赖步骤 X 的哪个数据"
  • "输出:产出什么数据,存储在哪里"

这相当于在计划层面就定义好了数据流图。

这三个方案在复杂程度和健壮性上依次递增,工程中根据任务复杂度选择。


面试素材:说清楚规划和执行的分离

面试题:你的 Agent 在执行之前会做规划吗?如何规划?

很多候选人描述的是 ReAct------在执行中隐式推理,没有显式规划。如果你实现过 Plan-and-Solve,这是一个很好的区分点:

"我们针对不同类型的任务用了不同架构。对于步骤数少、边界模糊的任务,用 ReAct 的隐式推理就够了。对于有明确依赖关系的多步任务(比如多数据源对比分析),我们引入了 Plan-and-Solve 架构。

具体是两个阶段:Plan 阶段用 LLM 做一次完整的任务分解,生成步骤列表,这一步不执行任何工具;Solve 阶段按计划逐步执行,每步内嵌一个 ReAct 子 Agent 处理工具调用。

这样设计的好处是:执行路径在开始就确定了,依赖关系明确,更容易排查问题;同时 Replan 机制保证了容错性。

实际工程中发现了一个坑:步骤间的信息传递需要结构化,不能只用自然语言摘要,否则关键数据容易在传递过程中丢失。"

这个回答展示了你不只会跑示例代码,还遇到过并思考过生产问题。


总结

三件事:

  1. Plan-and-Solve = 先规划,再执行:对比 ReAct 的贪心策略,Plan-and-Solve 在执行前生成完整步骤列表,让依赖关系可见、执行路径可预期。适合有明确结构的多步任务。

  2. 信息传递是最大的坑:步骤间用自然语言摘要传递数据会导致信息丢失。生产环境应使用结构化数据存储关键中间结果,而不依赖模型"记住"上一步的结果。

  3. Plan-and-Solve 和 ReAct 可以组合:Plan-and-Solve 提供全局结构,ReAct 负责每步内的工具调用。这种分层设计在复杂 Agent 系统中非常普遍。


下一篇 :Agent 系列第四篇------Tool Calling 深度解析:工具是 Agent 的"手",但手的设计决定 Agent 能做什么。我们会深入工具的设计原则、参数校验、错误处理,以及如何防止工具成为系统的安全漏洞。


参考资料


欢迎访问我的个人主页,获取更多有用的知识和有趣的产品

相关推荐
冬奇Lab10 小时前
每日一个开源项目 #110:ai-engineering-from-scratch - 从零构建 AI 工程全栈能力
人工智能·深度学习·llm
夜郎king10 小时前
基于 Trae Solo 的 Ant 遗留项目编译方案 —— 以 BaseformEpanet 为例
人工智能·trae solo·水力模型·java水力模型编译
测试员周周10 小时前
【Appium 系列】第20节-测试项目结构设计 — 从脚本到工程
人工智能·数据挖掘·回归·单元测试·appium·测试用例·测试覆盖率
IT_陈寒10 小时前
SpringBoot自动配置偷偷给我埋了个坑
前端·人工智能·后端
一切皆是因缘际会10 小时前
AI 从 “模仿智能” 到 “重构世界” 的范式跃迁
大数据·人工智能·深度学习·重构·架构
Are_You_Okkk_10 小时前
无需配环境、不受设备限!MonkeyCode重新定义研发
大数据·人工智能·开源·团队开发·ai编程
kyraaa110 小时前
618智能灭蚊器什么牌子好?电灭蚊灯哪个牌子好用?综合测评希亦、绳池等10大热门灭蚊灯品牌!
大数据·人工智能·python
deephub10 小时前
推理 → 行动 → 观察:用 LangChain + Python 实现一个智能体循环
人工智能·python·langchain·大语言模型·agent
生成论实验室10 小时前
Token即事件:Transformer为何是“事件-关系网络”的最佳实现——兼论大语言模型如何从“概率鹦鹉”进化为“认知主体”
人工智能·深度学习·语言模型·agi·安全架构