雨天小六

读懂 Codex(八):工作怎样被记住——持久化、恢复与回滚

· 更新于 2026-08-04 · 专栏:读懂 Codex

#Codex#Agent Runtime#持久化#Rollout#软件架构

上一章留下了一个实际问题:一条子 Agent Thread 可以被卸载,过一会儿又以同一个身份回来。进程内的 Session 已经消失,它之前的工作为什么没有一起消失?

最容易产生的误解,是把答案想成“Codex 定期把 Session 保存到数据库”。当前实现并不是对象快照系统。Codex 会把足以重建会话语义的记录按顺序写入 Rollout;恢复时创建新的 Session,再重放这些记录,重新计算模型历史、Turn 设置、World State 和上下文窗口。

因此,本章讨论的不是一个 save() 和一个 load(),而是三条必须分开的边界:什么是规范历史,什么只是查询投影,什么副作用根本不属于会话历史。

第八章源码级细节导航

总览建立权威边界,下面 34 个单元继续拆到数据结构、写入屏障、反向扫描、投影、恢复、Compact、Rollback、Fork 与崩溃点。

先分清五种看起来都像“记忆”的东西

一条 Thread 运行时,会同时碰到几种状态:

名称保存什么主要读者是否是重放权威
Context History当前要交给模型的 ResponseItem下一次模型请求否,只存在于运行中的 Session
Rollout经过持久化策略筛选的有序 RolloutItemResume、Fork、Rollback、诊断是,本地实现的规范重放日志
ThreadStore创建、追加、刷新、读取、派生 Thread 的接口Core Runtime它是边界,不是某一种文件格式
SQLite State / History ProjectionThread 列表、预览、cwd、Git 信息、Turn/Item 索引列表、搜索、分页读取否;部分元数据可修复,History Projection 可重建
AgentGraphStoreparent_thread_id → child_thread_id 及 Open/Closed多 Agent 恢复只对 Agent 拓扑权威

Event Stream 也不能等同于 Rollout。模型文字增量、命令输出增量、审批请求、警告和界面生命周期事件要及时送给客户端,但没有必要全部成为未来模型上下文的一部分。Rollout 的目标是重建,不是录屏。

Codex 中 Session、Context History、Event Stream、LiveThread、ThreadStore、Rollout JSONL、SQLite 投影和 AgentGraphStore 的权威边界
图 8-1:Rollout JSONL 保存规范重放记录;SQLite 保存便于查询的元数据和 Paginated 历史投影;AgentGraphStore 只保存父子拓扑。客户端见过的瞬时事件并不会全部进入 Rollout。

这张图也解释了为什么数据库中缺一行和 Rollout 丢一行不是同等级故障。前者可能让列表暂时显示旧预览,随后可以通过扫描日志修复;后者可能改变恢复出来的模型历史。

Rollout 是经过筛选的追加日志

持久化的基本单位是 RolloutItem。它不是只有聊天消息,当前协议还包括:

RolloutItem
├── SessionMeta
├── ResponseItem
├── InterAgentCommunication / Metadata
├── Compacted
├── TurnContext
├── WorldState
└── EventMsg

这些类型分别回答不同问题。ResponseItem 重建模型见过的消息、Reasoning、Tool Call 和 Tool Output;TurnContext 恢复上一个真实 Turn 的模型、配置哈希和 Realtime 状态;WorldState 恢复动态环境基线;Compacted 表示历史曾被一个替代历史接管;Turn Started、Complete、Aborted 等少量 EventMsg 则给出回合边界。

写入前,Persistence Policy 会再次筛选条目。Function Call、Tool Output、Shell Call、最终消息和 Compaction 等需要重放的 ResponseItem 会保留;AdditionalToolsCompactionTrigger 等运行中辅助项不保留。Event 的筛选更明显:

  • TurnStartedTurnCompleteTurnAborted、Token Count 和 Rollback Marker 会持久化;
  • Paginated 模式用 ItemCompleted(TurnItem) 保存结构化项目;Legacy 模式保留一组旧事件;
  • 文本 Delta、Reasoning Delta、命令输出 Delta、审批请求、Warning、Error 和大量 Begin 事件是瞬时信号,不进入规范 Rollout。

这里有一个容易忽略的后果:不能仅凭 Rollout 逐字重放用户曾经看到的界面,也不能拿 Event Stream 直接喂给模型。两条流部分重叠,但服务的是不同消费者。

