计划的敌人不是「模型不够聪明」,而是「计划留不住」。 长任务的计划必须外化到独立于进程的状态里——留在上下文里,上下文一压缩、一轮次就没了。而 Q2「第 15 步挂了怎么办」的错答案是「重新执行」,因为它会把已经发生的副作用再做一遍。
前七期建立了「循环怎么转」这条线。这一期往里走一层:循环里的计划从哪来、状态存哪里、挂了怎么续。
与相邻篇的分工:
- 本篇讲「决策的前置(计划)与留痕(状态)」;
- 循环怎么转见第 07 期;
- 状态存储挂在架构的哪一层见第 09 期(Harness 的 State Store 组件)。
本篇回答两个问题:
| # | 面试问题 | 指向哪个工程面 |
|---|---|---|
| Q1 | 长任务怎么规划?Plan-and-Execute 和 ReAct 怎么选?Reflection 什么时候无效? | 计划的来源与形态 |
| Q2 | 一个 Agent 执行到第 15 步突然挂了,怎么办? | 状态的持久化与恢复语义 |
Q1 的答案是「计划要外化」,Q2 的答案是「状态要外置 + 断点续跑」。两者是同一件事的两个面:凡是需要跨轮次/跨进程活下来的东西,都不能只活在上下文里。
一、为什么需要 Planning
1.1 ReAct 的贪心缺陷
第 07 期讲过 ReAct 的四类缺陷,第一个就是贪心短视:每步只看眼前,没有全局视图,长任务容易在中途走偏。
ReAct 做 20 步的任务:
第 1 步「先看看目录」→ 第 2 步「读这个文件」→ 第 3 步「嗯,再读一个」→ …
走了 12 步后,已经忘了最初的目标是「修复退款逻辑」,开始顺手重构别的代码
Planning 就是给循环一个全局骨架,让每一步都知道「我在整个任务的哪一段」。
1.2 Planning 的职责边界
关键一句话:让模型做不确定决策,让代码做确定性逻辑。
| 交给模型(不确定) | 交给代码(确定) |
|---|---|
| 理解目标、任务拆解 | 权限校验 |
| 分类、工具选择 | 金额计算 |
| 开放路径规划 | 状态转换 |
| 重规划判断 | 重试 / 超时 / 幂等 / 事务 |
这条边界和 07 期的「模型只提出决策、运行时负责执行」是同一条。Planning 是模型的能力,但「计划如何存储、如何校验、如何恢复」是代码的职责。
二、Plan-and-Execute 与 ReAct 怎么选
2.1 逐项对比
| 维度 | Plan-and-Execute | ReAct |
|---|---|---|
| 规划开销 | 1 次大调用产出整张计划 | 0(每步现场决策) |
| 全局一致性 | 强,有全局视图 | 弱,走一步看一步 |
| 调用次数 | 少(规划 1 次 + 执行 N 次) | 多(每步 1 次) |
| 纠错时机 | 计划错了要重规划 | 每步都能现场纠正 |
| 灵活性 | 弱(计划是约束) | 强 |
| 可中断/可人审 | 是(计划是显式产物) | 否 |
| 适用任务 | 目标清晰、步骤可枚举 | 信息逐步揭示、路径未知 |
2.2 计划可能一开始就错 —— 必须配 replanner
Plan-and-Execute 最大的风险:第一步的计划就基于错误的前提。
计划:「改 auth.py 修复 token 过期校验」
执行到第 3 步发现:根因其实在 token.py,auth.py 只是调用方
→ 不是继续执行,而是重规划
所以 P&E 必须配 replanner。而且重规划不是从零再来——只重排未完成的部分。配套代码的实测:
Plan-and-Execute:一次规划产出 7 步
执行到第 3 步(run_test)发现根因在 token.py,不在 auth.py → 触发重规划
重规划后共 7 步(已完成 3 步保留,只重排剩余 4 步)
→ 重规划次数 1 · 规划调用 1 次
「已完成 3 步保留」是重点——这是 P&E 相对 ReAct 的核心价值:失败不必从头再来(第 4 节会看到它在恢复场景下的意义)。
2.3 什么时候该重新规划而不是继续执行
三个信号:
- 前提假设被推翻(如上例:根因位置判断错了);
- 连续失败(同一子目标反复达不到);
- 出现计划外的新信息(检索发现了计划没考虑到的约束)。
2.4 生产里的常见做法:外层计划 + 内层 ReAct
不是二选一。长任务常见的是:
外层:Plan-and-Execute 打骨架(7 步计划,可人审、可中断、可恢复)
内层:每一「步」是一个小 ReAct 局部探索(这一步内部怎么走,现场决定)
这样既保住全局一致性,又保住局部灵活性。
三、计划外化
3.1 这是本期最重要的一句话
计划的敌人不是「模型不够聪明」,而是「计划留不住」。
计划如果只存在于上下文的 messages 里,那么:
- 上下文被压缩(第 05 期)→ 计划被压掉;
- 会话轮次变长 → 计划被挤到中间,lost in the middle;
- 进程重启 → 计划彻底消失。
所以计划必须外化到独立于上下文、独立于进程的状态里。
3.2 计划外化 vs「写 TODO」
| 维度 | 写 TODO | 外化计划 |
|---|---|---|
| 给谁看 | 人 | 给循环读 |
| 格式 | 自由文本 | 必须可机读(结构化) |
| 更新 | 手动 | 可增量更新 |
| 完成判定 | 人判断 | 可程序判定 |
外化计划是给循环读的——它必须能被程序解析出「当前在第几步、下一步做什么、这步完成了没」。这决定了它必须是结构化的(如 Step{idx, action, args, status}),而不是一句自然语言。
3.3 计划粒度的取舍
| 粒度 | 问题 |
|---|---|
| 太粗(「修复 bug」) | 无法执行、无法校验完成 |
| 太细(「按第 3 个字符」) | 失去调整空间,重规划成本爆炸 |
| 合适(一步原子可达且可验证) | 可独立执行、可独立判定完成 |
判据:「一个步骤原子可达且可验证」。检验方法:这一步能不能独立地判断「做完了没」?能,粒度就对了。
四、Q2:第 15 步挂了怎么办
4.1 错答案:重新执行
这是 Q2 的陷阱。三个理由说明「从头重跑」是错的:
- 副作用已发生——第 1–14 步里可能已经改了文件、发了邮件、扣了款。从零重跑 = 副作用重放 = 数据被改两遍;
- 成本——白烧了十几步的 token 和时间;
- 用户体验——用户看到进度倒退。
配套代码把这个后果量化了:
✗ 计划只在 messages(进程内存)里:
恢复到的计划:None
恢复到的进度:[]
结论:计划与进度随进程消失 → 只能从头重跑
4.2 正解:状态外置 + 断点续跑
✓ 计划与结果外置到 state + 事件日志:
恢复到的进度:[1, 2, 3, 4, 5, 6]
结论:从事件日志恢复到第 7 步继续,已完成的不再重做
同一份「第 15 步挂了」的场景,两种存法给出完全相反的结果:一个清零重跑,一个从断点续做。 这就是状态外置的全部价值。
4.3 Agent 状态的字段
一个任务的状态至少要有九个字段:
task_id 任务的唯一标识(状态挂在它上面,不挂 session)
user_id 用户标识(隔离与鉴权)
current_step 当前进度
messages 对话消息
tool_calls 已发起的工具调用
observations 已拿到的工具结果
variables 任务变量(中间结果、参数)
checkpoints 检查点序列
status 任务状态(running / paused / failed / done)
4.4 Checkpoint 策略
打点位置与内容都有讲究:
| 策略 | 做法 | 理由 |
|---|---|---|
| 打点时机 | 在副作用发生前后打,而不是每步都打 | 每步都打开销大;副作用点才是关键恢复点 |
| 存增量 | 存状态增量而非全量 | 全量快照随步数线性膨胀 |
| 可序列化 | 必须可序列化,不含连接/句柄/闭包 | 否则无法持久化与跨进程恢复 |
4.5 状态存储选型
| 状态类型 | 存储 | 特点 |
|---|---|---|
| 热状态(当前会话) | Redis | 快、易过期 |
| 冷状态(任务历史) | PG / MySQL | 持久、可查询、可审计 |
| 大对象(中间产物) | 对象存储 | 不占数据库 |
关键约束两个:跨进程可见(否则重启就读不到)与可序列化(否则存不下来)。
4.6 状态与上下文的分界
这是 L3 面试里区分度很高的一点:
| 维度 | 状态(State) | 上下文(Context) |
|---|---|---|
| 给谁读 | 给程序读 | 给模型读 |
| 结构 | 结构化、可查询 | 有预算、要压缩 |
| 长度 | 无上限 | 有硬上限 |
| 生命周期 | 跨进程存活 | 单次调用 |
把状态塞进上下文是常见的设计错误——上下文会被压缩、有长度上限、还会丢;状态不该受这些约束。
4.7 Resume 时上下文怎么恢复
从状态重建,而不是从历史消息重放。 这一点很容易搞错:
✗ 错误做法:把 1–14 步的全部历史消息重新拼回上下文
→ 上下文爆炸,且大部分历史已经无关
✓ 正确做法:从 state 重建一个「精炼的当前上下文」
→ 当前目标 + 当前计划 + 压缩后的历史摘要 + 最近几步
这和第 05 期的上下文组装是一回事——恢复时也要做上下文工程。
4.8 Resume / Rollback / Replay 的分工
配套代码用同一份事件日志支撑四种语义:
| 语义 | 用途 | 实现 |
|---|---|---|
| Resume | 任务没死,接着做 | 从最后检查点之后继续 |
| Rollback | 用错方案 / 人审回退 | 截断到某检查点 + 补偿副作用 |
| Replay | 排障与一致性校验 | 完整重放事件序列 |
| Fork | 方案对比 / 影子执行 | 从某检查点分叉出独立分支 |
实测输出:
Resume : 从第 5 步继续(最后检查点之后)
Replay : seq1 step → seq2 checkpoint → seq3 step → seq4 checkpoint → …
Fork@2 : 分叉出 6 条事件的独立分支(用于方案 A/B 对比)
Rollback@3 : 丢弃 2 条检查点事件 → 触发补偿(回滚副作用)
4.9 幂等键:Resume/Replay 的前提
所有写操作必须带幂等键(idempotency_key)。否则 Resume/Replay 都会把副作用做两遍。
配套代码的崩溃重放实测:
实际尝试 6 次 · 真正生效 3 次 · 幂等去重 3 次
同一幂等键(task-99:stepN:edit_file)重放第二次直接被拦掉。这就是「重跑会把数据改两遍」这句结论的可执行证明——有幂等键就改不了两遍。
4.10 Replay 的非确定性来源
Replay 校验一致性时会撞上非确定性:
| 来源 | 处理 |
|---|---|
| 时间 | 记录并回放「当时的时间」 |
| 随机数 | 固定 seed 或记录取值 |
| 外部 API | 记录响应并回放(录制回放) |
| 模型本身 | 记录模型输出(temperature>0 时输出不确定) |
这四个来源都要被记录,Replay 才可能一致。 本质上,Replay 的前提是「把所有外部输入显式化」。
4.11 Event Sourcing
不存「当前状态」,而存「事件序列」,状态由重放推导:
传统快照: 存 state_v15(当前长什么样)
Event Sourcing:存 [e1, e2, …, e15](发生了什么),state = fold(events)
优点:可审计、可回滚、可 Replay、可 Fork;代价:重放成本、事件版本演进(新代码要能读懂旧事件)。
五、Reflection:什么时候有效,什么时候无效
5.1 推荐回答
Reflection 只适合成败可判定的任务:代码能跑通、测试能过、数学有答案。在这些任务上,外部信号让反思有依据。
在开放任务上收益有限甚至退化——因为没有新信息,自我批判等于「又猜一次」。
5.2 三种形态,收益递增
| 形态 | 信号来源 | 收益 | 例子 |
|---|---|---|---|
| 自我批判 | 无外部信号 | 最弱 | 「再检查一遍答案」 |
| 带外部信号的反思 | 测试结果 / 编译错误 / 检索证据 | 中 | 「测试失败了,看报错改」 |
| 跨轨迹反思 | 多次尝试的教训 → 沉淀到记忆 | 最强 | 「上次这类 bug 是因为…,写进记忆」 |
5.3 失效边界一:无新信息 = 复读(代码实证)
配套代码把三种形态在「有 / 无外部新信息」两种情况下的效果做了对照:
| 形态 | 有外部新信息 | 无新信息 |
|---|---|---|
| 简单重试 | ✓ | ✗ |
| 自省修正 | ✓ | ✗ |
| 反思重规划 | ✓ | ✗ |
全部三种形态,一旦没有新信息输入,全部无效。 原因很直接:模型反思时看的还是同一份上下文,没有新信号,它能做的只是换种说法把同样的判断再说一遍。
所以生产里 Reflection 必须配外部信号。 这也是为什么 Reflection 在 coding agent 上有效(有测试/编译反馈)、在「帮我写首诗」上几乎无用(没有判定标准)。
5.4 失效边界二:归因正确才有用
反思要改对方向,前提是归因正确。归因质量取决于:
- 失败信号是否结构化(
{"code": "TEST_FAILED", "at": "line 42"}比一句「好像有问题」强太多); - 上下文里是否保留了失败现场(trace / 报错 / diff)。
5.5 失效边界三:反思有成本,且会过度修改
让模型反复「再检查一遍」,它会把原本正确的部分也改掉——over-editing。所以反思要设上限:
attempt 上限 + 收益递减就停,而不是无限循环
六、从机制到工程
6.1 状态设计的三条原则
- 跨进程可见——否则重启即清零;
- 可序列化——否则存不下来;
- 与上下文分离——状态给程序读,上下文给模型读,不要混。
6.2 恢复的验收标准
一个能上线的恢复机制要满足:
- 中断后返回部分结果 + 明确的未完成说明,而不是直接失败;
- Resume 后不重做已完成步骤(幂等键保证);
- Replay 结果与原始执行一致(非确定性来源已记录)。
6.3 同一问题多次采样投票(N8)
Agent 场景里也能用「多次采样取多数」,但成本乘 N。所以只用在判别点——比如「计划是否可行」「这个补丁对不对」这种单次判定价值高、但后续执行成本高的地方。
七、常见的错误认识
- 「ReAct 就够了,不需要 Planning」 —— 长任务会贪心短视、中途走偏;
- 「计划留在上下文里就行」 —— 会被压缩、被遗忘、重启即失;
- 「计划外化就是写个 TODO」 —— 外化计划是给循环读的,必须可机读可判定;
- 「P&E 计划一次定死」 —— 必须配 replanner,前提可能一开始就错;
- 「重规划要重新规划整张计划」 —— 只重排未完成部分,已完成的保留;
- 「第 15 步挂了就重跑」 —— 副作用会做两遍,是最典型的错答案;
- 「每步都打 checkpoint」 —— 开销大,应在副作用点打;
- 「状态就是上下文里的 messages」 —— 状态给程序读,上下文给模型读,必须分开;
- 「Resume 时把历史消息全拼回去」 —— 应从未状态重建精炼上下文;
- 「Reflection 万能」 —— 无外部信号时退化为复读,开放任务上收益极低。
八、概念速查
| 概念 | 一句话 |
|---|---|
| Planning 职责边界 | 模型做不确定决策,代码做确定逻辑 |
| Plan-and-Execute | 先出计划再执行,可中断可人审,需配 replanner |
| 计划外化 | 计划必须写进状态,而非留在上下文 |
| 计划粒度 | 一步原子可达且可验证 |
| 状态九字段 | task_id / user_id / current_step / messages / tool_calls / observations / variables / checkpoints / status |
| Checkpoint 策略 | 副作用前后打点、存增量、可序列化 |
| Resume / Rollback / Replay / Fork | 断点续跑 / 回滚补偿 / 重放校验 / 分支对比 |
| 幂等键 | Resume/Replay 的前提,防副作用重放 |
| Event Sourcing | 存事件序列,状态由重放推导 |
| Reflection 三形态 | 自我批判 / 带外部信号 / 跨轨迹,收益递增 |
九、动手实验
配套代码:
agent-developer-interview/agent-08-planning-state
实验用一个零依赖的规划与状态管理器,把计划外化、Checkpoint、四种恢复语义、幂等键全部参数化。
9.1 核心代码节选(一):计划外化对照
def plan_in_context_then_crash(plan):
"""反面教材:计划只存在于 messages(进程内存)里。"""
messages_state = {"messages": [f"计划步骤 {s.idx}: {s.action}" for s in plan],
"progress": [s.idx for s in plan if s.status == "done"]}
del messages_state # 模拟进程崩溃:进程内状态不可恢复
return {"recovered_plan": None, "recovered_progress": [],
"verdict": "计划与进度随进程消失 → 只能从头重跑"}
def plan_in_state_then_crash(plan, event_log):
"""正确做法:计划与结果都写进独立于进程的 state + 事件日志。"""
recovered_progress = [e["payload"]["step"] for e in event_log if e["kind"] == "step_done"]
next_step = max(recovered_progress) + 1 if recovered_progress else 1
return {"recovered_progress": recovered_progress, "resume_from": next_step,
"verdict": f"从事件日志恢复到第 {next_step} 步继续,已完成的不再重做"}
真实输出:前者恢复到 [],后者恢复到 [1,2,3,4,5,6]、从第 7 步继续。
9.2 核心代码节选(二):Resume + 幂等键
class SideEffectLog:
def apply(self, key, effect):
self.attempts += 1
if key in self.applied: # 幂等键已存在 → 直接回放结果
self.deduped += 1
return False, self.applied[key]
self.applied[key] = effect
return True, effect
真实输出(崩溃后重放):
实际尝试 6 次 · 真正生效 3 次 · 幂等去重 3 次
9.3 核心代码节选(三):一份日志四种语义
class EventStore:
def resume_from(self): # Resume:最后检查点之后
cps = [e["payload"]["step"] for e in self.events if e["kind"] == "checkpoint"]
return (max(cps) + 1) if cps else 1
def rollback_to(self, step): # Rollback:截断 + 触发补偿
...
def replay(self): # Replay:完整重放
return [f"seq{e['seq']} {e['kind']}" for e in self.events]
def fork_at(self, step): # Fork:分叉出独立分支
...
9.4 脚本与结论对应表
| 段落 | 验证的结论 |
|---|---|
| ① 范式对比 | 重规划只重排未完成部分,已完成的保留 |
| ② 计划外化 | 「重跑」= 副作用做两遍 |
| ③ 反思边界 | 无新信息 → 复读,三种形态全失效 |
| ④ 恢复语义 | 一份日志四种语义 + 幂等键防重放 |
9.5 三个「改一改再跑」的练习
- 把外置版的
log.checkpoint注释掉,观察resume_from()退化成从头开始; - 给
SideEffectLog.apply换成「不用幂等键,每次执行 +1」,观察重放后「真正生效」翻倍; rollback_to(step=2)而非 3,观察被丢弃的检查点数量变化(补偿范围的边界)。
十、延伸阅读
站内
- 上下文工程与压缩:计划外化与「外部化」压缩手段同源,都是把状态移出上下文。
- 第 06 期:记忆系统——
session与task的分离建模、跨会话续接。 - 第 07 期:Agent Loop——循环怎么转、怎么停、怎么防绕圈。
- 第 09 期:Harness——State Store 组件挂在运行时的哪一层。
- 第 12 期:生产系统设计——Checkpoint 存储与恢复在架构中的落地。
- AI 笔记:异步流水线:任务调度与断点续跑实践。
站外
- Shinn et al.:Reflexion: Language Agents with Verbal Reinforcement Learning——Reflection 的原始工作。
- Yao et al.:ReAct——与 Plan-and-Execute 的范式对照起点。
- Martin Kleppmann:Designing Data-Intensive Applications——Event Sourcing 与幂等性的系统论述。
十一、一句话总结
计划的敌人不是「够不够聪明」,而是「留不留得住」;状态的关键不是「存了什么」,而是「跨不跨进程」。 两者指向同一条工程原则:凡是需要跨轮次、跨进程活下来的东西,都不能只活在上下文里。
而 Q2 的正解只有一句话:状态外置 + 断点续跑 + 幂等键。 记住那个错答案——「重新执行」——因为它会把已经发生的副作用再做一遍,这才是这道题真正的考点。
