青雲的博客
拆开 Codex 第四部分 长会话如何保持秩序 第 06 章

上下文快满时,Codex 到底压缩了什么

拆开手动与自动、local 与 remote compaction,解释 live history 怎样被替换、rollout 为什么继续追加,以及 resume 如何找到新的工作集基线。

源码版本
rust-v0.144.6
验证日期
Commit
5d1fbf26c43abc65a203928b2e31561cb039e06d

长 thread 接近上下文上限时,Codex 会出现一次 context compaction。界面里的消息很短,最容易形成的理解也很短:Codex 把前面的对话总结一下,删掉旧消息,token 清零后继续。

实际行为有三处不一样。第一,官方 provider 的默认路径不一定运行本地 summary prompt;rust-v0.144.6 里还有 remote v1、remote v2 和 TokenBudget 分支。第二,被重写的是模型工作历史,rollout 文件不会因此缩短。第三,重估的是 active context,累计 token usage 不会归零。

上一章留下的问题是:append-only 的 rollout 怎样表达一次整体 history replacement。答案就在 CompactedItem.replacement_history,但在看 checkpoint 之前,先把触发和实现路径分开。

可复现实验:把触发、接口和恢复合同分开测

我在固定 tag 的 detached checkout 里跑了三组现成测试:

just test -p codex-core -E 'test(auto_compact_runs_after_token_limit_hit) | test(compact_resume_and_fork_preserve_model_history_view)'

just test -p codex-app-server \
  thread_compact_start_triggers_compaction_and_returns_empty_response

结果分别是 2 passed1 passed

第一条证明达到 token limit 后会走 auto compaction。第二条锁定的是规范化 request input 的 compacted prefix preservation:测试先移除空 user item 与 summarization prompt,再断言 compact 后的 input 是 resume 与 fork 后 input 的前缀。它不证明 raw wire input 字节等价。app-server 测试只锁住两项接口事实:thread/compact/start 的 response 为空,同一个 context-compaction item 会经过 started 与 completed notification。测试代码先读 response,再等 notification;这不能证明实际消息一定保持这个到达顺序。

这三条测试没有拿真实模型回复当断言。core 测试使用本地 mock Responses server,app-server 测试也在受控配置和 mock endpoint 下运行,失败时能区分是触发条件、compact 请求、history replacement,还是协议通知出了问题。

什么时候会触发 compaction

手动路径最好认。TUI 的 /compact 或 app-server 的 thread/compact/start 最终提交 Op::Compact,启动一个 CompactTask。它本身也占用 turn 生命周期,所以 compact 进行中不能被普通 turn/steer 当作 regular turn 插话。

自动路径分成 pre-turn 和 mid-turn。

  • pre-turn 会在新 context diff 和本轮 user input 写入之前检查。它先处理模型 compaction compatibility hash 改变,以及从大 context model 切到更小 model 时的越界情况;随后还会对当前 active context 运行普通 token_limit_reached 检查。配置的 auto-compact budget 用完,或在 model_context_window() 存在时到达 full-window 硬边界,都可以在普通 sampling 前触发。
  • mid-turn 在一次 sampling 结束后检查。只有模型或 pending input 还要求 follow-up,并且 token limit 已到、或 runtime 明确请求新 context window 时,才在下一次 sampling 前 compaction。

90% clamp 有存在性条件:只有 resolved_context_window() 返回值时,ModelInfo::auto_compact_token_limit() 才计算其 90%,并与 model 显式 limit 取更小者;没有 resolved window 时,显式 limit 原样返回。Total scope 使用这个 helper。BodyAfterPrefix 会先扣掉当前 window 的 prefill baseline;该 scope 若有显式 body budget,core 直接使用,因此可以高于 full window 的 90%。full-window check 也只在 turn_context.model_context_window() 存在时生效,该值是当前 effective/usable window,不是 raw resolved window。

这里有一个没被藏起来的缺口:pre-turn 检查发生在 incoming context 和 user message 写入之前。源码保留了注释,计划估算 pending items,在“当前还没过线,但本轮输入会把它推过线”时提前压缩。当前配置的阈值不是一个每次输入前都精确预测的即时闸门。

Manual、local 与 remote 不是同一个算法

CompactTask 先按 provider 和 feature 分流:

路径请求形状产物适用边界
TokenBudget新开 context window由 token-budget runtime 管理的新窗口实验性 feature,不能拿来解释所有 compaction
remote v2必要时重写 tool output 的 prompt-ready history,再追加 CompactionTriggerstream 中必须恰有一个 Compaction item;其他 output item 可存在但会忽略客户端过滤 retained input 后组成 replacement
remote v1完整 history、base instructions、tools 发往 compact endpoint服务端返回的 compacted history兼容路径
local合成 user summary prompt,走普通 Responses sampling明文 handoff summary 与保留的真实 user messages不走 remote compact 的 provider

所以“/compact 就是发送内置总结提示词”只对 local 路径成立。remote v2 先从可能已重写过大 tool output 的 prompt-ready history 构造请求,再追加协议级 CompactionTrigger。客户端等待 stream completed,只要其中恰有一个 Compaction 类型 item 就接受;其他 output item 会被忽略。该 item 的核心载荷是 encrypted_content

flowchart TD
  accTitle: Codex compaction 的触发、实现与持久化路径
  accDescr: 手动或自动触发先进入 compaction 分流,TokenBudget、remote v2、remote v1 和 local 生成不同 replacement history,最终都替换 live history 并向 rollout 追加 Compacted checkpoint。

  A["手动 Op::Compact"] --> D["compaction dispatcher"]
  B["pre-turn 条件"] --> D
  C["mid-turn token/new-context 条件"] --> D
  D --> E["TokenBudget: new window"]
  D --> F["remote v2: prompt-ready history"]
  D --> G["remote v1: compact endpoint"]
  D --> H["local: summary prompt"]
  E --> I["replacement history"]
  F --> F1["append CompactionTrigger"]
  F1 --> F2["server stream: exactly one Compaction item"]
  F2 --> F3["retained-input filter"]
  F3 -->|"pre-turn/manual"| I
  F3 -->|"mid-turn"| F4["inject fresh initial context"]
  F4 --> I
  G --> I
  H --> I
  I --> J["ContextManager.replace_history"]
  J --> K["append RolloutItem::Compacted"]
  K --> L["recompute active-context estimate"]

Local 路径保留的不只是一段摘要

local compaction 把 summary prompt 临时追加到 history clone,发起一次模型请求。如果 compact 请求本身超出窗口,它会从最旧 item 开始逐项删除后重试,尽量保住最近内容。

完成后,代码从原 live history 收集真实 user messages,按最多约 20,000 token 的预算从后往前保留,再追加带 SUMMARY_PREFIX 的 user-role summary。旧 assistant、reasoning 与 tool transcript 不会原样留在新的工作集里。

这里的 summary 用 user role 不是笔误。模型被训练成把这份 handoff 当作后续上下文的一部分;mid-turn compaction 还要把 initial context 插在最后一条真实 user message 之前,避免 summary 与当前任务的上下文顺序错位。

Remote v2 的 replacement 还保留真实输入

remote v2 会要求 stream completed 并且恰好包含一个 Compaction output;零个或多个都视为 fatal contract violation,其他类型 output item 可以同时存在但会被忽略。构造 replacement 时,它先把 prompt-ready history 限定为 user、developer、system message,随后调用通用 compacted-history filter:developer 和 system 被删除,只含 contextual wrapper 的 user item 也会因无法解析为 UserMessageHookPrompt 而被删除。如果同一 user item 还含已持久化 hook prompt,整个 item 可能按 HookPrompt 保留,其中的 contextual fragment 不会被单独剥离。过滤后的 user items 按 64k token 预算从后往前裁剪,再追加 opaque compaction item。

最终 replacement 同时包含上述真实输入和 opaque checkpoint。客户端无法把 encrypted_content 当作明文 handoff 展示或自行解释。

remote v2 的 completed event 若带 token usage,core 会记录 rollout budget,并把 input、output 与 cached input token 写入 compaction analytics。replacement 安装后,本地还会重估当前工作集 token。服务端 usage 与本地 active-context estimate 是两套用途,不应混成一个数。

Append-only rollout 怎样记录一次整体替换

不论 replacement history 来自哪条实现,最后都会进入 replace_compacted_history。这个函数先在 state lock 下整体替换 ContextManager,再追加带完整 replacement_history 与 window ids 的 RolloutItem::Compacted。需要时,随后再追加 full WorldState baseline 与 TurnContext。

旧 JSONL 行没有被删除。resume 时,reconstruction 找到最新仍有效的 compaction checkpoint,把它的 replacement history 当作新基线,然后顺序重放 checkpoint 后面的 suffix。append-only 与 history rewrite 并不冲突:前者记录事实,后者通过一个带 replacement 的新事实改变 replay 起点。