Rollout 使用 JSONL,每一行是一条带时间戳的记录。Paginated 模式还写入单调递增的 ordinal,使一段历史可以用“结束序号 + 字节偏移”表达稳定边界。冷日志可以压缩成 .jsonl.zst;恢复读取会透明处理压缩格式,需要继续追加时再物化回普通 .jsonl

写入顺序比“用了数据库”更重要

当 Session 收到一个新的模型消息或工具结果时,它先更新自己的 Context History,然后通过 LiveThread 把原始 RolloutItem 交给 ThreadStore。本地 Store 对同一 Thread 串行化写入,应用持久化策略,再交给 RolloutRecorder

RolloutRecorder 背后有一个专用写任务和 pending_items 队列。普通 append 先排队;flush() 才是等待此前写入完成的屏障。新建而尚无内容的 Thread 还可以延迟创建文件,直到出现实际记录或调用 persist() 明确要求物化。

三种操作因此不能混为一谈:

操作语义
append_items按顺序增加新的规范记录,并由 LocalThreadStore 完成一次 flush
persist_thread即使还没有 Turn 内容,也把延迟状态和 SessionMeta 物化
flush_thread等待已经排队的记录越过写入屏障,不凭空增加业务记录

如果第一次写失败,Recorder 会丢弃文件句柄、重新打开再试一次。只有成功写出的前缀才会从 pending_items 删除,未写出的后缀仍留在内存中,后续 persistflushshutdown 可以继续重试。这避免了“调用方收到错误,于是整批再写一次”造成的前缀重复。

Paginated 模式还要把 JSONL 物化成 SQLite 中的 Turn/Item 投影。这里的顺序是硬约束:

先完成 JSONL 写入屏障
再更新 SQLite Projection

投影失败会记录警告,但不会让已经成功的规范写入倒退。因此 SQLite 可以落后于 JSONL,却不允许领先于 JSONL。元数据同步也位于规范历史追加之后:只有这批历史成功写入,LiveThread 才根据它更新 preview、首条用户消息、cwd、Git 信息和最近活动时间。

Codex 从 Session 更新内存历史,经 LiveThread 和 LocalThreadStore 写入 Rollout JSONL、再投影到 SQLite,并在重启后反向扫描和重建 Session 的时序
图 8-2:写入的权威顺序是内存历史、规范 JSONL、SQLite 投影;恢复则从 ThreadStore 读取可重放项,构造新的 Session 状态。SQLite 投影不参与决定 JSONL 已经发生过什么。

Turn 的终止边界还会额外刷新。正常 Task 结束时,Runtime 先刷新主体记录,再追加 TurnComplete,然后再次刷新;中断路径会先写入模型可见的 interrupted marker,再持久化 TurnAborted 并刷新。正常写入成功时,客户端收到终止事件后立即重读 Thread 就能看见完整边界;若 I/O 屏障失败,Runtime 会记录警告并保留待重试项,但不会撤回已经送出的客户端事件。

这仍不是跨文件系统和数据库的分布式事务。Codex 保证的是单条 Thread 重放日志内部的顺序,以及“投影不能领先规范历史”的关系。突然断电、磁盘损坏或应用在屏障失败后被强制杀死,仍可能使最后一段工作不可恢复。

Resume 是重放,不是对象反序列化

进程重启后,旧的 Mutex、Channel、Task、网络流和子进程句柄都不存在。Resume 会用原 ThreadId 创建新的 Session,重新打开 Rollout 的追加写入器,并把已存记录交给 reconstruct_history_from_rollout

重建器需要同时恢复几类结果:

Rollout replay
├── model-visible history
├── previous turn settings
├── reference TurnContext
├── WorldState baseline
└── context-window number and IDs

它不是简单过滤出所有 ResponseItem。重建过程从新到旧识别 Turn 边界、Rollback Marker 和最新可用的 Compaction Checkpoint,再按时间顺序重放仍然有效的后缀。WorldState 也有自己的规则:完整快照建立基线,后续 merge patch 才能在该基线上应用;遇到 Compaction 时旧基线失效,新的窗口要重新建立完整快照。

Paginated Rollout 可以避免每次 Resume 都读取整个大文件。LocalThreadStore 从日志尾部反向扫描,找到以下两项后便可以停止:

  1. replacement_historywindow_number 的可用 CompactedItem
  2. 一个已完成真实用户 Turn 的兼容 TurnContextItem

