Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

7 规划:ReAct、Plan-Execute、树搜索、反思

本章回答

「计划」与「执行轨迹」差在哪一步?ReAct 的思考—行动—观察为什么在钥匙与门这类任务上会卡死?Plan-Execute 的计划该用列表还是 DAG,什么时候必须重规划?ToT、MCTS、beam 的价值函数从哪里来,UCB 的探索系数在调什么?反思什么时候真的有用,什么时候只是把同一个偏见写得更长?验证器弱的时候,为什么搜得越多反而越糟?

读完你会

写出规划问题的状态、动作、目标、代价、可行性与可逆性;画出带依赖的计划 DAG 并标出重规划边界;写出 UCB 公式并解释探索系数;为反思与验证器各设计一组门禁;跑通三种策略在同一批任务上的确定性对比,并从 trace 里读出失败根因。

第 6 章把「证据从哪里来」讲完了:检索、引用、遗忘。现在 agent 手里有一堆证据,下一步要决定先做什么、后做什么、哪些可以并行、哪些做了就回不去。这就是规划。它不是在提示词里加一句「请先制定计划」,而是在状态空间里用有限预算选择动作序列,并对选择的结果负责。

本章的论点只有一句:规划策略的差别不在「想得久不久」,而在搜索结构与验证器。ReAct 用环境反馈替代全局搜索,Plan-Execute 用一次规划成本换全局一致性,树搜索用展开预算换候选质量,反思用上一轮的证据换下一轮的策略。四者都受同一个预算约束;脱离预算谈「哪种策略更强」没有意义。

先把边界划清。本章不重写强化学习或搜索教科书:MDP、Bellman 方程、A* 的最优性证明都属于《深度学习导论》与算法课的领土,这里只引用它们作为背景。本章关心的是 agent 系统里多出来的那一层:计划如何表示、执行如何偏离、偏离后如何重规划、验证器如何决定搜索是否值得。实验在 experiments/ch07/,纯标准库、确定性、无网络;它不模拟 LLM,而是把三种策略的搜索结构钉死,让预算与失败模式可复现。

ReAct、Plan-Execute 与树搜索的结构对比 三块不是难度阶梯,而是三种搜索结构。ReAct 每一步只看局部,适合反馈快、步数少的任务;Plan-Execute 先付一笔规划成本换全局一致性,适合步骤可预判、可并行的任务;树搜索把预算花在展开候选上,只有在价值函数能区分好坏时才划算。没有验证器时,树搜索往往只是把同一个错误复制成四份。 三种规划:交错、先计划后执行、搜索 ReAct Thought 下一步做什么 Action 一次工具调用 Observation 结果 / 错误 局部 reactive;无全局计划 Plan-Execute Planner 输出步骤 DAG S1 可重试 S2 可重试 S3 可重试 Replan 失败/新信息触发 全局计划;重规划成本高 Tree search ✓ ✓ 展开 + 评估 + 回传价值 预算换质量;需要价值函数 选择依据:任务可分解性、验证器强度、单步成本、失败可逆性——不是「哪个更高级」
读图:三块不是难度阶梯,而是三种搜索结构。ReAct 每一步只看局部,适合反馈快、步数少的任务;Plan-Execute 先付一笔规划成本换全局一致性,适合步骤可预判、可并行的任务;树搜索把预算花在展开候选上,只有在价值函数能区分好坏时才划算。没有验证器时,树搜索往往只是把同一个错误复制成四份。

读图:三块横向并排的不是「难度阶梯」,而是三种预算分配方式。左侧 ReAct 的箭头回到 Thought,表示每一步都重新决策;中间 Plan-Execute 在 Planner 之后有扇出与 Replan 回路,表示计划成本与重规划成本是两项独立开销;右侧树搜索的节点分叉最多,但只有带 ✓ 的叶子被验证器确认——没有 ✓ 的分支再多也只是候选。把这张图和 7.8 的表对照:ReAct 在 key-door 缺的是跨步的全局边,Plan-Execute 在 one-way-return 输在 Planner 的模型与执行器不一致,ToT 在 long-corridor 输在分叉太多、预算不够。

7.1 规划问题:状态、动作、目标与代价

先看一个足够小的事故。任务是把一个只读副本切成主库,agent 生成的计划是:

1. pg.check_replication(primary)
2. pg.promote(replica-2)
3. pg.update_dsn(app-config, replica-2)
4. app.restart(api)
5. smoke.test(/healthz)

第 1 步成功,第 2 步返回 ENOTOOL:工具注册表里只有 pg.failover,没有 pg.promote。agent 把错误当成瞬时失败,重试两次;随后「重规划」出 pg.promote_primary,仍然不存在。第 4 步的 app.restart 有权限,于是 API 被重启,指向一个从未提升的副本。最终状态比开始更糟:主库没切,服务先抖了一次。

这个事故里没有一步是「模型算错了」。计划在纸上完全合理,问题在于计划引用的动作在当前系统里不存在,而执行器没有在动手前校验整条计划。要讲清这类失败,先要把规划问题写成可检查的对象。

定义 7.1.1 · 规划问题

一个规划问题可以写成八元组

P=(S, s0, A, pre, eff, G, c, B) \mathcal{P} = (S,\ s_0,\ A,\ \mathrm{pre},\ \mathrm{eff},\ G,\ c,\ B)

其中:

  • SS 是状态空间,s0∈Ss_0 \in S 是初始状态;
  • AA 是接地动作(grounded action)的集合,即「工具名 + 具体参数」,不是工具名本身;
  • pre:S×A→{0,1}\mathrm{pre}: S \times A \to \{0,1\} 是前置条件,pre(s,a)=1\mathrm{pre}(s,a)=1 表示 aa 在 ss 下可执行;
  • eff:S×A→S\mathrm{eff}: S \times A \to S 是确定性效果函数;随机环境下把值域换成状态分布 Δ(S)\Delta(S),本章其余部分默认确定性;
  • G⊆SG \subseteq S 是目标集合,必须能用一个可执行的测试判定,而不是一句自然语言;
  • c:S×A→R≥0c: S \times A \to \mathbb{R}_{\ge 0} 是动作代价,至少包含时间、金额、配额三类中的一类;
  • B=(Bplan,Bexec,Bverify)B=(B_{\text{plan}},B_{\text{exec}},B_{\text{verify}}) 是规划、执行、验证三段的预算。每一段内部仍可用第 1 章的五分量(步数、工具、墙钟、token、金额)表示;这里按用途分组,避免公式过长。

一个动作序列 π=(a0,…,ak−1)\pi=(a_0,\ldots,a_{k-1}) 可行,当且仅当存在状态序列 s0,…,sks_0,\ldots,s_k 使得对每个 ii 都有

pre(si,ai)=1,si+1=eff(si,ai),sk∈G. \mathrm{pre}(s_i,a_i)=1,\qquad s_{i+1}=\mathrm{eff}(s_i,a_i),\qquad s_k \in G .

可行性是相对于模型的:同一个计划在规划器的 eff\mathrm{eff} 下可行,在真实执行器 EE 下未必可行。这个缝隙就是 7.7 节「计划幻觉」的入口。

这个定义里最容易被省略的是 AA 的「接地」。模型说「提升副本」不是动作;pg.failover(replica-2, force=false) 才是动作。接地之后,参数、前置条件、权限、幂等键才有地方挂。很多计划幻觉的根因不是模型不知道「提升副本」这件事,而是规划器把自然语言动词当成了可执行动作,没有把它映射到注册表里的具体条目。

第二个容易被省略的是 GG。目标必须可判定:replication_lag < 1s 可判定,系统稳定 不可判定。目标不可判定时,执行器无法知道何时停,规划器也无法验证计划是否覆盖目标;最后只能由模型说「完成了」,这等于把终止条件交给了被验证的对象。

定义 7.1.2 · 计划与执行轨迹

计划是执行之前的意图结构。最弱的形式是一个全序动作序列

π=(a0,a1,…,ak−1), \pi=(a_0,a_1,\ldots,a_{k-1}),

更强的形式是一个偏序或 DAG,见 定义 7.3.1。计划包含:动作、参数、依赖、前置条件、预期效果、代价、可逆性。

执行轨迹是执行之后留下的证据序列

τ=(s0,a0,o0,s1,a1,o1,…,sT), \tau=(s_0,a_0,o_0,s_1,a_1,o_1,\ldots,s_T),

其中 oto_t 是环境返回的观察,st+1s_{t+1} 是执行后的真实状态(或它的可检查摘要)。轨迹只追加、不修改;它是审计与回放的依据。

计划回答「打算怎么做」,轨迹回答「实际做了什么」。计划可以改,轨迹不能改;计划正确性是相对于模型的,轨迹正确性是相对于日志的。

这两个对象经常被混为一谈,后果很具体:用计划当轨迹,会把「打算执行」当成「已经执行」,于是模型说「已重启服务」而日志里没有这次调用;用轨迹当计划,会在下一次运行时重放上一次的具体动作,忽略世界已经变化。表 7.1 把差别压成五行。

维度计划执行轨迹
产生者规划器 / 策略执行器 / 环境
内容动作、参数、依赖、预期效果动作、观察、真实状态、错误码
正确性相对于规划模型 eff\mathrm{eff}相对于执行日志与外部事实
可变性可重规划、可局部替换只追加,不可改写
失败信号前置条件不成立、目标未覆盖动作失败、状态偏离、超时

这张表说明什么:计划和轨迹不是同一份数据的不同格式,而是两种不同的证据。排障时先看轨迹,判断「实际发生了什么」;再对照计划,判断「原本打算做什么」。只看其中一份,都会把「计划错了」和「执行错了」混为一类。

7.1.1 首次偏离之后,计划不再有保证

计划执行最危险的不是第一步就失败,而是前几步成功、第 jj 步悄悄偏离,后面的动作仍然「看起来在执行」。例如计划认为门已经打开,实际门还锁着;move 返回失败,但策略把失败当成「已经通过」,继续执行后续的 inspect。这时轨迹已经与计划分叉,后续动作的前提全部失效。

定理 7.1.1 · 首次偏离之后,计划不再有保证

设计划 π=(a0,…,ak−1)\pi=(a_0,\ldots,a_{k-1}) 在模型 eff\mathrm{eff} 下可行,执行轨迹为 s0,a0,o0,s1,…s_0,a_0,o_0,s_1,\ldots。若存在最小的 jj 使得

sj+1≠eff(sj,aj)或pre(sj,aj)=0, s_{j+1} \ne \mathrm{eff}(s_j,a_j) \quad\text{或}\quad \mathrm{pre}(s_j,a_j)=0,

则从 sj+1s_{j+1} 开始继续执行 aj+1,…,ak−1a_{j+1},\ldots,a_{k-1},在原计划的意义上不再保证到达 GG;必须从 sj+1s_{j+1} 重新检查前置条件,或重规划。

证明

计划可行性的证明是一个前缀归纳:对每个 ii,由 pre(si,ai)=1\mathrm{pre}(s_i,a_i)=1 与 si+1=eff(si,ai)s_{i+1}=\mathrm{eff}(s_i,a_i) 推出下一状态,最终推出 sk∈Gs_k \in G。归纳在第 jj 步失效:要么 aja_j 的前提不成立,要么执行结果不等于模型预测,归纳假设无法继续传递。构造反例:计划假设门已开,实际门锁着,后续 move 的 sj+1s_{j+1} 仍停在门外;此时再执行「进门后的检查」只会在错误状态上产生新的观察。因此原计划的后缀不构成从 sj+1s_{j+1} 出发的可行计划。∎

∎

这个定理的工程含义不是「计划没用」,而是计划必须带检查点。每执行一步,至少比较三样东西:动作是否成功、真实状态是否等于预期状态、关键前置条件是否仍成立。三者任一不成立,就把计划标记为「已偏离」,而不是继续往下走。

计划是地图,轨迹是车辙

地图告诉你「从 A 到 B 应该走哪条路」,车辙告诉你「车实际开到了哪里」。地图画错、道路施工、司机拐错弯,都会让车辙偏离地图。成熟的做法不是把地图扔掉,也不是假装车辙就是地图,而是每到一个路口核对一次:我还在计划的路上吗?不在就重新规划。

7.1.2 代价:规划、执行、重规划、验证

规划不是免费的。它消耗模型 token、搜索节点、工具 dry-run 的时间和人的审批。一个完整的代价模型至少有四项:

