Agent X-Ray
NotesSkillsAbout
Notes/源码拆解/Codex Harness/第7章

第7章:上下文压缩 —— 本地与远端两条路

10 分钟 · 更新于 2026-09-01

第7章:上下文压缩 —— 本地与远端两条路

Codex 的压缩子系统有 4039 行、四种实现、四种触发原因、三个触发时机 [源码,实测]。本章解释这个复杂度从哪来——答案是:当压缩可以放到服务端做时,客户端就必须同时保留本地那一套,因为服务端不一定支持。


一、四种实现共存

run_auto_compact 是所有自动压缩的入口,它的主体是一个四路分发 [源码 core/src/session/turn.rs:1178]:

rust
if turn_context.config.features.enabled(Feature::TokenBudget) {
    // Compaction is the reset request, so force a new context window
    // instead of consuming a pending `new_context` tool request.
    crate::compact_token_budget::run_inline_auto_compact_task(…).await?;
    return Ok(());
}

match turn_context.provider.capabilities().remote_compaction {
    RemoteCompactionSupport::V2 if features.enabled(Feature::RemoteCompactionV2) => {
        emit_compact_metric(…, "remote_v2", …);
        run_inline_remote_auto_compact_task_v2(…).await?;      // ① 远端 v2
    }
    RemoteCompactionSupport::V2 => {
        emit_compact_metric(…, "remote", …);
        run_inline_remote_auto_compact_task(…).await?;         // ② 远端 v1
    }
    RemoteCompactionSupport::Unsupported => {
        emit_compact_metric(…, "local", …);
        run_inline_auto_compact_task(…).await?;                // ③ 本地
    }
}

加上开头那条 token budget 分支,一共四条路:

#实现条件谁在干活
远端 v2provider 支持 V2 特性开关打开服务端
远端 v1provider 支持 V2 但开关没开服务端
本地provider 不支持远端压缩客户端调模型做摘要
token budgetFeature::TokenBudget 打开(优先于以上全部)另一套预算机制

每条路都上报不同的指标名remote_v2 / remote / local)——他们在线上比较这几套的效果。

为什么必须留着本地实现 因为 RemoteCompactionSupport::Unsupported 这个分支真实存在:自建端点、本地 Ollama、企业代理都不会实现 OpenAI 的服务端压缩。

这就是第 5 章那个取舍的账单另一面:砍掉 Chat Completions 换来了服务端压缩,但服务端压缩不是所有 provider 都有,所以本地那套还得留着。 能力越强的路径,兼容面越窄。


二、本地压缩:一句提示词 + 一条前缀

本地实现最简单,也最能说明压缩的本质。

2.1 摘要提示词

全文只有 8 行 [源码 prompts/templates/compact/prompt.md]:

You are performing a CONTEXT CHECKPOINT COMPACTION. Create a handoff summary for another LLM that will resume the task.

Include:

  • Current progress and key decisions made
  • Important context, constraints, or user preferences
  • What remains to be done (clear next steps)
  • Any critical data, examples, or references needed to continue

Be concise, structured, and focused on helping the next LLM seamlessly continue the work.

(你正在执行一次上下文检查点压缩。为将要接手此任务的另一个 LLM 创建一份交接摘要。包括:当前进度与关键决策、重要的上下文/约束/用户偏好、剩余待办(清晰的下一步)、继续工作所需的关键数据、示例或引用。要简洁、结构化,聚焦于帮助下一个 LLM 无缝接续工作。)

关键在框架:"handoff summary for another LLM"(给另一个 LLM 的交接摘要),而不是"总结一下对话"。这个措辞把模型从"复述历史"引导到"传递工作状态"。

2.2 摘要前缀

摘要生成后不是直接塞回历史,而是加一段前缀 [源码 prompts/templates/compact/summary_prefix.md]:

Another language model started to solve this problem and produced a summary of its thinking process. You also have access to the state of the tools that were used by that language model. Use this to build on the work that has already been done and avoid duplicating work.

(另一个语言模型开始解决这个问题,并产出了它思考过程的摘要。你也可以访问那个模型使用过的工具的状态。用它来在已完成的工作之上继续,避免重复劳动。)

这两段配合,构成了一个完整的角色叙事:压缩时"你在给别人写交接",压缩后"这是别人给你的交接"。

可迁移的判断 ⑪ 把压缩写成"交接"而不是"总结",并且在两端都给出一致的叙事。

这一招几乎零成本,效果差异却很明显。"总结对话"会让模型产出一篇给人看的综述;"给接手的模型写交接"会让它产出待办、约束和关键数据。

