雨天小六

读懂 Codex(8.2):Rollout 持久化策略

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

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

一个回合运行时,Codex 会产生许多对象:完整消息、工具调用、命令输出片段、审批请求、告警、计数信息、回合边界以及供界面实时刷新的增量事件。若把它们原样写入历史,恢复程序就不得不理解大量只对当时界面有意义的中间状态;若过滤过度,当前会话看似正常,重启后却可能缺少继续执行所需的语义。

因此,持久化入口不能只有序列化器,还需要一层明确的选择策略。本节讨论 codex_rollout::policy 如何回答三个问题:

  1. 哪些顶层 RolloutItem 构成可重放历史;
  2. ResponseItemEventMsg 为什么采用不同的分类规则;
  3. LegacyPaginated 两种历史格式如何避免保存同一语义的两种表示。

至于通过通道异步写入、刷新以及关闭屏障,将在 8.4 节讨论。

持久化的对象不是运行时事件全集

RolloutItem 是持久化策略看到的顶层联合类型。它既包含模型协议对象,也包含 Codex 自己的执行标记:

类别代表项持久化目的
会话和上下文边界SessionMetaTurnContextWorldStateCompacted恢复会话配置、回合环境和压缩后的上下文
模型交互对象ResponseItem重建模型可见的消息、推理和工具调用链
Agent 间通信InterAgentCommunicationInterAgentCommunicationMetadata保留跨 Agent 交付及其本地控制信息
运行协议事件EventMsg保存回合边界、结构化条目和兼容旧历史所需的事件

顶层分类中,除 ResponseItemEventMsg 外,其余变体都直接保留。后两者还要进入各自的二级判定,因为它们的内部变体同时混合了长期语义和实时交互状态。

RolloutItem 持久化分类流程:顶层执行标记直接保留,ResponseItem 按响应类型判断,EventMsg 再按历史模式判断,最终仅将保留项送入 Rollout 写入路径
图 8.2-1:持久化策略是一个纯分类过程。它保持输入顺序,只移除不属于规范历史的对象;文件格式和写入时机不由这一层决定。

源码中的批量函数 persisted_rollout_items 依次调用单项判定,并把命中的对象克隆到新向量中。因而它有两个容易验证的性质:保留项的相对顺序不变,未命中策略的项不会留下占位符。

function select_for_rollout(items, history_mode):
    selected = []

    for item in items:
        if is_persisted(item, history_mode):
            selected.append(copy(item))

    return selected

这里的函数不写文件,也不报告“已经持久化”。它只把候选序列变成规范写入序列。

ResponseItem 保存完整语义,而不是临时能力描述

ResponseItem 来自模型交互协议。当前策略保留以下几组对象:

  • 消息与推理:MessageAgentMessageReasoning
  • 工具调用及结果:本地 Shell、Function、Tool Search、Custom Tool 的 Call 与 Output;
  • 其他模型操作:Web Search、Image Generation;
  • 上下文压缩结果:CompactionContextCompaction

这些对象的共同点,不是“用户可以在界面上看到”,而是它们已经形成模型交互或上下文演化中的完整语义单元。例如,只保存 FunctionCall 而丢掉 FunctionCallOutput,恢复后的调用链就不完整;只保存流式文本片段而没有最终消息,则恢复程序必须重新承担流合并职责。

当前不保存的 ResponseItem 有三类:

类型不进入 Rollout 的含义
AdditionalTools本次请求临时提供的附加工具描述不成为长期历史
CompactionTrigger触发压缩的控制信号不等同于压缩结果
Other未形成稳定恢复语义的兜底对象不自动落盘

这也说明“是否序列化得了”不是持久化判据。一个对象完全可以有 JSON 表示,却仍不适合作为跨版本、跨进程恢复的契约。

源码中还有一个名称相近的 should_persist_response_item_for_memories。它服务于记忆生成,规则与 Rollout 不同,例如会排除推理和图像生成项,并过滤 developer 消息。两者不能互换:前者回答“恢复 Thread 需要什么”,后者回答“生成长期记忆允许消费什么”。

EventMsg 必须先区分规范状态和传输过程

EventMsg 的类型远多于 ResponseItem。其中既有 TurnStartedTurnComplete 这样的生命周期边界,也有 AgentMessageContentDeltaExecCommandOutputDelta 这样的流式片段,还有审批请求、连接状态和界面通知。

不依赖历史模式、始终保留的事件包括:

事件组事件
资源与目标状态TokenCountThreadGoalUpdatedThreadRolledBack
回合边界TurnStartedTurnCompleteTurnAborted
Thread 配置ThreadSettingsApplied

这些事件描述的是恢复或查询仍然关心的状态边界。与之相对,内容 Delta、命令输出 Delta、Begin/Progress 通知、审批交互、Warning、Error、连接状态和实时语音事件等都被归为瞬时事件。它们仍可在当前 Session 的事件流中发送,但不会因此自动进入 Rollout。

“错误不持久化”尤其容易被误读。它只说明 EventMsg::Error 不属于这份可重放历史,并不代表错误不会写日志、上报指标或通过其他状态域记录。这里的策略边界只覆盖 Rollout。

History Mode 解决同一语义的双重表示

LegacyPaginated 并不只是两种文件分页方式。它们还决定某些运行语义以哪一种事件表示进入历史。

Paginated 模式使用 EventMsg::ItemCompleted 携带结构化 TurnItem,供后续 Turn/Item 投影和分页读取。Legacy 模式则保留一组旧事件,例如用户消息、Agent 消息、推理、Review Mode 切换和若干工具结束事件。若把两组表示同时保存,同一个完成动作就可能在恢复或投影时出现两次。

