LLM 面试备考
首页/Agent 架构

Agent 状态建模与持久化(Agent State & Checkpointing)

所属:01-fundamentals / 04-agent-architecture
面试权重:★★
来源:《深入理解 AI Agent:设计原理与工程实践》第 1 章(ReAct 循环;Graph 工程的「结构化状态沿边传递并在关键边界处持久化」)、第 2 章(上下文结构与状态栏;被替换的工具结果字符串要持久化冻结)、第 10 章「任务状态管理与 A2A 任务状态机」(李博杰,github.com/bojieli/ai-agent-book);《深入理解 AI Infra:量化分析与系统设计》第 11.1.3 节「任务完成条件与必须保留的状态」(李博杰);持久性语义与保存频率的账另参 ../06-agent-infra/10-agent-runtime.md §6。非一手实测
相关知识点:./memory.md(姐妹篇,分工见下:那篇讲记忆 —— 记什么、存哪、怎么读写、跨会话怎么沉淀;本篇讲执行状态 —— 驱动循环的那份状态怎么建模、落盘、恢复。不重复它的内容)、./multi-agent.md(LangGraph 的图 + 共享状态模型;「所有东西往 state 里塞」这个反模式的出处)、../06-agent-infra/10-agent-runtime.md(持久性语义:write() 返回 ≠ 落盘;要记「连续保存到哪」;「没做完」vs「做完了但确认丢了」)、./13-multi-agent-coordination.md(多写者冲突治理:乐观锁 → 工作副本 → 幂等结算点)、./human-in-the-loop.md(中断点要落在状态边界上、时间旅行 / 回滚的前提)、./context-engineering.md(状态投影成上下文的那一侧)、../06-agent-infra/observability.md(状态迁移要能进 trace,否则没法自动断言)

一句话#

Agent 状态是驱动循环的那份「单一事实来源」 —— 目标与约束、做完的步骤、待办、产物引用、已确认的决策。它是执行依据,上下文只是它的投影,记忆是它跨会话的那一小部分。所以它有一条硬性质:对话历史可以摘要、可以截断,状态被截断 Agent 就失去目标。

为什么重要(面试角度)#

  • 它是「能跑」与「能续」的分界线。 demo 的 Agent 状态就是一段不断变长的消息列表;生产系统必须回答状态存哪、多久存一次、挂了从哪接着跑。这三问答得出来,才像做过系统。
  • 它最容易和 ./memory.md 混为一谈。 面试官常连着问「记忆和状态什么区别」—— 能一句话切开(状态是执行依据,上下文是它的投影,记忆是跨会话的部分),后面都好谈。
  • 它有一批能当场推的取舍。 checkpoint 粒度是显式的权衡(保存开销 vs 故障后最多重做多少),不是「越频繁越安全」;并发控制是三层(乐观锁 → 工作副本 → 幂等结算点),不是「加个锁」。
  • 它有一个必答的硬边界:状态与副作用的同步。状态说「已发邮件」但外部没收到怎么办 —— 答不出「先查询、再决定」和「操作标识 / 幂等」,这题就废了。
  • 和你的项目接得上:Writing Copilot 用 LangGraph 的共享状态 + checkpoint 做中断恢复,这是你少数可以讲成一手经验的落点之一;但状态 schema 的演进、并发控制、恢复的幂等设计都用「这套我会这么设计」的口径讲。

⚠️ 边界声明:本篇的机制来自《AI Agent》第 1 / 2 / 10 章与《AI Infra》11.1.3,不是你的生产经验。你真实做过的是 Writing Copilot 里中断控制与状态持久化的那一小块;没做过的是多写者并发治理、跨机器恢复、分布式 checkpoint。面试里前者可以讲成经验,后者请用「这套我会这么设计」。

核心内容#

1. 先划清边界:状态 ≠ 上下文 ≠ 记忆#

是什么 谁看得见 可丢弃性 归哪篇
状态(state) 驱动循环的单一事实来源:目标、约束、进度、待办、产物引用、已确认的决策 运行时 / 编排器 不能丢 —— 丢了 Agent 就失去目标 本篇
上下文(context) 这一次模型调用实际看到的 token = 状态的投影 + 对话历史 + 检索结果 + 工具描述 模型 可摘要、可截断、可刷新(但投影必须保留状态的关键字段) ./context-engineering.md
记忆(memory) 跨会话沉淀的用户事实与偏好(语义 / 情景 / 程序性) 跨会话被召回 可带 TTL、可审计、可淘汰 ./memory.md

一句话:状态是执行依据,上下文是它的投影,记忆是跨会话的那部分。

