第十章 持久化与可恢复性:Agent 如何在断点之后找回自己

第九章讨论了控制权如何在人、模型和 harness 之间交接。一次提问可能等人几个小时,一次审批可能跨过前端断线,一项任务也可能在执行到一半时遇到进程退出。 这就引出一个更基础的问题:当内存里的 Session、LOOP、等待通道和网络连接全部消失后,下一次启动凭什么知道之前发生过什么? 本章讨论 Codex 如何用 rollout、checkpoint 和 replay 重建线程,如何区分 resume、fork、rollback 与 revert,以及一个常被忽略的事实:恢复对话状态,不等于回滚真实世界。


10.1 可恢复,不是把旧进程“冻住再解冻”

很多人第一次设计 agent 持久化,会自然地想到“保存 Session 对象”:把当前历史、配置、正在执行到哪一步全部序列化,进程回来时再反序列化。

这个思路在普通表单应用里也许可行,在 agent harness 里却很快失效。一个正在工作的 Session 里不只有数据,还有大量无法直接保存的运行时对象:

  • 正在读取的模型流和底层网络连接;
  • 已经 spawn 的异步任务、取消令牌和锁;
  • 正在运行的 shell 进程及其管道;
  • 等待用户审批的 oneshot 通道;
  • MCP 连接、远程环境句柄和前端订阅;
  • 此刻恰好位于哪一行代码的程序计数器。

这些对象有的属于旧进程,有的属于旧连接,有的甚至属于已经变化的外部世界。即使能把内存字节完整抄下来,也无法保证它们在另一台机器、另一个版本或几小时以后仍然有效。

Codex 采用的是另一条路线:

不保存“正在运行的机器”,而是保存足够多、顺序明确的事实,让一台新机器能够重建同一段有效历史。

这是一种 replay(重放)模型。进程重启后,并不是从旧函数的某一行继续执行,而是:

  1. 找到该线程的持久化记录;
  2. 按顺序解释已经发生的事实;
  3. 重建模型可见历史、配置基线和生命周期状态;
  4. 创建一套全新的运行时资源;
  5. 由用户或上层调度决定是否继续工作。
