ReAct、Plan-and-Solve 与反馈迭代怎样落到真实 Agent

写在前面:这篇文章想解决什么

我读 Hello-Agents 第四章 时,第一次把 ReAct、Plan-and-Solve、Reflection 放在一起看。它们很容易被讲成三个漂亮的流程图:一个边做边想,一个先规划,一个做完复盘。可是真正开始写 Agent,或打开 Codex、Claude Code 一类工具后,问题马上变了:模型是谁在调用?工具结果放在哪里?模型卡住了怎么办?为什么计划模式能让人放心一些?

这篇文章不把三种范式当成需要背诵的标签,而把它们当成三种控制任务过程的办法。读完后,你应该能回答下面几个问题:

  1. 一个 Agent 相比普通聊天,到底多了哪些部件。
  2. 什么任务适合边做边看,什么任务该先规划,什么任务值得多花一轮做检查。
  3. 为什么教程里用 Thought: 和正则就能跑,产品里却要工具 schema、权限、测试和状态机。
  4. 如何用一个小项目把这三种方法做出来,而不是只会复述定义。

本文默认你会读 Python,知道函数、列表、字典即可。不要求会 LangChain,也不要求先注册任何模型 API。示例中的 call_model 是一个占位函数,换成你使用的模型 SDK 即可。代码的重点是循环和状态,不是某一家 API 的写法。

先校准一个概念:Agent 的定义比工具调用更宽

聊天模型接收一段输入,生成一段输出。Agent 也会生成文本,但它处在一个循环里:模型提出下一步,运行时决定能不能做,工具执行后把结果交回模型。它会一直循环,直到完成、失败、超时,或需要人介入。

一个最小 Agent 至少有六个部分:

部件 它负责什么 常见失败
目标 定义任务和完成条件 任务写得太宽,模型不知道何时停止
模型 从当前上下文选择下一步 猜错、遗漏约束、错误使用工具
工具 读文件、搜索、运行测试、改代码等 参数错误、外部系统失败、结果不可信
状态 保存计划、已做动作、工具结果和预算 重复工作、忘记先前结论、上下文过长
运行时 编排循环、解析结构化调用、记录日志 死循环、异常未处理、无法恢复
护栏 权限、沙箱、轮数、预算、审批 模型做了不该做的事,或成本失控

后文会用 harness 指模型外面的这套运行时和护栏。这个词没有神秘含义,你可以把它理解成牵住模型的绳索和工具箱。模型擅长判断下一步可能做什么;harness 负责验证、执行、记账和叫停。

这里有一个很重要的边界:模型写出的 “思考过程” 不是可靠事实,也不是安全机制。它最多是帮助模型组织任务的中间文本。能否写文件、发请求、执行命令,必须由模型外面的程序决定。

三种范式到底在区分什么

三种方法都能做任务,区别在于何时把不确定性摊开处理。

方法 决策发生的时机 最适合的任务 容易付出的代价
ReAct 每拿到一次观察后 信息不全、路径会变的任务 可能绕圈,调用次数多
Plan-and-Solve 执行前先拆解 依赖关系清楚的多步骤任务 计划会过期,前期花时间
反馈迭代 得到候选结果后 能检查质量的任务 延迟和成本上升,检查也可能错

它们不互斥。一个代码 Agent 很常见的过程是:先读仓库并列计划,按计划执行时用 ReAct 根据测试报错调整,最后用测试、静态检查或独立审查来迭代。把三者理解成时间轴上的插槽,比理解成三选一更贴近实际。

ReAct:把 “看情况” 写进循环

ReAct 来自 Yao 等人在 2022 年发表的论文 ReAct: Synergizing Reasoning and Acting in Language Models。论文的意思很直接:让模型交替生成推理轨迹和面向环境的动作。推理帮助它更新计划、处理例外;动作让它去拿模型上下文里原本没有的资料。

从一个具体任务开始

假设用户问:”仓库里登录接口为什么返回 401?请找出最可能的原因,但不要修改文件。”

