Featured image of post 第六话|状态管理:循环怎么记得住、断得了、恢复得回来

第六话|状态管理:循环怎么记得住、断得了、恢复得回来

把只在内存里的状态显式化、落盘、checkpoint,让崩溃后的循环能接着跑,有副作用的步骤不重复执行。

系列 GYA(Get Your Agent) 第 6 / 13 篇
主题 AgentLLM状态管理Checkpoint幂等教程

第五话我们让模型的「想」和「做」交替起来了:模型想一步、做一步、看一眼真实结果、再想一步。循环跑通了。

但那个循环藏着一个没解决的口子:它跑的是「一口气」——从头到尾,中间不能断。 一旦进程崩了、断电了、被 OOM 杀了,循环进行到哪了、已经做过什么事,全没了;硬要重跑,它又从头开始,把已经做过的事再从头做一遍。

如果任务只是「查一次天气」,断了大不了重查。但如果任务是「处理订单」——查库存 → 扣库存 → 发通知——那「断了重来」就会出大事:扣库存这个有副作用的动作可能被重复执行,库存被多扣,用户收到的发货通知也可能是错的。

所以这一篇要讲的,就是怎么让循环不再是「只能一口气跑完」:断了能接回来,重跑时做过的事不会重做。

而这件事的病根,其实只有一个——进度只活在内存里,没有任何可靠的、能查的痕迹。 抓住这个病根往下看,问题会一个个现形,解法也会一个个浮出来。

但在动手之前,我想先把这篇真正要回答的那个问题钉在墙上。它才是第六篇的技术深度所在:

业务已经成功了,但 Agent 还没来得及把它记下来,怎么办?

「查库存」成功了、进程没崩——这种时候落盘不落盘无所谓,大不了重查。「扣库存」成功了、但就在落盘前那几毫秒进程崩了——这才是有意思的窗口。它逼我们想清楚两件事:进度该怎么记,以及已经发生的业务事实,靠什么保证不被重复执行。后面每一节,都是在往这个问题上撞。

一、没有状态管理的循环,问题在哪

第五话的循环,是靠 messages 这个列表撑起来的:模型说过什么、工具返回过什么,全都往里塞,靠它一路"喂"下去。这套东西跑一次性的任务没问题,但它没有「状态管理」——进度、做过的事,全都隐式地混在对话里、活在内存里。

具体来说,有两条实打实的毛病:

  1. 断不起,会重做。所有数据都在内存里,进程一崩、一断电、一 OOM,全部归零。即便硬着头皮重跑,它也不知道自己"已经做过哪些事"了。有副作用的步骤(扣库存、发通知、退款)会被重复执行。

  2. 看不出"做没做"的依据。进度藏在对话里,你想知道"扣库存这一步做过了吗",得把整段对话从头翻一遍,靠猜。没有一处能直接告诉你答案,也没有一处能在进程重启后还告诉你答案。

这两条背后是同一个病根:进度没有被当成一等公民对待——它没被单独存下来、单独落盘、单独记录。 后面几节,就顺着这个病根,一层层把问题拆开解决。

二、提取状态,不和消息耦合

先做的第一件事,是把「进度」从「对话」里拆出来,让它变成能被单独查看、单独保存的东西。

第五话把什么都塞进 messages,这一篇给它松绑:

@dataclass
class AgentState:
    task: dict          # 任务输入(order_id / sku)
    messages: list      # 只存「模型需要看到的对话」
    trace: list         # 执行轨迹(只读审计)
    status: RunStatus   # 运行状态
    next_step: str      # 运行阶段:等模型决策 / 等工具结果
    done_steps: list    # 已经做完了哪些副作用步骤(审计 / 幂等依据)

这里必须先说清楚一件事,免得读者误会:这一篇延续第五话的 ReAct 路线,next_step 记录的是"运行阶段",不是"该执行哪个业务步骤"。

具体来说,ReAct 循环里每走一步,循环只会处于两种状态之一:

  • waiting_model:循环刚把上一轮工具结果回填给模型,正等着模型给出下一轮决策(要么再调一个工具,要么给出最终答复)。
  • waiting_tool:模型已经发起了一次工具调用,循环正等着工具返回结果。

所以 next_step 回答的问题是"循环现在卡在哪一步了"——是卡在"等模型想"还是"等工具做"。而"下一步具体调哪个工具、发什么参数",这个决定仍然由模型做,跟第五话一样。

为什么要把它显式成一个字段,而不是继续靠隐式状态?因为「等模型」和「等工具」之间的那条边界,恰恰是崩溃最容易发生的地方——模型刚发起"扣库存"、工具还没返回的那一瞬间进程死了,恢复时你就能从 next_step 一眼看出"上一轮是模型在等工具结果",从而决定是把工具结果补回去、还是让模型重新决策。把运行阶段显式化,是"断了能接回来"的第一步。