flowchart LR
    A["旧进程
Session / task / 网络连接"] -->|"持续记录事实"| R["rollout
只追加的 canonical log"] A -->|"崩溃或退出"| X["运行时对象全部消失"] R -->|"读取 + replay"| S["新 Session
重建历史与基线"] S --> C{"是否继续?"} C -->|"新输入"| N["开启新 turn"] C -->|"恢复被中断 turn"| V["recover"] C -->|"只查看"| I["保持 idle"]

这里的关键不是“保存得足够多”,而是保存边界选得足够准确。记录太少,恢复后模型失忆;记录太多,又会把易失连接、半截 delta 和过时等待状态误当成可以复活的事实。


10.2 四种状态,四种不同的恢复承诺

讨论“恢复”之前,先要回答:系统里到底有哪些状态?

状态层例子能否可靠恢复恢复方式
对话事实用户消息、assistant item、reasoning、工具调用与结果可以从 rollout replay
harness 状态turn 边界、模型与权限配置、token 用量、世界状态基线、上下文窗口编号大部分可以从事件和 checkpoint 推导
运行时续体future、oneshot、HTTP 流、取消令牌、进程句柄不可以创建新对象,旧对象作废
外部世界已修改的文件、已发送的请求、已启动的服务、远端数据库状态不能仅靠 rollout 恢复重新观察、校验或补偿

这张表定义了本章最重要的边界。

第一层和第二层是 replay 的对象。 它们是数据,具有稳定的身份和顺序,可以跨进程保存。

第三层只能重建,不能恢复原物。 新进程可以重新建立 MCP 连接,但不能继续 await 旧进程里的 oneshot;可以重新创建取消令牌,但旧令牌的触发状态没有意义。

第四层独立存在。 agent 通过工具改过文件,文件不会因为对话 rollback 就自动复原;网络请求也可能已经被对方接收,只是工具结果还没来得及写回。

因此,“可恢复性”至少有三个等级:

  1. 可回看:用户能看到之前发生过什么;
  2. 可续聊:模型拿到足够上下文,可以继续推理;
  3. 可续做:系统能判断外部动作做到哪,并安全地继续。

Codex 的 rollout 很好地解决了前两级,也为第三级保存了证据;但第三级最终仍依赖工具的幂等性、外部系统的查询能力和必要的人为确认。任何宣称“恢复 Session 就等于恢复任务”的设计,都把这三层混在了一起。


10.3 Rollout:不是聊天记录,而是 canonical replay log

Codex 把每个线程的持久化记录称为 rollout。本地形态通常是一份 JSONL:每行一个带时间戳的结构化记录,按发生顺序只追加。

“只追加”不意味着“所有东西都记”。rollout 保存的是未来重建状态所需的 canonical facts(规范事实),而不是前端看到的每一帧动画。

可以把内容分成六类:

类型保存什么恢复时的作用
Session 元数据thread/session 身份、来源、父子关系、初始工作目录、基础指令、动态工具等确认“这是谁的记录”以及如何创建新 Session
Response item用户与 assistant 消息、reasoning、工具调用、工具结果重建模型真正读到的历史
生命周期事实turn 开始、完成、中止,线程设置变化划分 turn,推导状态,识别未完成工作
上下文 checkpoint压缩后的替换历史、上下文窗口身份不必从最早一条消息重新计算
世界状态与 turn 上下文全量快照、后续 patch、模型与权限等有效设置恢复差分注入基线
协调与计量多 agent 通信、token 用量、rollback 标记等恢复协作关系、预算和有效历史

反过来,下面这些通常不属于 durable facts:

  • assistant 文本 delta、reasoning delta;
  • 命令输出的实时片段和进度动画;
  • item started 之类可由完整 item 推导的瞬时状态;
  • “正在等待审批”的内存通道;
  • MCP 启动进度、重连提示和普通 warning;
  • 面向某个前端连接的临时请求。

这和第二章的“item 是权威,delta 是易失加速带”完全一致。前端可以依靠 delta 获得流畅体验,但恢复必须只依赖完整 item。否则一个在半句文本处崩溃的进程,会留下永远无法判断是否完整的 assistant 消息。

flowchart TD
    E["运行中的 Event / item"] --> P{"持久化策略"}
    P -->|"完整、可重建的事实"| R["写入 rollout"]
    P -->|"流式、临时、可推导"| T["只实时发送,不落盘"]

    R --> A["canonical JSONL
追加日志"] A --> M["replay:模型历史"] A --> U["projection:UI turn / item"] A --> D["索引:搜索 / 列表 / 分页"]

所以把 rollout 简单叫作“聊天记录”是不准确的。聊天文本只是其中一部分;它更像一本航行日志:既记乘客说了什么,也记航程在哪开始、在哪中止、换过什么导航配置、何时做过一次摘要交接。

10.3.1 一个 replay 示例:日志有 25 条,最后恢复出什么

下面用一份简化日志演示 replay。字段只保留理解流程所需的部分,并不是实际 wire format。

假设用户先让 agent 修复登录问题,长对话随后发生 compaction;接着用户让它发布到 staging;最后又讨论了一轮生产环境发布,但把这一轮 rollback 了。

01  SessionMeta
      thread = "thread-A"
      cwd = "/repo"

02  TurnStarted       turn = "fix-login"
03  UserMessage       "修复登录超时"
04  TurnContext       model = "model-large", approval = "on-request"
05  WorldState(full)  { cwd: "/repo", environment: "local" }
06  ResponseItem      user: "修复登录超时"
07  ResponseItem      assistant: "已修改重试逻辑并通过测试"
08  TurnComplete      turn = "fix-login"

09  Compacted
      replacement_history = [
        user: "修复登录超时",
        assistant: "摘要:已完成登录超时修复,测试通过"
      ]
      window = 2
10  WorldState(full)  { cwd: "/repo", environment: "local" }

11  TurnStarted       turn = "deploy-staging"
12  UserMessage       "发布到 staging"
13  TurnContext       model = "model-large", approval = "on-request"
14  WorldState(patch) { environment: "staging" }
15  ResponseItem      user: "发布到 staging"
16  ResponseItem      assistant: tool_call("deploy", target="staging")
17  ResponseItem      tool: "deployment succeeded"
18  ResponseItem      assistant: "staging 发布完成"
19  TurnComplete      turn = "deploy-staging"

20  TurnStarted       turn = "deploy-production"
21  UserMessage       "继续发布 production"
22  TurnContext       model = "model-small", approval = "never"
23  ResponseItem      assistant: "无法执行需要审批的发布"
24  TurnComplete      turn = "deploy-production"

25  ThreadRolledBack  num_turns = 1

如果只是从头到尾机械遍历,当然也能得到结果,但长线程会越来越慢。实际恢复更接近“先倒序找边界,再正序重建”。

第一步:从尾部倒序扫描。

读取到第 25 条 rollback marker 时,恢复器先记下:

pending_rollback_turns = 1

继续向前遇到 deploy-production 的完整 turn 后,这个 turn 被计入待删除数量,因此它的 Response item、TurnContext 和状态变化都不进入最终结果。此时:

pending_rollback_turns = 0

再向前遇到 deploy-staging,它是最新一个仍然有效的 turn。恢复器从这里拿到最近的有效 TurnContext:

model = "model-large"
approval = "on-request"

继续向前遇到第 9 条 Compacted。它带有完整 replacement_history,因此可以作为模型历史的基线;更早的第 2~8 条不必再逐条还原到模型上下文。

第二步:从 checkpoint 向后正序 replay。

模型历史先被替换为:

user:      修复登录超时
assistant: 摘要:已完成登录超时修复,测试通过

然后顺序追加第 15~18 条,于是恢复后的模型工作历史是:

user:      修复登录超时
assistant: 摘要:已完成登录超时修复,测试通过
user:      发布到 staging
assistant: 调用 deploy(target="staging")
tool:      deployment succeeded
assistant: staging 发布完成

被 rollback 的 production turn 仍在物理 rollout 中,但不会进入这份有效历史

第三步:单独重建世界状态。

世界状态不能只取“最后一条”,因为第 14 条是 patch,不是完整对象。恢复器先读取 compaction 后的第 10 条 full snapshot,再应用第 14 条 patch:

基线:{ cwd: "/repo", environment: "local" }
patch:{ environment: "staging" }
结果:{ cwd: "/repo", environment: "staging" }

production turn 中的 approval = "never" 已随 rollback 被排除,不能污染恢复后的设置。

最终 replay 得到的不是一个旧 Session 对象,而是一组新的初始化材料:

重建结果
thread 身份thread-A
模型工作历史compaction 摘要 + staging turn
最近有效模型model-large
最近有效审批策略on-request
世界状态基线/repo + staging
上下文窗口window 2
旧运行时任务不恢复

这个例子也解释了为什么 replay 要同时做两种方向的扫描:

  • 倒序适合寻找最近 checkpoint、最近有效设置,以及先知道后面的 rollback 会删除谁;
  • 正序适合追加 Response item、按顺序应用 merge patch,还原事件原本的因果关系。

Replay 的本质不是“把每条事件再执行一次”,而是一个 reducer:

新状态 = reduce(旧状态, 下一条 canonical record)

只不过它不只有一个 reducer,而是并行维护模型历史、世界状态、turn 生命周期、token 计量和 thread 元数据等多个 projection。


10.4 为什么用追加日志,而不是不断覆盖一份 Session 快照

假设每次状态变化都覆盖写一个 session.json,会遇到三个问题。

第一,写到一半崩溃,旧状态和新状态可能一起丢。 大对象覆盖很难天然形成清晰的提交边界。

第二,历史原因消失。 文件里只剩“现在是什么”,却无法回答“为什么变成这样”。审批审计、错误诊断、fork 到旧节点都失去了依据。

第三,多个派生视图被绑死。 模型需要 Response item,UI 需要 turn/item,列表页只需要标题和更新时间。把这些都塞进一个可变大对象,会让每次更新都牵动所有消费者。

追加日志把问题反过来处理:

  • 旧记录永不覆盖;
  • 新事实只追加在尾部;
  • 每条记录有稳定顺序;
  • 当前状态由 replay 推导;
  • 不同消费者可以建立自己的 projection(投影视图)。

这正是 event sourcing 的核心思路。但要加一个限定:Codex 是“以事件溯源思想组织的 replay log”,不是把运行时所有 Event 无差别落盘。 持久化层会过滤瞬时事件,只保留能够构成未来状态的规范记录。

10.4.1 Canonical log 与查询索引分离

本地存储中可以看到两类数据:

  • rollout JSONL:canonical history,负责回答“真正发生过什么”;
  • SQLite 投影:把日志投影成 thread、turn、item、标题、时间、分页位置等可查询结构,负责回答“怎样快速找到和展示它”。
flowchart LR
    W["Session 写入"] --> J["rollout JSONL
canonical log"] J --> P["增量 projection"] P --> DB["SQLite
列表 / 搜索 / 分页"] DB -.->|"落后或损坏"| RE["从 rollout 重建"] RE --> DB

这里的主从关系非常重要:索引可以落后,不能领先;可以重建,不能成为唯一真相。 写入流程先让 rollout 达到持久化屏障,再更新 SQLite projection。若 projection 失败,系统记录告警,但 canonical log 仍然可用,之后可以重新物化。

这与数据库的 WAL 思想相似:先保住事实,再更新便于查询的派生结构。恢复能力因此不依赖某一张索引表永远正确。

10.4.2 空线程不必急着落盘

一个刚创建、还没有任何有效输入的线程,可能只是用户误点了一次“新对话”。立即创建文件会留下大量空记录。

因此新 rollout 可以先停留在内存中的 deferred 状态:路径和 Session 元数据已经准备好,但直到第一个有意义的持久化边界才真正创建文件。用户消息被接受后、turn 即将开始采样时,会显式 materialize;临时线程则可以完全不进入持久化系统。

这是一个小但重要的原则:持久化的是事实,不是对象曾经被构造过。


10.5 写入时序:先排队,再设持久化屏障

如果每产出一个 delta 都同步写盘,模型流会被磁盘 I/O 拖慢;如果一直只放在内存里,turn 结束前崩溃又会丢掉全部过程。Codex 采用异步 writer 加显式 barrier 的折中。

普通记录先进入一个有界写队列,由单独 writer 串行追加。调用方不做阻塞式文件 I/O,因此模型流、工具执行和前端事件不会被每次写盘卡住。到了关键生命周期边界,再执行 flush:

  • turn 的主体工作结束、准备生成 terminal event 之前;
  • 中断标记写入后、准备发中止事件之前;
  • terminal event 入队并发出之后,再追加一次 flush;
  • fork、rollback、revert 读取源历史之前;
  • Session 正常关闭时。
sequenceDiagram
    participant L as LOOP
    participant Q as 持久化队列
    participant W as 单 writer
    participant R as rollout
    participant U as 前端

    L->>Q: 完整 item / 工具结果
    Q->>W: 串行消费
    W->>R: 追加 JSONL
    L->>W: flush 已完成的主体记录
    L->>Q: turn terminal event
    L->>U: 对外确认 turn 已结束
    L->>W: 再次 flush terminal event
    W-->>L: 已处理此前全部写入

这个顺序刻意设置了两道屏障:

  • 第一处先保证 turn 主体记录已经可读,再发布 terminal event;
  • 第二处随后把 terminal event 本身也推进持久化。

因此观察者收到“已完成/已中止”时,前面的 item 已经有可靠记录;terminal marker 紧接着由第二道屏障封口。这里仍存在一个很窄的 crash window:通知已经送达,但 terminal marker 尚未完成第二次 flush。恢复端不能只信前端曾经显示过什么,仍要以实际读到的 rollout 为准。

写入失败也不是立即丢弃。writer 会保留尚未成功写出的后缀,关闭并重开文件后重试;失败仍在时,对外发出明确 warning,后续 flush 或 shutdown 还会继续尝试。

JSONL 对 crash recovery 也很友好:一行损坏通常只影响一条记录。读取时可以跳过无法解析的行并统计错误,而不是让整份历史报废;再次追加前还会确保文件以换行结束,避免新记录粘在半截尾行后面。带顺序号的新式记录还能帮助 projection 判断缺口、重复和读取边界。

[!warning] “flush”不是魔法 持久化屏障解决的是应用层写队列的顺序与可见性,不应该被夸大成对所有断电、磁盘缓存和文件系统故障的绝对保证。工程上必须明确自己承诺的是“进程退出后可读”、还是“机器断电后仍不丢”;后者通常还需要更强的 fsync、原子 rename 和目录同步策略。


10.6 Checkpoint:不是内存快照,而是 replay 的捷径

只追加日志有一个明显问题:线程越长,resume 就越慢。一个工作数月、经历几十万条 item 的线程,如果每次都从第一行 replay,恢复成本会随历史无限增长。

Checkpoint 的作用,是为 replay 提供一个新的起点。但 Codex 的 checkpoint 不是进程内存 dump,而是领域语义上的可替换基线

最重要的三类 checkpoint 是:

  1. 压缩后的 replacement history:直接给出“此刻模型工作历史应该是什么”,早期长历史仍留在 rollout 中,但恢复模型上下文时可以从这里起步;
  2. 世界状态全量快照 + merge patch:先建立目录、权限、指令等事实基线,之后只记录变化;
  3. TurnContext:记录最近有效 user turn 使用的模型、工作目录、权限与模式等设置,使 resume 后的第一轮能够延续正确配置语义。
flowchart LR
    H1["早期 item × 很多"] --> C["Compacted checkpoint
replacement history"] C --> W0["World State 全量"] W0 --> W1["patch #1"] W1 --> W2["patch #2"] W2 --> T["最新 TurnContext"] T --> H2["近期 item"] C -.->|"恢复模型历史起点"| R["重建后的 Session"] W0 -.->|"依次 apply patch"| R T -.->|"恢复设置基线"| R H2 -.->|"顺序 replay"| R

恢复器可以从尾部向前扫描:找到最新仍然有效的 replacement history,同时收集最近 user turn 的配置基线、世界状态和上下文窗口信息;条件满足后,早于 checkpoint 的记录就不必再读。

这种 checkpoint 有三个特点:

  • 不删除证据。 压缩只替换模型的工作历史,不删除 rollout 里的原始事实;
  • 可以验证。 full snapshot 后的 patch 必须按顺序应用;缺少基线的 patch 不能凭空猜;
  • 与领域边界对齐。 checkpoint 落在压缩和 turn 边界,而不是任意字节偏移。

第四章说“模型的工作记忆变薄了,但会话的完整档案还在”,到这里可以更精确地表述:

Compaction 改变的是 replay 生成的模型上下文,checkpoint 改变的是 replay 的起点;两者都不需要改写已经发生过的 rollout。

10.6.1 Checkpoint 到底在什么时候触发

这里需要先区分两件经常都被叫作 checkpoint 的机制:

机制触发条件写入内容解决的问题
Compaction checkpoint手动 compact,或上下文即将耗尽,或模型切换要求重新整理历史Compacted + replacement history + 新窗口身份控制模型上下文,并为 replay 提供新基线
状态 checkpoint新上下文窗口首次建立,或世界状态相对基线发生变化WorldState(full/patch) + TurnContext恢复 cwd、权限、工具、指令等有效环境

它们有关联,但不是一回事。Compaction 一定会建立新的模型历史基线;世界状态则有自己的 full/patch 生命周期。一次 compaction 开启新窗口后,如果该路径已经把完整初始上下文放进 replacement history,就会紧接着写新的 WorldState(full) 和对应 TurnContext,保证历史和状态从同一个基线继续。

示例一:在 turn 开始前自动 compact

假设某模型的完整 context window 是 128K tokens,配置的自动压缩阈值是 100K。下面的数字只用于说明,实际阈值还会受到模型配置、计量范围和预留 buffer 影响。

上一轮结束:
  当前有效上下文 = 96K
  自动压缩阈值   = 100K

用户提交新问题后:
  预计本轮采样前上下文 = 103K

在发起正常模型采样前,harness 会先检查 token 状态。此时已经达到阈值,于是:

flowchart TD
    A["新 turn 准备开始"] --> B["计算当前 token 使用量"]
    B --> C{"达到 auto-compact 阈值
或完整窗口上限?"} C -->|"否"| D["正常模型采样"] C -->|"是"| E["运行 compaction"] E --> F["生成 replacement history"] F --> G["写入 Compacted checkpoint"] G --> H["建立新 context window"] H --> D

假设压缩后只剩 18K:

压缩前:96K 历史 + 7K 新输入 = 103K
压缩后:18K replacement history
窗口号:window 3 → window 4

rollout 里旧的 103K 历史不会被删除,只会追加一条带 replacement_history 和 window 身份的 Compacted。以后 resume 可以直接从这 18K 基线开始。

示例二:同一个 turn 中途触发 compact

有些 turn 不是“一次模型回答就结束”,而是:

模型提出工具调用
→ 工具返回大量结果
→ 模型还需要继续推理

假设采样前只有 90K,但工具输出让有效上下文增长到 105K,而且 LOOP 还需要再次调用模型。此时不能等下一次 user turn,harness 会在同一 turn 的两次模型采样之间执行 mid-turn compaction。

与 pre-turn compaction 不同,mid-turn compaction 必须保证当前任务还能继续。它会:

  1. 生成压缩后的 replacement history;
  2. 保留当前真实 user message,并把近期工具结果等关键信息纳入摘要;
  3. 把完整初始上下文放到合适位置;
  4. 写入 Compacted
  5. 重建 WorldState(full) 与 TurnContext 基线;
  6. 在新 context window 中继续当前 LOOP。

因此 checkpoint 不一定意味着“一个 turn 结束了”。它也可以是长 turn 内部的一次换窗:

同一个 turn:
  step 1:模型调用搜索工具
  step 2:工具返回大量内容
  checkpoint:旧窗口 → replacement history → 新窗口
  step 3:模型读取压缩结果,继续分析
  step 4:输出最终答案

示例三:模型切换触发 compact

用户可能在长线程中从 200K context 的模型切换到 64K context 的模型。即使旧模型认为当前历史还放得下,新模型也无法接收。

当前有效历史:82K
旧模型窗口:  200K
新模型窗口:   64K

新 turn 采样前,harness 会先使用合适的模型能力压缩旧历史,再把压缩结果交给新模型。除了窗口缩小,模型的 compaction compatibility hash 发生变化,也可能要求重新 compact,因为两个模型对摘要格式或恢复语义的约定可能不同。

这类触发说明 checkpoint 不只是“磁盘优化”,也是模型切换的兼容层

示例四:用户显式触发

用户也可以在尚未达到阈值时手动执行 compact。例如一个 60K 的线程仍放得下,但早期已经有大量探索失败记录,用户希望后面只围绕最终方案继续。

手动 compact 会启动一个独立的压缩 turn,产出摘要并写入 replacement history。它改变后续模型看到的工作历史,却不会删除原 rollout,因此未来仍可审计压缩前发生过什么。

10.6.2 世界状态 checkpoint 的实际变化

世界状态不是按固定时间间隔保存,而是首次写 full,变化时写 patch,不变时不重复写

假设第一个 turn 的状态是:

WorldState(full)
{
  cwd: "/repo",
  sandbox: "workspace-write",
  approval: "on-request",
  instructions: "AGENTS v1",
  tools: ["shell", "apply_patch"]
}

第二个 turn 只把工作目录切换到子项目,rollout 不需要重复完整对象:

WorldState(patch)
{
  cwd: "/repo/web"
}

第三个 turn 没有任何环境变化,就不写新的 WorldState。第四个 turn 加载了新的目录规则:

WorldState(patch)
{
  instructions: "AGENTS v1 + web/AGENTS"
}

Replay 时按顺序应用:

full
  + patch(cwd)
  + patch(instructions)
= 当前世界状态基线

若此后发生 compaction,旧 patch 链不适合作为新窗口的独立起点,于是系统重新写一份 WorldState(full)。后续 resume 即使跳过 compaction 之前的历史,也不会失去工作目录、权限和指令基线。

TurnContext 则为每个真实 user turn 保存一份稳定锚点。即使这一轮没有产生任何模型可见的上下文差异,最新模型、cwd、权限模式等仍能在 resume 时找到。可以把两者理解成:

  • WorldState 回答“环境事实是什么,以及变了什么”;
  • TurnContext 回答“这一轮实际使用了哪套设置”。

[!example] 判断是否会产生 checkpoint

  • 用户只问了一个新问题,环境完全不变:会有新的 TurnContext 和普通 Response item,但未必有新的 WorldState。
  • 用户切换 cwd 或权限策略:产生 WorldState patch,并记录本轮 TurnContext。
  • 上下文达到阈值且还要继续采样:产生 Compacted checkpoint,开启新 window。
  • 用户手动 compact:产生 Compacted checkpoint,即使 token 尚未达到阈值。
  • 只有 UI delta 在流动:不会产生 checkpoint,也不会把半截文本当作恢复基线。

10.7 Resume:重建状态,但不擅自继续副作用

Resume 是“沿同一条时间线继续”。它保留原 thread 身份,打开原 rollout,在 replay 完成后继续向同一条日志追加。

一次完整 resume 大致分六步:

flowchart TD
    A["定位 thread
索引优先,文件扫描兜底"] --> B["读取 canonical rollout
或最新可恢复后缀"] B --> C["识别 checkpoint 与有效 turn"] C --> D["重建模型历史
应用 compaction / rollback"] D --> E["恢复设置、token、世界状态
上下文窗口与父子身份"] E --> F["创建新的运行时资源
连接 / channel / cancel token"] F --> G["线程回到可交互状态"]

Replay 重建的并不只有聊天文本,还包括:

  • 最近一次有效模型与推理配置;
  • 世界状态差分所依赖的 baseline;
  • 当前上下文窗口编号和身份;
  • token 用量快照;
  • thread、session、父子与 fork 来源;
  • 被压缩或 rollback 后的有效历史

恢复后如果当前模型与历史最后使用的模型不同,系统会给出 warning。它不一定禁止继续,因为模型可能已下线;但必须让用户知道,换模型会改变推理风格、上下文兼容性与压缩语义。

10.7.1 Replay 不是 re-execute

Resume 最重要的安全规则是:

重放记录,不重放副作用。

历史里出现过一条 shell 调用,不代表恢复时再执行一次;出现过一次网络写请求,也不能因为缺少结果就自动补发。Replay 只把它们重新放进模型可见历史,并重建“我们知道什么”。

原因很简单:进程可能在下面任意一个时刻崩溃:

工具调用已生成
→ 调用记录已进入内存
→ 调用记录已排队写盘
→ 工具开始执行
→ 外部副作用已发生
→ 工具返回
→ 结果写入历史
→ 结果越过持久化屏障

如果恢复时只看到“调用,没有结果”,无法据此判断动作一定没发生。它可能尚未执行,也可能已经成功,只是结果没来得及落盘。自动重试会把“至少一次”误当成“恰好一次”,例如重复发邮件、重复发布版本、重复扣款。

正确做法通常是:

  • 先查询外部世界的当前状态;
  • 使用 call ID、幂等键或业务唯一键核对;
  • 能证明未发生时才重试;
  • 已部分发生时执行补偿或继续剩余步骤;
  • 无法判断且副作用较大时,把决定交还给人。

这也是为什么工具设计不能只提供 create,还应该提供 get/status/list可观察性是可恢复性的前提。

10.7.2 未完成 turn 如何处理

正常 interrupt 会留下中断标记和 TurnAborted,resume 后线程可以明确显示为 Interrupted。同进程内的 recover 可以沿用原 turn ID 继续。

进程直接崩溃时,最后一个 turn 可能只有 TurnStarted,没有完成或中止边界。恢复端应把这种 stale in-progress turn 视为已中断,而不是假装它仍在后台运行。旧网络连接、工具 future 和审批 waiter 都已经不存在,唯一诚实的状态就是:

“这项工作开始过,但没有观察到可靠的结束。”

用户可以发新输入要求 agent 检查现场;只有同进程内已经明确进入 Interrupted 状态的 turn,才适合用 recover 沿用原 turn ID。跨进程面对没有 terminal event 的半截 turn 时,系统不应在没有新决策的情况下自动把旧工具链跑下去。


10.8 Fork:共享过去,分开未来

Resume 是继续原时间线,Fork 则是从一个已知历史位置创建新的 thread 身份。它适合两类场景:

  • 从同一背景并行探索两个方案;
  • 保留原对话不动,从旧 turn 重新尝试。

Fork 的核心不是复制一个 Session 对象,而是冻结一个历史边界

  • 最新 durable state;
  • 包含某个已完成 turn;
  • 严格位于某个 turn 之前;
  • 活跃 turn 的安全快照。

边界必须落在可解释的位置。若一个 turn 仍在进行,不能把“恰好写到某个工具输出的半截”当作稳定分叉点。当前实现会把这种快照视为被中断的历史,必要时合成中断边界,让新线程得到自洽的上下文。

10.8.1 Copy 与 reference

最直接的 fork 是把父线程选中的 rollout 前缀复制到子线程,然后各自追加。这容易理解,但长历史会被重复存储。

分页历史采用更接近 Git 的方式:

flowchart LR
    A["共同前缀"] --> B["turn A"]
    B --> M["原线程:方向 2"]
    B --> F["fork 线程:方向 1"]

新线程不必复制共同前缀,只需记录一个 HistoryPosition

  • 指向哪个 rollout;
  • 截止到哪个顺序号;
  • 截止到哪个字节位置。

之后子线程只保存自己的增量后缀。读取时沿 lineage 把“祖先前缀 + 当前后缀”拼成完整历史。

这个引用必须是冻结的:父线程后来继续追加,不能偷偷改变子线程的过去。因此边界同时使用逻辑顺序和物理偏移;建立引用期间还要暂时保护源 rollout,直到子线程的引用关系已经可靠落盘,避免源文件被删除或替换。

这种结构共享带来一个新的存储原则:

垃圾回收不能只看“这个 thread 还在不在”,还要看“是否有别的 thread 引用了它的 rollout 前缀”。

冷 rollout 可以压缩保存,但被引用的历史不能在不更新 lineage 的情况下消失。


10.9 Rollback 与 Revert:对话时间倒退,世界不会倒退

“回到几轮之前”有两种实现语义,容易混在一起。

10.9.1 Rollback:legacy history 的逻辑回退

Legacy history 使用 marker 型 rollback:它不改旧记录,只追加一条“忽略最近 N 个 user turn”的记录。Replay 读到它时,从有效历史里移除相应后缀;连续 rollback 就累计生效。

flowchart LR
    T1["turn 1"] --> T2["turn 2"]
    T2 --> T3["turn 3"]
    T3 --> RB["rollback 2"]
    RB --> E["有效历史:只剩 turn 1"]

物理日志中 turn 2、turn 3 仍然存在,因此审计和故障分析没有丢证据;只是它们不再进入模型的有效上下文。Rollback 必须在线程 idle 时进行,并先 flush 后 replay,因为它需要基于一个稳定、完整的历史视图计算结果。

10.9.2 Revert:paginated history 的指针切换

Paginated history 不接受上述 marker 型 rollback,而是使用 revert:保留稳定的 thread ID,但创建一份新的 rollout,让它引用目标 turn 之前的历史前缀;旧 rollout 保持不变,最后只原子切换“这个 thread 当前指向哪份 rollout”。

flowchart TD
    OLD["旧 rollout
turn 1 → turn 2 → turn 3"] -->|"保留,不改写"| ARCHIVE["历史证据"] OLD -->|"选择 turn 2 之前的前缀"| NEW["新 rollout
history_base → turn 1"] PTR["thread ID 的当前指针"] -->|"CAS 切换"| NEW

这里有两个身份:

  • thread ID:用户眼中的逻辑会话,revert 前后保持不变;
  • rollout ID:某一份不可变历史载体,revert 后会变化。

切换使用 compare-and-swap 思路:只有“当前指针仍是我读取的那一版”时才提交。如果准备 revert 的同时另一个写入者已经推进了线程,操作会冲突失败,而不是覆盖新历史。

Rollback 和 revert 的共同点是:都只改变后续 replay 看到的对话历史。

它们都不会:

  • 撤销已经写入工作区的文件;
  • 停止或复活已经启动的外部服务;
  • 撤回已经发送的网络请求;
  • 回退数据库或云端资源;
  • 恢复旧时刻的权限环境。

协议甚至会明确提醒前端:本地文件变更需要客户端或用户另行 undo。对话回退和工作区回退是两个独立事务,不能靠一个按钮假装同时完成。


10.10 四种“继续”的对照

把第一章的 recover 与本章的三种历史操作放在一起,可以得到一张更清晰的表:

操作thread 身份使用哪段历史是否创建新分支是否撤销外部副作用
Recover不变当前内存历史,沿用被中断 turn
Resume不变原 rollout replay 后继续追加
Fork新 thread冻结的历史前缀 + 新后缀
Rollback / Revert不变丢弃或改指向后的有效历史

可以用 Git 做一个不完全但有帮助的类比:

  • resume 像重新打开仓库,继续当前分支;
  • fork 像从某个 commit 新建 branch;
  • rollback marker 像追加一个“后续视图忽略这些 commit”的逻辑操作;
  • revert 则像让分支引用指向一个新构造的历史;
  • 但 agent 操作过的真实文件、网络和数据库不是 Git commit,除非工具自身提供事务或补偿能力。

这个类比的价值不在命令一一对应,而在强调:历史指针与现实世界是两套状态。


10.11 多 agent:恢复的是一棵历史树

第七章说过,每个 agent 都是独立 thread,因此每个 agent 也有自己的 rollout。父子关系、角色、地址和来源作为持久化元数据存在,使进程重启后可以重新识别整棵 agent 树。

但“树能重建”不等于“所有分身自动继续跑”:

  • 每个分身的模型历史可以独立 replay;
  • 父子身份和 fork lineage 可以恢复;
  • 已持久化的 agent message 可以重新进入接收方历史;
  • 旧进程里的运行任务、wait future 和邮箱等待者不会复活;
  • 多个分身共享的文件系统已经处于 crash 后的真实状态,必须重新观察。
graph TD
    S["共享 session 身份"] --> R["根 thread rollout"]
    S --> A["子 thread A rollout"]
    S --> B["子 thread B rollout"]
    R -.->|"parent / lineage"| A
    R -.->|"parent / lineage"| B
    FS["共享文件系统
独立于 rollout"] --- R FS --- A FS --- B

这里还存在一个分布式系统问题:父 agent 发送任务、子 agent 接收任务、子 agent 回传结果,分别发生在不同 thread 的日志中,不是一个跨日志的原子事务。通信 ID、sender/receiver、turn 坐标和幂等处理因此很重要。恢复时宁可识别“这封消息可能重复”或“这个结果尚未确认”,也不能假装跨线程天然 exactly-once。

可以把多 agent 的持久化纪律概括为:

各线程独立记账,关系显式留痕,共享世界重新核对。


10.12 格式演进:今天写下的记录,未来版本仍要读懂

第二章说“事件一旦发出就是永恒的”。对 rollout 来说,这句话更严格:磁盘里可能躺着几年前的旧记录,用户升级 Codex 后仍然希望 resume。

持久化 schema 因此不是普通内部结构,而是一项长期兼容承诺:

  • 新字段要有合理默认值;
  • 字段改名要保留 alias 或迁移逻辑;
  • 旧事件形态要能投影到新语义;
  • 新 reader 要容忍未知或损坏的非关键记录;
  • 第一条 Session 元数据必须足以识别 thread 和历史模式;
  • fork 复制来的旧元数据不能覆盖当前 rollout 的 canonical 身份。

Codex 同时采用两种兼容策略:

读时兼容。 Reader 识别新旧字段、跳过无法解析的孤立行、把旧形态转换为当前领域对象。旧压缩记录缺少完整 replacement history 时,恢复器走更保守的全量 replay,并重新注入上下文基线。

离线迁移与可重建 projection。 当分页、索引或新存储布局需要更强结构时,可以从旧 rollout 生成新表示;SQLite 只是投影,损坏或落后时仍能从 canonical log 修复。

冷历史还可以压缩成 .zst,读取时透明解压;需要继续追加或被别的 fork 引用时,再安全地 materialize 成普通 JSONL。压缩只改变物理表示,不改变逻辑 rollout。

这揭示了一个常被低估的成本:

选择 event sourcing,就等于选择长期维护 replay 语义。

结构体改名只是一行代码,历史解释方式改变却可能让旧会话“变成另一个故事”。因此持久化协议的演进必须比普通内部 API 更克制。


10.13 一个完整例子:崩溃、恢复、分叉与回退

假设用户让 agent:

“迁移鉴权配置,运行测试,再发布到测试环境。”

执行过程如下:

  1. turn 开始,用户消息和 TurnContext 写入 rollout;
  2. agent 修改本地配置,工具调用与结果形成完整 item;
  3. 本地测试通过,结果写入 rollout;
  4. 发布需要网络权限,前端弹出审批;
  5. 用户批准,发布请求已经发出;
  6. 进程在服务端响应返回前崩溃。

重启后的正确流程不是“从第 5 步再发一次发布”,而是:

sequenceDiagram
    participant U as 用户
    participant H as 新 harness
    participant R as rollout
    participant E as 外部环境
    participant M as 模型

    H->>R: resume + replay
    R-->>H: 已知:修改完成、测试通过、发布调用无可靠结果
    H-->>U: 恢复线程,最后 turn 显示为 interrupted
    U->>H: 继续并先确认发布状态
    H->>E: 查询当前部署版本
    E-->>H: 新版本已存在
    H->>M: 回灌观察:发布其实成功
    M-->>U: 汇总完成,无需重复发布

接下来用户还可以做两种不同操作:

  • Fork 到发布之前:保留原线程,开新 thread 探索另一套发布参数;
  • Rollback 最近一轮对话:让模型忘掉这轮方案,重新讨论。

但工作区里的配置修改、测试环境中已经发布的版本都不会随对话一起消失。若用户真正想“恢复到迁移前”,还需要 Git、部署平台或数据库自己的 rollback 机制。

这个例子把本章的三条主线串在了一起:

  1. rollout 回答“我们观察到什么”;
  2. replay 回答“模型现在应该知道什么”;
  3. 外部查询回答“世界实际上变成了什么”。

三者缺一不可。


10.14 常见失败模式

失败模式表面现象根因更好的做法
只存聊天文本resume 后模型不知道工具、权限和压缩状态把 transcript 当成完整状态同时持久化生命周期、上下文与 checkpoint
把所有 Event 全存日志被 delta 和进度淹没,版本兼容困难没区分事实与动画只保存 canonical facts,瞬时事件可推导
恢复时自动重跑悬空工具重复发布、重复写入、重复扣费把“没记录结果”当成“没执行”先查询和对账,再决定重试
序列化 future 和连接恢复后等待永远不醒,句柄全部失效把运行时续体当成领域状态丢弃旧续体,创建新运行时
用可变快照覆盖历史审计、fork 和故障定位失去依据只保留最终态追加日志 + 可验证 checkpoint
把 SQLite 当唯一真相索引写失败后整个会话不可恢复混淆 canonical log 与 projection索引可重建,不能领先日志
把 rollback 当文件撤销对话回去了,工作区仍是新状态混淆模型历史与外部世界分别提供对话回退和环境补偿
任意位置 fork新线程从半个工具调用开始,历史不自洽没有稳定边界只在 canonical turn/step 边界冻结
checkpoint 只存摘要恢复后权限、目录、窗口身份丢失只压缩对话,没有恢复状态基线摘要、世界状态、TurnContext 协同 checkpoint
日志格式随意变化新版本无法读取旧会话把持久化类型当内部实现默认值、alias、迁移和兼容测试

这些失败大多来自同一个误解:把“恢复”当成一次对象反序列化。真正的恢复是一个跨版本、跨进程、跨外部世界的状态重建协议


10.15 更深一层:可恢复性是一份“确定性预算”

第五章说,LOOP 能被重放,是因为“历史 + 快照 → 请求”尽量接近纯函数。本章可以把这个结论再推进一步:

持久化保存的不是过去本身,而是未来继续决策所需的确定性。

每少记录一种事实,恢复时就多一分猜测:

  • 没有 turn 边界,就猜哪段属于同一次任务;
  • 没有工具 call ID,就猜结果对应哪个动作;
  • 没有世界状态基线,就猜模型之前知道哪些环境事实;
  • 没有 checkpoint,就从头 replay 或猜压缩后的上下文;
  • 没有外部幂等键,就猜副作用是否已经发生。

反过来,记录也不是越多越好。把 delta、future 和连接状态保存下来,只会制造“看起来精确、实际不可复用”的伪确定性。

因此好的持久化设计会不断问三个问题:

  1. 这条信息是事实,还是瞬时表现?
  2. 未来恢复时,能否只凭它作出安全决定?
  3. 如果不能,还需要哪个外部观察或人工确认?

这套问题把本章和前面所有模块连在一起:

  • 运行时模型提供生命周期边界;
  • 事件协议区分 item 与 delta;
  • 上下文管理提供可重建的状态片段;
  • LOOP 保证变化只在边界发生;
  • 工具系统提供动作 ID 与结果;
  • 多 agent 提供显式 lineage 和通信坐标;
  • 安全策略限制恢复后可以重新采取的动作;
  • Human in the loop 处理机器无法证明的部分。

持久化不是最后给系统加的一块磁盘,而是这些边界纪律的总验收。


10.16 小结:持久化与恢复的七条设计原则

  1. 保存事实,不保存运行时幻觉。 Response item、turn 边界、配置和 checkpoint 可以 replay;future、连接、锁和 waiter 属于旧进程,恢复时必须重新创建。

  2. Canonical log 与 projection 分离。 rollout 是只追加的事实源,SQLite 等结构是为列表、搜索和分页服务的可重建视图。投影可以落后,不能领先或取代事实源。

  3. 权威 item 持久化,瞬时 delta 可丢失。 恢复依赖完整 item,而不是打字机片段、进度动画或临时请求。日志记录的是故事的节点,不是播放时的每一帧。

  4. Checkpoint 是 replay 起点,不是证据删除。 replacement history、世界状态快照与 TurnContext 共同建立新的恢复基线;早期原始记录仍保留用于审计、fork 和诊断。

  5. Replay 绝不等于 re-execute。 悬空工具调用只能证明“曾经计划或开始过”,不能证明副作用未发生。恢复后先观察、对账和校验,再决定重试、补偿或询问用户。

  6. Fork 共享过去,Rollback 只改有效历史。 Fork 以稳定边界创建新 thread,可通过 lineage 引用 immutable prefix;rollback/revert 改变后续 replay 的历史视图。它们都不自动撤销文件、进程或外部系统。

  7. 持久化格式是长期协议。 只追加日志必须跨版本可读,旧字段要兼容、损坏行要隔离、索引要可重建、引用要可追踪。今天写下的一条记录,几年后仍可能决定一次 resume 的含义。

留给读者思考的几个问题

  • 如果工具已经产生副作用,但进程在结果落盘前崩溃,harness 应该把这个调用显示为“失败”“未知”还是“已中断”?哪一种最能阻止误重试?
  • Rollback 保留旧记录、replay 时再忽略;revert 创建新 rollout 并切换指针。两种方案在审计、读取性能、并发安全和存储回收上分别有什么代价?
  • Pending approval 不可跨进程复活,但审批请求可能对应一个昂贵的长任务。恢复后应该自动重新询问、转成拒绝,还是要求用户显式 recover?决定依据是什么?
  • Fork 通过引用共享 immutable prefix 后,删除、归档、压缩和跨设备同步都必须理解 lineage。结构共享节省了空间,却把哪些复杂性转移给了存储层?
  • Canonical rollout 容忍孤立损坏行可以尽量救回历史,但如果损坏的恰好是权限变化或 rollback marker,继续 replay 是否仍然安全?哪些记录应该被视为“缺失即停止恢复”?
  • 要让 agent 真正做到“任务级可续做”,工具协议还需要补充哪些能力?幂等键、状态查询、操作 checkpoint、补偿动作,哪一种应该由 harness 统一约束,哪一种只能由业务工具提供?

下一章我们进入可扩展性:当 MCP、插件、skills、hooks 和动态工具都能进入同一套 LOOP 时,harness 如何开放扩展点,又如何避免扩展破坏本章建立的持久化、兼容性与恢复边界。

分类:Agent Harness标签:#agent #harness #codex