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;要看压缩结果怎么存进历史,看 会话线程与消息历史。