Codex 上下文管理机制技术分析

0 阅读7分钟

基于 codex-rs 源码的完整分析,所有引用均标注精确文件路径和行号。


架构总览

┌─────────────────────────────────────────────────────────────────────────────┐
│                            用户输入 / 工具输出                               │
└───────────────────────────────────┬─────────────────────────────────────────┘
                                    │
                                    ▼
┌─────────────────────────────────────────────────────────────────────────────┐
│                          ContextManager (history.rs)                        │
│                                                                             │
│   items: Vec<ResponseItem>          ← 全量对话历史(时序,最老在前)          │
│   token_info: Option<TokenUsageInfo> ← 上次 API 返回的权威 token 数          │
│   reference_context_item            ← diff 基线,用于增量注入初始上下文       │
│                                                                             │
│   ┌──────────────────────────────────────────────────────────────────────┐ │
│   │                   record_items() 写入流程                            │ │
│   │                                                                      │ │
│   │  新 item 到来                                                        │ │
│   │      │                                                               │ │
│   │      ▼                                                               │ │
│   │  ┌─────────────────────────────────────────────┐                    │ │
│   │  │         机制 ①  Output Truncation           │                    │ │
│   │  │                                             │                    │ │
│   │  │  仅对 FunctionCallOutput /                  │                    │ │
│   │  │       CustomToolCallOutput 生效             │                    │ │
│   │  │                                             │                    │ │
│   │  │  单条输出 > 10,000 tokens?                  │                    │ │
│   │  │      ├── 否 → 原样写入                       │                    │ │
│   │  │      └── 是 → Middle Truncation             │                    │ │
│   │  │              保留前50% + 后50%               │                    │ │
│   │  │              中间替换为截断标记               │                    │ │
│   │  └─────────────────────────────────────────────┘                    │ │
│   │      │                                                               │ │
│   │      ▼                                                               │ │
│   │  写入 items[]                                                        │ │
│   └──────────────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────────┘
                                    │
                          每轮 API 调用前
                                    │
                                    ▼
┌─────────────────────────────────────────────────────────────────────────────┐
│                         Token 估算(机制 ②,度量工具)                       │
│                                                                             │
│  total_tokens = last_api_total_tokens                                       │
│               + Σ estimate(items added since last API response)             │
│                                                                             │
│  estimate 规则:                                                             │
│    普通 item  → JSON 序列化字节数                                            │
│    推理/压缩  → base64 解码公式 (encoded × 3/4 - 650)                       │
│    图像       → 7,373 bytes(original detail 按 patch 数计算)               │
│    GhostSnapshot → 0(模型不可见)                                           │
│    ÷ 4 → tokens(ceil 除法)                                                │
└──────────────────────────────┬──────────────────────────────────────────────┘
                               │
               total_tokens >= auto_compact_limit?
               (context_window × 90%,如 128k 模型 = 115,200)
                               │
              ┌────────────────┴─────────────────┐
              │ 否                               │ 是
              ▼                                 ▼
    正常构建 Prompt              ┌───────────────────────────────────────┐
    发送 API 请求                │       机制 ③  Auto-Compact            │
                                │                                       │
                                │  1. 追加 compact prompt(prompt.md)  │
                                │  2. 发送"压缩轮"给 LLM               │
                                │  3. 提取 LLM 摘要                     │
                                │  4. 构建新历史:                      │
                                │     最近用户消息(≤20k tokens)        │
                                │     + summary_prefix + 摘要           │
                                │  5. 替换整个历史                      │
                                └───────────────────────────────────────┘
                                                 │
                                                 ▼
                                       继续正常 API 请求

机制一:Output Truncation(输出截断)

单条工具输出防溢出。针对 shell 命令、代码执行等工具返回的文本,超过 10,000 tokens 时触发,与历史总量无关。

采用 Middle Truncation:保留前后各 50%,丢弃中间,插入截断标记。之所以不从尾部裁,是因为开头通常含最重要的摘要信息,结尾是最新状态和错误信息,中间往往是价值最低的重复细节。

原始输出(30,000 tokens):
[开头内容 ················ 中间内容 ················ 结尾内容]

截断后(10,000 tokens):
[开头 5,000 tokens]20,000 tokens truncated… [结尾 5,000 tokens]

只截断工具输出的文本体,用户消息、助手消息、推理内容均原样保留。图像永远不截断。


机制二:Token 估算(Token Estimation)

度量工具,不做任何修改。为 Truncation 提供 budget 参数,为 Auto-Compact 提供触发判据。

估算逻辑

不调用任何 tokenizer API,全部本地计算,分两层叠加:

total_tokens = last_api_total_tokens          // 上次 API 响应返回的精确值(基线)
             + Σ estimate(新增 items)          // 基线之后新增消息的本地估算(增量)

基线在每次 API 响应后自动更新为精确值,两次 API 调用之间的新消息用估算补足。这样避免了为每条新消息单独调 API 计 token。

单条 item 的估算规则:

item 类型估算方式
普通消息、工具调用/输出JSON 序列化字节数 ÷ 4(向上取整)
推理内容、压缩历史(加密 base64)encoded_len × 3/4 - 650,还原为原始字节数后 ÷ 4
图像固定 7,373 bytes ÷ 4 ≈ 1,844 tokens;detail:original 按 32×32 patch 数计算
GhostSnapshot0(客户端内部状态,模型不可见)

bytes ÷ 4 的比例来自 OpenAI tokenizer 对英文文本的经验均值。用天花板除法保守估计,宁多不少,确保不会低估历史长度而漏触发 Auto-Compact。

