ARTICLE · 1060258
Codex 源码分享(六):一段对话,怎样被压缩、撤回与分叉
本文基于提交
c46aa58a7baaec125612523f27f931d1ad22f8cd(2026-09-22)。代码片段作了节选,源码位置均按这一固定提交标注。当前公开接口是thread/compact/start、thread/revert与thread/fork;已经删除的thread/rollback只在兼容旧历史时出现。
上一篇把多 Agent 的运行时状态落到了 rollout、SQLite 与父子关系上。这次继续追问:一条已经保存的对话,怎样在有限上下文里继续,又怎样撤回错误方向,或者从同一段过去分出两种未来?
Codex 给出的三个操作是 Compaction、Revert 和 Fork。它们看起来都在“修改聊天记录”,实际改变的却不是同一层状态。界面 turn、模型 context、磁盘 rollout 和 Agent 已经改过的文件,各有自己的生命周期。把它们混成一份记录,就会误以为压缩是在删消息、撤回能恢复文件、分叉会自动创建独立工作区。

先看全图:四种状态不能混在一起

Canonical rollout 回答“发生过什么”。Codex 把 ResponseItem、TurnContext、Compacted 等记录顺序写入 JSONL;对 paginated history,先完成 durable JSONL write,再更新 SQLite 投影。投影可以落后,不能领先于事实记录。(源码:写入与投影顺序;codex-rs/thread-store/src/local/live_writer.rs:317–355)
SQLite 有两种职责。thread_history_1.sqlite 保存可重建的 turn/item 分页投影;state_5.sqlite 中的 threads.rollout_path 则指出 logical thread 当前选中了哪条 rollout。前者服务查询,后者在 revert 后承担版本选择。
Model context 回答“下一轮模型看见什么”。ContextManager 保存工作 transcript,送入模型前还会 normalization。Compaction 替换的是这份工作记忆,不是前面的 JSONL。(源码:ContextManager history;codex-rs/core/src/context_manager/history.rs:74–109、468–485)
外部世界包括文件、Git、进程和网络副作用。协议注释明确说明:revert 只改变持久化对话历史,不撤销本地文件修改。(源码:ThreadRevertParams;codex-rs/app-server-protocol/src/protocol/v2/thread.rs:1257–1267)
四者拆开后,三个操作的边界就很清楚:
Compaction 改变下一轮模型携带的工作记忆;Revert 改变同一个 logical thread 当前选择的持久化历史;Fork 从某个历史边界创建一条新的 logical thread。三者都不是文件系统的时间机器。
同一段过去,三种继续方式
假设一条对话已有 T1 到 T5。三种操作都在改变“从哪里继续”,但身份和持久化方式不同。
Compaction 在同一 rollout 追加 checkpoint;Revert 保持 thread ID,但把当前 path 切到一条新的 immutable rollout;Fork 创建新 thread,从选定前缀独立增长。
beforeTurnId | |||||
Compaction:写入检查点,而不是改写过去
当前 app-server 的公开入口是 thread/compact/start。请求只需要 threadId:
pubstructThreadCompactStartParams {pub thread_id: String,}处理器加载 thread 后提交 Op::Compact,随即返回空响应。真正的压缩由 compact task 异步完成;客户端随后才收到 contextCompaction 的 started、completed 与 turn completed。因此,空响应只表示任务已经提交。(源码:compact 请求处理;codex-rs/app-server/src/request_processors/thread_processor.rs:2338–2355;事件测试:codex-rs/app-server/tests/suite/v2/compaction.rs:224–270)
不只有一种压缩算法
CompactTask 在 TokenBudget、Remote V2 和 Local 之间选择,并非总是调用固定的总结 prompt。
if ctx.config.features.enabled(Feature::TokenBudget) { crate::compact_token_budget::run_manual_compact_task(session, ctx).await?;returnOk(None);}match ctx.provider.capabilities().remote_compaction { RemoteCompactionSupport::V2 => { crate::compact_remote_v2::run_remote_compact_task(session, ctx).await } RemoteCompactionSupport::Unsupported => { crate::compact::run_compact_task(session, ctx, input).await }}(源码:CompactTask;codex-rs/core/src/tasks/compact.rs:28–67)
Local 收集真实 user messages、排除旧摘要,从最近消息向前构造有预算的 replacement history,最后追加 CompactionSummary;旧 assistant、tool 和 reasoning 不会原样保留。Remote V2 则按另一套策略保留 user/hook、部分 developer/agent message,再追加 provider 返回的 compaction output。两者的共同结果是替代上下文,而不是同一种摘要格式。(源码:Local;codex-rs/core/src/compact.rs:347–388、537–601、672–762;Remote V2;codex-rs/core/src/compact_remote_v2.rs:505–531、556–600、620–660)
真正的原子语义:先换内存,再追加 CompactedItem
无论 replacement history 怎样产生,最后都要落到 replace_compacted_history。核心代码做了两件事:先替换 Session 内的 annotated history,再向当前 rollout 追加一个带 replacement_history 的 CompactedItem。
// 仅保留与本文有关的字段letmut compacted_item = CompactedItem { replacement_history: Some(items.clone()),// ...};state.replace_annotated_history( items, reference_context_item, HistoryReplacement::Compaction { /* ... */ },);self.persist_rollout_items(&[ RolloutItem::Compacted(compacted_item),// WorldState / TurnContext / settings checkpoint]).await;(源码:replace_compacted_history;codex-rs/core/src/session/mod.rs:3993–4076;内存替换:codex-rs/core/src/context_manager/history.rs:574–607)
旧 JSONL 不会被裁掉。新增的 CompactedItem 把 replacement history 固化为新的恢复基线。
恢复时,reconstruction 从后向前寻找最新有效 checkpoint,以 replacement history 为 base,再重放其后的 surviving suffix。账本仍然 append-only,模型工作集却从新的窗口继续。(源码:rollout reconstruction;codex-rs/core/src/session/rollout_reconstruction.rs:133–205、342–427)
压缩不是无损编码
“摘要”这个词很容易让人产生一种错觉,好像它只是把同样的信息换成更短的表达。源码展示的并不是这个语义。
无论是本地保留最近用户消息,还是 Remote V2 按策略保留若干输入,都会做选择。哪些事实进入摘要、哪些工具细节被省略、哪些中间假设被重新措辞,都会影响后续判断。多次压缩还可能把第一次压缩的选择,再当作第二次压缩的事实来源。Codex 自己也会在长 thread 和多次 compaction 时提醒准确性可能下降。(源码:多次压缩提示;codex-rs/core/src/compact.rs:402–409)
从 Agent 设计的角度看,compaction 不是存储优化这么简单。它是一道认识论边界:系统决定未来的自己还把什么当作过去。
Revert:Thread 不换,当前 lineage 换了
当前公开入口是 thread/revert:
pubstructThreadRevertParams {pub thread_id: String,pub before_turn_id: String,}它保留 beforeTurnId 之前的历史,不包含该 turn。当前实现只支持 paginated thread;其他 history mode 会返回thread/revert only supports paginated threads。(源码:ThreadRevertParams;codex-rs/app-server-protocol/src/protocol/v2/thread.rs:1257–1267;模式检查:codex-rs/app-server/src/request_processors/thread_processor.rs:2117–2130)
app-server 先保存当前 config、可恢复 settings 与 MCP extensions,关闭旧 runtime,再让 thread-store 切换持久化历史;随后用同一个 thread ID 恢复 Session、还原 settings 并重建 listener。完整动作是:建立替代历史,切换当前指针,重新加载运行时。(源码:revert request flow;codex-rs/app-server/src/request_processors/thread_processor.rs:2131–2335)
新建 rollout,而不是裁掉旧文件
旧 rollout 保持不可变;新 rollout 通过 history_base 引用保留前缀;唯一可变的 cutover,是 SQLite 中当前 rollout path。(源码:revert 设计;codex-rs/thread-store/src/local/revert_thread.rs:15–18)
核心过程可以缩成下面几步:
let history_base = history_base_at_boundary( store, thread_id, ForkBoundary::BeforeTurn(before_turn_id), &lineage,).await?;let rollout_id = ThreadId::new();let recorder = create_replacement_recorder( store, source_meta, rollout_id, history_base, forked_from_ordinal_exclusive, writer_lock,).await?;let replaced = state_db.replace_rollout_path_if_current( thread_id, expected_sqlite_path.as_path(), replacement_path.as_path(),).await?;if !replaced {let _ = tokio::fs::remove_file(replacement_path.as_path()).await;returnErr(ThreadStoreError::Conflict { message: format!("thread {thread_id} changed while it was being reverted"), });}(源码:revert 存储实现;codex-rs/thread-store/src/local/revert_thread.rs:80–149)
SessionMeta.id 仍是 stable thread ID,新 rollout_id 标识物理历史。一条 logical thread 因而可以拥有多条 rollout,threads.rollout_path 只选择其中一条作为当前版本。
切换使用旧 path 作为 expected value 做 compare-and-swap。CAS 失败说明 thread 已经变化;实现会删除刚创建的替代文件并返回 conflict,避免迟到的 revert 覆盖新历史。(源码:CAS 与冲突清理;codex-rs/thread-store/src/local/revert_thread.rs:133–149;SQLite update:codex-rs/state/src/runtime/threads.rs:401–418)
测试连续 revert 两次后,同一 logical thread 会留下三份 rollout。旧文件仍在,但旧 path 已失效;只用 stable thread ID 恢复时,resolver 会沿 SQLite pointer 找到当前 rollout。(源码:多 rollout 测试;codex-rs/thread-store/src/local/revert_thread_tests.rs:73–109;stale path 测试:codex-rs/app-server/tests/suite/v2/thread_revert.rs:421–455)
撤回历史,不撤回设置和现实副作用
Revert 有意保留操作发生时的当前 runtime settings。测试确认,即使活动 turn 因 revert 被标记为 Interrupted,它采用的 approval_policy = Never 仍会带回新 runtime。(源码:settings 恢复;codex-rs/app-server/src/request_processors/thread_processor.rs:2131–2135、2273–2289;测试:codex-rs/app-server/tests/suite/v2/thread_revert.rs:543–623)
它也不反做工具副作用。T3 若写过文件或调用外部 API,revert 到 T3 之前,只会让未来的模型上下文不再继承 T3;已经发生的动作仍然存在。
所以“撤回”这个中文标题适合读者理解方向,却必须配上技术限定:这是对话历史的撤回,不是现实世界的逆操作。
Fork:共享过去,分开未来
thread/fork 创建新的 thread。新 thread 的 forked_from_id 指向来源;source 的 ID、path 和原始 bytes 都不改变。(源码:fork 新身份;codex-rs/core/src/thread_manager.rs:1410–1428;source 不变测试:codex-rs/app-server/tests/suite/v2/thread_fork.rs:128–218)
Fork 可以把边界放在三个位置:
Latest:继承当前全部历史;lastTurnId:包含指定 terminal turn,是 inclusive;beforeTurnId:排除指定 turn,是 exclusive。
lastTurnId 与 beforeTurnId 不能同时使用,仍在执行的 turn 也不能作为 through boundary。(源码:ThreadForkParams;codex-rs/app-server-protocol/src/protocol/v2/thread.rs:536–568;边界映射:codex-rs/app-server/src/request_processors/thread_processor.rs:4856–4894)
Paginated fork 不必复制整段 JSONL
对 paginated history,thread-store 先持久化 source、解析 lineage、物化 SQLite 投影,再把边界定位成 HistoryPosition。它包含 source rollout ID、exclusive ordinal 和 JSONL byte offset。(源码:HistoryPosition;codex-rs/protocol/src/protocol.rs:3078–3092;paginated fork 准备:codex-rs/thread-store/src/local/paginated_fork.rs:15–178)
Core 随后用 reference-backed persistence 创建 child:
let history = InitialHistory::Resumed(ResumedHistory { conversation_id: prepared.source_thread_id, history: Arc::clone(&prepared.model_context), rollout_path: None,});let persistence = ForkPersistence::Referenced { history_base: prepared.history_base, inherited_item_count: prepared.model_context.len(),};(源码:fork_prepared_thread;codex-rs/core/src/thread_manager.rs:1469–1496)
child 的 SessionMeta 记录 history_base,自己的 rollout 只写设置、边界与后续内容。测试确认:child 文件里没有 source user message,但下一轮模型 input 同时含 source message 和 child 新消息。共享过去来自 lineage 引用,无需逐行复制。(源码:reference-backed fork 测试;codex-rs/app-server/tests/suite/v2/thread_fork.rs:1507–1614)
仓库里仍有 ForkPersistence::Copied 路径,用于把筛选后的初始历史实际写入新 rollout。两条路径的物理表示不同,身份结论相同:都会创建新的 thread ID,source 不受影响。
历史血缘,不是 Agent 父子树
普通 conversation fork 使用 forked_from_id,Multi-Agent 控制树使用 parent_thread_id。SessionMeta 分别保存二者;普通 fork 不会因此把新 thread 变成 source Agent 的子 Agent。(源码:两类关系;codex-rs/protocol/src/protocol.rs:3107–3117;fork lineage:codex-rs/core/src/thread_manager.rs:1498–1550)
这一区分很有用:
forked_from_id回答“这段历史从哪里来”;parent_thread_id回答“这个 Agent 在哪棵控制树里由谁创建”。
分叉对话,也不是自动创建 Git worktree
ThreadForkParams 可以覆盖 cwd 与 runtime_workspace_roots,但公开 API 的这条实现路径没有复制目录、checkout commit 或创建 Git worktree 的步骤。若没有额外编排,两个 fork 仍可能指向同一个 cwd,看到同一份当前文件状态。(源码:fork 环境参数;codex-rs/app-server-protocol/src/protocol/v2/thread.rs:570–610;配置加载:codex-rs/app-server/src/request_processors/thread_processor.rs:4938–4976、5052–5057)
这意味着 conversation fork 适合比较两种推理和方案;若两边都要实际改代码,还需要 worktree、容器或远程执行环境为它们提供真正的副作用隔离。
为什么源码里还能搜到 Rollback
源码仍能搜到 ThreadRolledBack、replay 和 legacy truncation,但这不是第四种当前操作。thread/rollback 已在提交 3052bbcf8c9d48e130599308c583784db43e57aa 中删除;paginated thread 现在使用 thread/revert。(源码:迁移说明;codex-rs/app-server/README.md:291–300)
保留下来的 marker 用于读取已经存在的旧 rollout。恢复逻辑仍需理解当年的 rollback,才能计算有效历史;这是磁盘兼容,不是公开 API。(源码:legacy replay;codex-rs/core/src/session/rollout_reconstruction.rs:165–210、382–418)
这也是阅读快速演进仓库时很重要的一条经验:搜到一个类型,只能证明源码还需要理解它,不能证明产品现在仍对外提供它。
对 Agent 的一些思考
把三个实现放在一起看,Codex 对历史的处理已经不像普通聊天软件,更像一个带不可变日志、检查点和 lineage 的运行时。
1. Agent 的“记忆”至少要拆成工作记忆与审计记录
模型上下文受预算约束,必须允许重写;审计记录却不能因为模型暂时不携带某些内容就被抹掉。Compaction 的工程答案是:工作记忆有损替换,事实日志继续追加,二者通过 checkpoint 联系。Prompt 是推理工作集,不是事实数据库。
2. 稳定身份与物理表示应当分离
Revert 把 logical identity 与 physical rollout 分开:用户面对稳定的 thread ID,底层以新 rollout 和 CAS pointer 切换 lineage。旧 rollout 负责审计来源,条件式更新避免并发历史被覆盖。这比原地裁文件更容易恢复,也更容易解释。
3. 撤回思考与撤回行动是两套机制
Agent 会改文件、运行命令、调用服务。对话 revert 只能改变它怎样理解过去,无法撤销已经造成的效果。真正的 undo 需要 Git checkpoint、事务、幂等键、可逆工具、补偿动作、环境快照,或至少一份明确的 effect receipt。
4. 分叉要成为反事实实验,还需要环境隔离
Fork 让两条 thread 共享过去并尝试不同计划;若它们仍写同一套文件,实验就会互相污染。真正的 counterfactual branch 需要同时分叉历史与环境:conversation fork 决定从哪段记忆开始,worktree 或容器决定在哪个现实里行动。
5. 压缩策略会改变 Agent 的性格
什么被保留,决定 Agent 更容易坚持什么。只留最近需求更敏捷,却可能忘记早期约束;保留大量工具证据更可审计,却消耗上下文;摘要只追求结论,又可能抹掉不确定性。Compaction policy 并不中性,它参与塑造 Agent 的连续性与风险偏好。
收束:它改变的是从哪里继续
Codex 没有用一套“编辑聊天记录”的逻辑实现三种操作。压缩在同一 rollout 追加认知 checkpoint;撤回让稳定 thread 指向只继承某段前缀的新 rollout;分叉则创建新 thread,让同一段过去长出不同未来。三者共同改变的是:下一步从哪里继续。
没有被自动处理的,是 Agent 已经作用过的外部世界。可靠的 Agent 系统必须同时设计三件事:它接下来记得什么,它承认发生过什么,以及它已经改变了什么。对应到源码,就是工作记忆的检查点、持久历史的指针切换,以及身份与未来的分支。