为什么这个划分值钱(也是它最有面试价值的一层):投影是可重新生成的。只要状态还在,上下文被压缩、被刷新、甚至整个换掉,都可以从状态重新投影一份出来 —— 这正是 LongHorizon-Harness 那类研究说的「把任务连续性从不断增长的执行历史中分离出来:上下文可以刷新,界面操作也可能失败,但下一轮仍能从最近一次核实的状态继续」(第 10 章)。反过来,状态丢了是不可逆的:没有任何东西能把它重建出来。

与 ./memory.md 的分工再明确一次:那篇已经给出「任务状态不能摘要掉」这条结论,也说了「要和对话历史显式分开」;但它停在原则上。怎么建模这份状态、schema 怎么定、多久落一次盘、挂了从哪接着跑、多个写者怎么办 —— 是本篇的内容。而「跨会话记住用户的偏好」仍然归 ./memory.md,本篇不重复。

2. 状态里放什么:和对话历史的可丢弃性完全不同#

对照《AI Infra》11.1.3 那张「必须保留的状态」表,一份可恢复的任务状态至少要有这几类字段:

字段 作用 丢了会怎样
任务目标与约束 判定「做完了没有」、判定「能不能这么做」的依据 Agent 失去目标:开始漂移、重复已完成的工作
已完成步骤(带证据) 避免重做;支撑审计与归因 重复劳动;「做没做过」不可知
待办 / 未满足的证据项 决定下一步做什么 漏步骤 —— 表现为「偷懒式假完成」(做了一部分就宣称全做完)
工具产物引用(路径 / 对象 id,不是内容) 让上下文可以变得很小 要么重跑工具,要么把大内容塞回上下文
用户确认过的决策 不重复问、不擅自改 反复确认,或违背用户已经拍板的约束
外部操作的操作标识与提交状态 支撑「先查询、再决定」 重复副作用(邮件发两次、单下两次)

核心判断(引 ./memory.md):对话历史可以摘要、可以截断,损失通常可接受;任务状态被截断,Agent 就失去执行依据。 两者的可丢弃性完全不同,所以必须物理分开,而不是「一个大 message list + 一段系统提示」。

混在一起的典型症状:长会话里 Agent 开始重复已经做完的工作、忘掉用户前面确认过的约束、或者偏离原始目标。根因就是上下文管理策略(滚动摘要、滑窗截断)无差别地作用在了两类东西上。

一句话方法论(11.1.3 的口径,值得原样记住):在释放或压缩任何东西之前,先问「丢失它会导致哪些工作重做」。 11.1.3 用它算了那笔账:三轮任务已过去 20 秒,其中工具累计只用了 2 秒 —— 如果丢掉工作目录必须从头执行,要重花的是 20 秒,不只是那 2 秒工具计算。这也是为什么「什么进状态」是按重做代价定的,不是按「看起来重要不重要」定的。

3. 状态 schema:显式字段 vs 自由字典#

先说反模式:把「所有东西都往 state 里塞」。 ./multi-agent.md 把它点得很清楚,两个后果:

  • ① 状态膨胀:中间产物、检索结果、全量工具输出统统进去,最后 state 变成一个巨大的上下文 —— 持久化变贵,投影进模型的代价也变贵。
  • ② 隐式耦合:某个节点悄悄改了某个字段,另一个节点依赖它但没人知道。「谁改了哪个字段」从接口契约退化成了隐式约定。

正面做法是显式 schema:字段有限、每个字段有明确语义。./multi-agent.md 给的分类可以直接拿来当第一层的切分 —— 明确区分「对话历史」「工具产物」「任务中间结果」三类东西。

在此之上再加两条纪律:

  • 每个字段声明「谁能写」(single writer)。 读的人可以多,写的人必须少。这是把 ./13-multi-agent-coordination.md 的「把唯一的写权限收敛给一个角色」搬到状态上 —— 它的原话说得很准:加锁是让并发安全,收敛写权限是让并发消失,后者通常更好。
  • 变长的部分放引用,不放内容。 待办清单可以内联(它很小、且是决策依据),但大产物、长工具输出放引用(对象存储路径 / 文件路径 + 摘要)。这与 ./11-async-event-driven.md 的「数据以文件路径引用传递而非内容拷贝」是同一条纪律。

一个最容易漏的工程点:schema 要带版本号。 状态结构会演进(加字段、改语义、删字段)。旧 checkpoint 里的状态是按旧版本的形状写的,恢复时必须能识别出来并决定迁移还是拒绝恢复。没有版本号,迁移就无从谈起,「恢复出一个字段缺失的状态」这类事故会一直发生。