前者给出新的模型历史起点,后者给出恢复设置和参考基线。扫描结果再补上文件头部的规范 SessionMeta,按时间顺序交给同一个重建器。

有些形状不能安全截断。旧 Compaction 没有替代历史或窗口号、日志中出现 Rollback Marker,或者无法证明 TurnContext 属于真实用户 Turn 时,扫描必须一直回到开头。Legacy 历史和已压缩的 Rollout 目前也走完整读取路径。这里的优化原则很明确:只有能证明较老记录不会改变结果,才允许少读。

读取 JSONL 时,单行 JSON 解析失败会被计数并跳过,而不是让整个文件立即不可读。这个策略提高了剩余历史的可用性,但它不是无损修复:如果坏掉的恰好是 Tool Output、Compaction Checkpoint 或 Turn 边界,重建语义仍可能变化。调试时不能只看“Resume 成功”,还要检查 parse error 和重建后的历史形状。

未完成工具调用在送给模型前被修成合法对话

进程可能恰好死在 Tool Call 已写入、Tool Output 尚未写入的时刻。把这样的历史原样发给 Responses API,会破坏“每个 Call 都有对应 Output”的协议不变量。

Codex 没有伪造工具真的执行成功。ContextManager::for_prompt 在生成模型输入副本时执行规范化:

  • Function Call、Custom Tool Call、Local Shell Call 或客户端 Tool Search 缺少结果时,在调用后插入稳定 ID 的合成 Output;
  • Function 和自定义工具的合成文本是 aborted,Tool Search 得到空工具集合;
  • 只有 Output、找不到对应 Call 的孤儿结果会从模型输入中移除;
  • 不支持的图片或音频内容也在同一步被替换或剥离。

关键是“模型输入副本”。源 Call 带 Item ID 时,合成 Output 会由它派生稳定 UUID,重复 Resume 和 Retry 不会不断改变 Prompt Cache Key;旧历史中的 Call 若没有 Item ID,则沿用无 Output ID 的兼容行为。两种情况都不会反写到原始 Rollout,把一次恢复性假设冒充成真实工具执行记录。

中断路径还有更高层的边界:Runtime 会记录 TurnAborted,必要时插入 interrupted guidance。Fork 若截到一个仍在进行的 Turn,可以丢弃未完成后缀,或为明确的 Interrupted Snapshot 附加同类中断边界。Call/Output 配对解决协议合法性,TurnAborted 解决回合语义;二者不是同一层修复。

Compact 不删除旧历史,只改变重放起点

Context 太长时,Codex 会构造一个更短的 replacement history。Local Compact 通常保留必要的用户消息并加入 Summary;Remote Compact 可以直接返回压缩后的 Transcript。安装新历史时,Session 同时完成三件事:

  1. 用 replacement history 替换当前 Context History;
  2. 追加一个 CompactedItem,其中保存相同 replacement history 和新窗口身份;
  3. 在需要时追加新的完整 WorldState 与 TurnContext 基线。

旧 ResponseItem 没有从 JSONL 删除。恢复器反向找到最新有效 Compaction 后,把它的 replacement history 当成完整历史基座,只重放更新的后缀。于是 Compact 同时做到两件事:模型不再承担全部旧 Token,审计日志仍保留压缩之前发生过的记录。

这个设计的成本是日志不会因为 Compact 自动变小;多次压缩还可能累积摘要误差。Compact 是上下文窗口管理,不是磁盘清理,也不是事实数据库的无损归档。

Rollback 回退的是会话历史,不是工作区

Legacy thread/rollback(num_turns=N) 的实现也是追加式的。它先确认当前没有活动 Turn,刷新 Rollout,读取规范历史,然后在内存中把 ThreadRolledBack { num_turns: N } 临时接到末尾进行完整重建。重建成功后,才把同一个 Marker 追加到 Rollout 并刷新。

重放器从后向前跳过最近 N 个真实用户 Turn。没有 User Message 的独立维护任务不消耗这个计数;不完整但已经出现用户边界的 Turn 仍可以被排除。之前的原始记录保留在日志中,后续 Resume 看到 Marker 后得到相同的逻辑历史。

当前 App Server 明确拒绝对 Paginated Thread 调用 thread/rollback,并且该 RPC 已标记为即将移除的旧接口。因此不能把 Legacy Rollback 描述成所有 History Mode 都具备的通用能力。

