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;要看壓縮結果怎麼存進歷史,看 會話執行緒與訊息歷史