判据一句话:一个字段该不该进 state,看它是不是「驱动下一步决策」所必需。只有观测价值、没有决策价值的,进 trace 不进 state(../06-agent-infra/observability.md)。

4. 持久化与 checkpoint 粒度#

三种粒度,代价各不同(对齐 ../06-agent-infra/10-agent-runtime.md §6 的保存频率账):

粒度 保存什么 正常路径代价 故障后最多重做
每步 每次工具调用后 大(写放大 + fsync,每步都付) 一小步
每轮 一轮「模型决策 — 工具执行 — 保存结果」整体提交 中 一轮
关键边界 只在高成本 / 不可逆动作前后保存 小 可能很多

⚠️ 不要把它讲成「越频繁越安全」。 那篇的算例已经把账摊开了:100 个请求、每个 200 ms,每次提交固定开销 8 ms、每条记录 2 ms —— 每请求提交:正常路径 21 s、故障后最多重做 0.2 s;每 10 个提交一次:正常路径省 0.72 s(20.28 s),但恰好赶在提交前故障就要重做 2 s。是一笔显式的权衡,不是单向的优化。

粒度该对着什么定? 对着**「重做的代价」**定,不是对着「保存的代价」定:

  • 重做贵 → 密集保存。信号包括:模型调用贵(长上下文、强模型)、工具不可逆(发了邮件、下了单)、工具执行久、外部操作有配额。
  • 重做便宜 → 稀疏保存。信号包括:一次幂等读、可重复的计算、产物已在别处持久化。

落盘位置怎么选:

位置 适合 短板
本地文件 单机、低延迟、临时轨迹(JSONL) 不抗机器故障;换机器就没了
对象存储 大产物、完整轨迹、长期留存 延迟较高;一致性偏最终
数据库 需要事务与并发控制的状态(乐观锁就靠版本号) 大 payload 不适合直接存

三条必须说清的持久性纪律(全部出自 ../06-agent-infra/10-agent-runtime.md §6):

  1. write() 返回 ≠ 落盘。 数据可能还在内核页缓存里;要真正持久得 fsync() 并检查成功。「保存成功」本身有等级:写本地页缓存 / 完成本地 fsync() / 收到云端持久化确认 —— 能扛的故障完全不同(本地 fsync() 能扛进程重启,扛不住整台机器 + 磁盘不可用)。所以向调用者报告「完成」时到底保证了什么,必须在接口上写明。
  2. 记「连续保存到哪」,不是「最后保存了哪」。 那篇的缺口例子:每轮先写本地、再后台异步上传,第二轮上传失败、第三轮成功 —— 本地历史完整,云端却只连续完整到第一轮。此时换台机器仅凭云端恢复,就会撞上这个缺口。所以恢复位置要记「从起点连续完整到哪一轮」,不能因为第三轮成功就把连续位置从第一轮改成第三轮。
  3. 恢复所需的信息可能分散在三处。 轨迹、工作目录、外部数据库各存一部分,要核对一致;只对聊天日志 fsync() 是不够的。

最后一句细节(第 2 章):被替换的工具结果字符串在首次出现时就被冻结、持久化保存 —— 即使后续会话重启也用完全相同的字符串,以保证恢复后的消息序列与缓存字节流一致、避免缓存失效。可见持久化不只是为了让流程能续跑,还为了让缓存与幂等也成立。

5. 恢复语义#

基本动作:从最后一个「完整」的 checkpoint 续跑。 「完整」是关键词 —— 一个半截的 checkpoint 比没有 checkpoint 更危险(状态说「已发邮件」,但那一轮的记录根本没落盘,恢复时就会漏做)。

四件事必须一起说清:

  • ① 恢复会重放,所以那一整段必须可安全重做。 从上一个 checkpoint 之后的所有工作都可能被重新执行一遍,因此它必须幂等。不幂等的写操作需要操作标识 / 事务 / 补偿(../06-agent-infra/10-agent-runtime.md)。这也正是 §6 状态与副作用那一节要解决的问题。
  • ② 「做完了但确认丢了」不等于「没做」。 控制器没收到确认时,无法区分「测试没跑完」和「跑完了但确认消息丢了」。正确动作不是重试,而是先查询已持久化的结果、核对操作标识与输入文件版本:结果在就继续,不在才判断能不能重试。关键是这个查询本身要幂等。 而且「消息去重」和「实际执行两次」是两回事 —— 前者靠消息层,后者只能靠持久化结果 + 目标系统的幂等协议。
  • ③ 恢复不等于无条件「按原计划继续」。 恢复时外部环境或用户要求可能已经变了(第 10 章明确提到要处理「外部环境或用户要求变化造成的旧状态失效」)。所以恢复的入口应当是:先重新核对目标与约束,再决定「继续 / 重规划 / 终止」。
  • ④ 恢复要有预算。 用轮数、时间、费用上限防止恢复循环无限运行(第 10 章)。恢复机制本身也会失控 —— 这与 ./09-coding-agent.md 的熔断上限、死亡螺旋防护是同一条纪律。

