ARTICLE · 1138355
Codex 源码-Compaction 上下文压缩
Codex 源码解析系列
第 14 讲:Compaction 上下文压缩
基于 OpenAI Codex 源码 · 2026-10-08
💡 本讲一句话:压缩不是"摘要一下扔了旧历史"这么简单——它是一条完整生命周期:触发判定 → 三路径分发(本地摘要 / 远程 V2 / 窗口重置)→ 协议校验 → 预算截断 → 窗口记账 → 检查点持久化。本讲逐行拆 run_auto_compact 的分发逻辑、V2 "恰好一个 Compaction item" 的硬契约、64k/20k 双预算、模型降级重试与 AutoCompactWindow 窗口链,读完你能说清 Codex 压缩后为什么既不丢用户授权事实、也不会把摘要装错位置。
一、三个入口,一个分发器
第 13 讲我们看过 ContextManager 管着"模型可见窗口 + 保留上下文"两层历史。本讲回答下一个问题:当这个窗口快装不下了,Codex 怎么压缩?触发点有三个——手动 /compact(core/src/tasks/compact.rs)、turn 开始前的 pre-turn 检查(session/turn.rs 第 1240-1258 行)、以及 turn 执行中采样完发现超限的 mid-turn rollover(同文件第 600-640 行)。三条路最终都汇到同一个分发器 run_auto_compact:
📄 codex-rs/core/src/session/turn.rs (第 1397-1455 行)
async fn run_auto_compact( // 自动压缩统一入口:pre-turn / mid-turn / 模型切换三处都调它 sess: &Arc<Session>, // 会话共享指针,历史、遥测、hook 全挂在上面 step_context: Arc<StepContext>, // 当前步上下文(模型 + 工具路由 + 设置快照),压缩请求按它构造 fallback_step_context: Option<Arc<StepContext>>, // 可选的"当前模型"备用上下文——上一模型压缩失败时拿它重试 client_session: &mut ModelClientSession, // 复用 turn 的连接会话,sticky routing / websocket 增量状态不丢 initial_context_injection: InitialContextInjection, // 新历史要不要重注入初始上下文(mid-turn 必须注入) reason: CompactionReason, // 触发原因:用户请求 / 窗口满 / 模型降级 / comp hash 变化 phase: CompactionPhase, // 所处阶段:独立 turn / pre-turn / mid-turn,决定错误上报时机) -> CodexResult<()> { let turn_context = &step_context.turn; // 取出 turn 级上下文读配置与 provider 能力 if turn_context.config.features.enabled(Feature::TokenBudget) { // TokenBudget 特性开启 → 压缩退化为"重置窗口" crate::compact_token_budget::run_inline_auto_compact_task( // 完全不调模型,直接装一个新上下文窗口 Arc::clone(sess), step_context, initial_context_injection).await?; return Ok(()); // 提前返回:这条路径不走任何摘要逻辑(第六节细讲) } match turn_context.provider.capabilities().remote_compaction { // 按 provider 声明的远程压缩能力三分支 RemoteCompactionSupport::V2 => { // V2:服务端直接产出结构化 Compaction item,客户端只负责组装保留历史 emit_compact_metric(&sess.services.session_telemetry, "remote_v2", /*manual*/ false); // 打点区分实现路径(自动触发 manual=false) run_inline_remote_auto_compact_task_v2( // 注意把 fallback_step_context 传进去——失败可换当前模型重试 Arc::clone(sess), step_context, fallback_step_context, client_session, initial_context_injection, reason, phase).await?; } RemoteCompactionSupport::Unsupported => { // provider 不支持远程压缩 → 本地摘要:把历史+提示词发给模型,取最后一条 assistant 消息当摘要 emit_compact_metric(&sess.services.session_telemetry, "local", /*manual*/ false); // 同样打点,遥测里能对比两条路径的成败率 run_inline_auto_compact_task( // 本地路径不需要 fallback(没有"上一模型"语义) Arc::clone(sess), Arc::clone(turn_context), initial_context_injection, reason, phase).await?; } } Ok(()) // 三条路径在此汇合:历史已替换、token 用量已重算、窗口号已推进}为什么这样设计:把"用哪条实现"的判断收敛到一个函数里,而不是散落在三个触发点——因为选择依据是 provider 能力 + feature flag,跟触发原因无关。手动和自动、pre-turn 和 mid-turn 的差异全部通过参数(reason / phase / injection)表达,分发器本身保持无状态。这样新增第四条实现路径时只改一处;而"上一模型压缩失败换当前模型重试"这种跨路径的兜底逻辑,也只需要在 V2 分支里挂一个 fallback 上下文即可。
三种触发场景(参数矩阵)
手动 /compact(用户主动)
Trigger manualPhase StandaloneTurn(独立 turn,自己 capture step)Injection DoNotInject(下一个正常 turn 会完整重注入初始上下文)入口 tasks/compact.rs → CompactTask::run
Pre-turn 自动(turn 开始前)
Trigger autoReason ContextLimit / ModelDownshift / CompHashChanged(三种原因,见第五节)Phase PreTurn(失败时错误延迟到 run_turn 上报,保住用户输入)Injection DoNotInject
Mid-turn rollover(turn 执行中)
Trigger autoReason ContextLimit(采样后 token_status.token_limit_reached,或模型请求 new_context)Phase MidTurnInjection BeforeLastUserMessage(摘要必须留在历史末尾,初始上下文插在最后一条真实用户消息之前)
二、手动入口:同一个三分支,只是参数不同
手动 /compact 走 SessionTask 体系(core/src/tasks/compact.rs,全文仅 76 行),分发逻辑和自动路径完全同构:
📄 codex-rs/core/src/tasks/compact.rs (第 36-49 行)
if ctx.config.features.enabled(Feature::TokenBudget) { // 手动压缩同样先查 TokenBudget 特性 crate::compact_token_budget::run_manual_compact_task(session, ctx).await?; // 直接重置窗口,不调模型 return Ok(None); // 提前返回:该模式下"压缩"= 换新上下文窗口}let result = match ctx.provider.capabilities().remote_compaction { // 否则按 provider 能力三分支(与自动路径一致) RemoteCompactionSupport::V2 => { // V2 远程压缩;manual=true 打点,遥测里可区分手动/自动 emit_compact_metric(&session.services.session_telemetry, "remote_v2", /*manual*/ true); crate::compact_remote_v2::run_remote_compact_task(session.clone(), ctx).await // 独立 turn:自己 capture step、发 turn_started } RemoteCompactionSupport::Unsupported => { // 本地摘要:合成一条用户消息发给模型 emit_compact_metric(&session.services.session_telemetry, "local", /*manual*/ true); let input = vec![UserInput::Text { text: ctx.config.compact_prompt.as_deref() // 允许用户在 config.toml 里覆盖摘要提示词 .unwrap_or(crate::compact::SUMMARIZATION_PROMPT).to_string(), // 默认用内置 SUMMARIZATION_PROMPT text_elements: Vec::new(), // 合成消息没有 UI 元素范围需要保留(自动路径同款注释) }]; crate::compact::run_compact_task(session.clone(), ctx, input).await // 本地摘要主流程在 core/src/compact.rs }};为什么这样设计:手动和自动共用同一套实现,差异只体现在三个元数据上——CompactionTrigger(manual/auto)、CompactionReason(UserRequested / ContextLimit / ModelDownshift / CompHashChanged,定义在 analytics/src/facts.rs 第 418-445 行)、CompactionPhase(StandaloneTurn / PreTurn / MidTurn)。这三个枚举贯穿 hook、遥测、错误上报三处,是压缩子系统的"坐标系"。
📌 细节:本地摘要路径发给模型的提示词不是"请总结对话",而是 prompts/templates/compact/summary_prefix.md 里的一段话:"Another language model started to solve this problem and produced a summary of its thinking process... Use this to build on the work that has already been done and avoid duplicating work."——它把摘要包装成"另一个模型干了一半的活",让压缩后的会话在语义上无缝续接。这个前缀同时是 is_summary_message()(compact.rs 第 593-595 行)识别"哪条是摘要、哪条是真实用户消息"的标记。
三、本地摘要路径:重试时从头部裁剪,保住前缀缓存
先看一个贯穿所有路径的枚举——初始上下文注入策略(core/src/compact.rs 第 72-78 行):
📄 codex-rs/core/src/compact.rs (第 72-78 行)
pub(crate) enum InitialContextInjection { // 控制压缩后的替换历史是否必须包含初始上下文 BeforeLastUserMessage { // mid-turn 专用:模型被训练成"摘要必须是历史最后一项",所以初始上下文要插在最后一条真实用户消息之前 world_state: Arc<WorldState>, // 携带当前世界状态快照——新窗口的 baseline 要用它重建(第 13 讲讲过 WorldState) step_context: Arc<StepContext>, // 渲染初始上下文需要完整步上下文(模型 + 设置 + 工具路由) }, DoNotInject, // pre-turn / 手动:只装摘要并清掉 reference item,下一个正常 turn 会完整重注入初始上下文}为什么这样设计:这是"模型训练分布"约束在代码里的直接体现。mid-turn 压缩发生在 turn 内部,紧接着还要继续采样——此时历史末尾必须是摘要(模型的预期),初始上下文只能往前挪一位;而 pre-turn / 手动压缩之后会开一个全新 turn,那个 turn 本来就会走完整的初始上下文注入流程,所以这里干脆不装,避免重复。同一个"压缩成功"动作,因为所处阶段不同,对历史的改写方式必须不同——枚举把这种差异显式化了。
再看本地路径的重试循环(compact.rs 第 267-348 行)。压缩请求本身也要发完整历史,所以它自己也可能撑爆窗口——这段 match 是整条路径里最见功力的部分:
📄 codex-rs/core/src/compact.rs (第 308-346 行,重试循环的两个错误分支)
Err(e) if matches!(e.details(), CodexErrorDetails::ContextWindowExceeded) => { // 压缩请求自己也超窗 → 裁剪历史重试 if turn_input_len > 1 { // 还有可裁的(至少剩一条) error!("Context window exceeded while compacting; removing oldest history item. Error: {e}"); // 记日志:正在丢最老的历史项 history.remove_first_item(); // 从头部删一条——保前缀缓存命中,同时保住最近消息完整 retries = 0; // 重置重试计数:裁剪是"换策略",不该消耗普通重试预算 continue; // 带着更短的历史重新进循环发请求 } sess.set_total_tokens_full(turn_context.as_ref()).await; // 只剩一条还超窗 → 标记窗口已满,让上层走兜底 sess.track_turn_codex_error(turn_context.as_ref(), &e); // 记错误遥测 if !matches!(compaction_metadata.phase(), CompactionPhase::PreTurn) { // pre-turn 阶段不在这报错——run_turn 会保住用户输入后统一上报 let event = EventMsg::Error(e.to_error_event(/*message_prefix*/ None)); sess.send_event(&turn_context, event).await; // 其他阶段直接把错误事件推给 UI } return Err(e);}Err(e) => { // 其余瞬时错误(网络抖动 / 5xx / 限流等) if retries < max_retries { // 还有重试预算 retries += 1; let delay = backoff(retries); // 指数退避,避免打爆服务端 sess.notify_stream_error(turn_context.as_ref(), format!("Reconnecting... {retries}/{max_retries}"), e).await; // UI 提示"重连中 x/y" tokio::time::sleep(delay).await; continue; // 同一份历史原样重试 } else { // 预算耗尽 → 上报并失败(pre-turn 同样延迟上报) sess.track_turn_codex_error(turn_context.as_ref(), &e); if !matches!(compaction_metadata.phase(), CompactionPhase::PreTurn) { let event = EventMsg::Error(e.to_error_event(/*message_prefix*/ None)); sess.send_event(&turn_context, event).await; } return Err(e); }}为什么这样设计:两个关键决策。其一,超窗时 remove_first_item() 从头部裁而不是尾部——Responses API 的 prompt cache 是前缀式的,保留头部才能命中缓存、省钱省延迟;被丢的是最老的历史项,而摘要本来就要把"重要信息"提炼出来,所以语义损失可控。其二,裁剪后 retries = 0——这是两种不同性质的失败(内容太大 vs 传输不稳)用两套预算,避免"裁了三次 + 重试三次"互相挤占。最后注意 pre-turn 分支的静默处理:pre-turn 压缩失败时用户输入还没进历史,错误必须延迟到 run_turn 统一上报,否则用户会看到"我还没说话就报错了"。
摘要拿到后,新历史的组装也有预算约束——保留的用户消息总量上限是 COMPACT_USER_MESSAGE_MAX_TOKENS = 20_000(compact.rs 第 61 行):
📄 codex-rs/core/src/compact.rs (第 684-710 行,build_compacted_history_with_limit 核心)
let mut selected_messages: Vec<CompactedUserMessage> = Vec::new(); // 将被保留进新历史的用户消息if max_tokens > 0 { // 预算即 COMPACT_USER_MESSAGE_MAX_TOKENS(20k) let mut remaining = max_tokens; // 剩余 token 预算,从最新消息往回扣 for message in user_messages.iter().rev() { // 倒序遍历:优先保留离摘要最近的用户消息 if remaining == 0 { break; } // 预算耗尽 → 更老的消息全部丢弃(信息已在摘要里) let tokens = approx_token_count(&message.message); // 粗略 token 估算,不追求精确 if tokens <= remaining { // 整条放得下 selected_messages.push(message.clone()); remaining = remaining.saturating_sub(tokens); // 扣减;saturating 防下溢(i64 不会变负) } else { // 这条太大 → 截断后保留,且只保留"尾部"(离摘要最近的部分) let truncated = truncate_text(&message.message, TruncationPolicy::Tokens(remaining)); selected_messages.push(CompactedUserMessage { id: message.id.clone(), message: truncated, .. }); // id 保留——回滚时能关联 thread-owned 证据 break; // 截断一条后预算必然耗尽,直接结束 } } selected_messages.reverse(); // 恢复时间顺序再写入历史(模型看到的仍是"旧→新")}为什么这样设计:"从新到旧保留、超预算截断尾部"这条规则背后是一个假设:摘要已经承载了全局信息,逐条用户消息只是"锚点"——越近的锚点对续接工作越有用。20k 的预算给的是"最近意图"而不是"完整记录";而 id 字段刻意保留(第 531-538 行注释写明:即使文本被截短也要保住来源身份),是为了回滚 / fork 时能把重建的消息和 thread-owned 的保留证据对上号——压缩可以丢内容,不能丢"这条消息是谁说的、对应哪次操作"。
四、远程 V2 路径:服务端产出摘要,客户端只装"保留件"
V2 路径把"生成摘要"这件事外包给服务端压缩服务。客户端侧的尝试逻辑在 core/src/compact_remote_v2_attempt.rs(全文 133 行),请求构造是核心:
📄 codex-rs/core/src/compact_remote_v2_attempt.rs (第 40-85 行)
let mut history = sess.clone_history().await; // COW 克隆当前历史——压缩过程不碰活状态,失败可整体丢弃let base_instructions = sess.get_prompt_base_instructions().await; // system instructions 参与 token 估算(它也在窗口里)let (rewritten_outputs, estimated_deleted_tokens) = trim_function_call_history_to_fit_context_window( // 预裁剪:把超大的工具输出改写/删掉,让请求先塞进窗口 &mut history, turn_context.as_ref(), &base_instructions);if rewritten_outputs > 0 { info!(rewritten_outputs, "rewrote history outputs before remote compaction v2"); } // 有裁剪就记日志(遥测里能算出删了多少 token)input.push(ResponseItem::CompactionTrigger {}); // 关键:在输入末尾追加 CompactionTrigger 标记——这就是 V2 协议的"压缩请求"信号let prompt = Prompt { input, // 完整历史(预裁剪后)作为输入,服务端据此生成摘要 + 决定保留什么 tools: tool_router.model_visible_specs(), // 带上可见工具规格:服务端需要知道历史里那些 function call 是什么 parallel_tool_calls: true, // 与正常 turn 请求形状保持一致,复用同一套流式管线 base_instructions, // system instructions output_schema: None, // 不约束结构化输出——靠 Compaction item 类型本身承载结果 output_schema_strict: true, // schema 为 None 时此标志无实际作用(保持字段完整) cyber_access_program: turn_context.cyber_access_program, // 透传访问计划标记};为什么这样设计:V2 与本地路径的本质区别是"摘要的产出方":本地是让通用模型按提示词写一段自然语言;V2 是在请求里放一个 CompactionTrigger 标记,由服务端压缩服务直接产出结构化的 ResponseItem::Compaction item。好处是摘要质量与格式都由服务端保证,客户端不再需要"猜最后一条 assistant 消息是不是摘要";代价是对协议契约的要求变硬——下面这段校验就是为此存在的。
📄 codex-rs/core/src/compact_remote_v2.rs (第 429-471 行,collect_compaction_output)
while let Some(event) = stream.next().await { // 消费响应流直到 Completed match event? { ResponseEvent::OutputItemDone(item) => { // 每个输出 item 完成事件 output_item_count += 1; // 统计总输出数(出错时用于诊断"到底吐了什么") if let ResponseItem::Compaction { .. } = item { // 只关心 Compaction 类型的 item compaction_count += 1; // 数出现了几次 if compaction_output.is_none() { // 第一个作为候选结果收下(后面还有就违反契约了) compaction_output = Some(item); } } } ResponseEvent::Completed { response_id, token_usage, .. } => { // 流结束事件,携带响应 id 与用量 completed_response_id = Some(response_id); // 存下来写进 CompactedItem 检查点(可回溯服务端原始请求) completed_token_usage = token_usage; // 摘要 token / 缓存命中数都从这里取,喂给分析事件 break; // Completed 之后不再消费 } _ => {} // 其余事件(reasoning、rate limit 等)在此路径忽略 }}if compaction_count != 1 { // 协议硬契约:必须恰好一个 Compaction item return Err(CodexErr::Fatal(format!( "remote compaction v2 expected exactly one compaction output item, got {compaction_count} from {output_item_count} output items"))); // 多一个少一个都是 Fatal——宁可失败,也不能把错误历史装进会话}为什么这样设计:"恰好一个"是 fail-loud 的典型:压缩结果会整体替换会话历史,装错一次就是永久污染(用户回滚都救不回来),所以校验必须比正常 turn 严格得多——正常 turn 多吐几条消息无所谓,这里不行。用 Fatal 而不是可重试错误,也是同理:服务端返回了"结构不对的压缩结果",重发大概率还是错的。
拿到摘要后,客户端组装新历史——保留哪些、留多少预算,都在 build_v2_compacted_history(compact_remote_v2.rs 第 483-510 行):
📄 codex-rs/core/src/compact_remote_v2.rs (第 73-77 行常量 + 第 496-509 行组装)
pub(crate) const RETAINED_MESSAGE_TOKEN_BUDGET: usize = 64_000; // V2 保留消息总预算:64k tokenconst MAX_RETAINED_AGENT_MESSAGE_TOKENS: i64 = 10_000; // 单条 agent 消息上限:10k(防止一条巨型回复吃掉全部预算)// Compact attempts can run much longer than normal turns, so keep the per-transport retry budget smaller.const MAX_REMOTE_COMPACTION_V2_STREAM_RETRIES: u64 = 2; // V2 流重试只给 2 次——压缩请求耗时远超普通 turn,重试要更克制let retained = v2_history_item_groups(prompt_input) // 把原始输入按来源(用户消息 / 工具调用组等)聚成"保留单元" .filter(|group| is_retained_for_remote_compaction_v2(&group.source, retain_client_developer_messages)) // 过滤:哪些来源值得跨压缩存活(客户端 developer 消息是否保留由 feature 决定) .flat_map(HistoryItemGroup::into_items) // 展平回 item 列表 .collect::<Vec<_>>();let mut retained = truncate_retained_messages(retained, RETAINED_MESSAGE_TOKEN_BUDGET, image_budget); // 64k 总预算内从新到旧截断;图片走独立预算(RetainedImageBudget)let retained_image_count = retained.iter().map(|envelope| retained_input_image_count(&envelope.item)).sum::<usize>(); // 统计存活图片数 → 分析事件retained.push(ResponseItemEnvelope::new(compaction_output)); // 最后追加服务端 Compaction 摘要——模型被训练成"它必须是最后一项"为什么这样设计:V2 的保留策略比本地路径更结构化:先按"来源组"过滤(哪些东西天生值得跨压缩存活),再套 64k 总预算 + 单条 10k 上限 + 图片独立预算——三层约束分别对应"语义价值 / 总量控制 / 多模态成本"。而摘要永远 push 到末尾,与本地路径的 CompactionSummary 位置约定一致:无论哪条实现,模型看到的压缩后历史都是"保留件在前、摘要殿后"。
| 摘要产出方 | ||
| 保留内容预算 | ||
| 输出校验 | ||
| 重试策略 |
五、模型降级重试:上一模型的压缩,可以换当前模型再跑一次
pre-turn 有两个特殊触发原因:CompHashChanged(新旧模型的压缩兼容 hash 不同)和 ModelDownshift(切到更小窗口的模型,旧历史装不下)。这两种情况 Codex 会用上一模型的上下文去压缩(因为摘要要按那个模型的分布生成),见 session/turn.rs 第 1299-1390 行的 maybe_run_previous_model_inline_compact。但上一模型可能已经下线、限流或窗口更小——所以调用时都带了一个 fallback:用当前模型的 step context 再试一次。哪些错误值得换模型重试,由这个白名单决定:
📄 codex-rs/core/src/compact_model_fallback.rs (第 9-20 行)
pub(crate) fn should_retry_with_current_model(error: &CodexErr) -> bool { // 判断失败是否"模型相关"、值得换当前模型重试 matches!(error.details(), CodexErrorDetails::InvalidRequest(_) | // 请求被拒(如旧模型不接受这种历史形状)——换个模型可能就接受 CodexErrorDetails::UnexpectedStatus(_) | // 意外 HTTP 状态码 CodexErrorDetails::ContextWindowExceeded | // 窗口超限——当前模型窗口可能更大,或裁剪后能装下 CodexErrorDetails::UsageLimitReached(_) | // 该模型的配额用尽——换模型绕开 CodexErrorDetails::ServerOverloaded | // 服务端过载(可能是该模型专属的拥塞) CodexErrorDetails::InternalServerError | // 5xx CodexErrorDetails::RetryLimit(_)) // 重试预算耗尽——以上全是"换个模型可能成功"的错误}为什么这样设计:白名单只收"与具体模型绑定"的失败——配额、窗口、过载都可能是某个模型的局部问题,换模型有真实收益;而像 TurnAborted(用户主动取消)这类错误不在名单里,因为换模型重试只会浪费一次请求、拖慢响应。调用点在 compact_remote_v2.rs 第 246-287 行:attempt 失败 → 有 fallback context 且命中白名单 → set_last_known_step_context 切换上下文再跑一次 attempt,无论成败都打点。
📄 codex-rs/core/src/compact_model_fallback.rs (第 40-53 行,record_model_fallback)
let outcome = if fallback_error.is_none() { "succeeded" } else { "failed" }; // 兜底尝试本身成功还是失败session_telemetry.counter( "codex.compaction.model_fallback", // 模型降级事件专用计数器——线上能直接看到"多少压缩靠换模型救回来" /*inc*/ 1, &[("reason", reason_tag), // 为什么压缩:user_requested / context_limit / model_downshift / comp_hash_changed ("implementation", implementation_tag), // 哪条实现:responses / responses_compaction_v2 ("outcome", outcome)], // succeeded / failed——三个维度交叉,能定位"哪种原因下降级最有效");为什么这样设计:降级重试是"静默兜底"——用户视角只是压缩慢了一点,但运维视角必须能回答三个问题:多频繁(counter)、什么场景触发(reason × implementation 交叉维度)、救回来的比例(outcome)。这三个标签正是为这三个问题设计的;同时 warn! 日志里带上 previous/current 模型名,方便把某次线上事故直接对到具体模型切换。
六、TokenBudget 路径:不调模型的"压缩"
最特殊的一条路径是 core/src/compact_token_budget.rs(全文 84 行):开启 TokenBudget feature 后,"压缩"根本不调模型——直接装一个新上下文窗口。但它仍然走完整的压缩生命周期:
📄 codex-rs/core/src/compact_token_budget.rs (第 57-84 行,run_compact_task_inner)
async fn run_compact_task_inner( sess: &Arc<Session>, step_context: &Arc<StepContext>, world_state: Arc<WorldState>, trigger: CompactionTrigger,) -> CodexResult<()> { let turn_context = &step_context.turn; // 取 turn 上下文(hook 需要) let pre_compact_outcome = run_pre_compact_hooks(sess, turn_context, trigger).await; // PreCompact hook 照常跑——用户脚本可以否决这次压缩 match pre_compact_outcome { PreCompactHookOutcome::Continue => {} // 放行 PreCompactHookOutcome::Stopped => return Err(CodexErr::TurnAborted), // hook 说停 → 整个 turn 中止(fail loud) } let compaction_item = TurnItem::ContextCompaction(ContextCompactionItem::new()); // 发 ContextCompaction item——UI / rollout 看到的生命周期与其他路径完全一致 sess.emit_turn_item_started(turn_context, &compaction_item).await; sess.start_new_context_window(step_context, world_state) // 核心动作:装新上下文窗口(零模型调用、零摘要) .await; sess.emit_turn_item_completed(turn_context, compaction_item).await; let post_compact_outcome = run_post_compact_hooks(sess, turn_context, trigger).await; // PostCompact hook 同样可以否决 if let PostCompactHookOutcome::Stopped = post_compact_outcome { return Err(CodexErr::TurnAborted); } Ok(())}为什么这样设计:文件头注释(第 19-23 行)说得很直白:token-budget 压缩"跳过模型/服务端摘要,直接装新窗口",但仍然建模为一次压缩——因为 hook 生态和 ContextCompaction turn item 是按"压缩生命周期"订阅的。如果这条路径绕过生命周期,用户的 PreCompact/PostCompact 脚本、UI 的压缩指示器、rollout 的检查点就会在三种实现之间出现行为分叉。用同一套外壳包住完全不同的内核(摘要 vs 重置),是扩展性最便宜的写法。
七、窗口记账:AutoCompactWindow 的链式 ID
每次压缩成功都会"开一个新窗口",窗口的编号与身份管理在 core/src/state/auto_compact_window.rs(第 34-93 行):
📄 codex-rs/core/src/state/auto_compact_window.rs (第 77-93、108-130 行)
pub(super) fn advance(&mut self) -> (u64, AutoCompactWindowIds) { // 压缩成功 → 窗口号 +1,签发新窗口身份 self.window_number = self.window_number.saturating_add(1); // saturating:防溢出(理论上不会发生,但记账代码不留隐患) self.ids.previous_window_id = Some(self.ids.window_id); // 链式结构:旧窗口变成"上一个"——resume / fork 回滚靠这条链定位 self.ids.window_id = Uuid::now_v7(); // UUIDv7 时间有序:跨机器、跨进程都能按 id 排序,不依赖本地时钟单调性 self.new_context_window_requested = false; // 清掉"请求新窗口"的挂起标志(模型通过 new_context 工具请求的那种) self.token_budget_reminder_delivered = false; // 每个窗口有且仅有一次"预算告警"机会——one-shot claim self.auto_compact_fallback_delivered = false; // fallback 提示同理,每窗口一次,防止刷屏 (self.window_number, self.ids) // 返回给调用方写进 CompactedItem 检查点(第八节)}pub(super) fn ensure_server_observed_prefill_from_usage(&mut self, usage: &TokenUsage) { // 用服务端首个用量样本记录"窗口基线" if matches!(self.prefill_input_tokens, Some(AutoCompactWindowPrefill::ServerObserved(_))) { return; } // 已有实测值 → 绝不用估算覆盖(实测优先) self.prefill_input_tokens = Some(AutoCompactWindowPrefill::ServerObserved(usage.input_tokens.max(0))); // input tokens 即本窗口的"前缀基线",后续增量 = 总用量 - 基线}pub(super) fn set_estimated_prefill(&mut self, tokens: i64) { // resume / recompute 场景还没有服务端样本 → 先用本地估算占位 if matches!(self.prefill_input_tokens, Some(AutoCompactWindowPrefill::ServerObserved(_))) { return; } // 实测值优先级更高,估算不能顶掉它 self.prefill_input_tokens = Some(AutoCompactWindowPrefill::Estimated(tokens.max(0))); // 打上 Estimated 标记——之后拿到实测样本会被替换为什么这样设计:三个细节值得注意。其一,窗口 ID 用 UUIDv7(时间有序)而不是随机 v4——压缩检查点会持久化到 rollout、跨进程 resume,需要全局可排序的身份;first_window_id / previous_window_id / window_id 三元组构成一条链,回滚到"第 N 次压缩之前"就是沿链走。其二,两个 delivered 标志用 std::mem::replace 做原子 claim(第 87-93 行),保证"每窗口一次提示"在并发下也成立。其三,prefill 基线区分 ServerObserved / Estimated 两个变体且实测优先——自动压缩的触发阈值是"本窗口增量 ≥ 预算",基线算错就会误触发或漏触发,所以这里宁可保守(等实测)也不乱猜。
八、检查点持久化:CompactedItem 里装了什么
三条路径最终都调用 Session::replace_compacted_history(core/src/session/mod.rs 第 3933-4000 行)落盘。它构造的 CompactedItem 是 resume / fork 的全部依据:
📄 codex-rs/core/src/session/mod.rs (第 3956-3971 行)
let mut compacted_item = CompactedItem { // 压缩检查点——持久化进 rollout,resume / fork 全靠它 message: metadata.message, // 本地路径存摘要文本;远程 V2 为空串(摘要在 Compaction item 里) replacement_history: Some(items.clone()), // 压缩后的完整替换历史——live 与 persisted 必须逐字节一致(前面已补齐缺失 id) retained_context: None, // 稍后填入当前"保留上下文"快照:用户授权过的事实跨压缩存活(第 13 讲的核心机制) guardian_history: None, // Guardian thread-owned 模式下的独立历史检查点 mcp_resource_origins: self.services.mcp_runtime.resource_origin_checkpoint(), // MCP 资源来源检查点——resume 后能校验资源出处没变 window_number: Some(metadata.window_number), // 这是第几次压缩(0-based) first_window_id: Some(metadata.window_ids.first_window_id.to_string()), // 线程首个窗口 id,跨所有窗口不变 previous_window_id: metadata.window_ids.previous_window_id.map(|id| id.to_string()), // 上一个窗口 → 与 first/current 组成回滚链 window_id: Some(metadata.window_ids.window_id.to_string()), // 当前窗口 id(UUIDv7) compaction_response_id: metadata.compaction_response_id, // 服务端响应 id——出事故可回溯到那次压缩的原始请求 latest_token_usage_record: self.state.lock().await.latest_token_usage_record.clone(), // 压缩时刻的 token 用量快照(审计基线)};为什么这样设计:检查点里同时装了"内容"(replacement_history)、"身份"(三个窗口 id + response_id)和"环境"(retained_context / mcp origins / token 快照)——因为 resume 要恢复的不只是对话,还有授权状态、资源出处和用量基线。注意第 3974-3998 行的顺序约束:先拿 acquire_persistence_lock 挡住后续设置更新,再替换历史、最后把 WorldState baseline 作为独立 item 排在替换历史之后持久化——"baseline 必须晚于建立它的那段历史落盘",否则 resume 时会出现基线指向不存在历史的悬空状态。这正是第 13 讲"压缩后模型还记得你批准过什么"的底层保证:retained_context 在 replace_annotated_history 时从活状态里 checkpoint 出来,跟着检查点一起穿越压缩。
九、数据流:一次 mid-turn 自动压缩的完整路径
Mid-turn 自动压缩(V2 路径)
① 触发判定:context_window_token_status
采样后算 token 状态;token_limit_reached 或模型请求 new_context → should_roll_over(turn.rs 第 600-640 行)。
▼
② 分发:run_auto_compact 三分支
🔹 TokenBudget → start_new_context_window(零模型调用)🔹 V2 → run_inline_remote_auto_compact_task_v2🔹 Unsupported → 本地摘要 run_inline_auto_compact_task
▼
③ 请求构造:trim + CompactionTrigger
COW 克隆历史 → trim_function_call_history 预裁剪 → 末尾追加 CompactionTrigger 标记(attempt.rs)。
▼
④ 流收集:恰好一个 Compaction item
collect_compaction_output 校验 count==1,否则 Fatal;失败且命中白名单 → 换当前模型重试一次。
▼
⑤ 历史组装:保留件 + 摘要殿后
按来源过滤保留组 → 64k/10k/图片三层预算截断 → Compaction 摘要 push 到末尾;mid-turn 再插初始上下文。
▼
⑥ 窗口推进:AutoCompactWindow.advance
window_number+1,签发新 UUIDv7 窗口 id,previous 入链;one-shot 提示标志复位。
▼
⑦ 落盘替换:replace_compacted_history
🔹 CompactedItem 检查点(历史 + 窗口链 + retained_context + MCP origins)🔹 WorldState baseline 排在替换历史之后持久化 → recompute_token_usage → turn 循环继续
回头看,Codex 的压缩子系统把"丢什么、留什么、怎么记"拆成了正交的四层:触发层(reason/phase 枚举)、实现层(三条路径 + 降级重试)、预算层(20k / 64k / 10k / 图片独立)、记账层(窗口链 + 检查点)。每一层都可以单独演进——比如 TokenBudget 特性就是只换内核、不动外壳。下一讲进入工具层:Tool Registry 与 Spec Plan,看 Codex 怎么把"模型能调什么工具"这件事也做成可配置、可裁剪的。
📚 系列导航
← 第 13 讲:Context Manager 与历史管理
→ 第 15 讲:Tool Registry 与 Spec Plan
关注公众号「AI技术推荐官」获取更多源码解析内容