状态、持久化与中断恢复:让长任务 Agent 活过重启

Agent; 持久化; 中断恢复; 状态机; Checkpoint 2855 words 15 min read
This post is not yet available in English. Showing the original.

前两篇讲的是记忆——短期记忆(会话内的工作记忆)和长期记忆(跨会话的事实与流程)。这一篇讲的是另一种”活下来”的能力:当进程崩溃、节点故障、或者部署打断了一个正在跑的 Agent,它能不能恢复过来——不重做已经花了大价钱做完的工作,也不把有副作用的操作(发邮件、扣款、建 PR)做第二遍。

这就是持久化执行(durable execution)。它在开发环境里几乎从不出问题,第一次真正咬到你,往往是在生产环境一次带负载的部署时。

本篇原理部分主要整合自 agentic-ai-system-course 第 08 章,真实项目对比来自「16 个开源 Agent 项目差异对比」的会话持久化与中断恢复两个维度。

一个真实的故障

一个编程 Agent 已经跑了 40 分钟。它读了 50 个文件、做了 12 次编辑、生成了 3 份 PR 描述。这时部署上线了,进程重启。Agent 丢掉了内存里的中止令牌,但 checkpoint 显示它当时在第 23 步。重放时你发现,Agent 把其中一份 PR 描述又发了一遍——因为发 PR 描述的那个工具不是幂等的,而 harness 重试了它。你团队的 GitHub 上现在有一个重复的 PR。

模型没问题,Agent 的代码也没问题。是持久化层漏了。

什么才算”运行时状态”

在写任何持久化代码之前,先盘清楚:一个运行中的 Agent 到底有哪些状态,每一样该怎么处理?

状态说明恢复策略
消息数组每轮模型输出、每次工具调用与结果,只增不改持久化,重放的真相来源
工具执行状态每个调用
/running/completed/failed
提交前落盘
在途副作用已开始但还没返回的写入/发送/扣款最难恢复,最容易误判
工作记忆上一篇讲的可变草稿纸必须持久化(从transcript重建未必精确)
中止令牌进程本地信号不跨重启——崩溃后失控的run停不下来
凭证auth profile启动时重载,或从凭证池重建
提示词指纹第04篇的 SHA往返落盘,保证重建的系统提示字节一致、缓存存活
成本/token 账本预算上限用从消息日志重算,或单独持久化

一个持久化的运行时,意味着上面每一项都有明确的策略

、提交后持久化、恢复时重建、或者接受丢失。没有”默认答案”,逐项决定并写下来。

这里最凶险的是中止令牌不跨重启这条。如果你只靠中止令牌来停止失控的 run,那么一次崩溃就会留下一个谁也停不掉的僵尸 run 在后台烧钱。

提交点

回到第02篇那个概念——一个”步骤”是一次完整的循环迭代(模型调用 → 工具分发 → 反思)。提交点就在 Reflect 和 Stop 之间,正是第02篇标记的”一切都挂靠在这里”的边界。

一个步骤完成后,循环把控制权交出去之前,三样东西必须已经在磁盘上:

  1. 新消息追加到审计日志
  2. 工具执行状态转到终态
  3. 工作记忆和成本/用量计数器更新

OpenCode 在每次 LLM.stream() 循环后 flush;Hermes 做同样的 _flush_messages_to_session_db;Paperclip 每个 heartbeat_run_events 行提交一次。模式是通用的

。一个在写盘落定之前就返回给调用方的步骤,就是一个可能丢失的步骤。

// 一次步骤边界提交携带的内容
type Checkpoint = {
  sessionId:         string;
  stepIndex:         number;
  status:            "running" | "waiting_for_approval"
                   | "completed" | "failed";
  messageRange:      [number, number];   // 本步追加的消息
  workingMemory:     WorkingMemory;
  tokensSpent:       number;
  costSpent:         number;
  promptFingerprint: string;             // 第04篇
  lastError?:        string;
  committedAt:       string;
};

两条纪律:不要把密钥写进 checkpoint——存密钥的引用,运行时再解析;不要把重试计数写进消息日志——它属于 checkpoint,在那里更新。

run 状态机

一个 run 是”从一条用户消息(或定时触发)到它的终态回答”之间的工作单元。每个生产系统都用一个显式状态机来建模。隐式转换是 Agent 系统里一半”副作用重复”bug 的来源。

stateDiagram-v2
    [*] --> Queued : 用户消息 / 定时任务
    Queued --> Running : 原子认领 (CAS)
    Running --> Running : 步骤边界提交
    Running --> WaitingApproval : 审批门触发
    WaitingApproval --> Running : 收到审批
    Running --> ScheduledRetry : 瞬时错误
    ScheduledRetry --> Queued : 退避时间到
    Running --> Completed : 最终答案
    Running --> Failed : 永久错误 / 步数上限
    Running --> Cancelled : 用户中止
    Completed --> [*]
    Failed --> [*]
    Cancelled --> [*]

Paperclip 几乎原样实现了这个——heartbeat_runs.status IN (queued, running, completed, failed, cancelled, scheduled_retry)。规则:

  • 每个依赖当前状态的转换都需要条件更新——UPDATE ... WHERE status = <预期> 是底线。典型竞态是 queued → running(两个 worker 抢同一行)。同样的 WHERE 也守护审批、中止、重试转换和终态写入,防止并发覆盖。基于一个已经在你脚下变了的状态做转换,就是丢失更新。
  • running → 终态 一旦赢得就是幂等的
    ,是个 no-op——这正是重放/重试时想要的行为。
  • 终态永不回头。一个需要重试的 failed run 产生一个新的 run,用 parent_run_id 链回去——绝不原地复活。

