ReAct 、Plan-and-Solve、Reflection:三种 Agent 范式的原理与代码

一句话让Agent变成昇腾专家,昇腾任务轻松搞定。评测入口:请按这个开源仓接入昇腾图谱 https://gitcode.com/agent0/kg-tools

适用:刚开始接触 Agent,想把 ReAct、Plan-and-Solve、Reflection 这三套循环搞清楚

前置:能读懂 Python 的函数和字典,知道 LLM 的输入输出是什么;不需要 GPU,也不需要 API key

口径:示例在 Python 3.13 上运行;模型调用用占位函数替代,接真模型只需换掉这一个函数

一个 Agent 和一次普通问答的区别,只有一件事:它能不能自己去看一眼,再决定下一步。这件事落到代码里就是一个循环:模型说「我要查一下」,程序去查,把结果塞回去,模型接着说。循环转几圈,答案就出来了。所有 Agent 框架,无论包装得多复杂,中间都是这个循环。

ReAct、Plan-and-Solve、Reflection 是给这个循环加东西的三种位置。ReAct 加在每一步的动作上,Plan-and-Solve 加在执行之前的计划上,Reflection 加在一次任务结束之后的复盘上。三者解决的不是同一类问题,也不存在谁替代谁。

一、先看清共同点:Agent 就是一个循环

一次普通的模型调用是「输入进、输出出」,中间没有回头的机会。Agent 在中间插了一步:模型可以要求调用某个工具,拿到结果再继续。这个「再继续」就是循环。

用户问题
   ↓
[模型] ── 要调工具 ──▶ [工具 / 环境]
   ↑                          │
   └────── 结果写回上下文 ◀────┘
   │
   └── 不再调工具 ──▶ 输出答案

拆开看只有四件东西:一个模型、一组工具、一份不断变长的上下文、一个停下来的条件。第一版 Agent 出问题,基本都出在这四件里:工具的参数没校验、上下文被无关内容撑爆、终止条件写得太松导致死循环。

三种范式的差别,全在「往这四件上加什么」。

ReActPlan-and-SolveReflexion
循环体是什么一步动作整个任务一次尝试(trial)
多出来的是什么工具与环境一份先定好的计划一个判卷器和一份复盘
谁决定下一步模型看 observation 现定按计划表走上一轮的复盘

结论:三个范式的区别不在「高级程度」,而在循环套在哪一层。

二、ReAct:想一步、做一步、看一步

ReAct 要解决的问题很具体:模型不知道的事,让它自己去查,而不是硬猜。

2.1 设计原理

ReAct 把模型的输出分成两类,这两类对世界的影响完全不同。

一类是 thought,也就是推理过程。它只写进上下文,下一轮模型看得见,但外部世界什么都没发生,数据库没被查、文件没被改。另一类是 action,它会被程序真的执行,回来的 observation 由外部给出,不是模型编的。

这个区分是整个范式的支点。纯 CoT 的推理链再长,也只在参数里打转:某一步记错了,后面顺着错的往下推,推得还很流畅,从文本上看不出问题。加上 action 之后,每一步都能拿外部返回对一次账,错了当场就露出来。

2.2 工作流程

① 问题 + 历史 → 拼成 prompt
② 模型输出:Thought + Action
③ 执行 Action,得到 Observation
④ Observation 原样写回历史 ──┐
⑤ 回到 ②                    ←┘
   直到模型输出 Final Answer,或撞到步数上限

在这里插入图片描述

图里只有 Action 那一格会改变外部状态,Thought 是留给下一轮自己的字条。第 ③ 步和第 ④ 步之间有两个容易写错的地方。一是终止条件只认 Final Answer,不能认「看起来像答案」,否则模型在 observation 里复述一遍也会被当成结束。二是 observation 要原样回灌,一旦顺手总结,模型拿到的是我们的总结,不是证据。

2.3 代码示例

这段代码只保留循环本身。工具是两个 lambda,模型是一个按 observation 条数返回脚本的函数。

"""最小 ReAct 循环:Thought / Action / Observation,不依赖框架。"""
import re

KB = {"SKU-42": 19.9}
TOOLS = {"lookup": lambda s: f"{s} 单价 {KB[s]} 元",
         "calc": lambda e: str(round(eval(e, {"__builtins__": {}}, {}), 2))}  # eval 仅演示