时间旅行 / 回滚(引 ./human-in-the-loop.md):回到某个历史 checkpoint 重新走。用途有三个 —— 人在环中的暂停与回退、比较不同分支、从已知的好状态重试。那篇已经点明:中断点必须在流程设计阶段就规划好,落在状态边界上(例如「工具执行前」),这是架构决策而不是后期能随手加的功能。

⚠️ 回滚最容易被问穿的一处:回滚状态不等于回滚副作用。 回到上一个 checkpoint 不会让已经发出去的邮件、已经写进数据库的记录撤销。所以「可回滚」有一个前提 —— 副作用也走可补偿的路径(幂等接口 / 操作标识 / 补偿处理),或者把不可逆的动作放在回滚点之后。只说「我们有 checkpoint,可以回滚」是不够的。

6. 并发控制:同一份状态被多个写者改#

问题有两个层次(描述与解法都来自 ./13-multi-agent-coordination.md,那篇讲的是多 Agent 协作,这里把它搬到状态上):

  • 简单冲突:两个写者同时改同一份状态,后写的把先写的覆盖了。
  • 语义冲突:文件 / 字段层面看不出任何冲突,但两个改动合起来是矛盾的 —— 更隐蔽也更危险。那篇的例子是「A 重编全书图片编号、B 在别处引用旧编号」,两者操作的是不同文件,文件层面完全没有冲突,结果却让 B 的引用全部失效。

三层解法,别停在第一层:

层次 解法 代价 / 边界
① 同字段写冲突 乐观锁:字段带版本号,读时记下、写时校验;不一致就写入失败,重新读最新版再做 代价是偶尔需要重试,换来数据一致性
② 跨字段语义冲突 乐观锁完全管不了。要么做更高层的语义校验(「这个改动会不会让别处的引用失效」),要么收敛写权限(每个字段单写者) 收敛写权限是更根本的做法:不是让并发写变安全,而是让它不发生
③ 收口的竞态 幂等结算点(settle_once):必须是「第一个已验证成功」而不是「第一个声称成功」,且由锁 / 事务保护 否则两个几乎同时到达的成功事件会触发两次汇总,产生重复或互相矛盾的状态

一句话:同字段用乐观锁,跨字段用单写者 / 工作副本,收口用幂等结算点。

7. 状态与副作用的同步#

危险的缝隙:状态说「已发邮件」,但外部其实没收到;或者外部收到了,状态却记着「没发」。

两种朴素写法都是错的:

写法 崩溃位置 后果
先写状态、再执行(write-ahead) 卡在中间 状态说做了、实际没做 → 恢复时不会重做 → 漏做
先执行、再写状态 卡在中间 实际做了、状态说没做 → 恢复时重做 → 重复副作用

正确做法只有一条:让重做本身是安全的。

  • 用操作标识(幂等键)标出「这一次操作」,由目标系统按标识去重。11.1.3 的状态表里「外部操作」一行写的正是这个:需要记录的是「目标系统中的操作标识与提交状态」,丢失后的恢复方式是「查询、去重或按业务规则补偿」。
  • 状态里存的是「意图 + 提交状态」,不是「已完成」这个结论。 例如 {op_id, status: pending | committed | unknown};恢复时对 pending / unknown 一律走**「先查询、再决定」**。
  • 硬边界(../06-agent-infra/10-agent-runtime.md):把一轮当事务在本地提交,保证不了外部服务恰好执行一次 —— 那一侧必须靠操作标识 / 幂等接口 / 补偿。仅在本地记「已提交」是不够的。

一句话:状态与副作用之间的同步,不能靠在本地把两件事写进同一笔事务;只能靠把外部操作本身变成可查询、可去重的。 这也是审批门禁的另一层动机(./human-in-the-loop.md):把不可逆动作挡在人工确认后面,等于把「状态与副作用不一致」的窗口缩小到人盯着的那一小段。

8. 评测:状态正确性怎么测#

核心判断:只看最终输出测不出状态问题。 一个跑出正确结果的 Agent,中间状态可能早就污染了 —— 漏了步骤、覆盖了已确认的决策、重复调用产生了副作用。换一个任务分布,这些问题才会暴露。所以评测要断言过程,不是只断言终点。