崩溃恢复 vs Resume vs “Resume 按钮”

这三个听起来像,行为差别很大:

  • 崩溃恢复(Crash recovery):同一意图,新的进程躯体。部署重启了,用户期待工作继续。系统提示没变,缓存可能还热(前提是前缀字节一致地往返了磁盘,而且没超过 provider 的 TTL,而且路由到同一模型同一区域)。在途工具调用需要仔细分诊。
  • Resume:同一会话,更晚的时间。用户关了标签页几小时后回来。缓存可能已过期,系统提示可能在两次访问间被编辑过,审计日志能干净重放但世界可能已经变了。
  • “Resume 按钮”:用户的显式动作,继续一个暂停的会话。用户知道中间有间隔,系统有更多自由去确认、展示发生了什么、必要时重置工作记忆。

混淆这三者会产生微妙的 bug。崩溃恢复对可安全重放的工作应该静默而激进(只读操作、标了 idempotent: true 的工具、有 outbox 兜底的副作用);其余的走在途分诊,把不可重放的工具调用暴露给用户而不是静默重试。

最难的一关

一个工具调用开始了、结果没回来、进程死了。重启后有四个选项,按优先级排:

  1. 工具标了 idempotent: true(第03篇)
    。第二次调用返回同样结果。
  2. 工具有外部幂等键
    ,下游系统去重。
  3. 工具在执行前写了持久化 outbox
    outbox,如果意图已标记完成就跳过,否则用同一个键重试。
  4. 工具不安全重放
    run 标记为 failed,暴露给用户。一句尴尬的”这个操作发生了吗?”好过一封重复的邮件。

这就是第03篇那个 idempotent / destructive 元数据标志真正兑现价值的地方——它不是给模型看的,是给恢复逻辑看的。

真实项目
”恢复”其实是四件事

「16 个开源项目对比」在中断恢复维度上,澄清了四个最容易混淆的概念——它们不是同一件事的不同实现,而是四件不同的事:

关注点代表项目解决的问题
执行状态精确恢复Eino从第五步继续,不是从第一步重来
用户取消与优雅退出GooseCancellation Token
运行时健康检测HermesAgent 还活着吗?卡死了吗?
跨会话任务进度Oh My Open Agent上次做到哪了?

Eino 在这个维度做得最深,有几个独有设计值得记:

  • InterruptWithState
    Checkpoint
  • CompositeInterrupt
  • SkipPreHandler
    (防止重复发邮件/重复扣费)——这正是上面”在途工具调用”那一关的框架级解法
  • 恢复时机保证
    Checkpoint,再通知用户(防止竞态)

会话持久化又是另一个维度。这里 Goose 的精确恢复(SQLite V11 schema、逐条消息恢复、7 种会话类型、LLM 自动命名会话)和 Oh My Open Agent 的摘要注入(压缩后注入”继续第 N 个任务”)形成对比——不是好坏之分,是场景之分:

Eino Checkpoint 解决的是”从哪里恢复执行”,Goose Session 解决的是”从哪里恢复对话”——两者不可互换。

存储选型
/ Postgres / 工作流引擎

  • SQLite
    、单进程、零依赖。Goose、Hermes、Nano Cloud 都用它。适合桌面工具和单用户 Agent。Goose 的 Schema V1→V11 自动迁移是产品演进的忠实记录。
  • Postgres
    、多 worker 并发、需要 CAS 认领和行级锁时。Paperclip 用它做 run 状态机 + 加密凭证 + 成本审计。
  • 持久化工作流引擎(Temporal 一类)
    “存状态”,而是”把整个执行编排成可重放的确定性工作流”时。重,但对复杂的多步长任务是最干净的答案。

选型问题回到「16 项目对比」那句忠告:没有最好的架构,只有最适合你约束的架构。单用户助手用文件 + SQLite 就够;企业多租户平台才需要 Postgres + CAS + 工作流引擎。

小结

  • 持久化执行的目标:崩溃后不重做已完成的工作,不重复有副作用的操作
  • 先盘清运行时状态,逐项决定持久化策略——中止令牌不跨重启是最容易被忽略的陷阱。
  • 提交点在步骤边界
    ,消息、工具状态、工作记忆都要落盘。
  • 用显式的 run 状态机 + 条件更新(CAS)防止并发重复;终态永不回头。
  • 区分崩溃恢复 / Resume / Resume 按钮——三者对缓存、对用户的态度都不同。
  • 在途工具调用按 幂等→幂等键→outbox→暴露给用户 四级分诊。
  • 中断恢复的四个概念(执行状态/用户取消/健康检测/任务进度)是四件事,别用一个方案硬套。

下一篇进入协调部分

Agent 不够用,怎么规划任务、怎么把工作拆给多个 Agent。


主要参考:

  • agentic-ai-system-course ch08(State and persistence)
  • .adb/agent开发/项目差异对比总览(维度7 会话持久化、维度9 中断恢复)
  • .adb/agent开发/8会话持久化11中断恢复