Context 壓縮演進
codex 把對話歷史 (conversation history) 當成有限的資源用。一旦累計的 token 靠近 model context window,就要把舊歷史折疊成摘要,騰出空間讓下一輪繼續跑。這條壓縮 (compaction) 路徑在 core/src/compact*.rs 裡被反覆重寫:本地用模型自己生成摘要,後來加了一條 server-side 的 remote compaction,再後來又演進出 v2。三套實現共享同一套 hook 和 analytics lifecycle,但拿到的「摘要」來源完全不同。
職責
- 判定何時觸發壓縮:auto(命中 token 閾值)、manual(使用者敲
/compact)、或CompactionReason(模型降級、comp hash 變更等)三類來源,由run_inline_auto_compact_task和run_compact_task分發。 - 跑摘要 turn:本地版本自己用 Responses API 跑一次模型呼叫拿
SUMMARIZATION_PROMPT的結果,remote 版本讓 server 直接吐ResponseItem::Compaction,v2 進一步要求 server 嚴格回傳單條 compaction item。 - 構造 replacement history:把摘要 + 末尾幾條使用者訊息拼成新歷史,舊歷史整段丟棄,
build_compacted_history_with_limit負責按 20k token 上限裁剪使用者訊息。 - 維護上下文視窗 (context window):每次壓縮開新視窗,透過
advance_auto_compact_window/start_new_context_window更新window_id、previous_window_id,供後續 token 計數和 rollout trace 使用。 - 暴露 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-219 — run_compact_task_inner,本地壓縮主入口,包含 pre/post hook 與 analytics 包裝。codex-rs/core/src/compact.rs:221-378 — run_compact_task_inner_impl,跑模型 stream、重試、最後調 replace_compacted_history。codex-rs/core/src/compact.rs:602-663 — build_compacted_history_with_limit,把使用者訊息從尾部往前塞,直到 20k token 上限。codex-rs/core/src/compact_remote.rs:76-107 — run_remote_compact_task,remote v1 入口,標記為 ResponsesCompact。codex-rs/core/src/compact_remote_v2.rs:85-115 — run_remote_compact_task v2 版,標記為 ResponsesCompactionV2。codex-rs/core/src/compact_remote_v2.rs:385-443 — collect_compaction_output,強約束 server 只能回一條 compaction item,否則 fatal。codex-rs/core/src/compact_remote_v2_attempt.rs:32-142 — run_remote_compact_v2_attempt,先 trim_function_call_history_to_fit_context_window 再發請求。codex-rs/core/src/compact_model_fallback.rs:8-19 — should_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,因為模型被訓練成「摘要後就是最後一條」,必須把上下文插在最後一條真使用者訊息前面。
// 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 識別。
// 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:
// 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:
InvalidRequest、ContextWindowExceeded、ServerOverloaded等錯誤會觸發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直接換視窗,不調模型,但仍發ContextCompactionturn 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;要看壓縮結果怎麼存進歷史,看 會話執行緒與訊息歷史。