一次性回答很危险,因为模型还没看过代码。一个 ReAct 式过程可能是:

  1. 先列出可能相关的位置,例如路由、认证中间件、环境变量和测试。
  2. 调用 search_code("401"),找到返回 401 的分支。
  3. 调用 read_file("src/auth.py"),观察 token 校验条件。
  4. 再读对应测试或配置示例。
  5. 信息足够后给出 “证据、推断、待验证项”,而不是继续翻文件。

每一步都可能改变下一步。若搜索发现实际由网关返回 401,继续盯着业务代码就错了。这就是 ReAct 的价值:计划不是一次性锁死,而是被观察结果持续修正。

把第 轮的上下文记为 ,模型策略为 ,工具环境为 ,可以写成:

这里的 是用户目标, 是动作, 是观察, 表示把新记录加入状态。真正要抓住的是最后一行:Agent 的 “记住” 不是魔法,通常就是把动作和结果保存在某种状态里。

教程版 ReAct 为什么能跑

很多入门实现让模型输出这样的文字:

1
2
Thought: 我需要先找到认证逻辑。
Action: read_file[src/auth.py]

运行时用正则提取 Action,执行工具后再追加:

1
Observation: 文件内容如下……

它很适合教学,因为循环清晰可见。不过不要把这套文本协议误当成生产方案。模型会漏字段、把括号写错、在工具输入里夹解释文字。更现实的接口让模型返回结构化的工具调用,例如:

1
2
3
4
{
"name": "read_file",
"arguments": {"path": "src/auth.py"}
}

运行时再按 schema 校验 path 是否存在、是否位于允许的目录。结构化调用不会保证模型的判断正确,却能把 “格式错了所以整个流程断掉” 变成一个可检测、可恢复的问题。

一个可运行时改造的最小骨架

下面的代码故意不绑定任何厂商 API。call_model 返回的不是自由文本,而是 tool_callfinal 两类对象。把自己的模型返回值转换为这个形状,就能把骨架接上。

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
44
45
46
47
48
49
50
51
52
53
54
55
56
57
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Callable


@dataclass
class State:
messages: list[dict[str, Any]] = field(default_factory=list)
steps: int = 0


TOOLS: dict[str, Callable[..., str]] = {}


def tool(name: str):
def register(func: Callable[..., str]):
TOOLS[name] = func
return func
return register


@tool("read_text")
def read_text(path: str) -> str:
root = Path(".").resolve()
target = (root / path).resolve()
if root not in target.parents and target != root:
raise PermissionError("只能读取工作目录中的文件")
return target.read_text(encoding="utf-8")[:12_000]


def run_agent(question: str, max_steps: int = 8) -> str:
state = State(messages=[{"role": "user", "content": question}])

while state.steps < max_steps:
reply = call_model(state.messages, tools=["read_text"])
state.steps += 1

if reply["type"] == "final":
return reply["content"]

if reply["type"] != "tool_call":
return "模型返回了无法识别的响应"

name = reply["name"]
args = reply["arguments"]
if name not in TOOLS:
result = f"错误:未知工具 {name}"
else:
try:
result = TOOLS[name](**args)
except Exception as exc:
result = f"工具失败:{type(exc).__name__}: {exc}"

state.messages.append({"role": "assistant", "tool_call": reply})
state.messages.append({"role": "tool", "name": name, "content": result})

return "已达到最大步骤数,需要人工检查任务是否卡住。"

这个版本比 “正则解析 Action” 多了几件看似琐碎、实际很重要的事:它限制最大轮数;它把工具异常作为观察交回模型;它限制读文件的范围;它只把明确注册的工具交给模型。没有这些,模型一次误判就可能变成无限重试、越权读取或难以排查的空白报错。