前缀那句 "avoid duplicating work"(避免重复劳动)尤其重要——压缩后最常见的失败模式就是模型把已经做过的事又做一遍。

2.3 用户消息被截断

rust
const COMPACT_USER_MESSAGE_MAX_TOKENS: usize = 20_000;

压缩过程中保留的用户消息有 2 万 token 上限。用户可能粘贴了一整个日志文件进来,那种内容不该在压缩后还原样保留。


三、远端压缩:把历史交给服务端

3.1 一个哨兵条目

远端 v2 的请求构造是这样的 [源码 core/src/compact_remote_v2_attempt.rs:71]:

rust
let (mut input, prompt_input_metadata) = history
    .for_prompt_annotated(&turn_context.model_info.input_modalities)…;
let tool_router = &step_context.tool_router;
input.push(ResponseItem::CompactionTrigger {});          // ← 就是这一行
let prompt = Prompt {
    input,
    tools: tool_router.model_visible_specs(),
    parallel_tool_calls: true,
    base_instructions,
    output_schema: None,
    output_schema_strict: true,
};

把完整历史发过去,末尾加一个空的 CompactionTrigger 条目,服务端看到它就执行压缩。

这个设计很干净:不需要新端点、不需要新参数,复用现有的 Responses 请求路径,靠一个特殊条目切换语义。

它也解释了第 5 章的那个取舍值不值——服务端压缩能看到客户端看不到的东西:完整的推理链(reasoning tokens 在服务端保存,客户端拿到的是摘要)、缓存状态、模型内部的注意力分布。客户端做摘要只能基于自己能看到的文本。

3.2 压缩前先修剪

远端压缩之前有一步本地预处理 [源码 core/src/compact_remote_v2_attempt.rs:42]:

rust
let (rewritten_outputs, estimated_deleted_tokens) =
    trim_function_call_history_to_fit_context_window(&mut history, turn_context.as_ref(), &base_instructions);

问题很实际:要压缩是因为历史太长了,但发起压缩本身也要把历史发过去。 如果历史已经超过上下文窗口,这个请求本身就会失败。

所以先把工具调用的输出重写掉,腾出空间。被重写的输出会替换成一句话 [源码 core/src/compact_remote.rs:49]:

rust
const CONTEXT_WINDOW_TRUNCATED_OUTPUT_MESSAGE: &str =
    "Output exceeded the available model context and was truncated";

这段代码里还有一个精细的数值处理 [源码 compact_remote.rs:407]:

rust
// Keep the unclamped total so replacing an item cannot lose an overflow hidden by i64
// saturation in the normal history estimator.
let base_tokens = i128::try_from(approx_token_count(&base_instructions.text)).unwrap_or(i128::MAX);

i128 而不是 i64 做中间计算——因为正常的历史估算器会做 i64 饱和,饱和之后就看不出"到底超了多少",也就无法判断替换一个条目够不够。这种精度问题只有在真实超限场景里才会暴露。

3.3 哪些条目在压缩后保留

should_keep_compacted_history_item 是一张明确的白名单 [源码 core/src/compact_remote.rs:370]:

条目类型保留?说明
Message{role: "developer"}系统注入,压缩后重新生成
Message{role: "user"}有条件只有 UserMessageHookPrompt 保留
Message{role: "assistant"} / AgentMessage助手的话保留
Compaction / ContextCompaction之前的压缩检查点保留
CompactionTrigger哨兵不入历史
Reasoning推理链丢弃
FunctionCall / FunctionCallOutput / CustomToolCall* / ToolSearch*工具调用与输出全丢
WebSearchCall / ImageGenerationCall / LocalShellCall同上
AdditionalTools / Other——

核心规则一句话:留下"谁说了什么",丢掉"怎么做的"。

那一行有条件的 user 判断值得单独看:

rust
ResponseItem::Message { role, .. } if role == "user" => {
    matches!(parse_turn_item(item), Some(TurnItem::UserMessage(_) | TurnItem::HookPrompt(_)))
}

因为第 6 章讲过——AGENTS.md 指令、世界状态、权限说明这些系统注入的内容 role 也是 "user"。不能因为 role 是 user 就一律保留,得看它解析出来到底是不是真的用户消息。这就是第 6 章那套 markers 机制的实际用途。


四、四种原因、三个时机

4.1 原因

[源码 analytics/src/facts.rs:406]

rust
pub enum CompactionReason {
    UserRequested,      // 用户敲了 /compact
    ContextLimit,       // 上下文要满了
    ModelDownshift,     // 切到了更小上下文的模型
    CompHashChanged,    // 模型的压缩兼容标识变了
}