更重要的是,Rollback 不执行以下操作:

  • 不把被覆盖的源文件恢复到旧字节;
  • 不撤销已经提交的 Git Commit;
  • 不杀掉历史 Turn 启动而仍存活的外部服务;
  • 不追回已经发送的网络请求或远程副作用。

它回退的是“下一次模型还应看见哪些会话 Turn”。真正要撤销代码,应使用 Git、反向 Patch、备份或目标系统自己的补偿操作。把会话 Rollback 当成工作区 Undo,可能得到一个忘记自己改过文件、但文件仍然已经改变的 Agent。

Fork 冻结一个前缀,然后产生新身份

Fork 与 Compact、Rollback 的共同点,是都从已有 Rollout 计算新的有效历史;不同点是 Fork 会创建一个新的 ThreadId。

Legacy 或复制式 Fork 会把处理后的历史写进子 Rollout。Paginated 的 Reference-backed Fork 可以只保存一个 history_base,其中的 HistoryPosition 指向某条物理 Rollout 的稳定前缀:

HistoryPosition
├── thread_id
├── end_ordinal_exclusive
└── end_byte_offset

prepare_fork 先持久化源 Thread,把所需段投影到 SQLite,解析 Latest、ThroughTurn 或 BeforeTurn 边界,再加载该边界对应的模型上下文。返回的 PreparedFork 还持有 Store 管理的源预留,在子 Thread 的引用变得耐久之前阻止源历史被删除。

子 Rollout 只写自己的本地增量,读取时 ThreadStore 沿 lineage 拼接祖先前缀和子增量。这样不需要为每个分支复制一份大历史,也意味着删除父 Thread 不再只是删除一个独立文件:Store 必须先检查仍在引用它的后代。

Compact、Legacy Rollback 和 Fork 分别通过替代历史检查点、回滚标记和冻结历史前缀改变有效会话历史,但都不会自动撤销工作区外部副作用
图 8-3:Compact 与 Rollback 都保留旧 Rollout 记录,只改变重放结果;Fork 则以冻结前缀创建新 Thread。三者都不提供文件系统或网络副作用的自动回滚。

上一章讲过,多 Agent Fork 通常还会过滤 Reasoning、Tool Call 和旧通信,并替换子角色指令。因此 Reference-backed Fork 解决“历史存在哪里”,Context Filter 解决“哪些内容适合交给子 Agent”,两者不能互相代替。

一条可操作的故障排查顺序

持久化问题最好按权威层级排查,而不是先删数据库:

  1. 确认 ThreadId、History Mode,以及当前是否仍有 Live Writer;
  2. 找到对应 .jsonl.jsonl.zst,检查第一条 SessionMeta 是否属于该 Thread;
  3. 检查尾部是否有完整 TurnStarted → TurnComplete/TurnAborted 边界,以及 Rollback、Compacted、TurnContext、WorldState 的相对顺序;
  4. 查看是否存在 JSON parse error、ordinal 断裂或压缩/物化切换问题;
  5. 再比较 SQLite metadata 和 history projection 是否只是落后;
  6. 对恢复后的模型请求,检查 Prompt Normalize 是否插入了 aborted Output 或删除了孤儿 Output;
  7. 最后单独核对工作区、Git、后台进程和远程服务,因为它们不在 Rollout 回退边界内。

也可以用几条不变量快速判断实现是否偏离:

JSONL canonical position >= SQLite projected position
terminal event path => append terminal marker and attempt an explicit flush
every model-visible tool call => a compatible output
every model-visible output => a compatible call
rollback changes effective conversation history, not external state
referenced child durable => its ancestor prefix cannot be deleted underneath it

这些不变量比“保存成功”更具体。它们分别约束写入顺序、终止可见性、Prompt 协议、回滚范围和 Fork 生命周期。

从恢复机制走向 Mini Codex

到这里,Codex 的主运行链已经闭合:Thread 接收输入,Turn 驱动模型和工具,事件把过程送给客户端,Rollout 把足以重建的语义留到进程之外。Session 可以消失,因为身份和规范历史没有与它绑定在同一段内存里。

下一章开始实现 Mini Codex。第一版不会复制 JSONL 压缩、Paginated Lineage、SQLite 修复和多种 History Mode,但会保留最关键的架构契约:有序追加日志、显式 flush、重放式恢复、Call/Output 规范化,以及“对话回滚不等于外部副作用回滚”这条边界。只有把这些约束装进一个能运行的 Python 项目,前面八章的理解才真正变成工程能力。

源码导航

评论


← 返回文章列表