ReAct 常见的四种失败

  1. 模型不停重复同一个动作。对策不是单纯把提示词写得更凶,而是记录动作指纹;相同参数连续失败两次后终止或要求换方案。
  2. 工具返回了网页、日志或代码中的恶意指令。工具输出是数据,不是系统指令。把它明确标成不可信内容,模型也不能因页面里写着 “删除文件” 就获得权限。
  3. 观察太长,把真正目标挤出上下文。截断、摘要、检索和把原始结果存到外部状态,都是必要设计。
  4. 模型觉得 “差不多了” 就结束。为任务定义可检验的完成条件,例如 “给出文件路径和行号”、”测试命令退出码为 0”。

ReAct 并不要求暴露详细的内部推理。很多产品只展示行动、理由摘要和工具结果,这既够用户理解进度,也避免把脆弱的中间文本当成依据。

Plan-and-Solve:把依赖关系先摆到桌面上

Plan-and-Solve Prompting 由 Wang 等人在 2023 年提出,起点是零样本 CoT 容易漏步骤、算错或误解题意。它先让模型产生计划,再按计划完成子任务。原论文讨论的是提示方法,不等于一个完整的调度系统,但它给 Agent 一个很实用的习惯:执行前先显式写出依赖关系。

什么叫 “可执行的计划”

“先分析代码,再修改并测试” 不算好计划,因为它无法检查是否完成。好的计划至少包含目标、前置条件、产物和验证方式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
[
{
"id": "inspect-auth",
"goal": "找出认证入口和 401 返回分支",
"depends_on": [],
"done_when": "记录相关文件和分支条件",
"write_required": false
},
{
"id": "add-test",
"goal": "为失效 token 增加回归测试",
"depends_on": ["inspect-auth"],
"done_when": "新增测试先失败,再在修复后通过",
"write_required": true
}
]

计划结构化后,运行时可以做它不该交给模型的工作:检查依赖是否已完成;在只读模式下拒绝 write_required: true;把每一步状态显示给用户;在失败时标记卡点。原始问题、完整计划和已完成步骤都要传给执行者,否则模型很容易只顾眼前一步,忘了最终目标。

计划不是圣旨,要设计重规划

初学者很容易把 “先规划” 误解为 “严格按计划到底”。这恰好是 Plan-and-Solve 最常见的误用。现实任务里,第一步读到的信息经常推翻假设。合理做法是给计划加一个重新评估点:

1
2
if step_failed or new_fact_changes_assumption:
plan = revise_plan(original_goal, plan, completed_steps, evidence)

重规划必须保留旧计划和证据,不能悄悄改写历史。否则你无法回答 “为什么从方案 A 改成了 B”,也无法判断它是在适应新信息,还是在为失败找借口。

何时值得先规划

下面三类任务通常值得花一轮规划:

  • 修改涉及多个模块,且有明确先后依赖。例如数据库迁移、接口改造、跨目录重构。
  • 代价高或不可逆。例如部署、删除数据、对外发送消息。
  • 用户需要在执行前审查方向。例如 “先给我迁移方案,确认后再改”。

反过来,查一个配置项、修一处明显拼写错误、回答一个范围很小的问题,先写一页计划只会拖慢速度。计划的价值来自减少返工,不来自计划文字本身。

Reflection、Reflexion 与 “反馈驱动迭代”

“Reflection” 常被用来泛指模型生成后再审查、再修改的流程。更具体的论文是 Shinn 等人的 Reflexion:Agent 根据任务反馈生成语言形式的反思,写入短期记忆,并在下一次尝试时使用。论文强调的不是让模型凭空自我表扬或自我批评,而是利用反馈信号改进后续决策。

因此,下面的描述更准确:

其中 是外部证据,例如测试输出、编译错误、检索到的资料或人工评语。最有价值的 evaluate 往往不是 “请严格检查自己”,而是可以复现的检查器。

用写函数的例子看清差别

目标是写一个判断素数的函数。第一版实现后,有三种反馈的强弱:

反馈 例子 可靠性
自我评论 “这个实现可能有边界问题” 低,模型可能漏掉真正错误
独立模型审查 n = 1 应返回 False” 中,能发现部分问题但仍会猜错
外部检查 pytestis_prime(1) == False 失败 高,错误可复现