手段 断言什么 为什么好用
状态迁移序列 期望的迁移是一条序列(如 pending → in_progress → needs_input → done),断言实际序列包含 / 等于期望序列 能抓住「跳过状态」「重入」「乱序」;这与 A2A 把任务生命周期标准化成状态机(已提交 / 进行中 / 需要输入 / 已完成 / 失败,第 10 章)是同一件事
不变式(invariant) 断言永不成立的命题,如「不能存在两个写者」「已确认的决策字段不被非授权节点改写」「待办非空时不得进入 done」 比逐次断言更耐改;schema 演进时不用重写全部用例
故障注入 在 checkpoint 之间杀进程 / 拔电,重启后核对恢复位置、实际重做量与重复副作用次数 这是 ../06-agent-infra/10-agent-runtime.md §6 的验收口径,也是唯一能真正测出幂等问题的办法
三处一致性核对 恢复后核对轨迹、工作目录、外部数据库是否一致 对应「只对聊天日志 fsync() 是不够的」那条
边界集 把「做完了但确认丢了」「半截 checkpoint」「旧 schema 的 checkpoint」「缺口后的恢复」做成固定用例 边界集与保留集的思想见 ../05-agent-evaluation/agent-eval.md;这些样本平时不出现,出现就是事故

与可观测性的接口:状态迁移要能被 trace 记录(每次 checkpoint 提交记一个事件 / span),否则没法自动化断言(../06-agent-infra/observability.md 的「trace 既当评测数据又当归因材料」在这里是具体化的)。

常见面试问法#

Q:记忆(memory)和状态(state)有什么区别? A:一句话 —— 状态是执行依据,上下文是它的投影,记忆是跨会话的那部分。状态是驱动 Agent 循环的单一事实来源:任务目标与约束、已完成步骤、待办、工具产物引用、用户已确认的决策;它不能丢,丢了 Agent 就失去目标(对话历史可以摘要、可以截断,两者可丢弃性完全不同 —— 这条结论在 memory.md 那篇)。上下文是这一次模型调用实际看到的 token,是状态的投影加对话历史与检索结果 —— 投影可以重新生成,所以上下文被压缩、刷新甚至整体换掉都不该是灾难;但状态丢了是不可逆的。记忆是跨会话沉淀的用户事实与偏好,归 memory.md。分工是:那篇讲「记什么、存哪、怎么读写」,本篇讲「这份状态怎么建模、怎么落盘、怎么恢复」。

Q:Agent 的状态里应该放什么?为什么不能就存在对话历史里? A:应当显式承载六类:任务目标与约束(判定做完了没有、能不能这么做的依据)、已完成步骤及证据、待办 / 未满足的证据项(决定下一步)、工具产物引用(路径而非内容,让上下文能变小)、用户确认过的决策(不重复问、不擅自改)、外部操作的操作标识与提交状态(支撑「先查询、再决定」)。不能只存在对话历史里,因为两者的可丢弃性完全不同:上下文管理策略(滚动摘要、滑窗截断)对消息列表是无差别作用的,一旦把状态混在里面,长会话的典型症状就是 Agent 重复已完成的工作、忘掉用户确认过的约束、偏离原始目标。判断某个字段该不该进状态,有一个很实用的通法(来自《AI Infra》11.1.3):先问「丢失它会导致哪些工作重做」 —— 那笔账算得很清楚,任务已过去 20 秒、工具只用 2 秒,丢掉工作目录要重花的是 20 秒而不是 2 秒。

Q:checkpoint 多久存一次?存在哪? A:这是一笔显式的权衡,不是「越频繁越安全」。 粒度大致三档:每步(写放大与 fsync 每步都付,故障最多重做一小步)、每轮(把「模型决策 — 工具执行 — 保存结果」当整体提交,重做一轮)、关键边界(只在高成本 / 不可逆动作前后存,正常路径便宜但重做多)。要对着重做的代价定:模型调用贵、工具不可逆、外部操作有配额 → 密集存;一次幂等读、可重复计算、产物已在别处持久化 → 稀疏存。参考算例:每请求提交正常路径 21 s、最多重做 0.2 s;每 10 个提交一次正常路径省 0.72 s、但最坏重做 2 s。落盘位置:本地文件(快、不抗机器故障)、对象存储(大产物与轨迹、一致性偏最终)、数据库(要事务与并发控制时,乐观锁就靠它的版本号)。三条纪律必须说:① write() 返回 ≠ 落盘,「保存成功」有等级(本地页缓存 / 本地 fsync / 云端确认),扛的故障完全不同,所以报告「完成」时到底保证什么要在接口上写明;② 记的是「从起点连续保存到哪」,不是「最后保存了哪」(异步上传可能中间断了一轮);③ 恢复所需信息可能分散在轨迹、工作目录、外部数据库三处,要核对一致 —— 只对聊天日志 fsync() 不够。