def llm(prompt):
    """占位模型:按已有 Observation 条数取下一条,接真模型时只换这个函数。"""
    return ["Thought: 不知道单价,先查表。\nAction: lookup[SKU-42]",
            "Thought: 单价 19.9,再算 37 件九折。\nAction: calc[19.9 * 37 * 0.9]",
            "Thought: 数字齐了。\nFinal Answer: 662.67 元"][prompt.count("Observation:")]

PAT = re.compile(r"Thought:\s*(.+?)\s*Action:\s*(\w+)\[(.*?)\]", re.S)

def react(q, max_steps=6):
    pad = ""
    for _ in range(max_steps):
        out = llm(f"Question: {q}\n{pad}")
        if "Final Answer:" in out:
            return out.split("Final Answer:")[1].strip()
        m = PAT.search(out)
        if not m:
            return f"格式不合规:{out!r}"
        obs = TOOLS[m[2]](m[3])   # 循环里只有这一行改变外部状态
        step = f"Thought: {m[1]}\nAction: {m[2]}[{m[3]}]\nObservation: {obs}\n"
        print(step)
        pad += step
    return "达到步数上限,没有给出答案"
print("答案:", react("SKU-42 买 37 件打九折,一共多少钱?"))

跑出来是这样:

Thought: 不知道单价,先查表。
Action: lookup[SKU-42]
Observation: SKU-42 单价 19.9 元

Thought: 单价 19.9,再算 37 件九折。
Action: calc[19.9 * 37 * 0.9]
Observation: 662.67

答案: 662.67 元

这段代码有三处要留意。工具放在字典里,Action 的名字只能命中 TOOLS 的键,模型写错一个名字就落到解析失败分支,不会执行任意函数。max_steps 是兜底,真模型偶尔会一直查下去,没有这个上限就是死循环。解析那段最脆弱。真模型常在 Action 后面加解释、用中文括号,或者把两个动作挤在一行。工程上有两种处理:在 prompt 里把格式钉死,或者解析失败时把错误原文回灌让它重写。直接抛异常,整个循环会停在一步格式错误上。

2.4 适用场景

适合:信息在外部且工具够得着,比如检索、数据库、内部 API;任务状态会被动作改变,必须边做边看;出了问题要能追到是哪一步、哪个动作。

不适合:答案基本在参数里,查一次反而更慢;工具有副作用,比如下单、发消息、删数据,一步错就回不去;步数预算很紧,每一步都贵。

三、Plan-and-Solve:先把清单列出来

这个范式没有引入新的执行结构,它改的是提示词里的一句话。

3.1 设计原理

「Let’s think step by step」有个隐含假设:模型知道该走几步。这句话只要求「有过程」,没规定过程的形状,所以漏步来自这个提示词自身的缺陷,与模型聪不聪明无关。模型可以从第一步直接跳到结论,中间那步它觉得显然,但对题目来说那步才是关键。

Plan-and-Solve 的做法是把步骤集合从执行里提出来,先定型再走。第一步只让模型输出子任务清单,第二步按清单逐条执行。执行时每一步都能看到完整计划和前面步骤的结果,所以它知道自己在哪儿、还差什么。

Plan-and-Solve 还有一个加强版 PS+,追加了三条要求:把题目里的变量和数值抽出来、显式计算中间量、最后回头检查一遍计算。前两条治的是「算错」,第三条治的是「算完不验」。

3.2 工作流程

① 问题 → 让模型只做计划,输出 k 个子任务
② 拆出计划表(这一步是程序做的,不是模型)
③ 取第 i 步,连同完整计划 + 已完成结果拼成 prompt
④ 模型给出这一步的结果,记进「已完成」
⑤ i + 1,回到 ③,直到走完
⑥ 汇总最后一步的结果作为答案

在这里插入图片描述

图里的箭头是单向的,执行阶段不回头改计划。计划基于开工前的信息做出来,执行中拿到新信息也不改,代价就在这里。

3.3 代码示例

计划和执行拆成两次调用,执行阶段把完整计划和已完成步骤一起塞进 prompt。

"""Plan-and-Solve:计划和执行分两次调用,执行阶段不回头改计划。"""

PLAN = """1. 查出 SKU-42 的单价
2. 算 37 件的原价
3. 按九折算应付金额"""

STEPS = [("查出 SKU-42 的单价", "单价 19.9 元"),
         ("算 37 件的原价", "19.9 × 37 = 736.3"),
         ("按九折算应付金额", "736.3 × 0.9 = 662.67")]


def llm(prompt):
    """占位模型:按提示词里的步骤返回预设结果,真模型下换成一次 completion。"""
    if "先做计划" in prompt:
        return PLAN
    return next((r for k, r in STEPS if f"当前步骤:{k}" in prompt), "")


