Codex 的压缩子系统有 4039 行、四种实现、四种触发原因、三个触发时机 [源码,实测]。本章解释这个复杂度从哪来——答案是:当压缩可以放到服务端做时,客户端就必须同时保留本地那一套,因为服务端不一定支持。
run_auto_compact 是所有自动压缩的入口,它的主体是一个四路分发 [源码 core/src/session/turn.rs:1178]:
加上开头那条 token budget 分支,一共四条路:
| # | 实现 | 条件 | 谁在干活 |
|---|---|---|---|
| ① | 远端 v2 | provider 支持 V2 且 特性开关打开 | 服务端 |
| ② | 远端 v1 | provider 支持 V2 但开关没开 | 服务端 |
| ③ | 本地 | provider 不支持远端压缩 | 客户端调模型做摘要 |
| ④ | token budget | Feature::TokenBudget 打开(优先于以上全部) | 另一套预算机制 |
每条路都上报不同的指标名(remote_v2 / remote / local)——他们在线上比较这几套的效果。
为什么必须留着本地实现 因为 RemoteCompactionSupport::Unsupported 这个分支真实存在:自建端点、本地 Ollama、企业代理都不会实现 OpenAI 的服务端压缩。
这就是第 5 章那个取舍的账单另一面:砍掉 Chat Completions 换来了服务端压缩,但服务端压缩不是所有 provider 都有,所以本地那套还得留着。 能力越强的路径,兼容面越窄。
本地实现最简单,也最能说明压缩的本质。
全文只有 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 的交接摘要),而不是"总结一下对话"。这个措辞把模型从"复述历史"引导到"传递工作状态"。
摘要生成后不是直接塞回历史,而是加一段前缀 [源码 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 万 token 上限。用户可能粘贴了一整个日志文件进来,那种内容不该在压缩后还原样保留。
远端 v2 的请求构造是这样的 [源码 core/src/compact_remote_v2_attempt.rs:71]:
把完整历史发过去,末尾加一个空的 CompactionTrigger 条目,服务端看到它就执行压缩。
这个设计很干净:不需要新端点、不需要新参数,复用现有的 Responses 请求路径,靠一个特殊条目切换语义。
它也解释了第 5 章的那个取舍值不值——服务端压缩能看到客户端看不到的东西:完整的推理链(reasoning tokens 在服务端保存,客户端拿到的是摘要)、缓存状态、模型内部的注意力分布。客户端做摘要只能基于自己能看到的文本。
远端压缩之前有一步本地预处理 [源码 core/src/compact_remote_v2_attempt.rs:42]:
问题很实际:要压缩是因为历史太长了,但发起压缩本身也要把历史发过去。 如果历史已经超过上下文窗口,这个请求本身就会失败。
所以先把工具调用的输出重写掉,腾出空间。被重写的输出会替换成一句话 [源码 core/src/compact_remote.rs:49]:
这段代码里还有一个精细的数值处理 [源码 compact_remote.rs:407]:
用 i128 而不是 i64 做中间计算——因为正常的历史估算器会做 i64 饱和,饱和之后就看不出"到底超了多少",也就无法判断替换一个条目够不够。这种精度问题只有在真实超限场景里才会暴露。
should_keep_compacted_history_item 是一张明确的白名单 [源码 core/src/compact_remote.rs:370]:
| 条目类型 | 保留? | 说明 |
|---|---|---|
| Message{role: "developer"} | ❌ | 系统注入,压缩后重新生成 |
| Message{role: "user"} | 有条件 | 只有 UserMessage 和 HookPrompt 保留 |
| Message{role: "assistant"} / AgentMessage | ✅ | 助手的话保留 |
| Compaction / ContextCompaction | ✅ | 之前的压缩检查点保留 |
| CompactionTrigger | ❌ | 哨兵不入历史 |
| Reasoning | ❌ | 推理链丢弃 |
| FunctionCall / FunctionCallOutput / CustomToolCall* / ToolSearch* | ❌ | 工具调用与输出全丢 |
| WebSearchCall / ImageGenerationCall / LocalShellCall | ❌ | 同上 |
| AdditionalTools / Other | ❌ | —— |
核心规则一句话:留下"谁说了什么",丢掉"怎么做的"。
那一行有条件的 user 判断值得单独看:
因为第 6 章讲过——AGENTS.md 指令、世界状态、权限说明这些系统注入的内容 role 也是 "user"。不能因为 role 是 user 就一律保留,得看它解析出来到底是不是真的用户消息。这就是第 6 章那套 markers 机制的实际用途。
[源码 analytics/src/facts.rs:406]
后两条很有意思,都是模型切换引发的:
comp_hash 的检查就在主循环里 [源码 core/src/session/turn.rs:1045]:
配合 maybe_run_previous_model_inline_compact——用旧模型压完,再切到新模型。因为压缩产物要和产生它的模型匹配。
[源码 analytics/src/facts.rs:423]
三个时机对应三处调用(第 4 章列过):入场时的 run_pre_sampling_compact、循环里的 run_auto_compact、以及独立的 CompactTask。
这是压缩里最微妙的一处,注释写得非常清楚 [源码 core/src/compact.rs:63]:
翻译一下这个约束:
注意那句 "the model is trained to see"。这不是架构偏好,这是模型训练数据决定的硬约束——客户端必须迁就模型的训练分布。
可迁移的判断 ⑫ 当你的模型是自家训练的,harness 的某些设计约束会来自训练数据而非工程考量;这类约束必须写成注释,否则下一个人一定会"顺手优化掉"。
Codex 这条注释就是范例:它没有说"这样比较好",它说的是"模型被训练成这样看"。
压缩是最容易出问题、又最难发现问题的环节(压坏了要几轮之后才看得出来)。Codex 为此做了三件事:
[源码 core/src/compact.rs:414]
压缩前多少 token、保留了几张图、摘要多少 token、缓存命中和写入各多少。有了这几个数就能算出压缩比和缓存代价。
rollout-trace crate(13259 行)里有 CompactionCheckpointTracePayload。远端 v2 里可以开启 trace,把压缩的输入和输出原样存下来 [源码 compact_remote_v2_attempt.rs:68]:
默认关,出问题时开。 因为存完整历史很贵。
run_pre_compact_hooks / run_post_compact_hooks——用户可以在压缩前后插入自己的逻辑(第 13 章)。这对"压缩前把重要东西存到外部记忆"这类需求是刚需。
Feature::TokenBudget 开启时,压缩走完全不同的路径 [源码 core/src/session/token_budget.rs、core/src/compact_token_budget.rs]。
它的默认值也来自模型元数据(第 5 章的 model_messages.token_budget)[源码 session/token_budget.rs:31]:
注意 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_number 和 window_ids 就是为此存在的 [源码 core/src/compact.rs:85]。
| 维度 | Pi | dsh | Codex |
|---|---|---|---|
| 压缩实现数 | 1 | 1(可替换 seam) | 4 |
| 压缩位置 | 客户端 | 客户端 | 客户端 + 服务端 |
| 触发原因 | 上下文满 | 上下文满 | 4 种(含模型切换引发的 2 种) |
| 触发时机 | 循环中 | 循环中 | 3 种(轮前 / 轮中 / 独立任务) |
| 保留规则 | 简单 | 可配置 | 逐条目类型的白名单 |
| 压缩产物与模型绑定 | 无 | 无 | comp_hash,模型换了就重压 |
Pi 教程第 9 章讲的压缩是一个完整、自洽、可以读完的实现。Codex 的压缩是一个在真实约束下反复妥协的产物:服务端能力更强但不通用、模型训练分布决定了摘要的位置、模型切换会让旧摘要失效、发起压缩本身可能就超限。
这四条约束没有一条是"设计"出来的,全是撞出来的。 这也是读产品级代码相对于读框架代码的价值。
实际观察一次压缩:
下一章回到工具:注册、路由、并行、以及"工具太多喂不下"这个新问题。