Q:Agent 崩了重启,怎么接着跑?「做完了但确认丢了」怎么办? A:从最后一个「完整」的 checkpoint 续跑 —— 「完整」是关键,半截 checkpoint 比没有更危险。配套四件事:① 恢复会重放,所以 checkpoint 之后那一整段必须幂等,不幂等的写操作要有操作标识 / 事务 / 补偿;② 「做完了但确认丢了」不等于「没做」:正确动作不是重试,而是先查询已持久化的结果、核对操作标识与输入文件版本,结果在就继续、不在才判断能不能重试,这个查询本身也要幂等;「消息去重」和「实际执行两次」是两回事 —— 前者靠消息层,后者只能靠持久化结果 + 幂等协议;③ 恢复不等于无条件按原计划继续,外部环境或用户要求可能已经变了,入口应当是先重新核对目标与约束,再决定继续 / 重规划 / 终止;④ 恢复要有预算(轮数、时间、费用),否则恢复循环自己会失控。另外补一句关于回滚的坑:回到历史 checkpoint 不回滚副作用 —— 邮件已经发出去了不会撤销。所以「可回滚」的前提是副作用也走可补偿路径,或者把不可逆动作放在回滚点之后。

Q:多个 Agent / 多个 worker 改同一份状态,怎么保证不打架? A:分三层答,别停在「加个乐观锁」。 ① 同字段写冲突 → 乐观锁:字段带版本号,读时记下、写时校验,不一致就写入失败、重新读最新版再做;代价是偶尔重试。② 跨字段的语义冲突 —— 乐观锁完全管不了:两个改动各在各自字段、层面毫无冲突,合起来却让别处的引用失效。这一层最实用的工程解是工作副本隔离(各自的 Git 分支 / worktree,把冲突推迟到显式的合并点),或者更根本地收敛写权限:每个字段单写者 —— 不是让并发写变安全,而是让它不发生。③ 并行收口的竞态 → 幂等结算点(settle_once),必须是「第一个已验证成功」而不是「第一个声称成功」,且由锁 / 事务保护,否则两个几乎同时的成功事件会触发两次汇总。一句话总结:同字段乐观锁,跨字段单写者 / 工作副本,收口幂等结算点。 顺带一个观察:乐观锁要的版本号可以直接复用 checkpoint 的序号,让「恢复位置」和「并发校验」共用一套编号。

Q:状态说「已发邮件」,但邮件其实没发出去。这类问题怎么防? A:先承认两种朴素写法都错:先写状态再执行(write-ahead)崩在中间 → 状态说做了实际没做 → 恢复时不会重做 → 漏做;先执行再写状态崩在中间 → 实际做了状态说没做 → 恢复时重做 → 重复副作用。唯一正确的方向是让重做本身安全:① 用操作标识(幂等键)标出这次操作,目标系统按标识去重;② 状态里存「意图 + 提交状态」而不是「已完成」这个结论(如 {op_id, status: pending | committed | unknown}),恢复时对 pending / unknown 一律走先查询、再决定;③ 硬边界是——本地把一轮当事务提交,保证不了外部服务恰好执行一次,那一侧必须靠操作标识 / 幂等接口 / 补偿。一句话:状态与副作用的同步不能靠在本地把两件事写进同一笔事务,只能靠把外部操作本身变成可查询、可去重的。 这也是审批门禁的动机之一:把不可逆动作挡在人工确认后面,等于把不一致窗口缩到最小。

易错点#