Demo:这段文字估算是多少 token?

Hello, how are you doing today? I'm working on a Rust project.

计算步骤:

  1. 字节数:63 bytes(纯 ASCII,1 字符 = 1 字节)
  2. tokens = ceil(63 / 4) = 16 tokens

实际用 tiktoken 精确计数是 15 tokens,误差 1 个,在可接受范围内。

再看中文:

你好,今天工作进展怎么样?我在做一个 Rust 项目。

计算步骤:

  1. 字节数:66 bytes(中文每字 3 字节,标点 3 字节,ASCII 字符 1 字节)
  2. tokens = ceil(66 / 4) = 17 tokens

实际 tiktoken 结果约 30 tokens——中文的 bytes/token 比例远高于 4,此处估算明显偏低。这是 bytes/4 启发式的固有局限:对中文、日文等多字节语言会低估,但 Codex 主要处理代码和英文,整体偏差可接受。


机制三:Auto-Compact(自动压缩)

定位

全局兜底。当对话历史积累到模型 context window 的 90% 时触发,用 LLM 生成摘要替换整个历史。

触发阈值

// codex-rs/protocol/src/openai_models.rs
pub fn auto_compact_token_limit(&self) -> Option<i64> {
    self.context_window.map(|w| (w * 9) / 10)  // context_window × 90%
}

以 128k 模型为例:

context_window      = 128,000 tokens
auto_compact_limit  = 115,200 tokens(90%)
tool_output_limit   =  10,000 tokens(7.8%,单条上限)

三种触发场景

场景触发时机初始上下文处理
Pre-turn用户发消息,采样前检查不注入(下次正式轮次自动重注入)
Mid-turn模型输出中途超限注入到最后一条用户消息之前
Manual用户执行 /compact不注入

三种场景的区别在于初始上下文(system prompt 等)的注入位置:Mid-turn 时模型训练期望在最后一条用户消息前看到初始上下文,其余场景留给下次正式轮次处理。

Compact Prompt(完整原文)

作为"最后一条用户消息"追加到历史末尾,告诉 LLM 它的任务是生成交接摘要:

// codex-rs/core/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.

Summary Prefix(完整原文)

压缩完成后,这段前缀 + LLM 生成的摘要,拼接为新历史的最后一条 user 消息:

// codex-rs/core/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. Here is the summary produced
by the other language model, use the information in this summary to assist
with your own analysis:

设计意图:明确告知下一轮 LLM "这是另一个模型的思考过程",防止模型把自己的历史混淆进来。

完整压缩流程

触发 Auto-Compact
      │
      ▼
将 compact prompt 作为最后一条 user 消息追加到当前历史
      │
      ▼
发送"压缩轮"给 LLM(流式)
      │
      ├─── 成功 ──────────────────────────────────────────┐
      │                                                   │
      ├─── ContextWindowExceeded                          │
      │    (连压缩本身也超限)                             │
      │         │                                         │
      │         └── 从历史最前面移除一条,重试 ────────────┤
      │             (保留后缀以复用 KV cache)             │
      │                                                   │
      └─── 网络错误                                        │
               │                                          │
               └── 指数退避,重试 ───────────────────────┘
                                                          │
                                                          ▼
                                          提取 LLM 返回的最后一条 assistant 消息作为摘要
                                                          │
                                                          ▼
                                          构建新历史:
                                            从旧历史中收集所有真实用户消息
                                            从新到旧贪心选取,上限 20,000 tokens
                                            末尾追加 summary_prefix + 摘要
                                                          │
                                                          ▼
                                          替换整个历史,重新计算 token 用量

新历史的结构

从最新到最旧贪心选取用户消息(上限 20,000 tokens),助手消息和工具调用历史全部丢弃,用摘要代替:

压缩前(100k+ tokens):
  [user]      帮我分析这段代码
  [assistant] 好的,我来看看...
  [tool_call] shell: cat main.rs
  [tool_output] <5000 行>
  [assistant] 发现了几个问题...
  ... × 数十轮

压缩后(< 25k tokens):
  [user]  帮我分析这段代码          ← 最近用户消息(≤ 20k tokens[user]  还有这个函数也看一下
  [user]  SUMMARY_PREFIX\nLLM 摘要,role 故意设为 "user"
          当前进度:已分析 main.rs,
          发现 line 42 内存泄漏...
          下一步:检查 utils.rs

摘要用 role: "user" 写入而非新角色,是因为模型训练时就期望这种格式。


三种机制的协作关系

"眼睛"
Token 估算 ─────────────────────────────→ Auto-Compact
    │                                       (总量超 90%?)
    │         提供 TruncationPolicy 参数
    └────────────────────────────────────→ Output Truncation
                                           (单条超 10k tokens?)

Output Truncation  ─── 治标 ───→ 控制单条体积,减缓历史增长速度
Auto-Compact       ─── 治本 ───→ 总量超限时,重置历史到可控规模

典型的长对话生命周期

第 1 轮:执行 cat 大文件,输出 50k tokens
  → Truncation 截断到 12k tokens,写入历史 ✓

第 3 轮:再次执行工具,输出 30k tokens
  → Truncation 截断,历史持续累积

第 15 轮:Token 估算发现 total = 116k ≥ 115.2k(90% 阈值)
  → Auto-Compact 触发
  → LLM 生成摘要,历史替换为 ~5k tokens
  → 重新从低点开始积累

第 30 轮:再次触发 Auto-Compact...