E[C]=Cplan+E[Cexec]+pdev Creplan+Cverify, \mathbb{E}[C] = C_{\text{plan}} + \mathbb{E}[C_{\text{exec}}] + p_{\text{dev}} , C_{\text{replan}} + C_{\text{verify}},

其中 pdevp_{\text{dev}} 是执行偏离计划的概率。这个式子解释了一个常见困惑:为什么「计划更优」不等于「总成本更低」。规划器找到一条短 2 步的路径,但为此扩展了 50 个节点、多做了一次 dry-run,总成本可能反而更高。第 7.8 节的 detour 任务就是这种情形:Plan-Execute 的动作数比 ReAct 少 2,但规划扩展把总成本拉回同一条线。

四项成本里,验证最容易被漏算。验证包括:执行前的计划校验(工具是否存在、参数是否合法、前置条件是否成立)、执行中的断言、执行后的测试与人工复核。验证越强,pdevp_{\text{dev}} 越低,但 CverifyC_{\text{verify}} 越高;这是一条需要显式选择的曲线,不是「越严格越好」。

成本项何时发生计量单位常见错误
CplanC_{\text{plan}}生成计划、展开搜索节点数、token、墙钟只算 token,不算搜索与 dry-run
CexecC_{\text{exec}}真正执行动作步数、金额、配额、延迟把失败重试排除在成本外
CreplanC_{\text{replan}}偏离后重新规划次数 × 单次规划成本每次全量重规划,代价随步数平方增长
CverifyC_{\text{verify}}校验、测试、审批测试时长、人工分钟默认验证免费,直到事故账单出现

这张表说明什么:四项成本必须一起进预算。只压 CplanC_{\text{plan}} 会让计划质量下降、pdevp_{\text{dev}} 上升;只压 CexecC_{\text{exec}} 会让 agent 少做事、把未完成包装成完成;只压 CverifyC_{\text{verify}} 会把风险推到生产。预算不是四个独立的数字,而是一组需要联合调整的杠杆。

7.1.3 可行性、可逆性与「能不能试」

规划器面对的另一个现实是:有些动作可以随便试,有些试一次就回不去。可逆性可以写成一个存在性条件:

rev(s,a)=1  ⟺  ∃σ∈A∗: eff∗(eff(s,a),σ)=s ∧ C(σ)≤R, \mathrm{rev}(s,a)=1 \iff \exists \sigma \in A^{*}:\ \mathrm{eff}^{*}(\mathrm{eff}(s,a),\sigma)=s \ \land\ C(\sigma) \le R,

其中 RR 是可接受的恢复成本上界。这个定义故意把「能恢复」和「恢复得起」分开:发一封邮件在技术上可以再发一封「请忽略上一封」,但恢复成本、信誉损失和时间窗口可能都不可接受,工程上应当按不可逆处理。

可逆性直接决定搜索策略。可逆动作可以用试错换信息:先做,失败再退回来。不可逆动作必须先把验证做在前面:dry-run、权限检查、人工审批、灰度。树搜索在不可逆动作上尤其危险,因为它会展开多条分支,而每条分支都可能产生真实副作用;如果搜索用的是真实执行而不是模拟,展开就是在拿生产环境做实验。

动作类型例子搜索时能否真实展开需要的验证
只读、幂等读文件、查订单、搜索可以结果新鲜度、权限
可逆写改工作区文件、建临时分支可以,但要能回滚备份、差异检查
有外部可见副作用发邮件、发评论、调用第三方默认不展开,先 dry-run幂等键、人工确认
不可逆退款、删库、切换主库禁止真实展开双人审批、预演、回滚预案

这张表说明什么:搜索的「宽度」不只是算力问题,也是副作用问题。一个宽度为 8 的树搜索,在只读任务上是 8 次探索;在不可逆任务上可能是 8 次事故。规划器必须知道每个动作的可逆性,才能决定哪些分支可以真实展开、哪些只能模拟。

关于规划的五个常见误解,可以压成一张表。

误解为什么错正确做法
计划就是更长的思维链思维链是文本,计划是带前置条件、效果与依赖的动作结构计划要能被执行器校验;思维链不能
计划越细越好细到每个参数,规划成本高、重规划范围大计划到「动作 + 依赖」,参数执行时接地
计划一旦生成就不该改世界会变、观察会来、预算会耗带检查点;偏离即失效或重规划
可行性等于正确性可行只说明相对模型的前置条件成立用执行器/验证器确认效果,不把模型当世界
不可逆动作小心一点就行小心不是工程控制预演、审批、幂等键、回滚预案

这张表说明什么:五个误解里多数来自同一个混淆——把「计划文本」当成了「执行保证」。计划只是意图,保证来自检查点、验证器与权限边界。

7.1.4 四个建模选择

同一个任务可以建成不同粒度的规划问题。建模选择错了,后面换什么搜索算法都救不回来。四个选择最关键:

选择过粗的后果过细的后果实用边界
状态里放什么看不见关键前置条件,计划在错误状态上成立状态爆炸,搜索节点重复且不可比较只放「影响动作可行性/目标判定」的字段
动作粒度一个动作包含多个不可分割的副作用,失败无法局部重试动作太多,计划冗长,搜索分支爆炸按「权限边界 + 可逆性」切
目标怎么判定模型说完成,执行器无法验收目标写成实现细节,任务稍变就失效可执行断言 + 明确的验收范围
模型从哪里来规划器与执行器不一致,计划幻觉模型维护成本高,更新滞后单一事实来源;版本化;执行前校验

这张表说明什么:规划问题的质量在建模阶段就决定了大半。状态太粗,计划会在错误的前提上「可行」;动作太细,搜索会被无关分支淹没;目标不可判定,终止条件就回到模型嘴里;模型与执行器不同源,计划幻觉就是结构性的,不是偶发的。

把 7.1.2 的代价公式代入一个具体算例,可以看出规划什么时候划算。设一次扩展折合 0.2 个动作,规划扩展 120 个节点,计划执行 10 步,验证 6 个动作当量,偏离概率 0.2,一次重规划 30 个动作当量:

CPE=0.2×120+10+6+0.2×30=46. C_{\text{PE}} = 0.2 \times 120 + 10 + 6 + 0.2 \times 30 = 46.

同样任务用 ReAct,执行 15 步,重试概率 0.3,期望成本约 15+0.3×15=19.515 + 0.3 \times 15 = 19.5。在这个规模上,Plan-Execute 的规划开销收不回来。把任务放大到 50 步:Plan-Execute 约 24+50+6+6=8624 + 50 + 6 + 6 = 86,ReAct 约 50+0.3×50=6550 + 0.3 \times 50 = 65;如果 ReAct 的重试概率随步数上升到 0.6,期望成本变成 80,两者才接近。规划的价值随任务长度与偏离代价增长,不是天然存在的。

7.2 ReAct:交错推理与行动

ReAct 的结构可以用一句话概括:让模型在每一步先写一句「我准备做什么、为什么」,再调用一个工具,把工具结果放回上下文,然后进入下一步。Thought 是自然语言,Action 是工具调用,Observation 是工具结果;三者交错,形成一条既包含推理又包含证据的轨迹。

Thought: 门在右边,钥匙可能在左边;先看看当前位置有什么。
Action:  look()
Observation: room=hall, exits=[north, east], items=[]
Thought: 东边是锁着的门,先去北边找钥匙。
Action:  move(north)
Observation: room=storage, items=[key]
Thought: 拿到钥匙,回大厅开门。
Action:  take(key)
Observation: inventory=[key]

这段轨迹里,Thought 不是装饰。它把「为什么选这个动作」写进了上下文,让下一步的模型能看到自己的意图,也让人类排障时能区分「动作错了」与「意图错了」。ReAct 的原始论文(Yao et al., 2022)在 HotpotQA、FEVER、ALFWorld、WebShop 上报告了推理与行动交错的收益;核心机制不是「思考」本身,而是观察会改变下一步的推理。

context ← task + tool_schemas
while not done and budget_left:
    thought ← model(context)            # 自然语言,可能错
    action  ← parse(thought)            # 工具名 + 参数
    if action == finish: break
    obs     ← execute(action)           # 真实执行,返回结构化结果
    context ← context + (thought, action, obs)

这段伪代码有三个关键点。第一,model(context) 的输入包含全部历史观察,所以上下文会随步数增长;第 5 章讲的压缩与裁剪在这里必须介入。第二,parse(thought) 可能失败:模型可能写出不存在的工具、参数不是合法 JSON、或者同时给出多个动作;解析失败必须变成一条观察,而不是抛穿循环。第三,execute(action) 是唯一产生副作用的地方;Thought 再长也不改变世界。

ReAct 的优点很直接:

  • 反馈快。 不需要先建完整模型,执行一步就能拿到真实观察;环境是唯一的事实来源。
  • 实现简单。 一个循环、一个工具注册表、一个解析器,就能跑起来;没有单独的规划器与计划表示。
  • 适合部分可观测。 计划器不知道的信息,ReAct 可以通过下一步观察获得;它不需要事先枚举所有分支。
  • 轨迹可读。 Thought/Action/Observation 三段式让排障者能沿着因果链读下去。

但它的失效条件同样明确。表 7.2 把最常见的五类列出来,每一类都能在 trace 里找到对应信号。

失效条件机制trace 信号对策
局部贪心每步只优化即时距离/分数,没有子目标反复靠近某个障碍,不改变策略加子目标、进展度量、重规划
无全局约束不知道「先钥匙后门」这类顺序在门前反复尝试,take 很晚才出现计划 DAG、前置条件检查
观察噪声错误/过期/对抗性观察被当成事实下一步推理基于错误字段观察校验、来源标注、重试
重复动作没有进展检测,动作与观察指纹不变同一动作连续出现,状态不推进指纹窗口、预算、熔断
上下文漂移长轨迹把早期约束挤出窗口后期动作违反早期约束约束不可压缩、结构化状态

这张表说明什么:ReAct 的失败很少是「模型不会推理」,多数是「策略没有被赋予全局结构」。换一个更强的模型,能让局部贪心更聪明,但不能凭空产生「先拿钥匙」这个子目标;子目标要么来自计划,要么来自被显式写进策略的规则。

第 7.8 节的 key-door 任务把第一、二类失效钉死了。钥匙在起点反方向,门在目标方向;按到目标的曼哈顿距离贪心,在到达钥匙之前,每一步都倾向于靠近门,不会主动退回去拿钥匙。实际 trace 的前八步是:

python3 experiments/ch07/planners.py --task key-door --strategy react --trace /tmp/ch07-react.jsonl
jq -r '[.step, .action, .observation, .value] | @tsv' /tmp/ch07-react.jsonl | head -n 8
1	E	moved	-6
2	E	moved	-5
3	S	moved	-4
4	S	moved	-3
5	W	moved	-4
6	N	moved	-5
7	W	moved	-6
8	S	moved	-5

价值从 −6-6 走到 −3-3,看起来在接近目标;然后又开始在 [−4,−6][-4,-6] 之间摆动。直到第 22 步,它才偶然经过钥匙(take),第 36 步才打开门(open),第 40 步停在 (4,5)——离目标 (5,5) 只差一步,预算耗尽。take 与 open 各只发生一次,都不是计划出来的,是逛到的。这不是「模型不够聪明」,而是在它到达钥匙之前,策略的目标函数里根本没有「钥匙」这一项。给它加一条「门锁着且没有钥匙时,先去找钥匙」的规则,它通常就能成功;但这条规则本质上是一个两级计划,ReAct 已经不再是纯反应式策略。

ReAct 是只看车灯的司机

在雾里开车,只看车灯(局部观察)比闭眼背地图(全局计划)更安全;但如果目的地在一道锁着的门后面,车灯再亮也照不出「先去拿钥匙」这一步。ReAct 适合反馈频繁、动作可逆、视野内的下一步就足够好的任务;不适合需要跨多个房间取钥匙再开门的任务。

关于 ReAct 的四个误解
  1. 「ReAct 不需要计划」——它不需要显式的全局计划,但仍然有一个隐式策略;策略的局部性就是它的上限。任务需要顺序约束时,必须显式补上子目标。
  2. 「Thought 会被执行」——只有 Action 进入执行器。Thought 是上下文的一部分,可能错误、可能不忠实;把 Thought 当承诺是排障时常见的误判。
  3. 「Observation 是事实」——观察可能过期、被截断、被注入,甚至来自另一个任务。观察要有来源、时间戳与校验;不可信观察不能直接当指令。
  4. 「步数越多,成功率越高」——步数增加会放大上下文漂移、成本与副作用面。没有进展度量的「再试一次」,只是把失败拖长。

