Agent 面试题

Planning、Reflection 与状态管理

Planning、Reflection 与状态管理

计划的敌人不是「模型不够聪明」,而是「计划留不住」。 长任务的计划必须外化到独立于进程的状态里——留在上下文里,上下文一压缩、一轮次就没了。而 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-ExecuteReAct
规划开销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 什么时候该重新规划而不是继续执行

三个信号:

  1. 前提假设被推翻(如上例:根因位置判断错了);
  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. 副作用已发生——第 1–14 步里可能已经改了文件、发了邮件、扣了款。从零重跑 = 副作用重放 = 数据被改两遍;
  2. 成本——白烧了十几步的 token 和时间;
  3. 用户体验——用户看到进度倒退。

配套代码把这个后果量化了:

✗ 计划只在 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 状态设计的三条原则

  1. 跨进程可见——否则重启即清零;
  2. 可序列化——否则存不下来;
  3. 与上下文分离——状态给程序读,上下文给模型读,不要混。

6.2 恢复的验收标准

一个能上线的恢复机制要满足:

  • 中断后返回部分结果 + 明确的未完成说明,而不是直接失败;
  • Resume 后不重做已完成步骤(幂等键保证);
  • Replay 结果与原始执行一致(非确定性来源已记录)。

6.3 同一问题多次采样投票(N8)

Agent 场景里也能用「多次采样取多数」,但成本乘 N。所以只用在判别点——比如「计划是否可行」「这个补丁对不对」这种单次判定价值高、但后续执行成本高的地方。


七、常见的错误认识

  1. 「ReAct 就够了,不需要 Planning」 —— 长任务会贪心短视、中途走偏;
  2. 「计划留在上下文里就行」 —— 会被压缩、被遗忘、重启即失;
  3. 「计划外化就是写个 TODO」 —— 外化计划是给循环读的,必须可机读可判定;
  4. 「P&E 计划一次定死」 —— 必须配 replanner,前提可能一开始就错;
  5. 「重规划要重新规划整张计划」 —— 只重排未完成部分,已完成的保留;
  6. 「第 15 步挂了就重跑」 —— 副作用会做两遍,是最典型的错答案;
  7. 「每步都打 checkpoint」 —— 开销大,应在副作用点打;
  8. 「状态就是上下文里的 messages」 —— 状态给程序读,上下文给模型读,必须分开;
  9. 「Resume 时把历史消息全拼回去」 —— 应从未状态重建精炼上下文;
  10. 「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 三个「改一改再跑」的练习

  1. 把外置版的 log.checkpoint 注释掉,观察 resume_from() 退化成从头开始;
  2. 给 SideEffectLog.apply 换成「不用幂等键,每次执行 +1」,观察重放后「真正生效」翻倍;
  3. 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 的正解只有一句话:状态外置 + 断点续跑 + 幂等键。 记住那个错答案——「重新执行」——因为它会把已经发生的副作用再做一遍,这才是这道题真正的考点。

AI Agent面试Planning状态管理

← 返回面试题