def plan_and_solve(q):
    plan = [l.split(".", 1)[1].strip() for l in llm(f"{q}\n先做计划").splitlines() if l.strip()]
    print("计划:", plan)
    done = []
    for step in plan:
        ctx = "\n".join(f"已完成:{d}" for d in done)   # 执行时要能看到完整计划和已完成的步骤
        result = llm(f"{q}\n计划:{plan}\n{ctx}\n当前步骤:{step}")
        done.append(f"{step} → {result}")
        print(f"[{step}] {result}")
    return done[-1].split("=")[-1].strip()

print("答案:", plan_and_solve("SKU-42 买 37 件打九折,一共多少钱?"))
计划: ['查出 SKU-42 的单价', '算 37 件的原价', '按九折算应付金额']
[查出 SKU-42 的单价] 单价 19.9 元
[算 37 件的原价] 19.9 × 37 = 736.3
[按九折算应付金额] 736.3 × 0.9 = 662.67
答案: 662.67

这里有个隐蔽的坑:执行阶段的 prompt 必须带上完整计划。只传当前步骤,模型不知道自己在第几步,第三步会把前两步重算一遍,等于做了三遍功。代码里 ctx 那一行就是干这个的。

3.4 适用场景

适合:步骤能事先列全,执行中基本不变;一次性任务,不值得为它攒 few-shot 样本;中间结果要给人看,计划本身就是一份可审的产物。

不适合:环境会中途变卦,计划基于旧信息,工具返回意外结果时它没有改计划的入口;一步能做完的任务硬拆成五步,只多花四次调用。

3.5 和 ReAct 的关系

两者是正交的,不冲突。ReAct 决定每一步怎么拿信息,Plan-and-Solve 决定步骤从哪来。先列计划、执行每一步时允许调工具,就是常见的 plan-and-execute 结构,LangGraph 的教程里有同名示例。

只上 Plan-and-Solve 不上工具,需要查证的信息只能靠模型编;只上 ReAct 不上计划,跑长了容易在局部打转。这两个短板正好是对方补的那一层。

四、Reflexion:把失败写成一句话

前两个范式比的是这一轮做得好不好,Reflexion 换了个问法:下一轮能不能比这一轮强。

4.1 设计原理

ReAct agent 有个常见的死法:每次重试都从同一个静态 prompt 出发,上次怎么错,这次还怎么错。它没有记忆,只有上下文,而上下文每轮都重置。

Reflexion 不动权重,动记忆。它由三部分组成:Actor 干活,Evaluator 判卷,Self-Reflection 写复盘。判卷器给一个分数或一个通过/不通过,Self-Reflection 把这个信号连同轨迹翻成一段自然语言批评,存进记忆,下一轮开头念一遍。

参数没动,动的是上下文。

4.2 工作流程

① Actor 按当前记忆执行任务,交出结果
② Evaluator 判卷:跑测试 / 编译 / 规则检查,给出通过与否
③ 通过 → 结束;不通过 → Self-Reflection 写一段批评
④ 批评写入记忆(跨轮累积)
⑤ 下一轮 trial,记忆前置进 prompt,回到 ①

在这里插入图片描述

三个角色里,判卷器的质量决定上限。它判不准,后面的复盘就是在放大噪声。

4.3 代码示例

判卷靠执行:exec 跑 Actor 交上来的函数,再逐条比对期望值。

"""Reflexion:Actor 执行、Evaluator 判卷、Self-Reflection 写复盘,复盘跨轮传递。"""

TESTS = [("add(2, 3)", 5), ("add(-1, 1)", 0), ("add(0, 0)", 0)]

def evaluate(code):
    ns = {}
    exec(code, ns)   # 演示用,生产环境必须放子进程或容器
    return [f"{e} 得到 {eval(e, ns)},期望 {v}" for e, v in TESTS if eval(e, ns) != v]

def actor(memory):
    """占位 Actor:没有记忆时交带 bug 的版本,读到复盘后交修复版。"""
    return "def add(a, b):\n    return a - b" if not memory else "def add(a, b):\n    return a + b"

def reflect(memory, fails):
    """占位 Self-Reflection:真模型下把代码、报错、旧记忆一起丢给模型。"""
    return f"第 {len(memory) + 1} 次失败:{fails[0]}。减法方向写反了,改成 a + b。"

def run(task, max_trials=3):
    memory = []
    for i in range(1, max_trials + 1):
        code = actor(memory)
        fails = evaluate(code)
        print(f"trial {i}:", "通过" if not fails else f"失败 {fails[0]}")
        if not fails:
            return code
        memory.append(reflect(memory, fails))
        print("  复盘写入记忆:", memory[-1])