合理的循环是:先让 Agent 写候选实现,运行测试,把失败输出交给它修,再运行测试。若任务是文章或方案,无法写单元测试,也应把评审标准具体化,例如 “每个事实有来源”、”结论能追溯到表格里的哪一行”、”删掉任何一段是否损失新信息”。

反馈迭代的护栏

反馈本身也可能错。测试可能没有覆盖关键边界,静态检查也不理解业务意图,另一个模型会继承同样的偏见。因此循环需要边界:

  • 限制最多迭代次数和预算。
  • 保存每轮产物、反馈和修改理由,便于回退。
  • 区分 “测试通过” 和 “需求满足”。前者只是一个信号。
  • 对高风险动作保留人工审批,不能因为 “反思过了” 就自动执行。

把 Reflection 直接等同于 “模型自省” 会把问题说窄。真正可用的版本是证据驱动的修订循环,模型只是循环中的一个参与者。

把三种方法组合成一个任务流程

以 “为现有项目增加一个登录失败的错误提示” 为例,一个稳妥的过程可以是:

这里的计划负责把范围讲清楚,ReAct 负责处理仓库中真实的意外,反馈迭代负责用测试校验结果。一个真实 Agent 不会因为用了某个范式就自动可靠,它只是把可靠性问题放到了更容易控制的位置。

从提示词原型到产品运行时,增加了什么

手写 Thought → Action → Observation 很有必要。它能让你看到每一次模型调用、工具执行和状态追加。但如果要让它替你操作本机或生产系统,还需要补上下面这些层。

教程里的简化做法 真实运行时需要补的东西 原因
自由文本 Action + 正则 原生结构化工具调用、参数 schema 把格式问题变成可验证错误
把历史不断拼进 prompt 上下文预算、摘要、检索和日志 长任务会变慢、变贵、丢目标
“请不要乱改” 文件系统和网络权限、沙箱、审批 提示词不能授予或收回权限
模型说完成就完成 测试、退出码、断言、人工验收 需要外部完成条件
出错后继续问模型 超时、重试策略、幂等性、回滚 外部调用会失败,也可能产生副作用
一个大上下文做到底 子任务隔离和摘要回传 降低上下文污染,也便于并行

这就是为什么 “Agent 的智能” 不能只理解成模型能力。模型提出动作,运行时决定动作的边界。权限、最大轮数、参数验证、审计日志和人工确认看起来不够聪明,却是让系统可用的部分。

用真实工具验证这张地图,但不要过度解读产品

Claude Code、Codex、Hermes Agent、OpenClaw 都会升级。产品文档描述的是某个版本的行为,博客拆解常常混入推测,开源代码也会随提交改变。下面的对照只讨论公开可观察的设计取向,不把 “某产品使用了某个内部循环” 当作永久事实。

工具或资料 能确认的现象 它和三种范式的关系
Claude Code 有工具权限、Plan mode、子代理等能力;官方也把 Plan mode 描述为先展示计划供用户审阅 可以把它看作 “计划 + 执行” 的产品化工作流,执行阶段仍需要根据工具结果调整
Codex 可以读写仓库、运行命令,且提供沙箱和审批控制 它体现了 ReAct 类循环在编码任务中的外壳:工具结果驱动下一步,系统权限不由模型一句话决定
Hermes Agent、OpenClaw 社区资料常以记忆、技能、网关和工具策略解释它们 这些概念值得研究,但应回到其版本化文档或源码核对,别据二手文章断言具体轮数、层数或内部实现

Claude 官方关于 Plan mode 和子代理的讨论 很能说明问题:先让用户审阅整体策略,把监督从每个细小动作提升到任务方向。Codex 的开源仓库也明确区分 sandbox policy 和审批流程。这些都不是论文里的一句 “请先规划” 或 “请小心执行”,而是运行时真的可以拒绝动作的状态。