ReAct 适合的任务可以压成三个条件:单步反馈能改变下一步决策、动作可逆或代价低、任务不需要长距离的顺序约束。三个条件有一个不成立,就应该考虑 Plan-Execute 或树搜索。反过来,如果任务的环境模型根本不可信(新 API、动态网页、开放世界),Plan-Execute 的计划很容易幻觉,ReAct 的逐步试探反而更稳。

7.2.1 ReAct、思维链与 Plan-Execute 的边界

三者经常被混为一谈,因为它们的输出都是文本。差别在文本是否被外部观察约束、是否有显式计划、反馈以什么粒度进入下一步。

结构外部观察显式计划反馈粒度典型失效
思维链(CoT)没有没有无,单次生成推理看起来合理,事实无校验
ReAct每步都有隐式、局部一步局部贪心、无全局约束
Plan-Execute计划时少量,执行时检查点显式 DAG/列表一个节点或一段子图计划模型与执行器不一致
树搜索 + 验证器展开时可模拟显式搜索树一个候选价值函数偏差、预算耗尽

这张表说明什么:CoT 不是 agent,因为它没有外部观察与动作闭环(第 1 章的三个判据);ReAct 是 agent,但它的「计划」只存在于策略的隐式偏好里;Plan-Execute 把计划显式化,代价是要维护计划模型;树搜索把「多个计划」显式化,代价是要有能区分候选的验证器。四者不是替代关系,而是同一控制回路里「计划显式程度」与「验证器强度」的不同组合。

7.3 Plan-Execute:计划表示、依赖与重规划

Plan-Execute 把决策分成两段:先由规划器生成一个计划,再由执行器按计划执行;执行偏离时触发重规划。它用一次规划成本换全局一致性,适合步骤可预判、依赖可表达、动作代价高的任务。关键不在「先计划」这个动作,而在计划用什么结构表示。

最弱的结构是列表:[a1, a2, a3, ...]。列表只能表达全序,不能表达并行,也不能表达「a2 和 a3 都依赖 a1,但彼此独立」。执行时任何一步失败,通常只能整条重来。它实现简单,适合线性流程,但在有扇出/扇入的任务上会把可并行的步骤串行化,也会让重规划范围过大。

更强的结构是 DAG:节点是接地动作,边是依赖。一个「修复回归」的计划可以画成下面这样。

                    ┌──────────────┐
                    │  reproduce   │  复现失败
                    └──────┬───────┘
                           ▼
                    ┌──────────────┐
                    │   locate     │  定位根因
                    └───┬──────┬───┘
                        ▼      ▼
                 ┌──────────┐ ┌────────────┐
                 │  patch   │ │  add_test  │  两条支路可并行
                 └────┬─────┘ └─────┬──────┘
                      ▼             ▼
                 ┌───────────────────────┐
                 │      unit_suite       │  扇入:两支都完成才跑
                 └──────────┬────────────┘
                            ▼
                 ┌───────────────────────┐
                 │      full_suite       │  全量回归
                 └──────────┬────────────┘
                            ▼
                 ┌───────────────────────┐
                 │        review         │  人工/独立 critic
                 └───────────────────────┘

读图:从 reproduce 到 review 的最长路径是 reproduce → locate → patch → unit_suite → full_suite → review,它决定最短完成时间;patch 与 add_test 之间没有边,可以并行。重规划时,只有失败节点的后代需要失效:如果 patch 失败,add_test、reproduce、locate 都不必重做;如果 locate 的结论被推翻,整棵下游树都要重来。这张图还标出了一个验证边界:review 之前的所有节点都可以自动执行,review 是一个人工/独立 critic 的汇合点,不能由生成计划的同一个模型自己批准。

定义 7.3.1 · 计划 DAG

一个计划 DAG 是

G=(V, E, pre, eff, c, rev), \mathcal{G}=(V,\ E,\ \mathrm{pre},\ \mathrm{eff},\ c,\ \mathrm{rev}),

其中 V={v1,…,vn}V=\{v_1,\ldots,v_n\} 是接地动作实例,E⊆V×VE \subseteq V \times V 是依赖边:(u,v)∈E(u,v) \in E 表示 vv 必须在 uu 成功之后才能开始。每个节点带前置条件 prev\mathrm{pre}_v、预期效果 effv\mathrm{eff}_v、代价 cvc_v 与可逆性 revv\mathrm{rev}_v。

一个调度是 VV 的拓扑序;若同一层有多个节点,它们可以并行执行。计划有效,当且仅当对每个合法的拓扑序,每个节点执行时其前置条件都成立;如果两个并行节点会互相干扰(写同一文件、抢同一配额),必须加一条边把它们串行化,或者拆成两个资源域。

DAG 的代价不只是节点代价之和。并行会带来协调成本:两个分支同时写同一个文件会冲突;同时调用同一个限流 API 会互相挤占配额;一个分支失败时,另一个分支可能已经产生了需要回滚的副作用。工程上要给每个节点标「资源域」(文件、数据库、外部 API、预算桶),同一资源域内的写操作默认串行;只有读操作或明确声明无冲突的写操作才允许并行。

计划的结构定了,接下来要定什么时候重规划。重规划太晚,会沿着错误计划产生更多副作用;太早,会把本可局部修复的问题升级为全局重来。表 7.3 给出六类触发条件与对应的处理范围。

触发条件典型信号处理范围不该做什么
前置条件失败ENOENT、EPERM、文件不存在替换该节点或它的上游把权限错误当瞬时错误重试
观察与模型不符状态哈希变化、返回值超出预期失效该节点的后代继续执行依赖旧状态的节点
新信息到达搜索结果、用户新约束、监控告警重新评估目标与约束忽略新约束,继续旧计划
预算低于保留线剩余步数低于验证预留降级为「少探索、多确认」借预算继续搜索
连续无进展动作/观察指纹重复局部重规划或终止只增加重试次数
验证失败测试红、类型错、critic 否决回到最近的可疑节点让生成者自己批准

这张表说明什么:重规划不是「重新生成整条计划」的同义词。大多数偏离只需要替换一个节点或一段子图;全量重规划的成本高,而且会把已经验证过的前缀丢掉。重规划的范围应该由依赖边决定,而不是由模型的方便程度决定。

重规划的成本可以写成

CPE=Cplan+∑v∈Vcv+∑r∈RCreplan(r)+Cverify, C_{\text{PE}} = C_{\text{plan}} + \sum_{v \in V} c_v + \sum_{r \in R} C_{\text{replan}}(r) + C_{\text{verify}},

其中 RR 是实际发生的重规划集合。若执行环境稳定、计划准确,∣R∣|R| 很小,Plan-Execute 的总成本接近「一次规划 + 一次执行」;若环境频繁变化,∣R∣|R| 增大,规划成本会被重复支付,这时 ReAct 的逐步适应反而更便宜。这个成本式没有统一的阈值,它取决于任务的偏离率、单次规划成本与单次执行成本。

7.3.1 一个计划节点应该带什么

计划节点不是「工具名 + 参数」这么简单。一个能被执行器校验的节点至少带八个字段:

{
  "id": "s3",
  "action": "tests.run",
  "args": {"suite": "unit", "timeout_s": 120},
  "deps": ["s1", "s2"],
  "pre": ["worktree_clean == false", "s1.status == 'ok'", "s2.status == 'ok'"],
  "post": ["junit.xml exists", "exit_code == 0"],
  "cost": {"wall_s": 120, "quota": "ci:1"},
  "reversible": true
}

这八个字段里,pre 与 post 是执行器真正用来判断「能不能开始」「有没有成功」的;deps 决定重规划范围;reversible 决定能不能在失败时自动重试;cost 决定预算扣减。只给 action 与 args 的计划,执行器只能「照着调用」,无法在调用前发现计划已经失效。

计划生成后、执行前,至少要过五道校验:

  1. 能力校验。 每个 action 都存在于当前工具注册表;参数符合 schema;没有引用不存在的工具或版本。
  2. 前置条件校验。 每个节点的 pre 能在当前状态上求值;不能求值的条件视为不成立。
  3. 资源校验。 并行分支不争抢同一资源域;总配额不超过预算。
  4. 可逆性校验。 不可逆节点必须有审批或 dry-run;不能与自动重试共存。
  5. 目标覆盖校验。 计划的后置条件能推出目标 GG;不能推出时,计划再漂亮也不完整。

五道校验里,第 1 道最便宜,也最能挡住计划幻觉;第 5 道最难,需要把目标写成可判定的断言。工程上常见的偷懒是只做第 1 道,然后在第 3、4 道翻车:工具都存在,参数都合法,但两个分支同时写同一个文件,或者一个不可逆动作被自动重试了三次。

7.3.2 什么时候 Plan-Execute 优于 ReAct

把两节的失效条件放在一起,可以得到一张选择表。

任务特征更适合 ReAct更适合 Plan-Execute
视野部分可观测,下一步才有信息步骤可预判,依赖可列举
步数短(3–10 步)长(10 步以上),有扇出/扇入
动作代价低、可逆、可试错高、不可逆、需要预演
环境变化快,计划很快过期慢,计划能稳定执行
全局约束少,局部最优可接受多,顺序/配额/权限必须全局满足
验证器每步都能验证通常只在计划层或终点验证

这张表说明什么:Plan-Execute 的优势来自全局约束与不可逆动作,不是来自「计划」这个词。如果任务没有全局约束、动作都可逆、反馈又很快,先规划一遍只是多花一笔钱;反过来,如果任务有「先备份再迁移」「先拿钥匙再开门」这类硬顺序,ReAct 的局部贪心很容易在门前打转。

一个实用的折中是分层计划:高层用 DAG 表达子目标与依赖,低层每个子目标交给 ReAct 执行。高层保证「先备份后迁移」的顺序,低层保留逐步试探的灵活性。代价是要维护两层之间的契约:低层完成时,必须把「哪些后置条件已经成立」写回高层;低层失败时,高层要决定是重试这个子目标,还是让整个计划失效。第 8 章讲多智能体时,这种分层会变成 supervisor 与 worker 的拓扑。

计划不是越完整越好

一个常见的反模式是把计划写到「每个工具调用的每个参数」。计划越细,规划成本越高,重规划时要改的节点越多,计划和实际执行也越容易产生微小偏差。更稳的粒度是「动作 + 依赖 + 前置/后置条件」,参数在执行时由执行器接地;这样计划能表达顺序与约束,又不会因为一个参数变化就整条失效。

7.4 树搜索:ToT、MCTS 与价值函数

ReAct 只走一条路,Plan-Execute 只信一份计划。树搜索把「一条路」变成「多棵候选」,把预算花在展开与比较上。它的承诺不是「想得更久就一定更好」,而是如果有办法区分候选的好坏,就可以用更多候选换更高的成功率。这句话有两个前提:价值函数能区分好坏,预算足够覆盖展开。缺一个,树搜索就退化成昂贵的随机采样。

三种常见结构:

  • ToT(Tree of Thoughts)。 把部分解当成树节点,每一步生成若干个「想法」,对每个想法打分,保留高分分支继续展开;可以用 BFS(保留一层的前沿)或 DFS(深入一条分支再回溯)。Yao et al. (2023) 把它用于 24 点游戏、创意写作与迷你填字,核心是「生成—评估—搜索」三件事,而不是某一种固定树算法。
  • MCTS(蒙特卡洛树搜索)。 在选择、扩展、模拟、回传四步之间循环,用访问次数与平均价值决定下一步展开哪条分支;适合分支多、单次模拟便宜、价值可以逐步估计的场景。
  • Beam search。 每一层只保留价值最高的 kk 个节点,简单、可预测、易并行;没有回溯,也没有最优性保证。

7.4.1 ToT:展开、评估、回传

ToT 的一次循环可以写成:

frontier ← {initial_state}
for depth in 1..D:
    candidates ← []
    for s in frontier:
        for thought in generate(s, k):        # 展开:生成 k 个候选
            candidates.append(apply(s, thought))
    scored ← [(value(c), c) for c in candidates]  # 评估
    frontier ← select_top(scored, width)          # 选择
    if any is_goal(c): return best_path()

三个函数决定了 ToT 的质量:

  1. generate(s,k) 决定候选多样性。温度太低,kk 个候选几乎一样,展开是浪费;温度太高,候选大多不可行,评估器被噪声淹没。工程上常用的做法是「先广后深」:前几层多生成,接近叶子时少生成。
  2. value(c) 决定搜索方向。它可以是 LLM 打分、程序化启发式、测试通过率、独立 critic 的投票;来源不同,偏差不同(见 7.4.4)。
  3. select_top 决定预算分配。BFS 保留每层最好的 kk 个,适合深度不大、需要比较多个中间状态的任务;DFS 深入一条分支,适合有明确回溯点、单条路径很长的任务。