不过这里要提前把一句"难听的话"说在前面,免得你读到后面觉得别扭:在 ReAct 这种模型自主决策的循环里,状态管理能给你的"进度"天然是粗粒度的。 它只能告诉你"循环停在哪一次交互"——是停在第几次"等模型想"、第几次"等工具做"——却给不了你"业务已经做到第几步、还差几步"这种精细答案。因为下一步调哪个工具、做哪个业务动作,是模型临时决定的,不是一张能预先勾选的清单。

这意味着,别指望靠状态管理本身去精确回答"扣库存到底做没做"。那件事,交给后面第四、五节的幂等去兜底——状态管理管"能不能恢复",业务正确性管"重做会不会错",各司其职。先把这句话记住,后面会回到它。

三、保存磁盘的时机:看关键步骤

进度摆到明面了,但它还只在内存里,进程一崩照样归零。要让它「断了还在」,就得把它存到磁盘上:

class CheckpointStore:
    def save(self, state):
        # 写临时文件 -> fsync -> 原子 rename,避免写到一半被中断留下半截文件
    def load(self):
        return ...       # 读回一个完整的进度

「存」这个动作谁都会写(json.dumps 而已),真正关键的是什么时候存。这里有一个工程上反复验证过的判断:

每个「有副作用」的步骤执行完,立即落盘。

为什么是「有副作用」的步骤?因为无副作用的步骤(比如查一次库存),断了大不了重查。但有副作用的步骤(扣库存、发通知、退款),执行之后重复执行会改变业务结果——所以要在它执行完的那一刻,尽快把「我已经做过了」这个事实写到磁盘上。

if step in {"deduct_inventory", "notify_shipped"} and 成功:
    state.done_steps.append(step)
    checkpoint.save(state)   # 立即落盘,绝不拖延

但这里我要踩一脚刹车,把话说准:「每个有副作用步骤完成后立即落盘」是一个保存时机,不是恢复的全部秘密。 落盘本身解决不了两个更棘手的问题——

  1. 落盘和业务执行之间有时间差。「扣库存成功」到「checkpoint 写成功」之间,始终有一个窗口;在这个窗口里崩溃,checkpoint 里没有记录,但业务已经发生了。
  2. 就算 checkpoint 记了"扣库存做过了",恢复时也未必能信它——因为「记了」这件事本身,和「业务真的成功了」之间,仍然隔着那几毫秒。

所以下面两节,才是这一篇真正的硬骨头。

四、中断恢复时,防重复执行

进度落盘了,断点就能恢复。但恢复时最怕一件事:重放。

还是那个订单:查库存 → 扣库存 → 发通知。进程在「扣库存」之后、「发通知」之前崩了。恢复时,从落盘的进度接着走,但它可能不知道「扣库存」已经做过了——于是又扣了一次。

为什么会这样?因为「从 checkpoint 恢复」这件事,本质是「至少一次」(at-least-once)语义,不是「恰好一次」(exactly-once)。它保证的是「断了能接回来、不会漏做」,但保证不了「已经做过的绝不重做」。扣库存那个动作,可能在「已经扣了」和「落盘记录了」之间那几毫秒里崩掉,恢复时谁都不知道它到底做没做。

这里要把三个不同层次分清楚,它们各管各的,别混为一谈:

机制负责什么
Checkpoint保存可恢复的执行状态(我记到哪了)
重试与恢复调度决定未完成的操作是否继续尝试
业务幂等控制同一逻辑操作重试后的业务效果

有 checkpoint 不等于「保证不会漏做」;幂等也不代表工具实际只被调用一次。准确的说法是:允许恢复过程中重复尝试,但在明确的业务边界内避免重复生效。

那么「在业务边界内避免重复生效」靠什么?靠幂等:同一个有副作用的操作,执行多次和执行一次,业务效果得一样。

def deduct(self, sku, order_id):
    # 查幂等 -> 扣库存 -> 写幂等,三步同一事务
    key = f"deduct:{order_id}:{sku}"
    if 已经扣过(key):              # 这笔订单已经扣过
        return {"status": "ALREADY_DONE"}
    扣库存(sku)
    记录(key)                       # 唯一约束兜底
    return {"status": "DEDUCTED"}

