Skip to content

Context 压缩演进

源码版本rust-v0.145.0

codex 把对话历史 (conversation history) 当成有限的资源用。一旦累计的 token 靠近 model context window,就要把旧历史折叠成摘要,腾出空间让下一轮继续跑。这条压缩 (compaction) 路径在 core/src/compact*.rs 里被反复重写:本地用模型自己生成摘要,后来加了一条 server-side 的 remote compaction,再后来又演进出 v2。三套实现共享同一套 hook 和 analytics lifecycle,但拿到的"摘要"来源完全不同。

职责

  1. 判定何时触发压缩:auto(命中 token 阈值)、manual(用户敲 /compact)、或 CompactionReason(模型降级、comp hash 变更等)三类来源,由 run_inline_auto_compact_taskrun_compact_task 分发。
  2. 跑摘要 turn:本地版本自己用 Responses API 跑一次模型调用拿 SUMMARIZATION_PROMPT 的结果,remote 版本让 server 直接吐 ResponseItem::Compaction,v2 进一步要求 server 严格返回单条 compaction item。
  3. 构造 replacement history:把摘要 + 末尾几条用户消息拼成新历史,旧历史整段丢弃,build_compacted_history_with_limit 负责按 20k token 上限裁剪用户消息。
  4. 维护上下文窗口 (context window):每次压缩开新窗口,通过 advance_auto_compact_window / start_new_context_window 更新 window_idprevious_window_id,供后续 token 计数和 rollout trace 使用。
  5. 暴露 hook 与 analytics:PreCompactHookOutcome / PostCompactHookOutcome 让插件能拦停压缩,CompactionAnalyticsAttempt 把每次尝试的 trigger、reason、implementation、phase 都上报。

设计动机

最早的本地压缩 (compact.rs) 是"客户端自己再发一次请求给模型做摘要"。它的问题在于摘要本身也吃 token,而且摘要质量取决于模型对 SUMMARIZATION_PROMPT 的执行情况。当模型 provider 开始原生支持 server-side compaction 后,就出现了 compact_remote.rs:server 在一次 Responses 请求里直接返回折叠好的 Compaction item,客户端不参与摘要生成。

但 v1 remote 把整段历史都发给 server,server 只回摘要,客户端要自己再裁一遍 function call 输出。v2 (compact_remote_v2.rs) 把这个流程收紧:server 不只回摘要,还负责决定哪些消息保留 (retained messages),客户端只需收集单条 Compaction 输出,出错就 fatal。同时 v2 引入了 RETAINED_MESSAGE_TOKEN_BUDGET = 64_000 上限,防止保留消息自己把上下文撑爆。

compact_token_budget.rs 是另一条岔路:它跳过摘要生成,直接开新窗口。这条路径保留给"不想再花一次模型调用做摘要"的场景——比如用户明确说"清空上下文"。它仍然走完整的 hook + ContextCompaction turn item lifecycle,只是不调模型。

关键文件

codex-rs/core/src/compact.rs:150-219run_compact_task_inner,本地压缩主入口,包含 pre/post hook 与 analytics 包装。codex-rs/core/src/compact.rs:221-378run_compact_task_inner_impl,跑模型 stream、重试、最后调 replace_compacted_historycodex-rs/core/src/compact.rs:602-663build_compacted_history_with_limit,把用户消息从尾部往前塞,直到 20k token 上限。codex-rs/core/src/compact_remote.rs:76-107run_remote_compact_task,remote v1 入口,标记为 ResponsesCompactcodex-rs/core/src/compact_remote_v2.rs:85-115run_remote_compact_task v2 版,标记为 ResponsesCompactionV2codex-rs/core/src/compact_remote_v2.rs:385-443collect_compaction_output,强约束 server 只能回一条 compaction item,否则 fatal。codex-rs/core/src/compact_remote_v2_attempt.rs:32-142run_remote_compact_v2_attempt,先 trim_function_call_history_to_fit_context_window 再发请求。codex-rs/core/src/compact_model_fallback.rs:8-19should_retry_with_current_model,定义哪些错误值得换模型重试。codex-rs/core/src/compact_token_budget.rs:64-90 — token-budget 压缩,跳过摘要直接开新窗口。

