我的博客

手写 ReAct 与 Plan-Execute:两种 Agent 范式的实战对比

Aug 2, 2026
AgentReActPlan-ExecuteMCP记忆
8分钟
1525字

手写 ReAct 与 Plan-Execute:两种 Agent 范式的实战对比

前言

W4 我手写了第一个 Agent Loop(think → act → observe),但它有个局限:只能做”一次工具调用 + 一次回复”。这周(W8)我把 Agent 升级成两种真正的”范式”——ReAct 和 Plan-Execute,并且接入了 MCP(Model Context Protocol)让工具调用走标准协议。

这篇文章讲三个东西:两种范式怎么选、MCP 怎么接入、以及模型选型踩的一个大坑。


一、ReAct:边想边做

ReAct 让 LLM 交替输出「思考」和「行动」:

1
Thought: 用户需要电子摇滚,BPM 130-150 → 调用 search_catalog
2
Action: search_catalog({style: 电子摇滚, bpm_min: 130, bpm_max: 150, budget: 3000})
3
Observation: 返回甜蜜蜜 (BPM 140) 和 Neon Pulse (BPM 135)
4
Thought: 两首都符合 → 回答用户
5
Action: Final Answer

和 W4 的区别:W4 是”调用一次工具就回答”,ReAct 是”可能连续调多个工具直到攒够信息”。所以 MAX_STEPS=6,比 W4 的 MAX_TURNS=3 大——给模型探索空间,但 6 轮封顶防死循环。

前端类比:ReAct ≈ 事件循环里每个 tick 决策——边跑边改,遇到新信息现场调整。

实测发现:7b 模型会重复调用同一个工具(拿到数据后又调了一次同样的参数)。MAX_STEPS 正好兜住这种行为——浪费一轮但不死循环。这是 ReAct 在小模型上的常见失败模式。


二、Plan-Execute:先计划再执行

Plan-Execute 分两步:第一步让 LLM 生成完整计划,第二步逐步执行

1
Step 1: {"plan": ["确定 BPM 范围", "检索曲库", "筛选适合健身房的", "查询授权价"]}
2
Step 2-5: 逐条执行,记录每步的 done/result

和 ReAct 的区别

  • ReAct:路径不确定,边走边看——适合探索型任务(“帮我查一下…”)
  • Plan-Execute:步骤明确,先拆解再执行——适合复杂规划(“写一个 RAG 架构文档”)

前端类比:ReAct ≈ 每个 tick 决策的事件循环;Plan-Execute ≈ 先写 TODO list 再执行——任务拆解 + 逐个完成。

踩的坑(这个必须记录):qwen2.5:7b 生成计划时输出了非法 JSON——数组元素写成 ["步骤1": "内容"](带键值对),json.loads 直接抛错。根因是模型对 prompt 示例 ["步骤1", "步骤2"] 过度模仿,把序号当成了对象键。解法是解析层加修复:正则剥掉 "步骤N": 前缀。

教训:永远别指望小模型输出严格 JSON,解析层必须容错。这是 W3 JSON mode 的实战补充——schema 校验是”事后检查”,修复层是”事前补救”。


三、MCP 接入:让工具调用走标准协议

项目要求接入至少一个 MCP server。我把 W4 的 search_catalog 注册成了 MCP 工具,然后写了适配器让 ReActAgent 通过 MCP 调用它。

MCP server@tool() 装饰器注册):

1
server = MCPServer("music-catalog-server")
2
3
@server.tool(name="search_catalog", description="根据风格/BPM/预算检索曲库")
4
async def search_catalog(style: str, bpm_min: int, bpm_max: int, budget: int):
5
...

接入 Agent 的关键设计——适配器:ReActAgent 只认 execute(name, arguments) → {"success", "result"/"error"} 这个接口。我写了个 MCPToolAdapter 实现同样的接口,内部走 MCP 协议:

1
# W4 手写:executor.execute(name, args) → 直接调本地函数
2
# MCP:adapter.execute(name, args) → JSON-RPC 到 server → 返回
3
4
# Agent 一行不改,只要注入不同的 executor:
5
agent = ReActAgent(executor=ToolExecutor(registry)) # W4 方式
6
agent = ReActAgent(executor=MCPToolAdapter(client)) # MCP 方式

前端类比:手写 executor ≈ 直接 import 函数调用;MCP 适配器 ≈ 换成 HTTP API 调用——协议变了,接口不变。这就是适配器模式:依赖接口而非实现。

踩的坑(这个最有价值):一开始适配器的 list_tools() 只返回了工具名和描述,没返回参数 schema。结果模型完全不知道工具要什么参数,瞎猜了 4 次(genre/bpm_range/budget_per_song)全部失败。把 input_schema 加进返回后,模型一次就猜对了style/bpm_min/bpm_max/budget)。

教训:schema 必须传给 LLM——这和 W4 的”JSON Schema 给模型的菜单”是同一个设计:不给菜单,模型只能瞎点菜。

MCP SDK 新版 API 的坑:旧版 fastmcp / Client(read, write) 全变了。新版是 MCPServer + Client(in-memory/URL 高层封装)+ ClientSession(stdio 流)。ClientSession 必须显式 await client.initialize(),否则 tools/list 报 Invalid request parameters——这是我 W5 精读时没覆盖到的细节,实测才发现。


四、模型选型:任务形态决定模型

W8 默认模型换了几次,踩了个大坑:

模型耗时结果
qwen2.5:7b5.8s✅ 但 JSON 不稳、ReAct 重复调工具
deepseek-r1:8b>113s❌ 慢到不可接受
qwen2.5:14b16.6s✅ JSON 稳定,定为默认

为什么 r1:8b 不可用:ReAct 的”思考”已经写死在 prompt 里(让模型输出 thought 字段),它不需要 R1 那样的隐式思维链。R1 把大量 token 花在 CoT 上,拖慢速度还干扰 JSON 输出——推理模型的强项在这里用不上,短板(慢 + JSON 不稳)却正中要害

选型原则:模型选型要匹配任务形态。推理模型(R1)适合深度分析场景;执行模型(qwen)适合”结构化输出 + 工具调用”场景。ReAct 是执行型任务,不是推理型任务。


五、多轮记忆

之前每次 run() 都从零开始,用户说”对,就是那首”时”那首”没有上下文。W8 补了两个记忆:

  • 短期记忆(ShortTermMemory):会话内的消息历史,放 messages 里——≈ React 组件内 state
  • 长期记忆(LongTermMemory):跨会话的用户事实,存 JSON 文件,启动时加载——≈ localStorage

长期记忆的持久化有个细节:__init__ 必须先读文件再初始化,否则重开实例数据全丢。测试 test_persistence 专门验证这个。


结论

ReAct 是”边想边做”的探索者,Plan-Execute 是”先计划再执行”的规划师——任务形态决定范式选择。

这周最大的三个收获:schema 必须传给 LLM(不然它只能瞎猜)、小模型 JSON 必须容错(修复层不是可选项)、模型选型匹配任务形态(推理模型不是万能的)。

从 W4 的手写 Agent Loop,到 W8 的 ReAct + Plan-Execute + MCP——你的 Agent 终于有了”范式”和”协议”,不再是单次调用。

本文标题:手写 ReAct 与 Plan-Execute:两种 Agent 范式的实战对比
文章作者:vu-ji
发布时间:Aug 2, 2026
Copyright 2026
站点地图