后两条很有意思,都是模型切换引发的:

  • ModelDownshift:从 372000 窗口的模型切到 272000 的,历史可能当场就超限了
  • CompHashChanged:第 5 章讲过 comp_hash 是"压缩兼容的模型配置的不透明标识"。它变了意味着之前那次压缩的产物对新模型不再有效,得重压

comp_hash 的检查就在主循环里 [源码 core/src/session/turn.rs:1045]:

rust
fn comp_hash_changed(previous: Option<&str>, current: Option<&str>) -> bool

配合 maybe_run_previous_model_inline_compact——用旧模型压完,再切到新模型。因为压缩产物要和产生它的模型匹配。

4.2 时机

[源码 analytics/src/facts.rs:423]

rust
pub enum CompactionPhase {
    StandaloneTurn,   // 独立的压缩任务(第 4 章的 CompactTask)
    PreTurn,          // 轮次开始前
    MidTurn,          // 轮次中间
}

三个时机对应三处调用(第 4 章列过):入场时的 run_pre_sampling_compact、循环里的 run_auto_compact、以及独立的 CompactTask

4.3 时机决定了历史怎么重建

这是压缩里最微妙的一处,注释写得非常清楚 [源码 core/src/compact.rs:63]:

rust
/// Controls whether compaction replacement history must include initial context.
///
/// Pre-turn/manual compaction variants use `DoNotInject`: they replace history with a summary and
/// clear `reference_context_item`, so the next regular turn will fully reinject initial context
/// after compaction.
///
/// Mid-turn compaction must use `BeforeLastUserMessage` because the model is trained to see the
/// compaction summary as the last item in history after mid-turn compaction; we therefore inject
/// initial context into the replacement history just above the last real user message.
pub(crate) enum InitialContextInjection {
    BeforeLastUserMessage { world_state: Arc<WorldState>, step_context: Arc<StepContext> },
    DoNotInject,
}

翻译一下这个约束:

  • 轮前 / 手动压缩DoNotInject。压完历史就是一份摘要,下一轮开始时会重新注入完整的初始上下文
  • 轮中压缩BeforeLastUserMessage因为模型被训练成"轮中压缩后,摘要应该是历史里的最后一条",所以初始上下文必须插在最后一条真实用户消息之上,不能盖在摘要后面

注意那句 "the model is trained to see"。这不是架构偏好,这是模型训练数据决定的硬约束——客户端必须迁就模型的训练分布。

可迁移的判断 ⑫ 当你的模型是自家训练的,harness 的某些设计约束会来自训练数据而非工程考量;这类约束必须写成注释,否则下一个人一定会"顺手优化掉"。

Codex 这条注释就是范例:它没有说"这样比较好",它说的是"模型被训练成这样看"。


五、压缩的可观测性

压缩是最容易出问题、又最难发现问题的环节(压坏了要几轮之后才看得出来)。Codex 为此做了三件事:

5.1 完整的分析记录

[源码 core/src/compact.rs:414]

rust
pub(crate) struct CompactionAnalyticsDetails {
    pub(crate) active_context_tokens_before: Option<i64>,
    pub(crate) retained_image_count: Option<usize>,
    pub(crate) compaction_summary_tokens: Option<i64>,
    pub(crate) cached_input_tokens: Option<i64>,
    pub(crate) cache_write_input_tokens: Option<i64>,
}

压缩前多少 token、保留了几张图、摘要多少 token、缓存命中和写入各多少。有了这几个数就能算出压缩比和缓存代价。

5.2 压缩追踪

rollout-trace crate(13259 行)里有 CompactionCheckpointTracePayload。远端 v2 里可以开启 trace,把压缩的输入和输出原样存下来 [源码 compact_remote_v2_attempt.rs:68]:

rust
let trace_input_history = compaction_trace.is_enabled()
    .then(|| history.raw_items().cloned().collect());

默认关,出问题时开。 因为存完整历史很贵。

5.3 压缩前后的钩子

run_pre_compact_hooks / run_post_compact_hooks——用户可以在压缩前后插入自己的逻辑(第 13 章)。这对"压缩前把重要东西存到外部记忆"这类需求是刚需。


六、Token 预算:第四条路

Feature::TokenBudget 开启时,压缩走完全不同的路径 [源码 core/src/session/token_budget.rscore/src/compact_token_budget.rs]。

它的默认值也来自模型元数据(第 5 章的 model_messages.token_budget)[源码 session/token_budget.rs:31]:

