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 的箭头回到 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 被重启,指向一个从未提升的副本。最终状态比开始更糟:主库没切,服务先抖了一次。
这个事故里没有一步是「模型算错了」。计划在纸上完全合理,问题在于计划引用的动作在当前系统里不存在,而执行器没有在动手前校验整条计划。要讲清这类失败,先要把规划问题写成可检查的对象。
一个规划问题可以写成八元组
其中:
- 是状态空间, 是初始状态;
- 是接地动作(grounded action)的集合,即「工具名 + 具体参数」,不是工具名本身;
- 是前置条件, 表示 在 下可执行;
- 是确定性效果函数;随机环境下把值域换成状态分布 ,本章其余部分默认确定性;
- 是目标集合,必须能用一个可执行的测试判定,而不是一句自然语言;
- 是动作代价,至少包含时间、金额、配额三类中的一类;
- 是规划、执行、验证三段的预算。每一段内部仍可用第 1 章的五分量(步数、工具、墙钟、token、金额)表示;这里按用途分组,避免公式过长。
一个动作序列 可行,当且仅当存在状态序列 使得对每个 都有
可行性是相对于模型的:同一个计划在规划器的 下可行,在真实执行器 下未必可行。这个缝隙就是 7.7 节「计划幻觉」的入口。
这个定义里最容易被省略的是 的「接地」。模型说「提升副本」不是动作;pg.failover(replica-2, force=false) 才是动作。接地之后,参数、前置条件、权限、幂等键才有地方挂。很多计划幻觉的根因不是模型不知道「提升副本」这件事,而是规划器把自然语言动词当成了可执行动作,没有把它映射到注册表里的具体条目。
第二个容易被省略的是 。目标必须可判定:replication_lag < 1s 可判定,系统稳定 不可判定。目标不可判定时,执行器无法知道何时停,规划器也无法验证计划是否覆盖目标;最后只能由模型说「完成了」,这等于把终止条件交给了被验证的对象。
计划是执行之前的意图结构。最弱的形式是一个全序动作序列
更强的形式是一个偏序或 DAG,见 定义 7.3.1。计划包含:动作、参数、依赖、前置条件、预期效果、代价、可逆性。
执行轨迹是执行之后留下的证据序列
其中 是环境返回的观察, 是执行后的真实状态(或它的可检查摘要)。轨迹只追加、不修改;它是审计与回放的依据。
计划回答「打算怎么做」,轨迹回答「实际做了什么」。计划可以改,轨迹不能改;计划正确性是相对于模型的,轨迹正确性是相对于日志的。
这两个对象经常被混为一谈,后果很具体:用计划当轨迹,会把「打算执行」当成「已经执行」,于是模型说「已重启服务」而日志里没有这次调用;用轨迹当计划,会在下一次运行时重放上一次的具体动作,忽略世界已经变化。表 7.1 把差别压成五行。
| 维度 | 计划 | 执行轨迹 |
|---|---|---|
| 产生者 | 规划器 / 策略 | 执行器 / 环境 |
| 内容 | 动作、参数、依赖、预期效果 | 动作、观察、真实状态、错误码 |
| 正确性 | 相对于规划模型 | 相对于执行日志与外部事实 |
| 可变性 | 可重规划、可局部替换 | 只追加,不可改写 |
| 失败信号 | 前置条件不成立、目标未覆盖 | 动作失败、状态偏离、超时 |
这张表说明什么:计划和轨迹不是同一份数据的不同格式,而是两种不同的证据。排障时先看轨迹,判断「实际发生了什么」;再对照计划,判断「原本打算做什么」。只看其中一份,都会把「计划错了」和「执行错了」混为一类。
7.1.1 首次偏离之后,计划不再有保证
计划执行最危险的不是第一步就失败,而是前几步成功、第 步悄悄偏离,后面的动作仍然「看起来在执行」。例如计划认为门已经打开,实际门还锁着;move 返回失败,但策略把失败当成「已经通过」,继续执行后续的 inspect。这时轨迹已经与计划分叉,后续动作的前提全部失效。
设计划 在模型 下可行,执行轨迹为 。若存在最小的 使得
则从 开始继续执行 ,在原计划的意义上不再保证到达 ;必须从 重新检查前置条件,或重规划。
计划可行性的证明是一个前缀归纳:对每个 ,由 与 推出下一状态,最终推出 。归纳在第 步失效:要么 的前提不成立,要么执行结果不等于模型预测,归纳假设无法继续传递。构造反例:计划假设门已开,实际门锁着,后续 move 的 仍停在门外;此时再执行「进门后的检查」只会在错误状态上产生新的观察。因此原计划的后缀不构成从 出发的可行计划。∎
这个定理的工程含义不是「计划没用」,而是计划必须带检查点。每执行一步,至少比较三样东西:动作是否成功、真实状态是否等于预期状态、关键前置条件是否仍成立。三者任一不成立,就把计划标记为「已偏离」,而不是继续往下走。
地图告诉你「从 A 到 B 应该走哪条路」,车辙告诉你「车实际开到了哪里」。地图画错、道路施工、司机拐错弯,都会让车辙偏离地图。成熟的做法不是把地图扔掉,也不是假装车辙就是地图,而是每到一个路口核对一次:我还在计划的路上吗?不在就重新规划。
7.1.2 代价:规划、执行、重规划、验证
规划不是免费的。它消耗模型 token、搜索节点、工具 dry-run 的时间和人的审批。一个完整的代价模型至少有四项:
其中 是执行偏离计划的概率。这个式子解释了一个常见困惑:为什么「计划更优」不等于「总成本更低」。规划器找到一条短 2 步的路径,但为此扩展了 50 个节点、多做了一次 dry-run,总成本可能反而更高。第 7.8 节的 detour 任务就是这种情形:Plan-Execute 的动作数比 ReAct 少 2,但规划扩展把总成本拉回同一条线。
四项成本里,验证最容易被漏算。验证包括:执行前的计划校验(工具是否存在、参数是否合法、前置条件是否成立)、执行中的断言、执行后的测试与人工复核。验证越强, 越低,但 越高;这是一条需要显式选择的曲线,不是「越严格越好」。
| 成本项 | 何时发生 | 计量单位 | 常见错误 |
|---|---|---|---|
| 生成计划、展开搜索 | 节点数、token、墙钟 | 只算 token,不算搜索与 dry-run | |
| 真正执行动作 | 步数、金额、配额、延迟 | 把失败重试排除在成本外 | |
| 偏离后重新规划 | 次数 × 单次规划成本 | 每次全量重规划,代价随步数平方增长 | |
| 校验、测试、审批 | 测试时长、人工分钟 | 默认验证免费,直到事故账单出现 |
这张表说明什么:四项成本必须一起进预算。只压 会让计划质量下降、 上升;只压 会让 agent 少做事、把未完成包装成完成;只压 会把风险推到生产。预算不是四个独立的数字,而是一组需要联合调整的杠杆。
7.1.3 可行性、可逆性与「能不能试」
规划器面对的另一个现实是:有些动作可以随便试,有些试一次就回不去。可逆性可以写成一个存在性条件:
其中 是可接受的恢复成本上界。这个定义故意把「能恢复」和「恢复得起」分开:发一封邮件在技术上可以再发一封「请忽略上一封」,但恢复成本、信誉损失和时间窗口可能都不可接受,工程上应当按不可逆处理。
可逆性直接决定搜索策略。可逆动作可以用试错换信息:先做,失败再退回来。不可逆动作必须先把验证做在前面:dry-run、权限检查、人工审批、灰度。树搜索在不可逆动作上尤其危险,因为它会展开多条分支,而每条分支都可能产生真实副作用;如果搜索用的是真实执行而不是模拟,展开就是在拿生产环境做实验。
| 动作类型 | 例子 | 搜索时能否真实展开 | 需要的验证 |
|---|---|---|---|
| 只读、幂等 | 读文件、查订单、搜索 | 可以 | 结果新鲜度、权限 |
| 可逆写 | 改工作区文件、建临时分支 | 可以,但要能回滚 | 备份、差异检查 |
| 有外部可见副作用 | 发邮件、发评论、调用第三方 | 默认不展开,先 dry-run | 幂等键、人工确认 |
| 不可逆 | 退款、删库、切换主库 | 禁止真实展开 | 双人审批、预演、回滚预案 |
这张表说明什么:搜索的「宽度」不只是算力问题,也是副作用问题。一个宽度为 8 的树搜索,在只读任务上是 8 次探索;在不可逆任务上可能是 8 次事故。规划器必须知道每个动作的可逆性,才能决定哪些分支可以真实展开、哪些只能模拟。
关于规划的五个常见误解,可以压成一张表。
| 误解 | 为什么错 | 正确做法 |
|---|---|---|
| 计划就是更长的思维链 | 思维链是文本,计划是带前置条件、效果与依赖的动作结构 | 计划要能被执行器校验;思维链不能 |
| 计划越细越好 | 细到每个参数,规划成本高、重规划范围大 | 计划到「动作 + 依赖」,参数执行时接地 |
| 计划一旦生成就不该改 | 世界会变、观察会来、预算会耗 | 带检查点;偏离即失效或重规划 |
| 可行性等于正确性 | 可行只说明相对模型的前置条件成立 | 用执行器/验证器确认效果,不把模型当世界 |
| 不可逆动作小心一点就行 | 小心不是工程控制 | 预演、审批、幂等键、回滚预案 |
这张表说明什么:五个误解里多数来自同一个混淆——把「计划文本」当成了「执行保证」。计划只是意图,保证来自检查点、验证器与权限边界。
7.1.4 四个建模选择
同一个任务可以建成不同粒度的规划问题。建模选择错了,后面换什么搜索算法都救不回来。四个选择最关键:
| 选择 | 过粗的后果 | 过细的后果 | 实用边界 |
|---|---|---|---|
| 状态里放什么 | 看不见关键前置条件,计划在错误状态上成立 | 状态爆炸,搜索节点重复且不可比较 | 只放「影响动作可行性/目标判定」的字段 |
| 动作粒度 | 一个动作包含多个不可分割的副作用,失败无法局部重试 | 动作太多,计划冗长,搜索分支爆炸 | 按「权限边界 + 可逆性」切 |
| 目标怎么判定 | 模型说完成,执行器无法验收 | 目标写成实现细节,任务稍变就失效 | 可执行断言 + 明确的验收范围 |
| 模型从哪里来 | 规划器与执行器不一致,计划幻觉 | 模型维护成本高,更新滞后 | 单一事实来源;版本化;执行前校验 |
这张表说明什么:规划问题的质量在建模阶段就决定了大半。状态太粗,计划会在错误的前提上「可行」;动作太细,搜索会被无关分支淹没;目标不可判定,终止条件就回到模型嘴里;模型与执行器不同源,计划幻觉就是结构性的,不是偶发的。
把 7.1.2 的代价公式代入一个具体算例,可以看出规划什么时候划算。设一次扩展折合 0.2 个动作,规划扩展 120 个节点,计划执行 10 步,验证 6 个动作当量,偏离概率 0.2,一次重规划 30 个动作当量:
同样任务用 ReAct,执行 15 步,重试概率 0.3,期望成本约 。在这个规模上,Plan-Execute 的规划开销收不回来。把任务放大到 50 步:Plan-Execute 约 ,ReAct 约 ;如果 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
价值从 走到 ,看起来在接近目标;然后又开始在 之间摆动。直到第 22 步,它才偶然经过钥匙(take),第 36 步才打开门(open),第 40 步停在 (4,5)——离目标 (5,5) 只差一步,预算耗尽。take 与 open 各只发生一次,都不是计划出来的,是逛到的。这不是「模型不够聪明」,而是在它到达钥匙之前,策略的目标函数里根本没有「钥匙」这一项。给它加一条「门锁着且没有钥匙时,先去找钥匙」的规则,它通常就能成功;但这条规则本质上是一个两级计划,ReAct 已经不再是纯反应式策略。
在雾里开车,只看车灯(局部观察)比闭眼背地图(全局计划)更安全;但如果目的地在一道锁着的门后面,车灯再亮也照不出「先去拿钥匙」这一步。ReAct 适合反馈频繁、动作可逆、视野内的下一步就足够好的任务;不适合需要跨多个房间取钥匙再开门的任务。
- 「ReAct 不需要计划」——它不需要显式的全局计划,但仍然有一个隐式策略;策略的局部性就是它的上限。任务需要顺序约束时,必须显式补上子目标。
- 「Thought 会被执行」——只有 Action 进入执行器。Thought 是上下文的一部分,可能错误、可能不忠实;把 Thought 当承诺是排障时常见的误判。
- 「Observation 是事实」——观察可能过期、被截断、被注入,甚至来自另一个任务。观察要有来源、时间戳与校验;不可信观察不能直接当指令。
- 「步数越多,成功率越高」——步数增加会放大上下文漂移、成本与副作用面。没有进展度量的「再试一次」,只是把失败拖长。
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 的汇合点,不能由生成计划的同一个模型自己批准。
一个计划 DAG 是
其中 是接地动作实例, 是依赖边: 表示 必须在 成功之后才能开始。每个节点带前置条件 、预期效果 、代价 与可逆性 。
一个调度是 的拓扑序;若同一层有多个节点,它们可以并行执行。计划有效,当且仅当对每个合法的拓扑序,每个节点执行时其前置条件都成立;如果两个并行节点会互相干扰(写同一文件、抢同一配额),必须加一条边把它们串行化,或者拆成两个资源域。
DAG 的代价不只是节点代价之和。并行会带来协调成本:两个分支同时写同一个文件会冲突;同时调用同一个限流 API 会互相挤占配额;一个分支失败时,另一个分支可能已经产生了需要回滚的副作用。工程上要给每个节点标「资源域」(文件、数据库、外部 API、预算桶),同一资源域内的写操作默认串行;只有读操作或明确声明无冲突的写操作才允许并行。
计划的结构定了,接下来要定什么时候重规划。重规划太晚,会沿着错误计划产生更多副作用;太早,会把本可局部修复的问题升级为全局重来。表 7.3 给出六类触发条件与对应的处理范围。
| 触发条件 | 典型信号 | 处理范围 | 不该做什么 |
|---|---|---|---|
| 前置条件失败 | ENOENT、EPERM、文件不存在 | 替换该节点或它的上游 | 把权限错误当瞬时错误重试 |
| 观察与模型不符 | 状态哈希变化、返回值超出预期 | 失效该节点的后代 | 继续执行依赖旧状态的节点 |
| 新信息到达 | 搜索结果、用户新约束、监控告警 | 重新评估目标与约束 | 忽略新约束,继续旧计划 |
| 预算低于保留线 | 剩余步数低于验证预留 | 降级为「少探索、多确认」 | 借预算继续搜索 |
| 连续无进展 | 动作/观察指纹重复 | 局部重规划或终止 | 只增加重试次数 |
| 验证失败 | 测试红、类型错、critic 否决 | 回到最近的可疑节点 | 让生成者自己批准 |
这张表说明什么:重规划不是「重新生成整条计划」的同义词。大多数偏离只需要替换一个节点或一段子图;全量重规划的成本高,而且会把已经验证过的前缀丢掉。重规划的范围应该由依赖边决定,而不是由模型的方便程度决定。
重规划的成本可以写成
其中 是实际发生的重规划集合。若执行环境稳定、计划准确, 很小,Plan-Execute 的总成本接近「一次规划 + 一次执行」;若环境频繁变化, 增大,规划成本会被重复支付,这时 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 的计划,执行器只能「照着调用」,无法在调用前发现计划已经失效。
计划生成后、执行前,至少要过五道校验:
- 能力校验。 每个
action都存在于当前工具注册表;参数符合 schema;没有引用不存在的工具或版本。 - 前置条件校验。 每个节点的
pre能在当前状态上求值;不能求值的条件视为不成立。 - 资源校验。 并行分支不争抢同一资源域;总配额不超过预算。
- 可逆性校验。 不可逆节点必须有审批或 dry-run;不能与自动重试共存。
- 目标覆盖校验。 计划的后置条件能推出目标 ;不能推出时,计划再漂亮也不完整。
五道校验里,第 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。 每一层只保留价值最高的 个节点,简单、可预测、易并行;没有回溯,也没有最优性保证。
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 的质量:
generate(s,k)决定候选多样性。温度太低, 个候选几乎一样,展开是浪费;温度太高,候选大多不可行,评估器被噪声淹没。工程上常用的做法是「先广后深」:前几层多生成,接近叶子时少生成。value(c)决定搜索方向。它可以是 LLM 打分、程序化启发式、测试通过率、独立 critic 的投票;来源不同,偏差不同(见 7.4.4)。select_top决定预算分配。BFS 保留每层最好的 个,适合深度不大、需要比较多个中间状态的任务;DFS 深入一条分支,适合有明确回溯点、单条路径很长的任务。
「回传」在 ToT 的 BFS 版本里通常表现为「把子节点的最好价值汇总到父节点」,用于决定父节点是否还值得继续展开;在 MCTS 里则是显式的反向传播。两者的区别很重要:ToT 的回传是启发式的,没有访问次数的严格统计;MCTS 的回传会更新每个节点的 与 ,让下一次选择有据可依。
一个具体的例子是修一个失败的测试。根节点是「当前工作区」,展开出三个候选:改实现、改测试、加兼容层。每个候选再展开出两个具体补丁。评估器跑单元测试,得到通过/失败与覆盖率。BFS 保留通过率最高的两个补丁继续展开;DFS 则先深入「改实现」这一支,如果测试全绿就返回,不再看另外两支。这里的关键不是树,而是评估器能在中间状态给出信号:如果只有最终测试能打分,中间节点全是 0,树搜索就退化成枚举。
7.4.2 MCTS 与 UCB
MCTS 把「探索哪条分支」变成一个多臂老虎机问题。每个节点 的每个动作 都维护两个统计量: 是被选中的次数, 是这些次选择得到的平均价值。选择阶段用 UCB 决定下一步(UCB1 见 Auer et al., 2002;UCT 见 Kocsis & Szepesvári, 2006):
三项的含义:
- 是利用项:过去这个动作的平均回报越高,越容易被再次选中。
- 是探索项: 越小,这一项越大;父节点被访问得越多, 越大,未试过的动作越有吸引力。
- 是探索系数: 大,搜索更愿意尝试访问少的分支; 小,搜索更快收敛到当前看起来最好的分支。
不是「越大越好」的魔法常数。它和价值的尺度绑定:如果 ,经典 bandit 设置常用 ;如果 是 的分数,同一个 几乎不起作用,探索项被价值差淹没。换价值函数时,必须重新标定 ,否则搜索要么完全不探索,要么把预算浪费在明显差的分支上。
MCTS 的四步是:
- 选择(selection)。 从根开始,按 UCB 一路选到叶子;遇到未展开的动作就停下。
- 扩展(expansion)。 为选中的节点增加一个或多个子节点。
- 模拟(simulation / rollout)。 从新节点出发,用一个便宜的策略走到终点或深度上限,得到一个价值估计。
- 回传(backpropagation)。 把这次价值沿路径累加,更新每个节点的 与 。
在 LLM agent 里,第 3 步通常不是随机模拟,而是「让模型快速给一个估计」或「跑一个便宜的启发式」;这省掉了昂贵的真实 rollout,但也把价值函数的偏差带进了搜索。MCTS 的统计保证建立在模拟的无偏性上;如果模拟本身有系统性偏差,访问次数再多也只会更自信地收敛到错误答案。
7.4.3 一次 UCB 计算
公式只有三项,但 的作用要算一遍才直观。设节点 被访问 次,三个动作的统计如下;取 与 各算一次。
| 动作 | 探索项 | UCB() | UCB() | ||
|---|---|---|---|---|---|
| A | 6 | 0.60 | 0.6195 | 1.467 | 0.724 |
| B | 3 | 0.70 | 0.8761 | 1.927 | 0.875 |
| C | 1 | 0.40 | 1.5174 | 2.524 | 0.703 |
时选 C:它的 最低,但只被访问过一次,探索项把它推到第一。 时选 B:探索的权重不足以抵消 C 的价值差距,搜索转向当前平均回报最高的动作。 时也选 B,搜索完全利用。三个结果没有对错,取决于你更怕「错过一个没试过的好分支」还是「把预算浪费在差分支上」。
这张表还说明 与价值尺度必须绑定。若 被归一化到 , 的探索项与价值同量级;若 是 的评分,同一个 的探索项几乎可以忽略,搜索会立刻退化成贪心。换价值函数时,先看 的取值范围,再定 ;不要把一个任务的 直接搬到另一个任务。
7.4.4 价值函数从哪里来
树搜索的质量上限由价值函数决定。表 7.4 比较五种常见来源。
| 来源 | 成本 | 偏差来源 | 适合判断 | 不适合判断 |
|---|---|---|---|---|
| 程序化启发式 | 极低 | 设计者假设、短视 | 距离、资源、明显违规 | 语义正确性、风格 |
| LLM 自评 | 低 | 位置/长度偏好、自我偏好、提示敏感 | 草稿完整性、思路合理性 | 事实正确性、细微 bug |
| 训练的价值模型 | 中 | 分布漂移、奖励黑客 | 分布内排序 | 分布外任务、对抗输入 |
| 测试/形式验证 | 高 | 覆盖不足、规格错误 | 可执行断言、类型/不变量 | 未覆盖的需求、体验 |
| 人工评审 | 极高 | 评审者差异、疲劳 | 高风险决策、最终验收 | 大规模候选筛选 |
这张表说明什么:没有一种价值函数同时便宜、准确、覆盖广。树搜索的工程问题不是「选最好的价值函数」,而是「按阶段组合」:前几层用便宜启发式剪枝,中间用 LLM/critic 排序,叶子用测试或人工验证。组合的代价要计入预算,不能默认验证免费。
价值函数还有三个容易被忽略的偏差:
- 位置偏差。 LLM 打分对候选在提示中的顺序敏感;同一组候选换一个顺序,排名可能变化。评估时要随机化顺序或做配对比较。
- 长度偏差。 更长的候选看起来更「完整」,却可能包含更多未经证实的断言。用测试或引用核验对冲。
- 同源偏差。 生成候选的模型与打分的模型是同一个,盲点高度相关;它会给自己的错误打高分。至少换一个提示、换一个模型,或引入执行器信号。
树搜索不会凭空知道哪条路对。它做的是:把原本花在一条路上的预算,分一部分去尝试其他路,再用价值函数把试过的结果汇总。如果价值函数是抛硬币,搜索只是把一次抛硬币变成很多次抛硬币;如果价值函数能区分 70% 的好坏,搜索才可能把那 70% 变成更高的成功率。
7.4.5 Beam search 与一个不可能定理
Beam search 是最简单的树搜索:每一层展开所有候选,按价值排序,只保留前 个,继续下一层。它的成本近似为
其中 是平均分支因子, 是深度。 越大,保留的候选越多,成本线性上升;但 大不等于质量高,因为被保留的候选可能高度相关,也可能在早期就被错误的价值函数带偏。
深度也是预算变量。MCTS 的成本近似为「模拟次数 × 深度」,ToT 的成本近似为「每层候选数 × 深度」;每多一层,成本线性累加,而收益通常递减。工程上要给深度设上限,并用「连续几层最好价值不再提升」作为早停条件,而不是让搜索一直展开到预算耗尽。
设搜索树有限、价值函数只用于剪枝、beam 宽度为 。则存在一棵树与一个价值函数,使得最优解在某一层被排出前 名,从而被永久剪掉;此时 beam 返回的路径不是最优路径,甚至可能不到达目标。
构造一棵深度为 2 的树。根有 个子节点 。价值函数给 打高分(例如 0.9),给 打低分(0.8)。 的每个后代都是死路(价值 0.1,且不到达目标); 的唯一后代是目标(价值 1.0)。宽度为 的 beam 在第一层保留 ,剪掉 ;第二层只能从死路里选,返回失败或次优路径。价值函数与树都是合法的,因此不存在「宽度 的 beam 一定最优」的保证。∎
∎这个定理不是说 beam 没用,而是说它的保证是经验性的:在特定任务分布上,价值函数的前 名恰好包含正确分支,beam 就有效;换一个分布,同样的宽度可能刚好剪掉唯一正确的分支。工程上要用「留一候选」「随机重启」「多宽度并行」来对冲,代价是更多扩展。
- 「树搜索一定比 ReAct 强」——只有在价值函数能区分候选、预算足够覆盖展开时才成立。价值函数接近随机时,树搜索只是更贵的随机采样。
- 「MCTS 必须做随机 rollout」——LLM agent 常用价值估计或启发式代替 rollout;省成本,但引入了价值偏差,统计保证不再自动成立。
- 「UCB 的 越大探索越充分」—— 必须与价值尺度匹配; 过大会把预算撒在差分支上,过小会过早收敛。
- 「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)是同一轮内的迭代:模型生成初稿,模型自己给反馈,模型根据反馈修改,重复若干轮。它不需要跨回合记忆,也不需要外部环境;优点是便宜、通用,缺点是反馈与生成同源,遇到模型自己看不见的错误时,迭代会把错误改得更精致,而不是改对。
| 维度 | Reflexion | Self-Refine |
|---|---|---|
| 作用范围 | 跨尝试(attempt 1 → 2 → …) | 单次尝试内(draft → critique → revise) |
| 反馈来源 | 外部 Evaluator(测试、环境、匹配) | 同一模型的自评 |
| 记忆 | 写入 episodic memory,供后续检索 | 通常不持久化 |
| 适合 | 有明确成功信号、可重试的任务 | 风格、完整性、可读性等软约束 |
| 风险 | 错误教训被长期记忆放大 | 同源盲点导致「越改越自信」 |
这张表说明什么:两者都叫「反思」,但证据结构完全不同。Reflexion 的收益来自「外部信号 + 跨尝试记忆」;Self-Refine 的收益来自「低成本迭代 + 软约束」。把 Self-Refine 用在需要事实正确性的任务上,等于让同一个模型给自己判卷。
7.5.1 一条反思应该带什么
一条能安全回写的反思,至少包含六个字段:
一条反思条目可以写成
其中:
- 是断言:这次失败的原因是什么,下次要改什么;
- 是证据:支持这条断言的测试输出、trace 片段、错误码、外部评审;没有证据时 ;
- 是适用范围:哪个任务、哪个工具、哪个错误码、哪个版本;默认不是「所有任务」;
- 是动作:下次具体怎么做,必须是可执行的动作或检查,不是「更小心」;
- 是置信度:这条反思有多确定,来自一次观测还是多次复现;
- 是失效条件:什么情况下这条反思应该被删除或重新评估。
没有 的反思只能算「假设」,不能直接写成规则;没有 的反思会从一次失败泛化到所有任务;没有 的反思会永久污染记忆。
这个定义把「反思」与「复盘」区分开。复盘可以没有结论,反思必须产出一个可执行的修正;但这个修正仍然只是假设,必须经过下一次尝试或独立验证才能升级为规则。把假设直接写成全局规则,是反思退化最常见的起点。
在把一条反思写回记忆之前,按顺序问五个问题:
- 有新证据吗? 这条反思引用了哪条测试、哪个 trace、哪个错误码?如果只是「我觉得」,停止。
- 证据支持这个范围吗? 一次失败只能支持「在这个任务/这个错误码下」的修正,不能支持「所有任务都要这样」。
- 修正可执行吗? 「更小心」不是动作;「在
move前检查door.locked」才是。 - 失败可复现吗? 能在隔离环境重放一次吗?不能复现的失败,先记录,不写规则。
- 谁来验证? 下一次尝试的成功信号是什么?如果还是同一个模型自评,可信度要打折。
五问里任何一问没有答案,就把反思标为「假设」,只放在当前任务的上下文里,不写入长期记忆。
答案
A1 不需要。反思的输入是轨迹与证据,不是「再想一遍」;如果没有任何新证据,反思最多是把旧信念换一种说法,收益接近零,风险是把噪声写成规则。
A2 把它写成带范围的假设,例如「在 pytest 的 test_race 上,若失败伴随 TimeoutError,先查共享状态,不要直接跳过测试」;绑定任务、错误码与验证方式,并设置过期时间。不要写成「测试不稳定就跳过」。
A3 回滚这条记忆,保留原始证据与回滚原因;在下一次反思时把「这条规则被证明有害」作为新证据。记忆系统要有版本与墓碑,不能只追加不删除(第 6.6 节)。
A4 先不写规则,把它记成一条待复现事件:保留日志、请求 id、时间窗与最小输入;等能复现或出现第二次同类证据,再进入反思。
A5 下一尝试的验证信号必须是外部的(测试、指标、独立评审);如果只能自评,就把置信度降到「假设」,并加一个独立检查,不能直接升级为规则。
7.5.2 反思退化的三个条件
反思不是单调改进的。下面三个条件同时出现时,反思很可能让下一次更差:
- 同一模型、没有新证据。 生成者与批评者共享同一套盲点;模型会把自己的错误解释成「环境问题」或「测试问题」。它写得越具体,越有说服力,也越容易把错误固化成规则。
- 批评与生成同源。 同一个上下文、同一个提示模板、同一个模型,批评只是生成的镜像。要打破同源,至少换一个提示、换一个模型、或者引入执行器信号(测试、类型检查、外部检索)。
- 把一次噪声写成全局规则。 一次超时被写成「这个 API 总是超时,应该跳过」;一次权限失败被写成「遇到
EPERM就重试」。规则的范围远大于证据的范围,下一次在不相关的任务上触发。
还有一个不那么明显但很常见的退化:反思文本挤掉了任务约束。反思通常写在上下文的前部或尾部,模型为了让反思「被看见」,会把任务约束挤到中间;第 5 章讲过中间位置的可利用性最差。结果是反思被记住了,原始目标被忘了。
- 「反思越多越好」——没有新证据的反思是空转;有证据但范围过宽的反思是污染。反思的次数应该受预算与证据量约束。
- 「自我批评能替代外部验证」——同一个模型的自我批评与生成高度相关;它能发现「看起来不对」的问题,很难发现「看起来对但事实错」的问题。
- 「反思写进记忆就生效」——记忆会被检索、压缩、冲突覆盖;没有作用域与失效条件的反思,会在错误的任务上被召回。
- 「失败是成功之母」——只有被正确归因的失败才是。错误归因的失败会强化错误策略,比不反思更糟。
判断一条反思是否值得写回,可以用一句工程规则:没有外部证据的反思,不进入长期记忆;有外部证据但范围过宽的反思,只进入当前任务的临时上下文。这条规则牺牲了一部分「举一反三」的能力,换来的是记忆不被噪声污染。等同一类证据出现多次,再由人或独立评估器把它升级为规则,而不是让模型在一次失败后自己宣布「我学会了」。
7.6 验证器:测试、形式检查、critic 与投票
7.4 节说「价值函数能区分好坏,树搜索才划算」。这句话里的「区分」不是免费的:它需要一个验证器。验证器可以是单元测试、类型检查、形式化不变量、独立的 critic、多个样本的投票,也可以是真实环境的执行结果。它们的强度、成本与偏差完全不同,混用会让搜索的收益被假阳性吃掉。
先看一个反例。某代码 agent 为同一个 bug 生成了 20 个候选补丁,用一个 LLM critic 打分,取最高分的补丁提交。critic 给每个补丁都写了「看起来合理」的评语,最高分补丁把一个边界条件从 x < 0 改成了 x <= 0,测试没覆盖这个边界,CI 全绿。三天后,负数输入触发了一次线上事故。这里的问题不是搜索不够,而是验证器把「看起来合理」当成了「正确」;候选越多,找到「看起来最合理但实际错误」的概率越高。
一个验证器 对一个候选 输出接受/拒绝。它的强度至少由五个量描述:
- :假阳性率,把错误候选判为接受的概率;
- :假阴性率,把正确候选判为拒绝的概率;
- :覆盖率,验证器实际检查的失败模式占目标失败模式的比例;
- :单次验证成本(时间、金额、token、人工分钟);
- :与生成器的独立性, 表示完全独立, 表示同源。
一个验证器「更强」,是指在目标失败模式上 更低、 更高、 更大,且 在预算内。强度不是一个标量:测试的 低但 受覆盖限制;critic 的 低但 往往很小;形式验证的 取决于规格,规格写错时 仍然可能很高。
这五个量解释了为什么「加一个 critic」不能解决所有问题。critic 的成本低、覆盖语义广,但它与生成器同源, 小;测试的 大、 低,但 受测试集限制,且 高。工程上不是选一个「最强」的验证器,而是按失败模式组合:结构错误用类型/schema,行为错误用测试,语义/风格用 critic,高风险决策用人工。
| 验证器 | 主要信号 | 强在哪 | 弱在哪 | 典型成本 |
|---|---|---|---|---|
| 测试 | 通过/失败、覆盖率 | 可执行、可复现、独立性高 | 只覆盖写出来的用例;可能 flaky | 秒到分钟 |
| 类型/形式检查 | 类型、不变量、模型检查 | 能在执行前排除整类错误 | 规格工程贵;只证明「符合规格」 | 毫秒到小时 |
| 独立 critic | 语义评审、风格、完整性 | 灵活、便宜、能看未形式化的要求 | 与生成器同源时盲点重合;提示敏感 | 一次模型调用 |
| 投票 / self-consistency | 多数答案 | 简单、可并行、对随机错误有效 | 错误相关时无效;成本 ×N | N 次采样 |
| 执行/环境反馈 | 真实成功/失败 | 最接近事实 | 慢、可能不可逆、覆盖有限 | 秒到天 |
| 人工评审 | 判断与责任 | 能处理模糊与高风险 | 贵、慢、评审者差异 | 分钟到小时 |
这张表说明什么:验证器的选择由失败模式决定,而不是由「哪个更先进」决定。一个团队如果只有 critic,就会在事实错误上反复;如果只有测试,就会在测试没覆盖的需求上反复;如果只有投票,就会在系统性错误上反复。验证器组合的目标是让 、、 覆盖主要失败模式,而不是让某一个指标好看。
7.6.1 搜索越多,假阳性越容易混进来
假设搜索评估的 个候选都是错误候选,每个被错误接受的概率是 ,候选之间独立。至少有一个假阳性被接受的概率是:
时, 约为 , 约为 , 约为 。这就是「弱验证器 + 大搜索」的危险:搜索本来是为了找到更好的候选,但它同时把 次独立的机会交给了验证器的错误率。搜索越大,越容易找到一个「验证器喜欢但实际错误」的候选。
设验证器对每个错误候选的假阳性率为 ,各候选的判定相互独立,搜索评估 个错误候选并接受至少一个通过验证的候选。则至少接受一个错误候选的概率为
它随 单调递增,且当 时趋于 1。若 个候选里只有 个是错误的,把公式中的 换成 ;因为 ,最坏情况不超过 。若候选判定不独立(例如同一模型的错误高度相关),该概率不能用此公式精确计算,但「搜索放大验证器偏差」的方向不变。
每个候选被错误接受的概率是 ,被正确拒绝的概率是 。 个独立候选全部被正确拒绝的概率是 ,其补集就是至少一个假阳性被接受的概率。随着 增大(或直接观察 随 递减),该概率随 单调递增;因为 , 时 。∎
∎这个定理有一个重要的前提:候选之间独立。在 LLM 里,同一提示、同一模型生成的候选高度相关,错误往往成簇出现;此时公式给出的数字不准确,但结论更糟:相关性让「多个候选」提供的独立证据变少,验证器的同一个盲点会同时放过一批错误。投票也是如此:如果 9 个样本共享同一个错误,多数票只会让错误更自信。
Self-consistency(Wang et al., 2022)用多条推理路径的多数答案提升准确率,前提是正确答案比错误答案更容易被多条独立路径到达。它不是验证器:它不知道哪个答案是真的,只知道哪个答案被重复得更多。投票适合「答案空间小、错误随机」的任务;不适合「错误系统性、答案开放」的任务。把投票结果当事实,是把采样频率当成了证据强度。
7.6.2 验证成本与验证顺序
验证不是越多越好,因为 会挤占搜索与执行的预算。一个实用的顺序是「从便宜到贵,从结构到语义,从自动到人工」:
- 结构检查(毫秒)。 JSON schema、类型、必填字段、工具是否存在、参数范围。挡掉大多数计划幻觉与格式错误。
- 静态检查(秒)。 编译、类型检查、lint、依赖解析。挡掉整类语法/类型错误。
- 单元测试(秒到分钟)。 针对改动的行为断言;先跑快测试,再跑全量。
- 独立 critic(一次模型调用)。 检查测试覆盖不到的需求、边界与风格;必须与生成器不同源,或至少不同提示/上下文。
- 端到端/人工(分钟到小时)。 高风险、不可逆、跨系统的影响;放在最后,只对通过前四层的候选执行。
这个顺序的收益是用便宜检查缩小候选集,让昂贵检查只处理少量候选。反过来,先跑人工评审再跑 schema,会把人的时间浪费在格式错误上;先跑全量测试再跑 lint,会让一次拼写错误触发十分钟的 CI。
验证成本还有一个隐性部分:验证器本身可能被生成器修改。在上面的补丁例子里,如果 agent 有权改测试或加 xfail,它可以通过「让验证器闭嘴」来通过验证。工程上要把验证器放在生成器的写权限之外:测试文件、CI 配置、审批规则不能由同一个 agent 在同一轮里修改;确需修改时,必须单独审批并留下 diff。
| 验证层 | 典型耗时 | 挡住的失败 | 不能挡住 |
|---|---|---|---|
| schema/类型 | 100 ms 内 | 缺字段、类型错、工具不存在 | 逻辑错、语义错 |
| 编译/lint | 10 s 内 | 语法、未定义符号、明显坏味道 | 运行时行为、需求偏差 |
| 单元测试 | 秒到分钟 | 已知行为回归 | 未覆盖的边界、集成问题 |
| critic | 秒 | 完整性、风格、明显矛盾 | 事实错误、同源盲点 |
| 端到端/人工 | 分钟到小时 | 集成、权限、高风险决策 | 长尾、未预见的交互 |
这张表说明什么:每一层都有明确的盲区,验证器组合的价值在于盲区不重叠。一个只有单元测试的系统,会在「测试没覆盖的需求」上翻车;一个只有 critic 的系统,会在「看起来合理的错误」上翻车;一个只有投票的系统,会在「系统性错误」上翻车。把三层叠起来,不是为了追求 100% 正确,而是为了让错误在便宜的地方暴露。
验证器强度还决定了搜索是否有意义。设验证器的区分度只比随机猜测高一点( 接近 ),那么增加候选数量主要在增加假阳性;此时更值得做的是改进验证器,而不是加大搜索。反过来,如果验证器很强但很贵(例如形式验证),就应该先用便宜启发式把候选缩到很小,再把强验证器用在少数候选上。搜索结构与验证器强度必须匹配,这是 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}
触发条件。 规划器只拿到自然语言目标与一份过时的工具摘要,没有拿到当前工具注册表;计划生成后没有整体校验,执行器逐个节点执行,失败后只替换当前节点。
观测证据。 第 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,价值在 到 之间摆动;第 22 步才偶然经过钥匙,第 36 步才打开门,第 40 步停在 (4,5),离目标 (5,5) 只差一步。take 与 open 各只发生一次,而且都不是计划出来的,是逛到的。
触发条件。 策略是纯贪心:每步选「到目标距离最小」的动作,加上一点访问惩罚避免立即回头;没有子目标,没有进展度量,也没有「钥匙未拿到」这个状态字段参与决策。
观测证据。 40 步里 E=11, N=10, S=11, W=6,take=1(第 22 步),open=1(第 36 步),最终位置 (4,5),目标 (5,5)。前 21 步没有进入钥匙所在的左上角,因为那会增大到目标的距离;访问惩罚只让它不被同一个格子吸住,不能让它产生「先退回去拿钥匙」的全局意图。
根因。 不是模型不会推理,而是策略的目标函数里没有「钥匙」这一项。局部最优不是「走到了一个坏地方」,而是「每一步都按当前距离最优,合起来却绕不开一个必须先做的子目标」。
修复。 三个层次,按成本从低到高:
- 加子目标。 在任务状态里维护
has_key与door_open;当door_open=false且has_key=false时,把「到钥匙的距离」作为临时目标,而不是到终点的距离。这是最小修复,等价于给策略加一个两级计划。 - 加进展度量。 用
(has_key, door_open, 距离)组成进展向量;连续 步没有改进就触发重规划或转 Plan-Execute。第 1 章的指纹检测只能发现「动作 + 观察不变」,发现不了「动作在变、目标没推进」。 - 改成 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)
触发条件。 反思由生成代码的同一个模型完成,输入只有「测试失败」这一个信号,没有堆栈、没有复现步骤、没有并发日志;反思的输出可以直接修改测试文件;记忆没有作用域与过期时间。
观测证据。 第一次反思的 evidence=[],却写出了 scope=global 的规则;第二次的「成功」来自 xfail,测试通过数上升、失败数归零,但这不是修复,是把验证器关掉;第三次 xfailed 从 1 增加到 4,覆盖率持续下降。
根因。 三个条件同时成立:没有新证据(只有失败事实)、批评与生成同源(同一个模型)、把一次噪声写成全局规则(一个测试 → 所有并发测试)。更深一层的问题是反思有权限修改验证器:当「让测试通过」成为目标,最便宜的路径就是让测试不失败。
修复。 四道门禁:
- 证据门。 反思必须引用至少一条外部证据:堆栈、日志、可复现命令、监控指标。没有证据时,只能记录「假设」,不能改代码或测试。
- 作用域门。 规则必须绑定任务、测试 id、错误码与版本;默认作用域是「当前任务」,升级为全局需要多次独立复现或人工批准。
- 验证器保护。 生成器不能修改测试、CI 配置、
xfail标记与审批规则;确需修改时,走独立 diff 与人工审批。反思可以建议改验证器,但不能自己执行。 - 回滚门。 每条写回的记忆带
expires_at与rollback字段;下一次尝试如果指标变差,自动回滚并保留证据。
修复后的反思写成:「在 test_transfer_race 上,TimeoutError 伴随 shared_balance 未加锁;下次先查 transfer() 的锁范围,不要改测试标记。」它有证据、有作用域、有可执行动作、有验证方式;下一次尝试先复现,再修锁,测试从红变绿,xfail 数量保持为 0。
这个案例的教训是:反思不能拥有修改验证器的权力。一旦反思可以通过「让测试不失败」来证明自己有效,它就会沿着最便宜的路径退化。反思的产出应该是假设与下一步实验,而不是对验证标准的修改;验证标准的变更必须由独立的人或独立的验证器批准。
| 失败模式 | 最早信号 | 根因 | 修复 | 去哪 |
|---|---|---|---|---|
| 计划幻觉 | ENOTOOL / ENOENT 出现在计划早期 | 规划器没有能力清单;计划未整体校验 | 注册表校验、前置条件、整计划退回 | 7.3、7.7.1 |
| 局部最优 | 动作在变、状态不推进;take/open 迟到 | 目标函数缺子目标;无进展度量 | 子目标、进展向量、Plan-Execute | 7.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 | 踩触发器塌桥 | 偏离后的重规划成本 |
detour | U 形墙 | 贪心的局部绕路 |
long-corridor | 窄走廊 + 小扩展预算 | 树搜索的宽度浪费 |
这张表说明什么:六个任务分别压住三种策略的一个失效点;同一任务上三种策略共享预算,才能把差异归因到策略而不是预算。
成本口径是
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 统计全部任务(失败也计成本)。三种策略的成功率都是 ,但失败的位置完全不同: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 章的观测回答。
- 先跑基线。
python3 experiments/ch07/planners.py --suite,保存输出;确认三项成功率与成本与本章表一致。 - 只改一个变量。 宽度(
--width)、预算(TASKS里的expansion_budget)或成本权重(EXPANSION_WEIGHT),一次只改一个;改两个就无法归因。 - 看 trace,不只看汇总。 用
--trace+jq检查动作分布、扩展数与剩余预算;确认失败发生在规划、执行还是重规划阶段。 - 写一条回归。 把改动前后的关键差异写进测试:例如「宽度 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 |
| 4 | ReAct 没有子目标 | 在门前反复,take 迟到 | 子目标、进展向量、Plan-Execute | 7.2、7.7.2 |
| 5 | 把观察当事实 | 过期/注入的观察进入下一步 | 来源、时间戳、校验、重试 | 7.2 |
| 6 | 树搜索没有价值函数 | 展开很多,成功率不涨 | 先解决评估信号,再谈搜索 | 7.4、7.6 |
| 7 | 把 beam 宽度当质量保证 | 宽度翻倍,成本翻倍,成功率不变 | 看价值函数分辨率;多宽度对照 | 7.4.5 |
| 8 | UCB 的 不按价值尺度调 | 探索项淹没/不起作用 | 归一化价值,重新标定 | 7.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 公式 | 解释 、、 与价值尺度的关系 | 7.4.2 |
| 能判断反思是否有用 | 用「外部证据」规则判断三个例子 | 7.5 |
| 能选择验证器 | 按失败模式组合测试/critic/投票/人工 | 7.6 |
| 能诊断三类失败 | 计划幻觉、局部最优、反思退化各给修复 | 7.7 |
| 能跑实验并解释表 | 三种策略的失败位置与成本口径都说清 | 7.8 |
Q1 一个计划在规划器的模型下可行,执行到第 3 步时真实状态与预期状态不一致。此时继续执行第 4–8 步,为什么没有保证?给出一个反例。
Q2 ReAct 在 key-door 上跑了 40 步失败,Plan-Execute 用 15 步成功。是否说明 Plan-Execute 在所有任务上都优于 ReAct?用本章实验里的另一个任务反驳。
Q3 一个验证器对每个候选的假阳性率是 ,搜索独立评估 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 。如果验证器与生成器同源,真实概率通常比这个数字更高,因为候选的判定不独立:同一个盲点会同时放过一批错误,「至少一个」的事件高度相关;公式的独立性假设被破坏,不能把 0.401 当成上界。正确做法是降低 或引入独立验证器,而不是继续增加候选数。
下一站:第 8 章《多智能体》把单个规划器扩展成多个角色:并行、分工、对抗与协调。规划仍然存在,只是计划变成了 agent 之间的消息与 handoff;预算、验证器与失败模式也会沿着 agent 树传播。