「回传」在 ToT 的 BFS 版本里通常表现为「把子节点的最好价值汇总到父节点」,用于决定父节点是否还值得继续展开;在 MCTS 里则是显式的反向传播。两者的区别很重要:ToT 的回传是启发式的,没有访问次数的严格统计;MCTS 的回传会更新每个节点的 NN 与 QQ,让下一次选择有据可依。

一个具体的例子是修一个失败的测试。根节点是「当前工作区」,展开出三个候选:改实现、改测试、加兼容层。每个候选再展开出两个具体补丁。评估器跑单元测试,得到通过/失败与覆盖率。BFS 保留通过率最高的两个补丁继续展开;DFS 则先深入「改实现」这一支,如果测试全绿就返回,不再看另外两支。这里的关键不是树,而是评估器能在中间状态给出信号:如果只有最终测试能打分,中间节点全是 0,树搜索就退化成枚举。

7.4.2 MCTS 与 UCB

MCTS 把「探索哪条分支」变成一个多臂老虎机问题。每个节点 ss 的每个动作 aa 都维护两个统计量:N(s,a)N(s,a) 是被选中的次数,Q(s,a)Q(s,a) 是这些次选择得到的平均价值。选择阶段用 UCB 决定下一步(UCB1 见 Auer et al., 2002;UCT 见 Kocsis & Szepesvári, 2006):