rust
pub(super) fn apply_model_defaults(config: &mut Config, model_info: &ModelInfo) {
    if !config.features.enabled(Feature::TokenBudget) || has_explicit_settings(config) {
        return;
    }
    let Some(model_defaults) = model_info.model_messages.as_ref()
        .and_then(|messages| messages.token_budget.as_ref()) else { return };
    …
}

注意 has_explicit_settings 这道闸:用户显式配过就不套模型默认值。这是配置系统里很容易做错的一件事——远程下发的默认值不该覆盖用户的明确选择。

run_auto_compact 里那句注释也点出了它和普通压缩的语义差别:

Compaction is the reset request, so force a new context window instead of consuming a pending new_context tool request.

(压缩本身就是重置请求,所以强制开一个新的上下文窗口,而不是消耗掉一个待处理的 new_context 工具请求。)

在 token budget 模式下,压缩 = 开新窗口,而不是"把旧窗口的内容摘要一下"。这是一个不同的心智模型:不再假装是一条连续的对话,而是明确地分窗口。

CompactedHistoryMetadata 里的 window_numberwindow_ids 就是为此存在的 [源码 core/src/compact.rs:85]。


七、和另外两个 harness 的对照

维度PidshCodex
压缩实现数11(可替换 seam)4
压缩位置客户端客户端客户端 + 服务端
触发原因上下文满上下文满4 种(含模型切换引发的 2 种)
触发时机循环中循环中3 种(轮前 / 轮中 / 独立任务)
保留规则简单可配置逐条目类型的白名单
压缩产物与模型绑定comp_hash,模型换了就重压

Pi 教程第 9 章讲的压缩是一个完整、自洽、可以读完的实现。Codex 的压缩是一个在真实约束下反复妥协的产物:服务端能力更强但不通用、模型训练分布决定了摘要的位置、模型切换会让旧摘要失效、发起压缩本身可能就超限。

这四条约束没有一条是"设计"出来的,全是撞出来的。 这也是读产品级代码相对于读框架代码的价值。


八、动手复核

bash
cd codex/codex-rs

# 1. 四路分发
sed -n '1178,1258p' core/src/session/turn.rs

# 2. 摘要提示词与前缀(全文很短)
cat prompts/templates/compact/prompt.md
cat prompts/templates/compact/summary_prefix.md

# 3. 压缩后保留哪些条目
sed -n '370,398p' core/src/compact_remote.rs

# 4. 远端 v2 的哨兵条目
grep -n 'CompactionTrigger' core/src/compact_remote_v2_attempt.rs

# 5. 四种原因、三个时机
sed -n '396,430p' analytics/src/facts.rs

# 6. 初始上下文注入的两种策略与那段关键注释
sed -n '63,80p' core/src/compact.rs

# 7. 压缩子系统总规模
wc -l core/src/compact*.rs | tail -1

实际观察一次压缩:

bash
# 开一个长会话,然后手动触发
codex
# 会话里输入 /compact

九、总结

  1. 四种实现共存:远端 v2、远端 v1、本地、token budget。留着本地那套是因为自建端点不支持服务端压缩——能力越强的路径兼容面越窄
  2. 远端压缩靠一个哨兵条目 CompactionTrigger 切换语义,复用现有请求路径,不需要新端点
  3. 压缩前要先修剪——发起压缩本身也要把历史发过去,历史已经超限时得先重写工具输出腾空间
  4. 保留规则是"留下谁说了什么,丢掉怎么做的",且不能只看 role——系统注入的内容 role 也是 user
  5. 四种触发原因里有两种来自模型切换ModelDownshift / CompHashChanged),压缩产物和产生它的模型是绑定的
  6. 轮中压缩的摘要必须是历史最后一条——这是模型训练分布决定的硬约束,不是工程偏好
  7. 把压缩写成"交接"而不是"总结",并在压缩前后给出一致叙事,这是零成本的提示词改进

下一章回到工具:注册、路由、并行、以及"工具太多喂不下"这个新问题。


  • 第6章-上下文工程-指令到底从哪来
  • 第8章-工具系统-注册路由与延迟加载
  • 第5章-模型调用-只说一种协议的客户端 —— 服务端压缩是砍掉兼容性换来的能力
  • Pi 教程第 9 章 —— 单一实现的对照
  • dsh 教程第 9 章

本章目录
一、四种实现共存二、本地压缩:一句提示词 + 一条前缀三、远端压缩:把历史交给服务端四、四种原因、三个时机五、压缩的可观测性六、Token 预算:第四条路七、和另外两个 harness 的对照八、动手复核九、总结Related Documents
苏ICP备2025204887号-2