易错说法 问题 应该说
「状态就是对话历史」 混淆了执行依据与过程记录 两者可丢弃性完全不同:对话历史可摘要可截断,状态被截断 Agent 就失去目标;必须物理分开
「状态和记忆是一回事」 没切分工 状态是执行依据,上下文是它的投影,记忆是跨会话的那部分;本篇讲执行状态,memory.md 讲记忆
「上下文不能丢」 高估了投影 投影可以从状态重新生成(LongHorizon 的价值正在于此:上下文可以刷新,状态接着用);不可逆的是状态
「所有东西都往 state 里塞」 反模式 两个后果:状态膨胀(变成巨大的上下文)与隐式耦合(谁改了哪个字段没人知道)。要显式 schema,区分对话历史 / 工具产物 / 任务中间结果
「字段谁都能写」 缺所有权 每个字段应声明单写者 —— 收敛写权限比加锁更根本:不是让并发写变安全,而是让它不发生
「schema 不用管版本」 恢复旧 checkpoint 会炸 schema 会演进,必须带版本号,恢复时决定迁移还是拒绝恢复
「保存越频繁越安全」 单向优化 是一笔显式权衡:每请求提交正常路径 21 s / 最坏重做 0.2 s vs 每 10 个提交 20.28 s / 最坏重做 2 s。粒度对着重做代价定
「write() 返回就是存好了」 混淆了写入与持久化 数据可能还在页缓存;「保存成功」有等级(本地页缓存 / 本地 fsync / 云端确认),扛的故障完全不同
「记最后保存了哪一轮就行」 漏了异步上传的缺口 要记「从起点连续保存到哪」—— 第二轮失败、第三轮成功时,云端只连续完整到第一轮
「只有聊天日志要 fsync」 漏了两处 恢复信息可能分散在轨迹、工作目录、外部数据库三处,要核对一致
「崩了就重试」 混淆两种失败 「没做完」和「做完了但确认丢了」处理完全不同:后者要先查询已持久化结果、核对操作标识与输入版本,且查询本身要幂等
「有 checkpoint 就能回滚」 回滚不了副作用 回滚状态 ≠ 回滚副作用;邮件已发出不会撤销。前提是副作用可补偿,或把不可逆动作放在回滚点之后
「多个 Agent 加个乐观锁就行」 只答了一层 乐观锁只防同一字段 / 文件;跨字段语义冲突要工作副本或收敛写权限;收口的竞态要幂等结算点
「仅在本地记『已提交』,外部操作就恰好执行一次了」 越过信任边界 本地事务保证不了外部服务恰好执行一次,必须靠操作标识 / 幂等接口 / 补偿
「结果对了就说明状态没问题」 测错了对象 只看最终输出测不出状态问题(漏步骤、覆盖决策、重复副作用);要断言状态迁移序列与不变式,并做故障注入
把本篇讲成生产经验 触碰仓库硬规矩 真实做过的是 Writing Copilot 的中断控制与状态持久化那一小块,其余用「这套我会这么设计」

自测#

1. 「状态、上下文、记忆」三者如何区分?为什么说「上下文被压缩甚至换掉」不该是灾难,而「状态丢了」是?

答案

区分:状态是驱动 Agent 循环的单一事实来源(目标与约束、已完成步骤、待办、产物引用、已确认的决策);上下文是这一次模型调用实际看到的 token = 状态的投影 + 对话历史 + 检索结果 + 工具描述;记忆是跨会话沉淀的用户事实与偏好。

为什么投影可丢、状态不可丢:投影可以从状态重新生成。只要状态还在,上下文被压缩、被刷新、甚至整个换一套,都能从状态重新投一份出来 —— 这正是第 10 章那类「任务状态管理」研究的核心论点:把任务连续性从不断增长的执行历史中分离出来,上下文可以刷新,但下一轮仍能从最近一次核实的状态继续。反过来,状态丢了是不可逆的:没有任何东西能把它重建出来 —— 你不知道任务目标是什么、做过哪几步、用户确认过什么。工程上还有一点:投影并非完全免费 —— 投影必须保留状态的关键字段(目标、约束、待办),否则「投影」就把状态本身丢掉了;这也是为什么不能把状态混在消息列表里靠摘要机制处理。

2. 为什么「把所有东西都往 state 里塞」是反模式?你会怎么设计 state 的 schema?

答案

两个后果:① 状态膨胀 —— 中间产物、检索结果、全量工具输出全进去,state 最后变成一个巨大的上下文,持久化变贵、每次投影也变贵;② 隐式耦合 —— 某个节点悄悄改了某个字段,另一个节点依赖它却没人知道,「谁改了这个字段」从接口契约退化成了隐式约定。

设计要点:① 显式 schema,字段有限、语义明确;先按 multi-agent.md 的分类切一层 —— 严格区分「对话历史」「工具产物」「任务中间结果」;② 每个字段声明单写者(读的人可以多,写的人必须少),这正是 13-multi-agent-coordination.md 的「收敛写权限比加锁更根本」;③ 变长部分放引用不放内容(大产物与长工具输出存对象存储 / 文件,state 只留路径 + 摘要),与「以文件路径引用传递而非内容拷贝」一致;④ 带 schema 版本号,恢复旧 checkpoint 时决定迁移还是拒绝恢复。

判据一句话:一个字段该不该进 state,看它是不是驱动下一步决策所必需;只有观测价值、没有决策价值的,进 trace 不进 state。

3. checkpoint 该多久存一次?给出你的定标方法和落盘方案,并说清「保存成功」意味着什么。

答案