注意这里有两层防线,缺一不可:

  1. done_steps(状态里的记录):把"哪些副作用步骤已经做过"显式记下来,恢复时 runtime 和模型都能看到这份清单,作为"哪些不该重做"的参考依据;
  2. 业务自己的幂等记录(副作用自己的记录):即使模型重放了、runtime 漏看了,副作用本身也能认出「这笔订单扣过了」,返回 ALREADY_DONE 而不是真扣。

第二层尤其重要——因为它对应的是真实世界:数据库里的扣款记录、消息队列里的已发送标记,这些「外部世界的痕迹」才是「不重复」的最终兜底。光靠进程内的进度记录,护不住「进程都重启了」的场景——所以必须有一层落在外面的世界。

这也正是第二节埋下的那个伏笔的答案:ReAct 里状态管理能给的"进度"是粗粒度的,精确的"扣库存做没做"它给不了;而幂等这一层,恰好补上了这个缺口。 状态管恢复,幂等管正确,二者合起来,才撑得起"断了能接回来、做过不重做"这句承诺。

这个"三层各管各的"判断,不是本系列的一家之言。业界主流的做法也是如此:OpenAI 的 Agents SDK、LangGraph 这些框架,都只负责"会话/图状态的持久化",明确把"副作用恰好一次"留给应用层自己;而专门啃后者这块硬骨头的,是 Temporal、Restate 这类 durable execution 引擎——Temporal 官方文档甚至专门用一篇《Idempotency and durable execution》,教你怎么用幂等键 + 唯一约束把 at-least-once 补成 exactly-once,思路跟下面第五节要展开的完全一致。

五、幂等的关键:原子性

上一节那段 deduct,看着已经"幂等"了,但其实藏着一个致命的漏洞。我把三步拆开看:

检查执行记录 → 扣库存 → 写执行记录

如果「扣库存」成功之后、「写执行记录」之前进程崩了,重试时「检查执行记录」仍然查不到,于是又扣了一次。 把这个列表换成数据库表,问题也不会自动消失;更糟的是,并发请求还可能同时通过「检查」,两个一起扣。

这就是这一篇真正要回答的那个问题——「业务已经成功了,但 Agent 还没来得及记下来」——在幂等这层上的具体化。答案不在"记得更快",而在原子性:

本地数据库业务:把「幂等记录」和「业务变更」放进同一个事务,再用唯一约束处理竞争。

CREATE TABLE side_effect_log (
    step      TEXT NOT NULL,
    order_id  TEXT NOT NULL,
    sku       TEXT NOT NULL,
    PRIMARY KEY (step, order_id, sku)   -- 唯一约束:同一操作只允许一条
);
try:
    with conn:  # 一个事务:扣库存和写幂等要么都成功,要么都回滚
        if 已经扣过(order_id, sku):
            return ALREADY_DONE
        UPDATE stock SET qty = qty - 1 WHERE sku = ?
        INSERT INTO side_effect_log VALUES ('deduct', order_id, sku)
except IntegrityError:   # 唯一约束冲突 = 别人已经扣过
    return ALREADY_DONE

这里的关键是:「查幂等、扣库存、写幂等」三步在同一个事务里,要么一起成功、要么一起回滚,不存在「扣了但没记」的中间态。 而唯一约束兜住了并发——两个请求同时到达,只有一个能把那条幂等记录插进去,另一个撞唯一约束、收到 ALREADY_DONE。

远程工具调用:扣库存的库如果是别人的服务,你没法用本地事务。这时只能依赖对方支持的幂等请求标识(同一个幂等键,重复请求返回相同结果)或结果查询(超时后去查一下到底成没成)。而且要注意:超时之后,操作可能处于"结果未知"——不能直接当作"没执行",也不能当作"执行了",得去核实。

这一节是本篇分量最重的地方,也是你熟悉的 Java 事务经验最能派上用场的所在。原子性不是"写得快",是"不存在中间态"。

六、动手实验一次真实的崩溃

光说不练假把式。这一节,我们把上面讲的每一件事,都用真实的两进程跑一遍——不是"模拟崩溃",是真的让一个进程退出、另一个进程从落盘的数据里恢复。

任务是「处理订单:查库存 → 扣库存 → 发通知」。配套代码在 GYA/c06-state-management(Python,零第三方依赖,业务数据落在 SQLite 里):

工具作用副作用
check_inventory查库存无(可安全重跑)
deduct_inventory扣库存有(重复扣会改变结果)
notify_shipped发通知有(重复发会打扰用户)

配套的离线测试与演示脚本都在这个目录里:离线回归 7 项,覆盖 checkpoint 往返与原子写、正常跑、崩溃前未落盘、崩溃后恢复、重复恢复不重启、幂等重放;演示脚本跑一遍正常流程,再用两个真实进程模拟一次崩溃恢复。命令见该目录的 README。