InitialContextInjection 是一个看似无关紧要但很关键的枚举:它决定了压缩后新历史要不要插入 initial context。pre-turn / manual 压缩用 DoNotInject,让下一轮正常 turn 自己重新注入;mid-turn 压缩必须用 BeforeLastUserMessage,因为模型被训练成"摘要后就是最后一条",必须把上下文插在最后一条真用户消息前面。

rust
// compact.rs:56-69 — mid-turn 与 pre-turn 压缩的上下文注入策略
#[derive(Debug)]
pub(crate) enum InitialContextInjection {
    BeforeLastUserMessage(Arc<WorldState>),
    DoNotInject,
}

本地压缩的核心是把整段历史 + SUMMARIZATION_PROMPT 发给模型,stream 完后取最后一条 assistant 消息作为 summary。注意它不直接把模型输出当新历史,而是 format!("{SUMMARY_PREFIX}\n{summary_suffix}") 包一层前缀,方便后续 is_summary_message 识别。

rust
// compact.rs:323-336 — 从模型 stream 结果里取出摘要并打标
let history_snapshot = sess.clone_history().await;
let history_items = history_snapshot.raw_items();
let summary_suffix = get_last_assistant_message_from_turn(history_items).unwrap_or_default();
let summary_text = format!("{SUMMARY_PREFIX}\n{summary_suffix}");
let user_messages = collect_user_messages(history_items);

let mut new_history = build_compacted_history(Vec::new(), &user_messages, &summary_text);

v2 remote 的关键约束是 server 必须返回恰好一条 ResponseItem::Compaction,否则直接 fatal:

rust
// compact_remote_v2.rs:419-434 — server 返回多条 compaction 视为协议错误
if !saw_completed {
    return Err(CodexErr::Stream(
        "remote compaction v2 stream closed before response.completed".to_string(),
        None,
    ));
}
if compaction_count != 1 {
    return Err(CodexErr::Fatal(format!(
        "remote compaction v2 expected exactly one compaction output item, got {compaction_count} from {output_item_count} output items"
    )));
}

数据流

边界与失败

  • ContextWindowExceeded 自愈:本地压缩跑摘要时如果自己的 prompt 也爆窗口,会循环 history.remove_first_item() 砍掉最旧条目再重试,直到只剩一条还爆才报错退出 (codex-rs/core/src/compact.rs:285-300)。
  • Stream 重试上限更紧:v2 remote 把重试次数 clamp 到 MAX_REMOTE_COMPACTION_V2_STREAM_RETRIES = 2,因为压缩本身耗时长,沿用通用 stream 的重试预算会让一次压缩卡太久 (codex-rs/core/src/compact_remote_v2.rs:54-57)。
  • 模型 fallback:InvalidRequestContextWindowExceededServerOverloaded 等错误会触发 should_retry_with_current_model,换当前主模型再试一次,避免 provider 特异性把压缩卡死 (codex-rs/core/src/compact_model_fallback.rs:8-19)。
  • mid-turn 注入位置:摘要后的新历史里,initial context 不能简单 push 到末尾,要 splice 到最后一条 real user message 之前;若没有 real user message,就插在 summary 之前,保证 summary 始终是最后一条 (codex-rs/core/src/compact.rs:542-587)。
  • token-budget 压缩无摘要:走 start_new_context_window 直接换窗口,不调模型,但仍发 ContextCompaction turn item,让 hook 和 UI 不会因为路径不同而漏掉事件 (codex-rs/core/src/compact_token_budget.rs:76-82)。

小结

压缩这块代码的复杂度主要来自"三套实现并存":本地 stream、remote v1、remote v2 各自有独立的 entry 和 inner_impl,但共享 CompactionAnalyticsAttempt、hook、InitialContextInjection 这层骨架。新写功能时先确认 provider 走哪条路径——should_use_remote_compact_task 是分支点——再去看对应文件。下一轮要看 model 调用本身怎么发出去,可以接着读 Client 与 Responses API;要看压缩结果怎么存进历史,看 会话线程与消息历史