核心选择可以写成下表:

事件类别LegacyPaginated
公共状态事件保留保留
旧式完成事件集合保留丢弃
一般 ItemCompleted(TurnItem)丢弃保留
ItemCompleted(Plan)保留保留
ItemCompleted(Extension::Sleep)保留保留
瞬时事件丢弃丢弃

最后两个例外来自源码中的兼容规则:在 Legacy Rollout 中,Plan 和 Sleep 没有可替代它们的原始 ResponseItem 或旧式等价事件,所以仍保留对应的 ItemCompleted。因此,不能把实现简化成“Legacy 一律不要 ItemCompleted”。

function persist_event(event, mode):
    if event is common_state_event:
        return true

    if event is ItemCompleted(item):
        return mode == Paginated
            or item is Plan
            or item is Extension.Sleep

    if event is legacy_completion_event:
        return mode == Legacy

    return false
Legacy 与 Paginated 历史模式的事件选择矩阵,展示公共状态事件、旧式完成事件、ItemCompleted 和瞬时事件在两种模式中的保留结果
图 8.2-2:History Mode 的主要职责之一,是为具有双重协议表示的完成事件选定唯一的规范形式。

历史模式属于 Thread 的持久化契约。创建 Thread 时,它写入 SessionMeta;恢复现有历史时,本地 Store 从第一个规范 SessionMeta 取得模式。后续写入不能根据单个事件临时切换规则,否则同一 Rollout 的前后部分会采用不同语义。

策略在 Store 边界重新生效

LiveThread::append_items 会先计算过滤结果,用于持久化指标和后续元数据同步;但它传给 ThreadStore::append_items 的仍是原始 RolloutItem 序列。AppendThreadItemsParams 的注释明确规定:Store 实现负责在写入规范历史和自身投影之前应用共享策略。

本地 Store 的实际路径如下:

LiveThread.append_items(raw_items)

    ├─ 计算 selected_items,供指标和元数据同步使用

    └─ ThreadStore.append_items(raw_items)

           ├─ 按该 Thread 的 history_mode 再次执行共享策略
           ├─ 结果为空:直接成功返回
           └─ 结果非空:交给 RolloutRecorder,并执行 flush

这种分工看似有一次重复计算,却守住了抽象边界:调用者可以知道哪些项会影响元数据,Store 则不能信任上游已经过滤。内存 Store 与本地 Store 都必须遵守同一契约,未来替换存储实现时也不会绕开策略。

还要注意操作顺序。LiveThread 只有在 Store 写入成功后,才用过滤后的项更新 Thread 元数据。因此,“预览文本已经更新而规范历史尚未接受对应项”不应成为正常成功路径。

穷举白名单是一项演进约束

策略函数使用 Rust 的穷举 match,没有 _ => true 这样的默认分支。为 RolloutItemResponseItemEventMsg 增加新变体时,编译器会迫使维护者在相应匹配中作出选择。

这项约束同时控制四类风险:

  1. 恢复风险:漏存关键项会使热 Session 与恢复后的 Session 不等价;
  2. 重复风险:兼容格式的两种表示同时落盘,可能造成重复消息或重复投影;
  3. 体积风险:高频 Delta 和 Progress 事件会迅速放大 Rollout;
  4. 契约风险:把临时对象写入历史,会让尚未稳定的结构变成长期兼容负担。

白名单的代价是每次扩展协议都要更新策略和测试。这不是附带工作,而是协议设计的一部分:新增事件时,开发者必须说明它是规范状态、兼容表示,还是只服务当前连接的瞬时通知。

可用于核对实现的不变量

  • 过滤只能删除对象,不能改变保留项的相对顺序;
  • Call 与其完成后的 Output 都应有明确的持久化判定;
  • 同一 Thread 的事件策略始终使用其规范 history_mode
  • Paginated 的一般 ItemCompleted 与对应 Legacy 完成事件不能同时成为规范表示;
  • 过滤结果为空时,不应制造空的 Rollout 记录;
  • Store 不能假设调用者已经执行过持久化策略;
  • Rollout 策略与 Memory 输入策略即使函数形态相似,也不能相互替代。

源码与测试证据

位置可以核对的结论
codex-rs/protocol/src/protocol.rsThreadHistoryModeEventMsgRolloutItem 的类型定义
codex-rs/rollout/src/policy.rs顶层、ResponseItem、Memory 输入和 EventMsg 的分类规则
codex-rs/thread-store/src/types.rsStore 接收原始项并负责应用共享策略;恢复时从规范元数据确定模式
codex-rs/thread-store/src/live_thread.rs写入前测量、Store 调用和写入后元数据同步的顺序
codex-rs/thread-store/src/local/live_writer.rs本地 Store 过滤、空结果短路、记录与刷新
codex-rs/rollout/src/persistence_metrics_tests.rs两种模式下 ItemCompleted、Review Mode 表示选择的断言

本节边界

本节只回答“哪些对象有资格进入 Rollout”。选中对象何时对恢复可见、实时事件与持久化事件为何可能分叉,以及 Recorder 如何处理并发写入和关闭,需要继续考察事件发送顺序与异步写入协议。

源码导航

  • codex-rs/rollout/src/policy.rs
  • codex-rs/protocol/src/protocol.rs
  • codex-rs/thread-store/src/types.rs
  • codex-rs/thread-store/src/live_thread.rs
  • codex-rs/thread-store/src/local/live_writer.rs
  • codex-rs/rollout/src/persistence_metrics_tests.rs

阅读导航

上一节:8.1 · 下一节:8.3

评论


← 返回文章列表