我在这里刻意做了四件能被验证的事,每一件都对应上面某一节的结论:

实验一:扣库存成功、checkpoint 尚未写时,进程被杀。

这是最狠的窗口。子进程里直接调 deduct 把库存扣了(业务成功),然后刻意不写 checkpoint,进程退出。此时内存状态全丢,只有 SQLite 里的业务数据还在。父进程(新进程)重新执行同一笔扣库存,读到的 checkpoint 是不存在的,于是重新 deduct——结果返回 ALREADY_DONE,库存仍停在 9,只扣了一次(脚本 state_management.py)。

这验证的是第四节第二层防线、第五节原子性的结论:业务事实(SQLite 里的幂等记录 + 唯一约束)才是"不重复"的最终兜底,跟 checkpoint 有没有写无关。

实验二:checkpoint 已保存后恢复,完成剩余操作。

子进程里,模型决策到「扣库存」这一步、扣库存执行完并落盘 checkpoint 后,脚本耗尽、进程退出(模拟崩溃)。新进程恢复,模型接着做决策——它读到"扣库存已经做过了"的上下文,于是决定发通知,最终 status=COMPLETED。新进程恢复时打出的是 recovered_from_checkpoint,也就是它确实从落盘的检查点接上了。

这验证的是第二节的结论:恢复恢复的是决策上下文,而不是靠一个硬编码的位置指针——模型接着 messages 里的对话继续想、继续做。扣库存没被重放(deduct_count=1),漏掉的发通知被补上了(notify_count=1)。

实验三:对已完成的任务再次恢复,不会重新启动业务动作。

任务已经跑完(checkpoint 里 status=COMPLETED),再调一次 recover()。结果没有任何业务动作被触发,deduct_count 和 notify_count 都保持 1。

实验四:验收看的是持久化数据,不是日志。

四个实验的断言,全部落在 SQLite 里的库存数、幂等记录、以及 checkpoint 里的最终状态上——而不是"日志里打印了几次"。因为日志可以被重放误导,只有持久化数据不会说谎。

需要诚实说明的是:这一篇的实验是确定性夹具,模型决策被一个顺序脚本模型替代了,没有引入真实 LLM。这样做的理由,恰恰是这一篇的核心不是"模型聪明不聪明",而是"状态怎么记、业务怎么不重复"——用真实 LLM 反而会引入不确定性和 API 依赖,干扰对恢复机制的验证。恢复机制本身(落盘、运行阶段、幂等、原子性)在这里是被真实验证了的。

七、这一篇管「这一笔」的进度,跨会话的记忆还没解决

到这里,我们把这半天的功夫盘一盘:

  • 进度摆到明面(next_step 记录运行阶段,一眼看清循环卡在哪);
  • 关键步骤落盘(有副作用的步骤执行完立即 checkpoint,断得起);
  • 副作用幂等 + 原子性(事务 + 唯一约束,重做不重复生效)。

循环从「只能一口气跑完」,变成了「断了能接着跑,做过的不重做」。

但我们真正回答的那个问题,值得再复述一遍:「业务已经成功了,但 Agent 还没来得及记下来,怎么办?」 答案是两层的——checkpoint 负责"记得快一点、记的是进度",而业务数据自己的幂等记录 + 原子性,负责"就算没记下来,重复执行也不会改变业务结果"。 前者是尽力而为,后者才是兜底。

不过,回头看这一篇,它解决的始终是「这一笔订单 O-1001」从开始到结束的进度。而真实世界里,Agent 不会只处理一笔订单:它今天处理一百笔、明天再处理一百笔。于是有个更实际的问题冒出来了:

  • 处理第 1 笔订单时,Agent 学到「这个 SKU 经常缺货,得先查库存」;到第 100 笔,它还记得吗?还是每一笔都像第一次那样,从头摸索一遍?
  • 上一笔订单处理到一半崩了,靠落盘接回来了——这是「这一笔」的进度。但「过去一百笔攒下的经验」,存在哪?进程一重启,是不是也一起没了?

「记住这一次任务进行到哪、做过什么」,和「记住过去做过的那些事、攒下的那些经验」,是两码事。前者这一篇解决了;后者——让 Agent 跨任务、跨会话,还记得以前的事——正是第七话要讲的:记忆。


演示代码:GYA/c06-state-management(Python:state_management.py + test_state_management.py,零第三方依赖,7 项离线测试)。崩溃恢复用真实两进程模拟,业务数据(库存、幂等记录)落在 SQLite,幂等靠事务 + 唯一约束落地,不依赖外部 API。

留言

欢迎分享你的想法。评论提交后会在审核通过后显示。