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 机制保证了容错性。
实际工程中发现了一个坑:步骤间的信息传递需要结构化,不能只用自然语言摘要,否则关键数据容易在传递过程中丢失。"
这个回答展示了你不只会跑示例代码,还遇到过并思考过生产问题。
总结
三件事:
-
Plan-and-Solve = 先规划,再执行:对比 ReAct 的贪心策略,Plan-and-Solve 在执行前生成完整步骤列表,让依赖关系可见、执行路径可预期。适合有明确结构的多步任务。
-
信息传递是最大的坑:步骤间用自然语言摘要传递数据会导致信息丢失。生产环境应使用结构化数据存储关键中间结果,而不依赖模型"记住"上一步的结果。
-
Plan-and-Solve 和 ReAct 可以组合:Plan-and-Solve 提供全局结构,ReAct 负责每步内的工具调用。这种分层设计在复杂 Agent 系统中非常普遍。
下一篇 :Agent 系列第四篇------Tool Calling 深度解析:工具是 Agent 的"手",但手的设计决定 Agent 能做什么。我们会深入工具的设计原则、参数校验、错误处理,以及如何防止工具成为系统的安全漏洞。
参考资料
- Wang et al., Plan-and-Solve Prompting, ACL 2023
- LangGraph 官方文档:StateGraph
- hello-agents 开源教程(第六章)
- 本文配套代码:agent-02-plan-and-solve
欢迎访问我的个人主页,获取更多有用的知识和有趣的产品