前言
在前面写了第一个 Agent Loop(think → act → observe),但它有个局限:只能做"一次工具调用 + 一次回复" 。这次我把 Agent 升级成两种真正的"范式"------ReAct 和 Plan-Execute,并且接入了 MCP(Model Context Protocol)让工具调用走标准协议。
这篇文章讲三个东西:两种范式怎么选、MCP 怎么接入、以及模型选型踩的一个大坑。
一、ReAct:边想边做
ReAct 让 LLM 交替输出「思考」和「行动」:
yaml
Thought: 用户需要电子摇滚,BPM 130-150 → 调用 search_catalog
Action: search_catalog({style: 电子摇滚, bpm_min: 130, bpm_max: 150, budget: 3000})
Observation: 返回甜蜜蜜 (BPM 140) 和 Neon Pulse (BPM 135)
Thought: 两首都符合 → 回答用户
Action: Final Answer
和 Agent Loop 的区别 :Agent Loop 是"调用一次工具就回答",ReAct 是"可能连续调多个工具直到攒够信息"。所以 MAX_STEPS=6,比 Agent Loop 的 MAX_TURNS=3 大------给模型探索空间,但 6 轮封顶防死循环。
前端类比:ReAct ≈ 事件循环里每个 tick 决策------边跑边改,遇到新信息现场调整。
实测发现 :7b 模型会重复调用同一个工具(拿到数据后又调了一次同样的参数)。MAX_STEPS 正好兜住这种行为------浪费一轮但不死循环。这是 ReAct 在小模型上的常见失败模式。
二、Plan-Execute:先计划再执行
Plan-Execute 分两步:第一步让 LLM 生成完整计划,第二步逐步执行。
bash
Step 1: {"plan": ["确定 BPM 范围", "检索曲库", "筛选适合健身房的", "查询授权价"]}
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() 装饰器注册):
python
server = MCPServer("music-catalog-server")
@server.tool(name="search_catalog", description="根据风格/BPM/预算检索曲库")
async def search_catalog(style: str, bpm_min: int, bpm_max: int, budget: int):
...
接入 Agent 的关键设计------适配器 :ReActAgent 只认 execute(name, arguments) → {"success", "result"/"error"} 这个接口。我写了个 MCPToolAdapter 实现同样的接口,内部走 MCP 协议:
ini
# W4 手写:executor.execute(name, args) → 直接调本地函数
# MCP:adapter.execute(name, args) → JSON-RPC 到 server → 返回
# Agent 一行不改,只要注入不同的 executor:
agent = ReActAgent(executor=ToolExecutor(registry)) # W4 方式
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------这是我精读时没覆盖到的细节,实测才发现。
四、模型选型:任务形态决定模型
默认模型换了几次,踩了个大坑:
| 模型 | 耗时 | 结果 |
|---|---|---|
| qwen2.5:7b | 5.8s | ✅ 快,但 JSON 不稳、ReAct 重复调工具 |
| deepseek-r1:8b | >113s | ❌ 慢到不可接受 |
| qwen2.5:14b | 16.6s | ✅ JSON 稳定,定为默认 |
为什么 r1:8b 不可用 :ReAct 的"思考"已经写死在 prompt 里 (让模型输出 thought 字段),它不需要 R1 那样的隐式思维链。R1 把大量 token 花在 CoT 上,拖慢速度还干扰 JSON 输出------推理模型的强项在这里用不上,短板(慢 + JSON 不稳)却正中要害。
选型原则:模型选型要匹配任务形态。推理模型(R1)适合深度分析场景;执行模型(qwen)适合"结构化输出 + 工具调用"场景。ReAct 是执行型任务,不是推理型任务。
五、多轮记忆
之前每次 run() 都从零开始,用户说"对,就是那首"时"那首"没有上下文。前面补全两个记忆:
- 短期记忆(ShortTermMemory):会话内的消息历史,放 messages 里------≈ React 组件内 state
- 长期记忆(LongTermMemory):跨会话的用户事实,存 JSON 文件,启动时加载------≈ localStorage
长期记忆的持久化有个细节:__init__ 必须先读文件再初始化 ,否则重开实例数据全丢。测试 test_persistence 专门验证这个。
结论
ReAct 是"边想边做"的探索者,Plan-Execute 是"先计划再执行"的规划师------任务形态决定范式选择。
这周最大的三个收获:schema 必须传给 LLM (不然它只能瞎猜)、小模型 JSON 必须容错 (修复层不是可选项)、模型选型匹配任务形态(推理模型不是万能的)。
从 手写 Agent Loop,到 ReAct + Plan-Execute + MCP------ Agent 终于有了"范式"和"协议",不再是单次调用。