第九章讨论了控制权如何在人、模型和 harness 之间交接。一次提问可能等人几个小时,一次审批可能跨过前端断线,一项任务也可能在执行到一半时遇到进程退出。 这就引出一个更基础的问题:当内存里的 Session、LOOP、等待通道和网络连接全部消失后,下一次启动凭什么知道之前发生过什么? 本章讨论 Codex 如何用 rollout、checkpoint 和 replay 重建线程,如何区分 resume、fork、rollback 与 revert,以及一个常被忽略的事实:恢复对话状态,不等于回滚真实世界。
10.1 可恢复,不是把旧进程“冻住再解冻”
很多人第一次设计 agent 持久化,会自然地想到“保存 Session 对象”:把当前历史、配置、正在执行到哪一步全部序列化,进程回来时再反序列化。
这个思路在普通表单应用里也许可行,在 agent harness 里却很快失效。一个正在工作的 Session 里不只有数据,还有大量无法直接保存的运行时对象:
- 正在读取的模型流和底层网络连接;
- 已经 spawn 的异步任务、取消令牌和锁;
- 正在运行的 shell 进程及其管道;
- 等待用户审批的 oneshot 通道;
- MCP 连接、远程环境句柄和前端订阅;
- 此刻恰好位于哪一行代码的程序计数器。
这些对象有的属于旧进程,有的属于旧连接,有的甚至属于已经变化的外部世界。即使能把内存字节完整抄下来,也无法保证它们在另一台机器、另一个版本或几小时以后仍然有效。
Codex 采用的是另一条路线:
不保存“正在运行的机器”,而是保存足够多、顺序明确的事实,让一台新机器能够重建同一段有效历史。
这是一种 replay(重放)模型。进程重启后,并不是从旧函数的某一行继续执行,而是:
- 找到该线程的持久化记录;
- 按顺序解释已经发生的事实;
- 重建模型可见历史、配置基线和生命周期状态;
- 创建一套全新的运行时资源;
- 由用户或上层调度决定是否继续工作。
flowchart LR
A["旧进程<br/>Session / task / 网络连接"] -->|"持续记录事实"| R["rollout<br/>只追加的 canonical log"]
A -->|"崩溃或退出"| X["运行时对象全部消失"]
R -->|"读取 + replay"| S["新 Session<br/>重建历史与基线"]
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 就自动复原;网络请求也可能已经被对方接收,只是工具结果还没来得及写回。
因此,“可恢复性”至少有三个等级:
- 可回看:用户能看到之前发生过什么;
- 可续聊:模型拿到足够上下文,可以继续推理;
- 可续做:系统能判断外部动作做到哪,并安全地继续。
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<br/>追加日志"]
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<br/>canonical log"]
J --> P["增量 projection"]
P --> DB["SQLite<br/>列表 / 搜索 / 分页"]
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 是:
- 压缩后的 replacement history:直接给出“此刻模型工作历史应该是什么”,早期长历史仍留在 rollout 中,但恢复模型上下文时可以从这里起步;
- 世界状态全量快照 + merge patch:先建立目录、权限、指令等事实基线,之后只记录变化;
- TurnContext:记录最近有效 user turn 使用的模型、工作目录、权限与模式等设置,使 resume 后的第一轮能够延续正确配置语义。
flowchart LR
H1["早期 item × 很多"] --> C["Compacted checkpoint<br/>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 阈值<br/>或完整窗口上限?"}
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 必须保证当前任务还能继续。它会:
- 生成压缩后的 replacement history;
- 保留当前真实 user message,并把近期工具结果等关键信息纳入摘要;
- 把完整初始上下文放到合适位置;
- 写入
Compacted; - 重建
WorldState(full)与 TurnContext 基线; - 在新 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<br/>索引优先,文件扫描兜底"] --> B["读取 canonical rollout<br/>或最新可恢复后缀"]
B --> C["识别 checkpoint 与有效 turn"]
C --> D["重建模型历史<br/>应用 compaction / rollback"]
D --> E["恢复设置、token、世界状态<br/>上下文窗口与父子身份"]
E --> F["创建新的运行时资源<br/>连接 / 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<br/>turn 1 → turn 2 → turn 3"] -->|"保留,不改写"| ARCHIVE["历史证据"]
OLD -->|"选择 turn 2 之前的前缀"| NEW["新 rollout<br/>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["共享文件系统<br/>独立于 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:
“迁移鉴权配置,运行测试,再发布到测试环境。”
执行过程如下:
- turn 开始,用户消息和 TurnContext 写入 rollout;
- agent 修改本地配置,工具调用与结果形成完整 item;
- 本地测试通过,结果写入 rollout;
- 发布需要网络权限,前端弹出审批;
- 用户批准,发布请求已经发出;
- 进程在服务端响应返回前崩溃。
重启后的正确流程不是“从第 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 机制。
这个例子把本章的三条主线串在了一起:
- rollout 回答“我们观察到什么”;
- replay 回答“模型现在应该知道什么”;
- 外部查询回答“世界实际上变成了什么”。
三者缺一不可。
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 和连接状态保存下来,只会制造“看起来精确、实际不可复用”的伪确定性。
因此好的持久化设计会不断问三个问题:
- 这条信息是事实,还是瞬时表现?
- 未来恢复时,能否只凭它作出安全决定?
- 如果不能,还需要哪个外部观察或人工确认?
这套问题把本章和前面所有模块连在一起:
- 运行时模型提供生命周期边界;
- 事件协议区分 item 与 delta;
- 上下文管理提供可重建的状态片段;
- LOOP 保证变化只在边界发生;
- 工具系统提供动作 ID 与结果;
- 多 agent 提供显式 lineage 和通信坐标;
- 安全策略限制恢复后可以重新采取的动作;
- Human in the loop 处理机器无法证明的部分。
持久化不是最后给系统加的一块磁盘,而是这些边界纪律的总验收。
10.16 小结:持久化与恢复的七条设计原则
-
保存事实,不保存运行时幻觉。 Response item、turn 边界、配置和 checkpoint 可以 replay;future、连接、锁和 waiter 属于旧进程,恢复时必须重新创建。
-
Canonical log 与 projection 分离。 rollout 是只追加的事实源,SQLite 等结构是为列表、搜索和分页服务的可重建视图。投影可以落后,不能领先或取代事实源。
-
权威 item 持久化,瞬时 delta 可丢失。 恢复依赖完整 item,而不是打字机片段、进度动画或临时请求。日志记录的是故事的节点,不是播放时的每一帧。
-
Checkpoint 是 replay 起点,不是证据删除。 replacement history、世界状态快照与 TurnContext 共同建立新的恢复基线;早期原始记录仍保留用于审计、fork 和诊断。
-
Replay 绝不等于 re-execute。 悬空工具调用只能证明“曾经计划或开始过”,不能证明副作用未发生。恢复后先观察、对账和校验,再决定重试、补偿或询问用户。
-
Fork 共享过去,Rollback 只改有效历史。 Fork 以稳定边界创建新 thread,可通过 lineage 引用 immutable prefix;rollback/revert 改变后续 replay 的历史视图。它们都不自动撤销文件、进程或外部系统。
-
持久化格式是长期协议。 只追加日志必须跨版本可读,旧字段要兼容、损坏行要隔离、索引要可重建、引用要可追踪。今天写下的一条记录,几年后仍可能决定一次 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 如何开放扩展点,又如何避免扩展破坏本章建立的持久化、兼容性与恢复边界。