上一章留下了一个实际问题:一条子 Agent Thread 可以被卸载,过一会儿又以同一个身份回来。进程内的 Session 已经消失,它之前的工作为什么没有一起消失?
最容易产生的误解,是把答案想成“Codex 定期把 Session 保存到数据库”。当前实现并不是对象快照系统。Codex 会把足以重建会话语义的记录按顺序写入 Rollout;恢复时创建新的 Session,再重放这些记录,重新计算模型历史、Turn 设置、World State 和上下文窗口。
因此,本章讨论的不是一个 save() 和一个 load(),而是三条必须分开的边界:什么是规范历史,什么只是查询投影,什么副作用根本不属于会话历史。
第八章源码级细节导航
总览建立权威边界,下面 34 个单元继续拆到数据结构、写入屏障、反向扫描、投影、恢复、Compact、Rollback、Fork 与崩溃点。
- 8.1 持久化系统中的状态权威边界
- 8.2 Rollout 持久化策略:从运行事件中提取可重放历史
- 8.3 事件投递与 Rollout:有顺序但不原子的两条路径
- 8.4 RolloutRecorder 的单写者协议:命令队列、确认与重试
- 8.5 延迟物化、SessionMeta 和第一批写入
- 8.6 pending items 的前缀提交和失败重试
- 8.7 Flush、Persist、Shutdown 与 Discard 的不同保证
- 8.8 JSONL 尾部换行、坏行跳过和 Parse Error
- 8.9 Ordinal、Byte Offset 与稳定历史位置
- 8.10
.jsonl.zst压缩和重新物化 - 8.11 ReverseJsonlScanner 的反向分块算法
- 8.12 Rollout Reference Index 和被引用祖先保留
- 8.13 ThreadStore trait 的存储中立契约
- 8.14 LiveThread 初始化 Guard 和唯一 Writer
- 8.15 跨进程 Writer Lock 和同 Thread 写冲突
- 8.16 JSONL 先写、SQLite 后投影的顺序
- 8.17 Thread Metadata Sync 的 preview、cwd 和 Git 更新
- 8.18 Turn/Item Projection 的增量物化
- 8.19 History 分段分页、Turn Lookup 和搜索
- 8.20 Resume 怎样创建新 Session 而不是还原旧对象
- 8.21 Rollout Reconstruction 的反向分段和前向重放
- 8.22 Previous Turn Settings 与 Context Window Identity 恢复
- 8.23 WorldState Full/Patch 基线恢复
- 8.24 不完整 Turn 和 Prompt-only Call/Output 修复
- 8.25 Compact Checkpoint 的 Replacement History
- 8.26 Local/Remote Compact 在恢复语义上的差异
- 8.27 Legacy Rollback Marker 的追加式回退
- 8.28 Copied Fork 的历史筛选和新 SessionMeta
- 8.29 Reference-backed Fork 的冻结前缀
- 8.30 Rollout Lineage 的祖先拼接和删除约束
- 8.31 Archive、Delete 与被引用历史
- 8.32 State DB 中 Thread、Goal、Memory 和 Log 的职责
- 8.33 AgentGraphStore 恢复与 Thread History 恢复的协作
- 8.34 崩溃点矩阵:模型、工具、日志和投影分别可能留下什么
先分清五种看起来都像“记忆”的东西
一条 Thread 运行时,会同时碰到几种状态:
| 名称 | 保存什么 | 主要读者 | 是否是重放权威 |
|---|---|---|---|
| Context History | 当前要交给模型的 ResponseItem | 下一次模型请求 | 否,只存在于运行中的 Session |
| Rollout | 经过持久化策略筛选的有序 RolloutItem | Resume、Fork、Rollback、诊断 | 是,本地实现的规范重放日志 |
| ThreadStore | 创建、追加、刷新、读取、派生 Thread 的接口 | Core Runtime | 它是边界,不是某一种文件格式 |
| SQLite State / History Projection | Thread 列表、预览、cwd、Git 信息、Turn/Item 索引 | 列表、搜索、分页读取 | 否;部分元数据可修复,History Projection 可重建 |
| AgentGraphStore | parent_thread_id → child_thread_id 及 Open/Closed | 多 Agent 恢复 | 只对 Agent 拓扑权威 |
Event Stream 也不能等同于 Rollout。模型文字增量、命令输出增量、审批请求、警告和界面生命周期事件要及时送给客户端,但没有必要全部成为未来模型上下文的一部分。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 会保留;AdditionalTools、CompactionTrigger 等运行中辅助项不保留。Event 的筛选更明显:
TurnStarted、TurnComplete、TurnAborted、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 删除,未写出的后缀仍留在内存中,后续 persist、flush 或 shutdown 可以继续重试。这避免了“调用方收到错误,于是整批再写一次”造成的前缀重复。
Paginated 模式还要把 JSONL 物化成 SQLite 中的 Turn/Item 投影。这里的顺序是硬约束:
先完成 JSONL 写入屏障
再更新 SQLite Projection
投影失败会记录警告,但不会让已经成功的规范写入倒退。因此 SQLite 可以落后于 JSONL,却不允许领先于 JSONL。元数据同步也位于规范历史追加之后:只有这批历史成功写入,LiveThread 才根据它更新 preview、首条用户消息、cwd、Git 信息和最近活动时间。
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 从日志尾部反向扫描,找到以下两项后便可以停止:
- 带
replacement_history和window_number的可用CompactedItem; - 一个已完成真实用户 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 同时完成三件事:
- 用 replacement history 替换当前 Context History;
- 追加一个
CompactedItem,其中保存相同 replacement history 和新窗口身份; - 在需要时追加新的完整 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 必须先检查仍在引用它的后代。
上一章讲过,多 Agent Fork 通常还会过滤 Reasoning、Tool Call 和旧通信,并替换子角色指令。因此 Reference-backed Fork 解决“历史存在哪里”,Context Filter 解决“哪些内容适合交给子 Agent”,两者不能互相代替。
一条可操作的故障排查顺序
持久化问题最好按权威层级排查,而不是先删数据库:
- 确认 ThreadId、History Mode,以及当前是否仍有 Live Writer;
- 找到对应
.jsonl或.jsonl.zst,检查第一条SessionMeta是否属于该 Thread; - 检查尾部是否有完整
TurnStarted → TurnComplete/TurnAborted边界,以及 Rollback、Compacted、TurnContext、WorldState 的相对顺序; - 查看是否存在 JSON parse error、ordinal 断裂或压缩/物化切换问题;
- 再比较 SQLite metadata 和 history projection 是否只是落后;
- 对恢复后的模型请求,检查 Prompt Normalize 是否插入了
abortedOutput 或删除了孤儿 Output; - 最后单独核对工作区、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 项目,前面八章的理解才真正变成工程能力。
评论
登录后即可评论