对 local thread store,append 会先向 RolloutRecorder enqueue items,然后显式等待 recorder.flush()。writer 对文件调用的是 write_allfile.flush,这条路径没有 sync_all/fsync。因此 flush 成功证明 writer barrier 已返回,不证明 checkpoint 已 crash-durable。如果 append 或 flush 失败,persist_rollout_items 只记录 error 不向上抛,live history 不回滚,上层仍可继续走到 item completed。

Token 没有清零

compaction 后 recompute_token_usage 会按新 history 估算当前上下文,把结果写进 last_token_usage.total_tokens,并更新 model context window 与当前 auto-compact window prefill。

它没有把 total_token_usage 重置为零。前者回答“下一次请求大约还背着多少上下文”,后者保留 session 累计用量。UI 若把两者都叫 token count,很容易出现“刚 compact,为什么总消耗没下降”的误判。

源码依据

本章覆盖的是 compaction control flow、history replacement 和 rollout checkpoint,不保证不同模型对明文 summary 或 encrypted compaction item 的质量等价。local 与 remote 的语义目标相同,prompt、保留策略、重试和可观察数据并不相同。

现成测试分别覆盖 token-limit 触发、app-server 空 response 与 notification 生命周期,以及 resume/fork 后规范化 request input 的 compacted prefix preservation。它们没有证明 raw wire input 或完整模型视图等价,没有证明 response 与 notification 的到达顺序,也没有证明每个 provider 都启用了 remote v2。测试里的 token estimate 同样不是 tokenizer 精确值。

失败边界

  • hook 状态为 HookRunStatus::Failed:进程错误、非零退出或没有 exit code,以及 stdout 形似 JSON 却无法按 compact-hook schema 解析,会记录失败;普通非 JSON stdout 被忽略,状态仍是 CompletedFailedshould_stop 仍为 false,pre/post hook 都不会因此中断 compaction。只有合法 hook 输出把 continue 设为 false,状态转为 HookRunStatus::Stopped,才会中止。
  • pre-compact hook 进入 HookRunStatus::Stopped:wrapper 在 compaction inner task 之前返回 TurnAborted,history 尚未替换。
  • compact sampling 超窗:local 会逐项删旧 history 后重试;只剩一个 item 仍失败时结束。
  • remote v2 返回零个或多个 Compaction 类型 item:视为协议错误,不安装不完整 checkpoint;其他类型 output item 不触发这个错误。
  • post-compact hook 进入 HookRunStatus::Stopped:该 hook 只在 inner task 成功后运行,replacement 已安装;wrapper 随后返回 TurnAborted,但不会回滚旧 history。
  • rollout append 失败:错误只记录不向上抛,live history 可能已经进入新 window,重启后却只能从旧 checkpoint 恢复。
  • UI 收到 context-compaction completed:证明运行链到达完成事件,不等同于 checkpoint 已经过 fsync 并可在 crash 后恢复。

动手改一个地方

run_turn 已经写明一个尚未实现的边界:pre-turn compaction 没有把 pending context diff 和 incoming user input 算进去。可以从这里做一个 projected-token experiment,但先把合同写清楚:

  1. 当前 active context 低于阈值、加上 incoming 后仍低于,不触发;
  2. 当前低于、projected 超过,pre-turn 触发;
  3. incoming 是否进入 compact 请求必须明确,不能既参与触发又在 replacement 中消失;
  4. compaction 失败时不能继续发送必然超窗的普通 sampling;
  5. BodyAfterPrefix 仍按同一 baseline 计算,不能退回 Total。

这不是加一个 estimated + input_tokens >= limit 就结束。context diff、图片、tool output truncation 和模型 input modalities 都会影响真正的 prompt size。实验应先复用现有 history estimate 和 incoming snapshot 测试,接受它是上界或近似值,再决定触发语义。

这一章建立了什么

Codex compaction 重写的是下一次模型请求看到的工作历史。local 生成明文 handoff;remote v2 过滤 developer、system 和 contextual-only user item,保留可解析为真实 user message 或 hook prompt 的 item,再追加 opaque compaction item。混有 hook prompt 的 user item 可能连同 contextual fragment 整项保留。两条路径都把 replacement checkpoint 追加进 rollout。

到这里解释的是 live session 如何换一副更小的工作集。进程退出以后,同一个 checkpoint 怎样恢复成原 thread,或者变成一条新 thread,属于下一章:Resume 与 Fork:恢复的不是同一件事