# 上下文压缩 · 窗口快满时怎么办

> 压缩在四个时机触发：开轮前（换了模型或已超限）、一轮中途（模型还要继续但窗口快满）、轮末（可选）与手动的 /compact。OpenAI 等支持的提供方走远程压缩：在历史末尾追加一个触发条目，由服务端返回一个不透明的压缩条目；其余提供方走本地压缩，让模型按提示词写交接摘要。压缩后历史被整体替换，只保留最近的用户消息，这是“只追加”原则唯一的常规例外。

- 作者：David（道雾轩）
- 专栏：Codex 源码解读（https://daiw.net/manual/codex-source.md）
- 最后更新：2026-09-29
- 原文：https://daiw.net/manual/codex-source/compaction
- 转载与引用：请注明出处并附原文链接（https://daiw.net/about/copyright）

# 上下文压缩 · 窗口快满时怎么办

[上一篇](https://daiw.net/manual/codex-source/context-history)说，历史只追加、不改写，真正的例外只有压缩与回滚。这一篇讲压缩：什么时候触发、有哪几种实现、压缩后的历史长什么样。

## 用户看到的样子

对话很长时，Codex 会自动压缩上下文，界面上出现一个“上下文压缩”条目；也可以随时输入 `/compact` 手动压缩（见[斜杠命令](https://daiw.net/manual/codex/slash-commands)）。相关配置有 `model_auto_compact_token_limit`（触发阈值）、`model_auto_compact_token_limit_scope`（`total` 或 `body_after_prefix`，默认 `total`）、`model_post_turn_compact_threshold_percent`（轮末压缩，默认 0 即关闭）、`model_context_window`，以及自定义摘要提示词的 `compact_prompt` 与 `experimental_compact_prompt_file`（见[配置项速查](https://daiw.net/manual/codex/config-reference)）。`PreCompact`、`PostCompact` 两个 hook 在压缩前后触发，返回 `continue: false` 可以中止（见 [Hooks](https://daiw.net/manual/codex/hooks)）。

## 用量怎么算、阈值在哪

判断“满没满”的是 `context_window_token_status`（`codex-rs/core/src/session/context_window.rs:26`）。当前用量取自 `ContextManager::get_total_token_usage`：以上一次响应里服务端报告的 `total_tokens` 为准，加上此后新追加条目的估算值（按字节粗估）；服务端没声明已计入推理时，再加上更早轮次推理条目的估算。阈值来自模型元数据：

```rust
    pub fn auto_compact_token_limit(&self) -> Option<i64> {
        let context_limit = self
            .resolved_context_window()
            .map(|context_window| (context_window * 9) / 10);
        let config_limit = self.auto_compact_token_limit;
        if let Some(context_limit) = context_limit {
            return Some(
                config_limit.map_or(context_limit, |limit| std::cmp::min(limit, context_limit)),
            );
        }
        config_limit
    }
```

（`codex-rs/protocol/src/openai_models.rs:525`）

默认的 `total` 范围下，自动压缩阈值是上下文窗口的 90%，配置或模型目录给的 `auto_compact_token_limit` 只能把它调低。另有一道硬上限：窗口乘以模型的 `effective_context_window_percent`（默认 95）。用量达到任一个，`token_limit_reached` 就为真。`body_after_prefix` 范围下，阈值直接取配置值（没配时才用上面这个数），并且只和“本窗口开头那段前缀之后的增量”比，前缀大小优先取新窗口里第一次响应报告的输入 token；硬上限仍按全量算。普通请求若直接被服务端以上下文超窗拒绝，本轮以错误结束，但用量会被标记为已满，下一轮开轮前必然先压缩。

## 四个触发时机

| 时机 | 位置 | 条件 | 压缩后注入初始上下文吗 |
| --- | --- | --- | --- |
| 开轮前 | `run_pre_sampling_compact` | 压缩兼容哈希变了；或换到窗口更小的模型且已超新模型的限制；或 token 已到阈值 | 不注入 |
| 一轮中途 | `run_turn` 采样后 | 模型还要继续，且 token 已到阈值 | 注入到最后一条用户消息之前 |
| 轮末 | `run_turn` 收尾前 | 用量越过 `model_post_turn_compact_threshold_percent`，且没有待处理输入 | 不注入 |
| 手动 | `Op::Compact` 起一个 `CompactTask` | 用户执行 `/compact` | 不注入 |

开轮前的前两种情况用的是**上一轮的模型**：先用旧模型压缩一次，再交给新模型（原因分别记为 `CompHashChanged` 与 `ModelDownshift`）。旧模型压缩失败时，在 OpenAI 提供方且走 Codex 后端的前提下，再用当前模型重试一次。

最后一列的差别来自一个枚举：

```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,
}
```

（`codex-rs/core/src/compact.rs:57`）

开轮前、轮末与手动压缩清空 `reference_context_item`，下一轮自然会按上一篇的机制重新注入完整上下文。一轮中途压缩后，循环直接接着采样，不会再经过开轮时的全量注入，所以初始上下文要直接放进替换后的历史；位置选在最后一条真实用户消息之前，因为按注释的说法，模型受过的训练是中途压缩后压缩结果应当是历史的最后一项。

## 三种实现

```mermaid
flowchart TB
  T1[开轮前<br/>换模型或已超限] --> R{run_auto_compact<br/>选实现}
  T2[一轮中途<br/>还要继续且超限] --> R
  T3[轮末<br/>越过百分比阈值] --> R
  T4[手动 compact<br/>CompactTask] --> R
  R -->|token_budget 开关<br/>开发中| B[开新上下文窗口<br/>不做摘要]
  R -->|提供方支持远程压缩 V2| V[历史末尾加 CompactionTrigger<br/>服务端返回一个 Compaction 条目]
  R -->|其他提供方| L[本地压缩<br/>模型按提示词写交接摘要]
  V --> H[replace_compacted_history<br/>替换历史 · 新窗口 · 写 rollout]
  L --> H
  B --> H
```

选哪种由提供方能力决定。`ProviderCapabilities::remote_compaction` 为 `V2` 的有：内置的 OpenAI 提供方、Azure 的 Responses 端点、Amazon Bedrock；自定义提供方与 Ollama、LM Studio 都是 `Unsupported`，走本地压缩。功能开关 `token_budget`（开发中，默认关闭）打开时两者都不走，改为直接开一个新的上下文窗口，新窗口总是带着完整的初始上下文。

**远程压缩 V2**（`codex-rs/core/src/compact_remote_v2.rs`）几乎就是一次普通请求：

```rust
    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,
        cyber_access_program: turn_context.cyber_access_program,
    };
```

（`codex-rs/core/src/compact_remote_v2_attempt.rs:77`）

输入是当前历史，末尾追加一个 `CompactionTrigger` 条目，工具清单、基础指令与普通请求相同，自动压缩时还复用这一轮的 `ModelClientSession`。这样压缩请求与之前的请求共享前缀，能沿用同一份提示缓存和 WebSocket 连接。发出前若估算的历史已经超过窗口，`trim_function_call_history_to_fit_context_window` 从历史末尾起，把连续的工具输出逐个换成 “Output exceeded the available model context and was truncated”，直到放得下或遇到非工具输出为止；改的只是这次请求的副本。服务端的响应里必须**恰好有一个** `Compaction` 条目，否则报错。这类请求耗时长，流级重试最多 2 次（`MAX_REMOTE_COMPACTION_V2_STREAM_RETRIES`），不用普通请求的 5 次。新历史由两部分组成：从最新往前保留的用户消息（外加少量 agent 间消息），总预算 64,000 token（`RETAINED_MESSAGE_TOKEN_BUDGET`）；最后是那个 `Compaction` 条目。

**本地压缩**（`codex-rs/core/src/compact.rs`）把历史加一条用户消息发给当前模型，这条消息就是摘要提示词：默认的 `SUMMARIZATION_PROMPT`（`codex-rs/prompts/templates/compact/prompt.md`）要求模型写一份交接摘要，写明进度与关键决策、约束与用户偏好、剩余工作、继续所需的数据。`compact_prompt` 与 `experimental_compact_prompt_file` 只替换这条提示词，所以**对走远程压缩的提供方不起作用**。请求本身也可能超窗：

```rust
            Err(e) if matches!(e.details(), CodexErrorDetails::ContextWindowExceeded) => {
                if turn_input_len > 1 {
                    // Trim from the beginning to preserve cache (prefix-based) and keep recent messages intact.
                    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;
                return Err(e);
            }
```

（`codex-rs/core/src/compact.rs:310`）

超窗就从最旧的一条删起，保住最近的内容；其他错误按退避重试，次数上限是提供方的 `stream_max_retries`。拿到回复后，新历史是：从最新往前保留的用户消息，总预算 20,000 token（`COMPACT_USER_MESSAGE_MAX_TOKENS`），超出的那条截断；最后一条是摘要，前面加上 `SUMMARY_PREFIX`，告诉接手的模型“另一个模型已经做了一部分，这是它的摘要”。本地压缩完成后还会发一条警告：长对话与多次压缩会让模型变得不那么准确，最好开新线程保持对话小而专注。

## 压缩之后

三种实现都以 `replace_compacted_history`（`codex-rs/core/src/session/mod.rs:4061`）收尾：给新条目补齐 ID，在 `state` 锁里用 `HistoryReplacement::Compaction` 替换历史，把推理强度的固定值重置，然后往 rollout 写一条 `RolloutItem::Compacted`（带完整的替换后历史、窗口编号与 ID、压缩响应 ID 等），必要时跟上新的世界状态快照与 `TurnContextItem`。恢复会话时，重放逻辑直接取这份替换后的历史，不必重新压缩。压缩还会推进“自动压缩窗口”（`codex-rs/core/src/state/auto_compact_window.rs`）：窗口编号加一、生成新的窗口 ID，请求元数据里的 `x-codex-window-id` 随之改变；最后排入一个来源为 `compact` 的 SessionStart hook，在下一次采样前触发。

顺带一提，`thread_rollout_truncation.rs` 名字里有 truncation，但它处理的是分叉与回滚时按用户轮次截断 rollout，与压缩无关。

## 和《从 LLM 到 Coding Agent》对照

[自动压缩](https://daiw.net/manual/llm-to-agent/auto-compaction)一篇的方案是“保留近期、摘要远古”，并强调摘要提示词决定成败、proactive 与 reactive 两种触发都要有。Codex 的本地压缩与之同构，但“近期”只保留用户消息原文，助手回复与工具输出都交给摘要；proactive 对应 90% 阈值与轮末压缩；reactive 则体现为普通请求超窗时本轮报错、用量记为已满，下一轮开轮先压缩，以及压缩请求自身超窗时从最旧一条删起再试。远程压缩 V2 把写摘要这件事整个搬到了服务端，客户端只负责追加触发条目、保留用户消息。那篇讲的“压缩与缓存天然打架”，在 Codex 里的缓解办法是：本地压缩超窗时从头部删，远程压缩与普通请求共享前缀。[精细化上下文管理](https://daiw.net/manual/llm-to-agent/context-compaction)主张先处理占地方的工具结果，对应这里写入历史时按预算截断工具输出，以及压缩请求里把末尾工具输出换成占位文字的做法。

---

上一篇：[上下文与历史 · 只追加、不改写](https://daiw.net/manual/codex-source/context-history) · 下一篇：[指令从哪来 · AGENTS.md、系统提示词与协作模式](https://daiw.net/manual/codex-source/instructions)