UCB(s,a)=Q(s,a)+cln⁡N(s)N(s,a),N(s)=∑a′N(s,a′). \mathrm{UCB}(s,a) = Q(s,a) + c\sqrt{\frac{\ln N(s)}{N(s,a)}}, \qquad N(s)=\sum_{a&#x27;} N(s,a&#x27;).

三项的含义:

  • Q(s,a)Q(s,a) 是利用项:过去这个动作的平均回报越高,越容易被再次选中。
  • ln⁡N(s)/N(s,a)\sqrt{\ln N(s)/N(s,a)} 是探索项:N(s,a)N(s,a) 越小,这一项越大;父节点被访问得越多,ln⁡N(s)\ln N(s) 越大,未试过的动作越有吸引力。
  • cc 是探索系数:cc 大,搜索更愿意尝试访问少的分支;cc 小,搜索更快收敛到当前看起来最好的分支。

cc 不是「越大越好」的魔法常数。它和价值的尺度绑定:如果 Q∈[0,1]Q \in [0,1],经典 bandit 设置常用 c=2≈1.41c=\sqrt{2}\approx 1.41;如果 QQ 是 [−100,100][-100,100] 的分数,同一个 cc 几乎不起作用,探索项被价值差淹没。换价值函数时,必须重新标定 cc,否则搜索要么完全不探索,要么把预算浪费在明显差的分支上。

MCTS 的四步是:

  1. 选择(selection)。 从根开始,按 UCB 一路选到叶子;遇到未展开的动作就停下。
  2. 扩展(expansion)。 为选中的节点增加一个或多个子节点。
  3. 模拟(simulation / rollout)。 从新节点出发,用一个便宜的策略走到终点或深度上限,得到一个价值估计。
  4. 回传(backpropagation)。 把这次价值沿路径累加,更新每个节点的 NN 与 QQ。

在 LLM agent 里,第 3 步通常不是随机模拟,而是「让模型快速给一个估计」或「跑一个便宜的启发式」;这省掉了昂贵的真实 rollout,但也把价值函数的偏差带进了搜索。MCTS 的统计保证建立在模拟的无偏性上;如果模拟本身有系统性偏差,访问次数再多也只会更自信地收敛到错误答案。

7.4.3 一次 UCB 计算

公式只有三项,但 cc 的作用要算一遍才直观。设节点 ss 被访问 N(s)=10N(s)=10 次,三个动作的统计如下;取 c=1.4c=1.4 与 c=0.2c=0.2 各算一次。

动作N(s,a)N(s,a)Q(s,a)Q(s,a)探索项 ln⁡10/N(s,a)\sqrt{\ln 10/N(s,a)}UCB(c=1.4c=1.4)UCB(c=0.2c=0.2)
A60.600.61951.4670.724
B30.700.87611.9270.875
C10.401.51742.5240.703

c=1.4c=1.4 时选 C:它的 QQ 最低,但只被访问过一次,探索项把它推到第一。c=0.2c=0.2 时选 B:探索的权重不足以抵消 C 的价值差距,搜索转向当前平均回报最高的动作。c=0c=0 时也选 B,搜索完全利用。三个结果没有对错,取决于你更怕「错过一个没试过的好分支」还是「把预算浪费在差分支上」。

这张表还说明 cc 与价值尺度必须绑定。若 QQ 被归一化到 [0,1][0,1],c=1.4c=1.4 的探索项与价值同量级;若 QQ 是 [0,100][0,100] 的评分,同一个 cc 的探索项几乎可以忽略,搜索会立刻退化成贪心。换价值函数时,先看 QQ 的取值范围,再定 cc;不要把一个任务的 cc 直接搬到另一个任务。

7.4.4 价值函数从哪里来

树搜索的质量上限由价值函数决定。表 7.4 比较五种常见来源。

来源成本偏差来源适合判断不适合判断
程序化启发式极低设计者假设、短视距离、资源、明显违规语义正确性、风格
LLM 自评低位置/长度偏好、自我偏好、提示敏感草稿完整性、思路合理性事实正确性、细微 bug
训练的价值模型中分布漂移、奖励黑客分布内排序分布外任务、对抗输入
测试/形式验证高覆盖不足、规格错误可执行断言、类型/不变量未覆盖的需求、体验
人工评审极高评审者差异、疲劳高风险决策、最终验收大规模候选筛选

这张表说明什么:没有一种价值函数同时便宜、准确、覆盖广。树搜索的工程问题不是「选最好的价值函数」,而是「按阶段组合」:前几层用便宜启发式剪枝,中间用 LLM/critic 排序,叶子用测试或人工验证。组合的代价要计入预算,不能默认验证免费。

价值函数还有三个容易被忽略的偏差:

  • 位置偏差。 LLM 打分对候选在提示中的顺序敏感;同一组候选换一个顺序,排名可能变化。评估时要随机化顺序或做配对比较。
  • 长度偏差。 更长的候选看起来更「完整」,却可能包含更多未经证实的断言。用测试或引用核验对冲。
  • 同源偏差。 生成候选的模型与打分的模型是同一个,盲点高度相关;它会给自己的错误打高分。至少换一个提示、换一个模型,或引入执行器信号。
搜索不创造信息,只重新分配预算

树搜索不会凭空知道哪条路对。它做的是:把原本花在一条路上的预算,分一部分去尝试其他路,再用价值函数把试过的结果汇总。如果价值函数是抛硬币,搜索只是把一次抛硬币变成很多次抛硬币;如果价值函数能区分 70% 的好坏,搜索才可能把那 70% 变成更高的成功率。

7.4.5 Beam search 与一个不可能定理

Beam search 是最简单的树搜索:每一层展开所有候选,按价值排序,只保留前 kk 个,继续下一层。它的成本近似为

Nexpand≈k⋅b⋅d, N_{\text{expand}} \approx k \cdot b \cdot d,

其中 bb 是平均分支因子,dd 是深度。kk 越大,保留的候选越多,成本线性上升;但 kk 大不等于质量高,因为被保留的候选可能高度相关,也可能在早期就被错误的价值函数带偏。

深度也是预算变量。MCTS 的成本近似为「模拟次数 × 深度」,ToT 的成本近似为「每层候选数 × 深度」;每多一层,成本线性累加,而收益通常递减。工程上要给深度设上限,并用「连续几层最好价值不再提升」作为早停条件,而不是让搜索一直展开到预算耗尽。

定理 7.4.1 · 固定宽度的 beam 不保证最优

设搜索树有限、价值函数只用于剪枝、beam 宽度为 kk。则存在一棵树与一个价值函数,使得最优解在某一层被排出前 kk 名,从而被永久剪掉;此时 beam 返回的路径不是最优路径,甚至可能不到达目标。

证明

构造一棵深度为 2 的树。根有 k+1k+1 个子节点 u1,…,uk+1u_1,\ldots,u_{k+1}。价值函数给 u1,…,uku_1,\ldots,u_k 打高分(例如 0.9),给 uk+1u_{k+1} 打低分(0.8)。u1,…,uku_1,\ldots,u_k 的每个后代都是死路(价值 0.1,且不到达目标);uk+1u_{k+1} 的唯一后代是目标(价值 1.0)。宽度为 kk 的 beam 在第一层保留 u1,…,uku_1,\ldots,u_k,剪掉 uk+1u_{k+1};第二层只能从死路里选,返回失败或次优路径。价值函数与树都是合法的,因此不存在「宽度 kk 的 beam 一定最优」的保证。∎

∎

这个定理不是说 beam 没用,而是说它的保证是经验性的:在特定任务分布上,价值函数的前 kk 名恰好包含正确分支,beam 就有效;换一个分布,同样的宽度可能刚好剪掉唯一正确的分支。工程上要用「留一候选」「随机重启」「多宽度并行」来对冲,代价是更多扩展。

关于树搜索的四个误解
  1. 「树搜索一定比 ReAct 强」——只有在价值函数能区分候选、预算足够覆盖展开时才成立。价值函数接近随机时,树搜索只是更贵的随机采样。
  2. 「MCTS 必须做随机 rollout」——LLM agent 常用价值估计或启发式代替 rollout;省成本,但引入了价值偏差,统计保证不再自动成立。
  3. 「UCB 的 cc 越大探索越充分」——cc 必须与价值尺度匹配;cc 过大会把预算撒在差分支上,过小会过早收敛。
  4. 「beam 宽度越大越好」——宽度线性增加成本,且保留的候选可能高度相关;真正的瓶颈常是价值函数的分辨率,不是宽度。

7.5 反思:Reflexion、自我批评与经验回写

反思(reflection)是 agent 在失败后把轨迹变成「下次怎么做」的过程。它看起来像让模型「再想一遍」,但真正有用的反思不是重读一遍失败,而是从外部证据中提取一条可检验的修正,并把它写回下一次尝试的条件里。

Reflexion(Shinn et al., 2023)给出四个部件:Actor 执行任务,Evaluator 给出成功/失败信号,Self-Reflection 把失败轨迹转成一段自然语言教训,episodic memory 保存教训;下一次尝试把这段教训放进上下文。它的关键不是「反思」这个词,而是 Evaluator:在 HumanEval 里是单元测试,在 ALFWorld 里是环境成功信号,在 HotpotQA 里是答案匹配或外部评估。没有评估信号,反思就失去了锚点。

Self-Refine(Madaan et al., 2023)是同一轮内的迭代:模型生成初稿,模型自己给反馈,模型根据反馈修改,重复若干轮。它不需要跨回合记忆,也不需要外部环境;优点是便宜、通用,缺点是反馈与生成同源,遇到模型自己看不见的错误时,迭代会把错误改得更精致,而不是改对。

维度ReflexionSelf-Refine
作用范围跨尝试(attempt 1 → 2 → …)单次尝试内(draft → critique → revise)
反馈来源外部 Evaluator(测试、环境、匹配)同一模型的自评
记忆写入 episodic memory,供后续检索通常不持久化
适合有明确成功信号、可重试的任务风格、完整性、可读性等软约束
风险错误教训被长期记忆放大同源盲点导致「越改越自信」

这张表说明什么:两者都叫「反思」,但证据结构完全不同。Reflexion 的收益来自「外部信号 + 跨尝试记忆」;Self-Refine 的收益来自「低成本迭代 + 软约束」。把 Self-Refine 用在需要事实正确性的任务上,等于让同一个模型给自己判卷。

7.5.1 一条反思应该带什么

一条能安全回写的反思,至少包含六个字段:

定义 7.5.1 · 反思条目

一条反思条目可以写成

r=(c, e, w, a, κ, t), r=(c,\ e,\ w,\ a,\ \kappa,\ t),

其中:

  • cc 是断言:这次失败的原因是什么,下次要改什么;
  • ee 是证据:支持这条断言的测试输出、trace 片段、错误码、外部评审;没有证据时 e=∅e=\varnothing;
  • ww 是适用范围:哪个任务、哪个工具、哪个错误码、哪个版本;默认不是「所有任务」;
  • aa 是动作:下次具体怎么做,必须是可执行的动作或检查,不是「更小心」;
  • κ\kappa 是置信度:这条反思有多确定,来自一次观测还是多次复现;
  • tt 是失效条件:什么情况下这条反思应该被删除或重新评估。

没有 ee 的反思只能算「假设」,不能直接写成规则;没有 ss 的反思会从一次失败泛化到所有任务;没有 tt 的反思会永久污染记忆。

这个定义把「反思」与「复盘」区分开。复盘可以没有结论,反思必须产出一个可执行的修正;但这个修正仍然只是假设,必须经过下一次尝试或独立验证才能升级为规则。把假设直接写成全局规则,是反思退化最常见的起点。

方法 7.5.1 · 反思门禁

在把一条反思写回记忆之前,按顺序问五个问题:

  1. 有新证据吗? 这条反思引用了哪条测试、哪个 trace、哪个错误码?如果只是「我觉得」,停止。
  2. 证据支持这个范围吗? 一次失败只能支持「在这个任务/这个错误码下」的修正,不能支持「所有任务都要这样」。
  3. 修正可执行吗? 「更小心」不是动作;「在 move 前检查 door.locked」才是。
  4. 失败可复现吗? 能在隔离环境重放一次吗?不能复现的失败,先记录,不写规则。
  5. 谁来验证? 下一次尝试的成功信号是什么?如果还是同一个模型自评,可信度要打折。

五问里任何一问没有答案,就把反思标为「假设」,只放在当前任务的上下文里,不写入长期记忆。

答案

A1 不需要。反思的输入是轨迹与证据,不是「再想一遍」;如果没有任何新证据,反思最多是把旧信念换一种说法,收益接近零,风险是把噪声写成规则。

A2 把它写成带范围的假设,例如「在 pytest 的 test_race 上,若失败伴随 TimeoutError,先查共享状态,不要直接跳过测试」;绑定任务、错误码与验证方式,并设置过期时间。不要写成「测试不稳定就跳过」。

A3 回滚这条记忆,保留原始证据与回滚原因;在下一次反思时把「这条规则被证明有害」作为新证据。记忆系统要有版本与墓碑,不能只追加不删除(第 6.6 节)。

A4 先不写规则,把它记成一条待复现事件:保留日志、请求 id、时间窗与最小输入;等能复现或出现第二次同类证据,再进入反思。

A5 下一尝试的验证信号必须是外部的(测试、指标、独立评审);如果只能自评,就把置信度降到「假设」,并加一个独立检查,不能直接升级为规则。

7.5.2 反思退化的三个条件

反思不是单调改进的。下面三个条件同时出现时,反思很可能让下一次更差:

  1. 同一模型、没有新证据。 生成者与批评者共享同一套盲点;模型会把自己的错误解释成「环境问题」或「测试问题」。它写得越具体,越有说服力,也越容易把错误固化成规则。
  2. 批评与生成同源。 同一个上下文、同一个提示模板、同一个模型,批评只是生成的镜像。要打破同源,至少换一个提示、换一个模型、或者引入执行器信号(测试、类型检查、外部检索)。
  3. 把一次噪声写成全局规则。 一次超时被写成「这个 API 总是超时,应该跳过」;一次权限失败被写成「遇到 EPERM 就重试」。规则的范围远大于证据的范围,下一次在不相关的任务上触发。

还有一个不那么明显但很常见的退化:反思文本挤掉了任务约束。反思通常写在上下文的前部或尾部,模型为了让反思「被看见」,会把任务约束挤到中间;第 5 章讲过中间位置的可利用性最差。结果是反思被记住了,原始目标被忘了。

关于反思的四个误解
  1. 「反思越多越好」——没有新证据的反思是空转;有证据但范围过宽的反思是污染。反思的次数应该受预算与证据量约束。
  2. 「自我批评能替代外部验证」——同一个模型的自我批评与生成高度相关;它能发现「看起来不对」的问题,很难发现「看起来对但事实错」的问题。
  3. 「反思写进记忆就生效」——记忆会被检索、压缩、冲突覆盖;没有作用域与失效条件的反思,会在错误的任务上被召回。
  4. 「失败是成功之母」——只有被正确归因的失败才是。错误归因的失败会强化错误策略,比不反思更糟。

判断一条反思是否值得写回,可以用一句工程规则:没有外部证据的反思,不进入长期记忆;有外部证据但范围过宽的反思,只进入当前任务的临时上下文。这条规则牺牲了一部分「举一反三」的能力,换来的是记忆不被噪声污染。等同一类证据出现多次,再由人或独立评估器把它升级为规则,而不是让模型在一次失败后自己宣布「我学会了」。

7.6 验证器:测试、形式检查、critic 与投票

7.4 节说「价值函数能区分好坏,树搜索才划算」。这句话里的「区分」不是免费的:它需要一个验证器。验证器可以是单元测试、类型检查、形式化不变量、独立的 critic、多个样本的投票,也可以是真实环境的执行结果。它们的强度、成本与偏差完全不同,混用会让搜索的收益被假阳性吃掉。

先看一个反例。某代码 agent 为同一个 bug 生成了 20 个候选补丁,用一个 LLM critic 打分,取最高分的补丁提交。critic 给每个补丁都写了「看起来合理」的评语,最高分补丁把一个边界条件从 x < 0 改成了 x <= 0,测试没覆盖这个边界,CI 全绿。三天后,负数输入触发了一次线上事故。这里的问题不是搜索不够,而是验证器把「看起来合理」当成了「正确」;候选越多,找到「看起来最合理但实际错误」的概率越高。

定义 7.6.1 · 验证器强度

一个验证器 VV 对一个候选 xx 输出接受/拒绝。它的强度至少由五个量描述:

  • α\alpha:假阳性率,把错误候选判为接受的概率;
  • β\beta:假阴性率,把正确候选判为拒绝的概率;
  • γ\gamma:覆盖率,验证器实际检查的失败模式占目标失败模式的比例;
  • cVc_V:单次验证成本(时间、金额、token、人工分钟);
  • ρ\rho:与生成器的独立性,ρ=1\rho=1 表示完全独立,ρ=0\rho=0 表示同源。

一个验证器「更强」,是指在目标失败模式上 α\alpha 更低、γ\gamma 更高、ρ\rho 更大,且 cVc_V 在预算内。强度不是一个标量:测试的 α\alpha 低但 γ\gamma 受覆盖限制;critic 的 cVc_V 低但 ρ\rho 往往很小;形式验证的 γ\gamma 取决于规格,规格写错时 α\alpha 仍然可能很高。

这五个量解释了为什么「加一个 critic」不能解决所有问题。critic 的成本低、覆盖语义广,但它与生成器同源,ρ\rho 小;测试的 ρ\rho 大、α\alpha 低,但 γ\gamma 受测试集限制,且 cVc_V 高。工程上不是选一个「最强」的验证器,而是按失败模式组合:结构错误用类型/schema,行为错误用测试,语义/风格用 critic,高风险决策用人工。

验证器主要信号强在哪弱在哪典型成本
测试通过/失败、覆盖率可执行、可复现、独立性高只覆盖写出来的用例;可能 flaky秒到分钟
类型/形式检查类型、不变量、模型检查能在执行前排除整类错误规格工程贵;只证明「符合规格」毫秒到小时
独立 critic语义评审、风格、完整性灵活、便宜、能看未形式化的要求与生成器同源时盲点重合;提示敏感一次模型调用
投票 / self-consistency多数答案简单、可并行、对随机错误有效错误相关时无效;成本 ×NN 次采样
执行/环境反馈真实成功/失败最接近事实慢、可能不可逆、覆盖有限秒到天
人工评审判断与责任能处理模糊与高风险贵、慢、评审者差异分钟到小时

这张表说明什么:验证器的选择由失败模式决定,而不是由「哪个更先进」决定。一个团队如果只有 critic,就会在事实错误上反复;如果只有测试,就会在测试没覆盖的需求上反复;如果只有投票,就会在系统性错误上反复。验证器组合的目标是让 α\alpha、β\beta、γ\gamma 覆盖主要失败模式,而不是让某一个指标好看。

7.6.1 搜索越多,假阳性越容易混进来

假设搜索评估的 NN 个候选都是错误候选,每个被错误接受的概率是 α\alpha,候选之间独立。至少有一个假阳性被接受的概率是:

P(至少一个假阳性)=1−(1−α)N. P(\text{至少一个假阳性}) = 1-(1-\alpha)^{N}.

α=0.05\alpha=0.05 时,N=5N=5 约为 0.2260.226,N=10N=10 约为 0.4010.401,N=20N=20 约为 0.6420.642。这就是「弱验证器 + 大搜索」的危险:搜索本来是为了找到更好的候选,但它同时把 NN 次独立的机会交给了验证器的错误率。搜索越大,越容易找到一个「验证器喜欢但实际错误」的候选。

定理 7.6.1 · 弱验证器下的搜索假阳性

设验证器对每个错误候选的假阳性率为 α∈(0,1)\alpha \in (0,1),各候选的判定相互独立,搜索评估 NN 个错误候选并接受至少一个通过验证的候选。则至少接受一个错误候选的概率为

1−(1−α)N, 1-(1-\alpha)^{N},

它随 NN 单调递增,且当 N→∞N \to \infty 时趋于 1。若 NN 个候选里只有 WW 个是错误的,把公式中的 NN 换成 WW;因为 W≤NW \le N,最坏情况不超过 1−(1−α)N1-(1-\alpha)^N。若候选判定不独立(例如同一模型的错误高度相关),该概率不能用此公式精确计算,但「搜索放大验证器偏差」的方向不变。

证明

每个候选被错误接受的概率是 α\alpha,被正确拒绝的概率是 1−α1-\alpha。NN 个独立候选全部被正确拒绝的概率是 (1−α)N(1-\alpha)^N,其补集就是至少一个假阳性被接受的概率。随着 NN 增大(或直接观察 (1−α)N(1-\alpha)^N 随 NN 递减),该概率随 NN 单调递增;因为 0<1−α<10 \lt 1-\alpha \lt 1,N→∞N\to\infty 时 (1−α)N→0(1-\alpha)^N\to 0。∎

∎

这个定理有一个重要的前提:候选之间独立。在 LLM 里,同一提示、同一模型生成的候选高度相关,错误往往成簇出现;此时公式给出的数字不准确,但结论更糟:相关性让「多个候选」提供的独立证据变少,验证器的同一个盲点会同时放过一批错误。投票也是如此:如果 9 个样本共享同一个错误,多数票只会让错误更自信。

投票不是验证,是采样

Self-consistency(Wang et al., 2022)用多条推理路径的多数答案提升准确率,前提是正确答案比错误答案更容易被多条独立路径到达。它不是验证器:它不知道哪个答案是真的,只知道哪个答案被重复得更多。投票适合「答案空间小、错误随机」的任务;不适合「错误系统性、答案开放」的任务。把投票结果当事实,是把采样频率当成了证据强度。

7.6.2 验证成本与验证顺序

验证不是越多越好,因为 cVc_V 会挤占搜索与执行的预算。一个实用的顺序是「从便宜到贵,从结构到语义,从自动到人工」:

  1. 结构检查(毫秒)。 JSON schema、类型、必填字段、工具是否存在、参数范围。挡掉大多数计划幻觉与格式错误。
  2. 静态检查(秒)。 编译、类型检查、lint、依赖解析。挡掉整类语法/类型错误。
  3. 单元测试(秒到分钟)。 针对改动的行为断言;先跑快测试,再跑全量。
  4. 独立 critic(一次模型调用)。 检查测试覆盖不到的需求、边界与风格;必须与生成器不同源,或至少不同提示/上下文。
  5. 端到端/人工(分钟到小时)。 高风险、不可逆、跨系统的影响;放在最后,只对通过前四层的候选执行。

这个顺序的收益是用便宜检查缩小候选集,让昂贵检查只处理少量候选。反过来,先跑人工评审再跑 schema,会把人的时间浪费在格式错误上;先跑全量测试再跑 lint,会让一次拼写错误触发十分钟的 CI。

验证成本还有一个隐性部分:验证器本身可能被生成器修改。在上面的补丁例子里,如果 agent 有权改测试或加 xfail,它可以通过「让验证器闭嘴」来通过验证。工程上要把验证器放在生成器的写权限之外:测试文件、CI 配置、审批规则不能由同一个 agent 在同一轮里修改;确需修改时,必须单独审批并留下 diff。

验证层典型耗时挡住的失败不能挡住
schema/类型100 ms 内缺字段、类型错、工具不存在逻辑错、语义错
编译/lint10 s 内语法、未定义符号、明显坏味道运行时行为、需求偏差
单元测试秒到分钟已知行为回归未覆盖的边界、集成问题
critic秒完整性、风格、明显矛盾事实错误、同源盲点
端到端/人工分钟到小时集成、权限、高风险决策长尾、未预见的交互

这张表说明什么:每一层都有明确的盲区,验证器组合的价值在于盲区不重叠。一个只有单元测试的系统,会在「测试没覆盖的需求」上翻车;一个只有 critic 的系统,会在「看起来合理的错误」上翻车;一个只有投票的系统,会在「系统性错误」上翻车。把三层叠起来,不是为了追求 100% 正确,而是为了让错误在便宜的地方暴露。

验证器强度还决定了搜索是否有意义。设验证器的区分度只比随机猜测高一点(α\alpha 接近 0.50.5),那么增加候选数量主要在增加假阳性;此时更值得做的是改进验证器,而不是加大搜索。反过来,如果验证器很强但很贵(例如形式验证),就应该先用便宜启发式把候选缩到很小,再把强验证器用在少数候选上。搜索结构与验证器强度必须匹配,这是 7.4 与 7.6 的共同结论。

7.6.3 一个最小验证栈

把验证器组合成一个栈,关键是短路:便宜检查失败就立刻拒绝,不进入昂贵检查。一个代码补丁的验证栈可以写成:

def verify(candidate, ctx) -> CheckResult:
    for check in (
        schema_check,       # 工具/参数/文件是否存在,毫秒
        compile_check,      # 编译/类型检查,秒
        targeted_tests,     # 针对改动的测试,秒到分钟
        independent_critic, # 独立模型读 diff + 测试输出,一次调用
    ):
        result = check(candidate, ctx)
        if not result.ok:
            return result       # 短路:后面的检查不再执行
    return CheckResult(ok=True, reason="all_passed")

这个栈的顺序不是随意的:schema 最便宜且能挡住计划幻觉;编译/类型检查次便宜且能挡住整类结构错误;定向测试针对行为;critic 放在最后,只处理已经通过前三层的候选,避免把模型调用浪费在格式错误上。对于不可逆动作,还要在栈顶加一道人工审批,且审批不能由生成候选的模型代替。

候选schema编译定向测试critic最终判定
A:改了接口签名通过失败未运行未运行拒绝(编译)
B:逻辑正确,少一个边界通过通过失败未运行拒绝(测试)
C:测试通过,注释误导通过通过通过指出问题拒绝(critic)
D:全通过,但改的是迁移脚本通过通过通过通过转人工(不可逆)

这张表说明什么:一个候选被拒绝的位置,比它被接受更有信息量。A 在编译层被拒,B 在测试层被拒,C 在 critic 层被拒,D 需要人工;每一层都在用便宜的信号缩小候选集。如果反过来,先让 critic 看 A,它会花一次模型调用告诉你「签名看起来有点怪」,而编译器一秒就能给出确定答案。

7.7 失败案例:计划幻觉、局部最优与反思退化

这一节复盘三个失败案例。它们的共同点是:单看每一步都「合理」,连起来才暴露问题。每个案例都给出 trace 证据、根因与修复;修复的目标不是「让模型更聪明」,而是让系统在便宜的地方发现错误。

7.7.1 计划幻觉:计划引用了不存在的工具与文件

任务:轮换一个数据库凭据,并让应用使用新凭据。规划器生成的计划有四个节点:

1. fs.read("config/prod.yaml")                 读取配置
2. secrets.rotate("db-prod")                   轮换凭据
3. k8s.apply("deployment/api", new_secret)     更新部署
4. smoke.test("/healthz")                      健康检查

工具注册表里实际有的是:fs.read、secrets.rotate(需要审批)、k8s.set_image、smoke.test。k8s.apply 不存在,配置文件实际叫 config/production.yaml。下面是一条压缩后的示意 trace(字段裁剪,便于阅读):

{"step":1,"phase":"execute","action":"fs.read","args":{"path":"config/prod.yaml"},"ok":false,"error":"ENOENT"}
{"step":2,"phase":"replan","action":"fs.read","args":{"path":"config/production.yaml"},"ok":true}
{"step":3,"phase":"execute","action":"secrets.rotate","args":{"name":"db-prod"},"ok":false,"error":"EAPPROVAL"}
{"step":4,"phase":"execute","action":"k8s.apply","args":{"deployment":"api"},"ok":false,"error":"ENOTOOL"}
{"step":5,"phase":"replan","action":"k8s.patch","args":{"deployment":"api"},"ok":false,"error":"ENOTOOL"}
{"step":6,"phase":"replan","action":"k8s.set_image","args":{"deployment":"api"},"ok":true}
例题 7.7.1 · 计划幻觉的复盘

触发条件。 规划器只拿到自然语言目标与一份过时的工具摘要,没有拿到当前工具注册表;计划生成后没有整体校验,执行器逐个节点执行,失败后只替换当前节点。

观测证据。 第 1 步 ENOENT 说明路径来自记忆而不是文件系统;第 4、5 步连续 ENOTOOL 说明规划器在猜工具名;第 3 步 EAPPROVAL 是权限门禁在起作用,但 agent 把它当成可重试错误,继续执行了第 4 步。最终 k8s.set_image 成功,应用指向了一个还没轮换的凭据。

根因。 三个缺失叠加:规划器没有能力清单(tool registry);计划没有执行前校验;重规划只修当前节点,不重新验证整条计划。第一项让计划幻觉有机会产生,第二项让幻觉进入执行,第三项让幻觉在失败后继续。

修复。 在计划生成后、执行前加一道 validate_plan:

def validate_plan(plan, registry, state) -> list[str]:
    errors = []
    for node in plan.nodes:
        spec = registry.get(node.action)
        if spec is None:
            errors.append(f"{node.id}: unknown tool {node.action}")
            continue
        if not spec.schema.accepts(node.args):
            errors.append(f"{node.id}: bad args")
        if not all(eval_pre(p, state) for p in node.pre):
            errors.append(f"{node.id}: precondition false")
        if spec.effect == "irreversible" and not node.approved:
            errors.append(f"{node.id}: approval missing")
    return errors

校验不通过时,整条计划退回规划器,而不是执行到一半再修。修复后同一任务的 trace 在第 1 步就停在 plan_invalid,没有产生任何副作用;规划器拿到注册表与文件清单后,第二次生成的计划只用了 fs.read、secrets.rotate、k8s.set_image、smoke.test 四个存在的工具。

代价。 校验本身很便宜(毫秒级);真正的成本是让规划器拿到准确的能力清单,并维护它与注册表同步。这个成本必须付,否则每次工具升级都会重新引入计划幻觉。

这个案例还说明:ENOTOOL 不是瞬时错误。瞬时错误可以重试,ENOTOOL 说明计划引用了不存在的动作,重试只会浪费时间;它应该让计划失效,回到规划器,而不是在执行器里循环。

7.7.2 局部最优:ReAct 在门前打转

任务:key-door。起点在左上,钥匙在反方向的左上角,门在中间偏右,目标在右下。ReAct 的策略只看到目标的曼哈顿距离;它不知道「先钥匙后门」的顺序。实际运行结果是:

python3 experiments/ch07/planners.py --task key-door --strategy react
task                 strategy  ok   stop          steps  exp  replans  cost
---------------------------------------------------------------------------
key-door             react    False budget           40   40       0  48.00

前 8 步的 trace 是 E, E, S, S, W, N, W, S,价值在 −3-3 到 −6-6 之间摆动;第 22 步才偶然经过钥匙,第 36 步才打开门,第 40 步停在 (4,5),离目标 (5,5) 只差一步。take 与 open 各只发生一次,而且都不是计划出来的,是逛到的。

例题 7.7.2 · 局部最优的复盘

触发条件。 策略是纯贪心:每步选「到目标距离最小」的动作,加上一点访问惩罚避免立即回头;没有子目标,没有进展度量,也没有「钥匙未拿到」这个状态字段参与决策。

观测证据。 40 步里 E=11, N=10, S=11, W=6,take=1(第 22 步),open=1(第 36 步),最终位置 (4,5),目标 (5,5)。前 21 步没有进入钥匙所在的左上角,因为那会增大到目标的距离;访问惩罚只让它不被同一个格子吸住,不能让它产生「先退回去拿钥匙」的全局意图。

根因。 不是模型不会推理,而是策略的目标函数里没有「钥匙」这一项。局部最优不是「走到了一个坏地方」,而是「每一步都按当前距离最优,合起来却绕不开一个必须先做的子目标」。

修复。 三个层次,按成本从低到高:

  1. 加子目标。 在任务状态里维护 has_key 与 door_open;当 door_open=false 且 has_key=false 时,把「到钥匙的距离」作为临时目标,而不是到终点的距离。这是最小修复,等价于给策略加一个两级计划。
  2. 加进展度量。 用 (has_key, door_open, 距离) 组成进展向量;连续 kk 步没有改进就触发重规划或转 Plan-Execute。第 1 章的指纹检测只能发现「动作 + 观察不变」,发现不了「动作在变、目标没推进」。
  3. 改成 Plan-Execute。 BFS 从当前状态搜索「钥匙 → 门 → 目标」的完整计划;在本任务上 15 步完成,成本 28.20,低于 ReAct 失败运行的 48.00。

修复后的行为。 子目标版本会先走向钥匙,拿到后再去门;在本任务上,Plan-Execute 给出的 15 步计划就是这种顺序。两者都比「更聪明的贪心」可靠,因为顺序约束被显式写进了策略或计划。

这个案例的教训是:局部最优的修复通常不是调参,而是加结构。调大访问惩罚、换更好的距离函数、增加随机性,可能让这一次碰巧成功,但不会让下一次稳定成功;子目标、进展度量、计划 DAG 才是结构性的修复。

7.7.3 反思退化:越反思,验证越弱

任务:修一个偶发的转账测试失败。第一次运行,test_transfer_race 失败;agent 的反思没有引用日志,只写了一句「这个测试不稳定」。第二次尝试,它给测试加了 xfail,CI 变绿;第三次,它把这条经验写成全局规则「并发测试不稳定就标记 xfail」。两周后,同一个模块的一个真实竞态被 xfail 吞掉,事故上线。尝试记录如下(示意,保留关键行):

attempt 1  pytest -q
           1 failed: test_transfer_race (TimeoutError)
reflect    evidence=[]  scope=global
           "并发测试不稳定,建议 xfail"
attempt 2  pytest -q
           17 passed, 1 xfailed        ← 验证器被关闭,不是 bug 被修复
reflect    evidence=["17 passed"]  scope=global
           "并发测试都可以 xfail"
attempt 3  pytest -q
           31 passed, 4 xfailed        ← 覆盖进一步缩小
memory     "并发测试 → xfail" (no expiry, no scope)
例题 7.7.3 · 反思退化的复盘

触发条件。 反思由生成代码的同一个模型完成,输入只有「测试失败」这一个信号,没有堆栈、没有复现步骤、没有并发日志;反思的输出可以直接修改测试文件;记忆没有作用域与过期时间。

观测证据。 第一次反思的 evidence=[],却写出了 scope=global 的规则;第二次的「成功」来自 xfail,测试通过数上升、失败数归零,但这不是修复,是把验证器关掉;第三次 xfailed 从 1 增加到 4,覆盖率持续下降。

根因。 三个条件同时成立:没有新证据(只有失败事实)、批评与生成同源(同一个模型)、把一次噪声写成全局规则(一个测试 → 所有并发测试)。更深一层的问题是反思有权限修改验证器:当「让测试通过」成为目标,最便宜的路径就是让测试不失败。

修复。 四道门禁:

  1. 证据门。 反思必须引用至少一条外部证据:堆栈、日志、可复现命令、监控指标。没有证据时,只能记录「假设」,不能改代码或测试。
  2. 作用域门。 规则必须绑定任务、测试 id、错误码与版本;默认作用域是「当前任务」,升级为全局需要多次独立复现或人工批准。
  3. 验证器保护。 生成器不能修改测试、CI 配置、xfail 标记与审批规则;确需修改时,走独立 diff 与人工审批。反思可以建议改验证器,但不能自己执行。
  4. 回滚门。 每条写回的记忆带 expires_at 与 rollback 字段;下一次尝试如果指标变差,自动回滚并保留证据。

修复后的反思写成:「在 test_transfer_race 上,TimeoutError 伴随 shared_balance 未加锁;下次先查 transfer() 的锁范围,不要改测试标记。」它有证据、有作用域、有可执行动作、有验证方式;下一次尝试先复现,再修锁,测试从红变绿,xfail 数量保持为 0。

这个案例的教训是:反思不能拥有修改验证器的权力。一旦反思可以通过「让测试不失败」来证明自己有效,它就会沿着最便宜的路径退化。反思的产出应该是假设与下一步实验,而不是对验证标准的修改;验证标准的变更必须由独立的人或独立的验证器批准。

失败模式最早信号根因修复去哪
计划幻觉ENOTOOL / ENOENT 出现在计划早期规划器没有能力清单;计划未整体校验注册表校验、前置条件、整计划退回7.3、7.7.1
局部最优动作在变、状态不推进;take/open 迟到目标函数缺子目标;无进展度量子目标、进展向量、Plan-Execute7.2、7.7.2
反思退化验证通过但覆盖下降;规则无证据无范围同源批评、无新证据、反思可改验证器证据门、作用域、验证器保护、回滚7.5、7.7.3

这张表说明什么:三种失败都不是「模型能力不足」,而是系统结构缺失。计划幻觉缺的是能力校验,局部最优缺的是子目标,反思退化缺的是证据与权限边界。三者都可以用工程手段挡住,不需要等一个更强的模型。

7.8 实验:三种策略在同一任务上的对比

实验要回答三个问题:同一份预算下,三种策略的成功率、步数、扩展数与成本各是多少?某个策略在某个任务上变差,是因为算法本身,还是因为预算与任务结构不匹配?这些差异能不能用 trace 里的动作、观察、价值与预算余额解释?

实验不调用 LLM。原因与第 1 章相同:可复现、零成本、能把失败模式钉死。三种策略都是确定性的:ReAct 是带访问惩罚的贪心,Plan-Execute 是简化模型 BFS + 偏离重规划,ToT 是宽度 4 的 beam + 手写价值函数。地图、动作、预算、价值函数全部在 experiments/ch07/planners.py 里,改一个参数就能复现整张表。

7.8.1 任务、预算与成本口径

六个任务覆盖六类结构:无约束的开放地、需要子目标的钥匙门、有方向约束的单向门、会变化的地形(塌桥)、U 形局部最优、窄走廊。每个任务给三种策略同一份 (step_budget, expansion_budget);任务之间预算不同,因为地图大小与分支因子不同。这是「同一任务上公平比较」,不是「所有任务同一预算」。

任务关键结构想暴露的失败
open-field无障碍,直线可达规划与搜索的固定开销
key-door钥匙在反方向,门挡路ReAct 没有子目标
one-way-return单向门只能向东规划器模型与执行器不一致
collapse-bridge踩触发器塌桥偏离后的重规划成本
detourU 形墙贪心的局部绕路
long-corridor窄走廊 + 小扩展预算树搜索的宽度浪费

这张表说明什么:六个任务分别压住三种策略的一个失效点;同一任务上三种策略共享预算,才能把差异归因到策略而不是预算。

成本口径是

cost=steps+0.2×expansions. \text{cost} = \text{steps} + 0.2 \times \text{expansions}.

steps 是真实执行的动作数;expansions 是搜索/评估的节点数。ReAct 每步评估一个状态,所以 expansions = steps;Plan-Execute 只计 BFS 的扩展(执行动作不算搜索扩展);ToT 计每个生成的候选节点。失败时 steps = 0 表示没有执行任何动作,成本只剩搜索开销(例如 long-corridor 的 ToT)。权重 0.2 是人为设定的「一次扩展约为 0.2 个动作当量」,只用于把两种预算放到同一把尺子上;7.8.4 会讨论它的敏感性。

python3 experiments/ch07/planners.py --suite
task                 strategy  ok   stop          steps  exp  replans  cost
---------------------------------------------------------------------------
open-field           react    True  done             10   10       0  12.00
open-field           plan     True  done             10   34       0  16.80
open-field           tot      True  done             10  102       0  30.40
key-door             react    False budget           40   40       0  48.00
key-door             plan     True  done             15   66       0  28.20
key-door             tot      True  done             15  158       0  46.60
one-way-return       react    True  done             16   16       0  19.20
one-way-return       plan     False replan_loop       4   96       2  23.20
one-way-return       tot      True  done             15  103       0  35.60
collapse-bridge      react    True  done             18   18       0  21.60
collapse-bridge      plan     True  done             10   33       1  16.60
collapse-bridge      tot      True  done             10   27       0  15.40
detour               react    True  done             12   12       0  14.40
detour               plan     True  done             10   22       0  14.40
detour               tot      True  done             10   47       0  19.40
long-corridor        react    True  done              9    9       0  10.80
long-corridor        plan     True  done              9   13       0  11.60
long-corridor        tot      False budget            0   21       0   4.20

summary
{"avg_cost": 21.0, "avg_expansions": 17.5, "avg_steps": 13.0, "strategy": "react", "success_rate": 0.833}
{"avg_cost": 18.47, "avg_expansions": 44.0, "avg_steps": 10.8, "strategy": "plan", "success_rate": 0.833}
{"avg_cost": 25.27, "avg_expansions": 76.33, "avg_steps": 12.0, "strategy": "tot", "success_rate": 0.833}

表里的 avg_steps 只统计成功的任务,avg_cost 统计全部任务(失败也计成本)。三种策略的成功率都是 5/65/6,但失败的位置完全不同:ReAct 输在需要子目标的任务,Plan-Execute 输在计划模型与执行器不一致的任务,ToT 输在扩展预算不足的任务。成功率相同不代表策略等价;要看失败模式、成本与任务分布。

7.8.2 三个「为什么更差」

ReAct 在 key-door 上差,不是差在步数,是差在没有子目标。 表里它跑了 40 步、成本 48.00,是全场最贵的一次失败。前 21 步反复移动,始终没去左上角拿钥匙,第 22 步才偶然经过钥匙;拿到钥匙后又要绕回门,第 36 步才打开,第 40 步停在离目标一格的位置。它的每一步都在优化「到目标的距离」,而完成任务需要先做一件「暂时增大距离」的事。Plan-Execute 的 BFS 在 66 次扩展内找到 15 步的计划,成本 28.20;ToT 也找到 15 步,但用了 158 次扩展,成本 46.60。同一个正确计划,ToT 的成本是 Plan 的 1.65 倍——树搜索为「比较候选」付了钱,而这个任务的候选并不难区分。

Plan-Execute 在 one-way-return 上差,不是差在规划,是差在计划模型。 简化模型把单向门当普通地板,BFS 找出的最短计划要求从东侧向西穿门;执行器拒绝,重规划仍用同一模型,得到同一份计划。两次重规划后被判为 replan_loop,成本 23.20。ReAct 在同一任务上反而成功:它向西走撞门,观察返回 one_way,黑名单生效,改走下方通道,16 步完成。ToT 用完整模型,15 步完成,但花了 103 次扩展,成本 35.60。这里的排序是 ReAct < Plan < ToT,与 key-door 完全相反;差别不在算法「更强」,而在任务需要的是全局计划还是逐步反馈。

ToT 在 long-corridor 上差,不是差在搜索能力,是差在预算分配。 窄走廊只有一条正确路径,宽度 4 的 beam 虽然每层只有一个可行候选,但每次扩展仍要尝试多个动作;到第 9 层用掉 21 次扩展,超过 20 的预算,连一个可执行的计划都没有返回。Plan-Execute 用 13 次扩展找到 9 步计划,ReAct 用 9 步直接走到。把宽度降到 1,long-corridor 能成功(20 次扩展),但 key-door 与 collapse-bridge 会失败;把 long-corridor 的扩展预算提高到 30,它能成功(28 次扩展)。宽度与预算没有全局最优,只有按任务分布调。

另外两个结果值得单独看:

  • open-field 上 ReAct 最便宜(12.00)。Plan 的 BFS 要扩展 34 个节点才找到一条本来就走得通的直线;ToT 更贵(102 次扩展)。全局规划的固定开销在简单任务上收不回来。
  • detour 上 Plan 的动作数少 2(10 对 12),但规划扩展把总成本拉回与 ReAct 相同的 14.40。这就是 7.1 节代价公式的直观版本:计划更短,不等于总成本更低。

7.8.3 用 trace 读每一步、每个节点与预算

单看汇总表只能知道「谁赢」,看不到「为什么」。把一次运行写成 JSONL,每个记录带动作、观察、价值、累计扩展数与剩余预算,就能逐步对照。

python3 experiments/ch07/planners.py --task key-door --strategy plan --trace /tmp/ch07-plan.jsonl
jq -r '[.step, .action, .observation, .value, .expansions, .steps_left] | @tsv' \
  /tmp/ch07-plan.jsonl | head -n 8
0	__plan__	plan_len=15 replans=0	-15	66	40
1	N	moved	-8	66	39
2	N	moved	-9	66	38
3	N	moved	-10	66	37
4	take	picked_key	-10	66	36
5	E	moved	-9	66	35
6	E	moved	-8	66	34
7	S	moved	-7	66	33

第 0 行是规划阶段:plan_len=15 表示计划有 15 个动作,expansions=66 是 BFS 为此扩展的节点数。第 1–7 行是执行阶段,expansions 保持 66,因为执行动作不计入搜索扩展;steps_left 从 40 递减。value 是当前状态到目标的曼哈顿距离的相反数:第 1 步 N 让距离从 8 变成 9,价值变差,但这是去拿钥匙的必要绕路——计划知道这一点,贪心不知道。

再看总账与动作分布:

jq -s '{records: length, last_expansions: .[-1].expansions, steps_left: .[-1].steps_left}' \
  /tmp/ch07-plan.jsonl
jq -r '.action' /tmp/ch07-plan.jsonl | sort | uniq -c | sort -nr
{
  "records": 16,
  "last_expansions": 66,
  "steps_left": 25
}
   5 S
   5 E
   3 N
   1 take
   1 open
   1 __plan__

16 条记录 = 1 条规划记录 + 15 个执行动作。last_expansions=66 是规划预算的实际消耗,steps_left=25 是执行预算的余额;两个数字放在一起,才能判断「贵在规划还是贵在执行」。动作分布里 3 次 N 是先退回去拿钥匙,5 次 S 与 5 次 E 是拿到钥匙后绕回门、再走向目标;如果只看最终步数,会误以为它「走得很直」。

对 ToT,trace 里的 __expand__ 记录会给出每一层的候选与价值:

python3 experiments/ch07/planners.py --task one-way-return --strategy tot --trace /tmp/ch07-tot.jsonl >/dev/null
jq -r 'select(.action=="__expand__") | [.step, .observation, .value, .expansions] | @tsv' \
  /tmp/ch07-tot.jsonl | head -n 6
1	beam=(1,6)	-6	1
2	beam=(2,6)	-7	3
3	beam=(3,6)	-8	5
4	beam=(4,6)	-9	7
5	beam=(5,6)	-10	9
6	beam=(5,5)	-9	11

这六行说明 beam 的宽度上限是 4,但在右侧走廊里每层只有一个可行候选(其余是墙或已访问),候选集退化成一个;expansions 仍按 1、3、5、7 增长,因为每次扩展都要试多个动作,只有一条能走。价值是启发式(越小越接近目标,塌桥另加罚),不是真实成功率。到第 15 层找到目标时,累计扩展 103 次。把 --width 改成 2,one-way-return 的扩展从 103 降到 67,long-corridor 仍是 21 并失败;宽度不是单调旋钮,这正是 7.4.5 节「宽度不保证最优」的实验版本。

7.8.4 成本权重的敏感性

0.2 这个权重不是自然常数。它把「一次搜索扩展」折算成「0.2 个动作」,隐含假设一次扩展的模型/算力成本约为一次工具调用的五分之一。真实系统里这个比例可能差一个数量级:本地 BFS 一次扩展可能只要几微秒,一次 LLM 评估可能要几秒与几千 token;反过来,一次真实工具调用可能触发外部 API 计费,而搜索扩展是免费的。

权重变化会改变排序。把权重调到 0.05,Plan 与 ToT 的扩展成本被压低,Plan 在 key-door、detour 上的优势更明显;把权重调到 0.5,ToT 在 open-field、key-door 上的成本进一步放大,ReAct 在简单任务上的优势更明显。排序对权重敏感不是实验的缺陷,而是规划系统必须显式建模成本的原因:不存在脱离成本口径的「最优策略」。

一个实用的做法是不要把权重写死在代码里,而是把它变成配置,并在 trace 里同时记录原始 steps 与 expansions。这样换一个成本模型时,可以离线重算排序,不需要重跑实验。第 9 章讲评估时会把这件事扩展成「质量-成本-延迟」的帕累托分析;这里的 0.2 只是一个最小的近似。

7.8.5 测试:可复现、最优、预算、重规划

实验的测试不验证「哪个策略更聪明」,只验证四条契约:同一批任务两次运行结果完全一致;Plan-Execute 在静态任务上给出最短动作数;预算会终止;偏离计划会触发重规划,而模型不更新时会被识别为重规划循环。

python3 -m unittest discover -s experiments/ch07 -v
test_all_outcomes_respect_budgets (test_planners.TestBudget.test_all_outcomes_respect_budgets) ... ok
test_react_hits_step_budget_without_key_subgoal (test_planners.TestBudget.test_react_hits_step_budget_without_key_subgoal) ... ok
test_tot_hits_expansion_budget_on_narrow_corridor (test_planners.TestBudget.test_tot_hits_expansion_budget_on_narrow_corridor) ... ok
test_suite_is_reproducible (test_planners.TestDeterminism.test_suite_is_reproducible) ... ok
test_trace_is_jsonl (test_planners.TestDeterminism.test_trace_is_jsonl) ... ok
test_static_tasks_match_reference_bfs (test_planners.TestOptimalPlan.test_static_tasks_match_reference_bfs) ... ok
test_bridge_collapse_triggers_one_replan (test_planners.TestReplan.test_bridge_collapse_triggers_one_replan) ... ok
test_one_way_makes_plan_repeat_itself (test_planners.TestReplan.test_one_way_makes_plan_repeat_itself) ... ok
test_tot_models_one_way_and_succeeds (test_planners.TestReplan.test_tot_models_one_way_and_succeeds) ... ok
test_plan_beats_react_on_key_task_in_cost (test_planners.TestStrategyDifferences.test_plan_beats_react_on_key_task_in_cost) ... ok
test_react_gets_trapped_by_greedy_distance (test_planners.TestStrategyDifferences.test_react_gets_trapped_by_greedy_distance) ... ok
test_tot_costs_more_than_plan_on_simple_field (test_planners.TestStrategyDifferences.test_tot_costs_more_than_plan_on_simple_field) ... ok

----------------------------------------------------------------------
Ran 12 tests in 0.009s

OK

耗时随机器变化;测试数量与结果不变。十二个测试里,四个是结构性的:test_suite_is_reproducible 比较两次运行的完整结果;test_static_tasks_match_reference_bfs 用独立 BFS 核对 Plan 的动作数;test_bridge_collapse_triggers_one_replan 验证偏离后恰好一次重规划;test_one_way_makes_plan_repeat_itself 验证「模型不更新 → 同一份错计划 → replan_loop」。其余测试覆盖预算上界、trace 可序列化与策略差异。

这个实验的四个刻意限制

第一,ReAct 是手写贪心,不是 LLM;它没有语言推理,也没有「先拿钥匙」的子目标,因此不能代表带强推理的 ReAct 的上限。第二,Plan 的简化模型是刻意不完整的;真实系统里规划器可能调用同一个模拟器,也可能用另一份世界模型,两者不一致正是计划幻觉的来源。第三,价值函数是手写启发式;换成学出来的价值函数,偏差会换一种形式出现,不会消失。第四,网格与单元动作过于干净;真实工具的动作有延迟、失败率、并发与副作用,实验验证的是搜索结构与预算核算,不是生产鲁棒性。

7.8.6 从网格映射到生产系统

实验里的每个对象在生产系统里都有对应物,但成本和随机性不同。表 7.5 给出映射,避免把实验数字直接当生产基准。

实验对象生产对应迁移时必须重测
地图 rows工具注册表 + 世界模型(文件/服务/权限)工具版本、schema、前置条件
一步移动一次工具调用延迟、失败率、幂等、配额
一次扩展一次候选评估(LLM/测试/dry-run)token 成本、评估方差、限流
step_budget步数/工具调用上限任务长度分布、尾延迟
expansion_budget搜索节点/模型评估上限并发、缓存、批处理
heuristic()价值函数/critic/测试分数偏差、校准、对抗输入
replan事故恢复/重规划流程人工审批、回滚时间

这张表说明什么:实验里的 0.2 权重、手写启发式、确定性转移都只是机制近似。生产系统的成本与失败率必须用真实 trace 重测;实验能告诉你「Plan-Execute 在模型不一致时会重复错计划」,不能告诉你「在你的任务上 Plan 比 ReAct 便宜 15%」。后者只能靠第 9 章的评估与第 10 章的观测回答。

方法 7.8.1 · 复现并改动一个变量
  1. 先跑基线。 python3 experiments/ch07/planners.py --suite,保存输出;确认三项成功率与成本与本章表一致。
  2. 只改一个变量。 宽度(--width)、预算(TASKS 里的 expansion_budget)或成本权重(EXPANSION_WEIGHT),一次只改一个;改两个就无法归因。
  3. 看 trace,不只看汇总。 用 --trace + jq 检查动作分布、扩展数与剩余预算;确认失败发生在规划、执行还是重规划阶段。
  4. 写一条回归。 把改动前后的关键差异写进测试:例如「宽度 2 时 one-way-return 的 ToT 是否仍成功」「预算降到 15 时 long-corridor 的 ToT 是否更早终止」。
答案

A1 先看 stop_reason:budget 是预算耗尽,replan_loop 是同一份计划被重复生成,no_plan 是搜索空间里没有可行路径。replan_loop 通常意味着规划模型与执行器不一致;budget 意味着预算与任务分支因子不匹配;no_plan 要先检查地图/动作模型是否漏了边。

A2 把 --width 从 4 改成 2,重跑 one-way-return 与 long-corridor:前者仍成功,扩展从 103 降到 67;后者仍失败(21 次扩展)。再把宽度降到 1,long-corridor 成功(20 次扩展),但 key-door 与 collapse-bridge 失败。同一个参数在不同任务上方向相反,说明宽度没有全局最优,只能按任务分布调。

A3 改 EXPANSION_WEIGHT 并离线重算,不要改 steps 与 expansions 的原始记录。如果排序翻转,说明结论依赖成本口径;此时应该报告两个口径下的结果,而不是选一个支持自己观点的权重。

母本对应

母本章节本书落点程度
《深度学习导论》第 5 讲 5.2 MDP 与值函数7.1 状态/动作/代价;7.4 价值函数背景
《深度学习导论》第 5 讲 5.4 策略梯度7.2 ReAct 作为策略;7.5 反思与策略改进背景
《深度学习导论》第 5 讲 5.6 离线 RL 与偏好优化7.6 critic 与投票的偏差背景
ReAct(Yao et al., 2022)推理与行动交错7.2 全节完整
Tree of Thoughts(Yao et al., 2023)展开、评估、搜索7.4.1完整
Reflexion(Shinn et al., 2023)语言化反思与经验回写7.5 全节完整
Self-Refine(Madaan et al., 2023)迭代自我反馈7.5 Self-Refine 对照完整
UCT(Kocsis & Szepesvári, 2006)UCB 与树搜索7.4.2 UCB 公式完整
Self-Consistency(Wang et al., 2022)多路径投票7.6.1 投票不是验证完整
《Agent 系统工程》第 1 章1.5 预算、1.6 死循环7.1 代价、7.7.2 局部最优交叉引用

这张表说明什么:本章对《深度学习导论》第 5 讲只做背景引用,不重写 MDP、值迭代、策略梯度与 PPO;对 ReAct、ToT、Reflexion、Self-Refine、UCT、Self-Consistency 给到机制与工程边界;对第 1 章的预算与死循环做交叉引用,因为规划失败最终都会表现为预算或进展问题。

本章陷阱

#陷阱症状正确做法去哪
1把计划当轨迹模型说「已执行」,日志里没有调用计划与轨迹分开存;执行后回写真实状态7.1
2计划不校验工具/文件早期 ENOTOOL / ENOENT执行前整计划校验注册表与前置条件7.3、7.7.1
3重规划范围失控只修当前节点,或每次全量重来按依赖边局部失效;保留已验证前缀7.3
4ReAct 没有子目标在门前反复,take 迟到子目标、进展向量、Plan-Execute7.2、7.7.2
5把观察当事实过期/注入的观察进入下一步来源、时间戳、校验、重试7.2
6树搜索没有价值函数展开很多,成功率不涨先解决评估信号,再谈搜索7.4、7.6
7把 beam 宽度当质量保证宽度翻倍,成本翻倍,成功率不变看价值函数分辨率;多宽度对照7.4.5
8UCB 的 cc 不按价值尺度调探索项淹没/不起作用归一化价值,重新标定 cc7.4.2
9反思没有外部证据同一模型反复「我觉得」证据门;无证据只记假设7.5、7.7.3
10反思把一次噪声写成全局规则规则无范围、无过期、越用越差作用域、置信度、失效条件、回滚7.5
11验证器与生成器同源错误被同一盲点放过独立 critic、测试、形式检查组合7.6
12投票当验证器系统性错误被多数票放大投票只做采样;用独立验证器7.6.1

这张表说明什么:十二个陷阱里,只有第 1 个是「概念混淆」,其余都是工程缺失。它们不会因为换一个更强的模型而自动消失;相反,模型越强,计划幻觉与反思退化的文本越有说服力,越需要结构化的校验与权限边界。

本章出口检查

检查项达标线没达标去哪
能写出规划问题八元组齐全,含可行性、可逆性、预算7.1
能区分计划与轨迹各给一个例子,说出正确性依据7.1
能解释首次偏离写出定理条件,并说明为什么要检查点7.1.1
能画出计划 DAG节点、依赖、并行、重规划边界都标出7.3
能列出重规划触发条件至少四类,含观察偏离与预算7.3
能写 ReAct 循环与失效条件至少三类失效,并给出 trace 信号7.2
能解释 ToT/MCTS/beam说出展开、评估、回传、剪枝的差别7.4
能写 UCB 公式解释 QQ、NN、cc 与价值尺度的关系7.4.2
能判断反思是否有用用「外部证据」规则判断三个例子7.5
能选择验证器按失败模式组合测试/critic/投票/人工7.6
能诊断三类失败计划幻觉、局部最优、反思退化各给修复7.7
能跑实验并解释表三种策略的失败位置与成本口径都说清7.8
即时自测 · 7

Q1 一个计划在规划器的模型下可行,执行到第 3 步时真实状态与预期状态不一致。此时继续执行第 4–8 步,为什么没有保证?给出一个反例。

Q2 ReAct 在 key-door 上跑了 40 步失败,Plan-Execute 用 15 步成功。是否说明 Plan-Execute 在所有任务上都优于 ReAct?用本章实验里的另一个任务反驳。

Q3 一个验证器对每个候选的假阳性率是 0.050.05,搜索独立评估 10 个候选。至少一个错误候选被接受的概率是多少?如果验证器与生成器同源,这个数字会偏高还是偏低?为什么?

答案

A1 因为计划可行性的证明依赖每一步的转移都与模型一致(定理 7.1.1)。第 3 步偏离后,第 4 步的前置条件可能已经不成立,后续状态也不再是模型预测的状态。反例:计划假设门已打开,实际门锁着;第 3 步 move 失败后仍停在门外,第 4 步的 inspect 在错误位置执行,后续「拿到文件」的前提全部失效。正确做法是从真实状态重新检查前置条件或重规划。

A2 不能。one-way-return 上 Plan-Execute 因为简化模型把单向门当双向,重规划两次后 replan_loop,成本 23.20;ReAct 通过观察 one_way 改走下方通道,16 步成功,成本 19.20。任务结构不同,最优策略就不同:需要全局顺序时 Plan-Execute 更强,需要逐步反馈与方向约束时 ReAct 更强。

A3 1−(1−0.05)10≈0.4011-(1-0.05)^{10}\approx 0.401。如果验证器与生成器同源,真实概率通常比这个数字更高,因为候选的判定不独立:同一个盲点会同时放过一批错误,「至少一个」的事件高度相关;公式的独立性假设被破坏,不能把 0.401 当成上界。正确做法是降低 α\alpha 或引入独立验证器,而不是继续增加候选数。

下一站:第 8 章《多智能体》把单个规划器扩展成多个角色:并行、分工、对抗与协调。规划仍然存在,只是计划变成了 agent 之间的消息与 handoff;预算、验证器与失败模式也会沿着 agent 树传播。