print(run("实现 add(a, b)"))
trial 1: 失败 add(2, 3) 得到 -1,期望 5
  复盘写入记忆: 第 1 次失败:add(2, 3) 得到 -1,期望 5。减法方向写反了,改成 a + b。
trial 2: 通过
def add(a, b):
    return a + b

Actor 和 Reflection 是占位实现。evaluate 执行三条用例、逐条比对结果。生产环境里 exec 必须隔离,放子进程或容器,别在主进程执行模型生成的代码。

4.4 和 Self-Refine 的区别

这两个名字很像,机制差得远:

Self-RefineReflexion
谁给反馈同一个模型自评外部判卷器
改什么这一稿的答案下一轮的策略
记忆单轮内有效,结束即丢跨 trial 累积
能治什么文风、结构、遗漏点对错

Self-Refine 适合质量多维、容易用话说清的任务,比如把一封邮件改得更客气。它治不了对错,因为答题的和判卷的是同一个盲区。

4.5 适用场景与一条前提

适合:有确定性判卷器,比如单测、编译器、schema 校验、规则引擎;任务可重复执行,多跑几轮不出副作用;单轮失败的代价高于多跑几轮的成本。

这里有个硬前提:判卷器必须比答题的多知道一点。测试用例知道答案,编译器知道语法,规则引擎知道约束,第二个模型至少换了组参数。模型自己什么都没有。Huang 等人专门验证过这件事:没有外部反馈时让模型自查推理题,准确率不会涨,有时还会掉,因为被要求「挑错」的模型会幻觉出不存在的错误,把对的改成错的。

所以判卷器如果是同一个模型,这套结构不是在纠错,是在给错误找理由。这时候不如不加。

五、三个问题定选型

选型不需要背结论,按顺序问三个问题,前一个答不上来就别问下一个。

问题是否
答案是不是在模型外面?上 ReAct别上,纯成本
步骤能不能事先列全、执行中不变?上 Plan-and-Solve别上,计划会变负债
有没有一个不依赖模型的判卷器?上 Reflexion别上,会退化

能叠,但顺序不能反:先定信息通道,再定计划粒度,最后加反思。反过来先加反思,等于给一个信息不足的循环加一层自我安慰,成本上去了,准确率没动。

三个范式叠在一起看,包含关系就出来了:

一次 trial
├─ 规划    Plan:先列 k 步                        ← 没有反馈入口
├─ 执行    Step i:Thought → Action → Observation ← 工具 / 环境
│          (ReAct 在这一层循环,一直反复到给出答案)
└─ 校验    Evaluator 判卷 → Reflection 写复盘 → 写入记忆
                              ↓
                    下一轮 trial 的 prompt 前置这段复盘

常见的组合是 plan-and-execute:Plan 定骨架,执行每一步时允许调工具,把 ReAct 塞进 Plan 的每一步里。两个范式在这里套在一起用。

六、小结

  • 三个范式是同一个循环的三处加料:ReAct 加在一步的动作上,Plan-and-Solve 加在执行前的计划上,Reflexion 加在一次尝试之后的复盘上。
  • 每个都有一条前提。ReAct 的前提是信息在外部且拿得到,Plan-and-Solve 的前提是步骤能事先定,Reflexion 的前提是有一个不依赖模型的判卷器。前提不成立时加上去只是成本。
  • 代码里最容易错的三处:ReAct 的解析与终止条件,Plan-and-Solve 的执行上下文,Reflexion 的 exec 隔离。

练习

三道题由浅到深:

  1. 把 ReAct 示例里的 KB 换成一个真实的 HTTP 查询(比如查天气),跑一遍,记录循环走了几步、哪一步的 observation 最长。
  2. 给 Plan-and-Solve 示例加一个分支:某一步返回空结果时重新做一次计划。对比加上前后,调用次数和最终答案分别有什么变化。
  3. 给 Reflexion 示例换一个判卷器,把单测换成「检查函数名和参数个数是否正确」,观察复盘内容会变成什么样,还能不能修好这个 bug。

参考

Logo

鲲鹏昇腾开发者社区是面向全社会开放的“联接全球计算开发者,聚合华为+生态”的社区,内容涵盖鲲鹏、昇腾资源,帮助开发者快速获取所需的知识、经验、软件、工具、算力,支撑开发者易学、好用、成功,成为核心开发者。

更多推荐