这里要避免两个常见误读。

第一,Plan mode 不是 Plan-and-Solve 论文的同义词。前者是产品交互和权限模式,后者是提示策略;它们有相似的 “先明确路径” 精神,但实现目标不同。

第二,/review、测试失败、子代理审查也不自动等于 Reflexion。它们更像是可以给反馈迭代提供证据的机制。是否真的形成 “评估后修订” 的闭环,要看任务有没有把反馈带回下一次行动。

初学者应该亲手做的三个练习

只读懂流程图很容易产生错觉。下面三个练习不需要大型框架,按顺序做完,你会真正碰到三种范式解决的问题。

练习一:做一个只读 ReAct 文件助手

给它两个工具:list_files(path)read_text(path)。让它回答 “项目入口在哪里”、”哪些文件引用了某个函数”。要求如下:

  1. 工具参数使用 JSON,不解析自由文本。
  2. 最多执行 8 步。
  3. 所有路径必须在工作目录内。
  4. 记录每步的工具名、参数、耗时和结果长度。
  5. 故意让一次工具抛异常,确认 Agent 会把错误当作观察继续处理,而不是直接崩溃。

做完后你应该能解释:为什么 max_steps 是产品功能而不是教学代码里的装饰;为什么工具结果不能直接当指令;为什么状态不能只靠模型 “记得”。

练习二:给它加一个可审查计划

把 “修改一个函数并补测试” 的任务拆成 JSON 计划。每一步有 iddepends_ondone_whenwrite_required。先以只读模式运行,输出计划让自己审阅;确认后才允许写工具。

刻意制造一个变化:让第一步发现函数不在你预期目录。此时不要强行执行旧计划,而是调用 revise_plan,并打印 “旧假设、发现的证据、新计划”。这一步会让你理解,规划的目标不是控制模型,而是把变化记录得清清楚楚。

练习三:把 “反思” 换成可验证反馈

让 Agent 写一个小函数,例如解析日期或判断素数。准备 5 到 8 个边界测试,按 “生成代码 → 跑测试 → 读取失败 → 修订 → 再跑” 的方式执行。设置最多两次修订。

最后查看轨迹:它到底根据哪条失败信息修了什么?如果测试全绿但函数需求仍不对,说明问题在测试规格,不是 Agent “不够会反思”。这也是工程里最有用的一课。

一份选型清单

开始一个任务前,不妨逐项问自己:

  • 下一步高度依赖外部信息吗?是的话,先选 ReAct 式循环。
  • 子任务是否有清晰依赖,且执行前需要用户点头?是的话,先做计划。
  • 产物能被测试、编译、规则检查或人工标准验证吗?是的话,加入反馈迭代。
  • 有写文件、发消息、花钱、删数据等副作用吗?有的话,把权限和审批放在模型外。
  • 失败后能安全重试吗?不能的话,给工具设计幂等键、确认步骤或回滚方案。
  • 什么时候算完成?如果这句话答不出来,不该先优化 prompt,而应先补完成条件。

这份清单也解释了为什么有些看似 “更自主” 的 Agent 实际体验更差:它们把规划、验证和权限都留给模型猜,用户只能在结果出来后承担后果。

小结

ReAct 处理的是执行中不断出现的新信息;Plan-and-Solve 处理的是执行前可见的依赖关系;反馈迭代处理的是候选结果是否经得起检查。三者可以组合,组合方式取决于任务的风险、路径是否稳定、以及是否拿得到可信反馈。

如果只记住一句话,我希望是:Agent 的循环可以由模型提出,但边界和验收必须由系统和证据来承担。手写原型能让我们看见循环,工具 schema、状态、测试、权限和审批才能让循环在真实环境里站得住。

下一步如果想继续深入,我建议不要急着比较更多工具名。先把上面的三个练习做完,再回头读任意 Agent 的文档,你会更容易分清它是在改进模型行为、任务状态、工具能力,还是安全边界。


参考资料