定标方法:对着「重做的代价」定,不是对着「保存的代价」定。 重做贵的(模型调用贵、工具不可逆、工具执行久、外部操作有配额)就密集保存;重做便宜的(一次幂等读、可重复计算、产物已在别处持久化)就稀疏保存。参考算例:每请求提交 → 正常路径 21 s、最坏重做 0.2 s;每 10 个提交一次 → 20.28 s、最坏重做 2 s —— 省 0.72 s 换最坏多重做 1.8 s,是一笔显式权衡。

粒度三档:每步(写放大 + fsync 每步都付,重做最少)、每轮(把「模型决策 — 工具执行 — 保存结果」整体提交)、关键边界(只在高成本 / 不可逆动作前后存)。

落盘方案:本地文件(快、不抗机器故障)、对象存储(大产物与轨迹、一致性偏最终)、数据库(要事务与并发控制,乐观锁的版本号就靠它)。可以混用:轨迹走对象存储,可并发写的状态走数据库,产物走对象存储,本地只留工作副本。

「保存成功」意味着什么:有三个等级 —— 写入本地页缓存 / 完成本地 fsync() / 收到云端持久化确认,能扛的故障完全不同(本地 fsync() 能扛进程重启,扛不住整台机器 + 磁盘不可用)。所以向调用者报告「完成」时到底保证到哪一级,必须在接口上写明;而且只对聊天日志 fsync() 不够 —— 恢复所需信息可能分散在轨迹、工作目录、外部数据库三处,要核对一致。另外要记的是「从起点连续保存到哪」,不是「最后保存了哪」(异步上传中间断了一轮时,云端可能只连续完整到更早的一轮)。

4. 一个工具调用超时了,你不知道它是「没执行」还是「执行了但结果丢了」。直接重试有什么问题?状态该怎么设计?

答案

直接重试的问题:它把「不确定」当成了「没发生」。如果操作其实已经执行(已经付款、已经创建资源、已经发了消息),重试会让同一件事发生两次,而这类操作往往不可逆。

正确顺序是「先查询,再决定」:① 查持久化结果,核对操作标识与输入文件版本,有结果就接续;② 没有结果时,判断该操作是否幂等 —— 付款 / 创建资源 / 发消息这类不幂等的操作,需要目标系统支持操作标识、事务或补偿才敢重试;③ 区分两种"重复":同一份结果被传送两次 → 消息层去重就够了;同一项操作实际执行两次 → 已经烧了两份资源,只能靠持久化结果 + 幂等协议避免。

状态该怎么设计:存**「意图 + 提交状态」而不是「已完成」这个结论 —— 例如 {op_id, status: pending | committed | unknown};恢复时对 pending / unknown 一律走先查询、再决定**。这正对应《AI Infra》11.1.3 状态表里「外部操作」一行:需要记录的是目标系统中的操作标识与提交状态,恢复方式是查询、去重或按业务规则补偿。

最后一条硬边界:把一轮当事务在本地提交(Agent 事务的方向)能保证本地一致,但保证不了外部服务恰好执行一次 —— 那一侧必须靠操作标识 / 幂等接口 / 补偿。仅在本地记「已提交」是不够的。

5. 你怎么验证一个 Agent 的状态管理是对的?(提示:为什么不能只看最终输出?)

答案

不能只看最终输出,因为过程错了终点可能对。 一个跑出正确结果的 Agent,中间状态可能早就污染了 —— 漏了步骤、覆盖了用户已确认的决策、重复调用产生了副作用。这些在单次任务上不一定表现为错误输出,换一个任务分布才会暴露。所以评测要断言过程:

  1. 状态迁移序列:给定期望的迁移序列(如 pending → in_progress → needs_input → done),断言实际序列包含 / 等于期望序列 —— 能抓住「跳过状态」「重入」「乱序」。这与 A2A 把任务生命周期标准化成状态机(已提交 / 进行中 / 需要输入 / 已完成 / 失败)是同一件事。
  2. 不变式(invariant):断言永不成立的命题 —— 「不能存在两个写者」「已确认的决策字段不被非授权节点改写」「待办非空时不得进入 done」。比逐次断言更耐改。
  3. 故障注入:在 checkpoint 之间杀进程 / 拔电,重启后核对恢复位置、实际重做量与重复副作用次数 —— 这是唯一能真正测出幂等问题的办法。
  4. 三处一致性核对:恢复后核对轨迹、工作目录、外部数据库是否一致。

最后一块拼图是可观测性:状态迁移要能被 trace 记录(每次 checkpoint 提交记一个事件 / span),否则没法自动化断言 —— trace 在这里既是评测数据也是归因材料。

另外把「做完了但确认丢了」「半截 checkpoint」「旧 schema 的 checkpoint」「缺口之后的恢复」做成固定用例 —— 这些样本平时不出现,一出现就是事故。