Codex 上下文压缩:Agent 装不下长记忆时,它到底扔了什么
Agent 的循环天然会让上下文无限膨胀,而塞进去的东西基本都不该扔——你不知道十轮之后会需要哪一条报错信息。Codex 的解法不是滑窗,而是用一份新历史整体替换旧历史。
这篇记六个我关心的点:
| 问题 | 简短结论 |
|---|---|
| 压缩到底改变了什么 | 换一份新历史,不是裁剪旧历史 |
| 什么时候压 | 90%/95% 双阈值,四个检查点,但到阈值不一定压 |
| 什么值得留 | 预算制:用户消息优先原文,从新往旧装 |
| 摘要从哪来 | 远端服务端 / 本地请求 / 不摘要三种路径 |
| 摘要放哪 | 永远置于历史末尾 |
| 代价是什么 | 有损,多次压缩累积精度损失 |
一、核心视角:换历史,不是裁剪
普通做法是”保留最近 N 条”,这在对话场景够用,但在 agent 场景有两个致命问题:一是丢掉的早期内容里往往有最初的任务目标(“把登录逻辑迁到 JWT”),二是压缩后剩下的历史仍然是连续的长对话,模型容易在中间细节里打转。
Codex 的做法是构造一份全新的历史来替换旧的:
旧历史(膨胀) ──压缩──▶ 新历史 = 初始上下文(重注入) + 预算内原文消息 + 一条摘要
三个组成部分:
- 初始上下文重注入:
AGENTS.md、环境信息、当前计划等 world state 重新放回历史头部,让接手模型重新知道”我在哪、我是谁”。 - 预算内原文消息:少量消息保留原文(不转述),主要保真。
- 一条摘要:所有工具调用、工具输出、推理过程、被挤掉的消息,全部坍缩成一条交接摘要。
也就是说,压缩后模型读到的不是”一段被截断的对话”,而是”一份简报 + 少量原文 + 关键状态”。
这个视角的差别在于:它把”什么值得留”建模成了预算问题,而不是规则问题。 规则制要预先定死”哪类消息该保留”,预算制只问”预算还剩多少”,然后从新往旧装——对不同类型的消息一视同仁,代价可调、行为可预测。
二、什么时候压:两个阈值,四个检查点
2.1 两级阈值
| 阈值 | 默认值 | 作用 |
|---|---|---|
auto_compact_token_limit | min(配置值, 窗口 × 90%) | 常规触发线。只能收紧不能放宽 |
effective_context_window_percent | 95% | 硬顶,碰到必压 |
95% 这个硬顶是安全网,防止某种原因(配置写错、模型返回超长内容)绕过 90% 触发线直接把请求打到服务端被拒。
还有一个默认关闭的 model_post_turn_compact_threshold_percent(默认 0 = 不判定),用来在回合交付完成后做后台压缩。
2.2 四个检查点
| 相位 | 时机 | 说明 |
|---|---|---|
StandaloneTurn | 手动 /compact | 唯一到点必压的入口 |
PreTurn | 新回合发出前 | 模型切换、上下文哈希变化等 |
MidTurn | agent 循环中每个模型步之后 | 活还没干完才压,压完继续跑 |
PostTurn | 答案交付后 | 后台压缩,失败不影响已给出的答案 |
判定逻辑在 core/src/session/turn.rs 的 run_auto_compact(),按 provider 能力路由:
达到阈值?
│
├─ Feature::TokenBudget 开启? ──是──▶ 直接重置窗口(不摘要)
│
├─ provider 支持 remote_compaction V2? ──是──▶ 服务端压缩
│
└─▶ 本地摘要压缩(兜底)
2.3 一个容易误解的点:MidTurn 检查完不一定会压
这是整套机制里最反直觉的地方。每个模型步结束后都会无条件算一次 token 用量(“读表”),但真正触发压缩的条件更严(“踩闸”):
let should_roll_over = needs_follow_up
&& (sess.take_new_context_window_request().await || token_limit_reached);
关键在 needs_follow_up = model_needs_follow_up || has_pending_input,它回答的是”这个回合还需要继续吗”。压缩之后有三条去向:
| 情形 | needs_follow_up | 结果 |
|---|---|---|
| 模型发过工具调用,活没干完 | true | 到阈值就压,压完继续跑同一个回合 |
| 活没干完,但还没到阈值 | true | 不压,直接进下一步模型调用 |
| 模型只回了一句话就结束了 | false | 走收尾分支:stop hooks → after-agent hook → PostTurn 判定 |
所以准确的表述是:MidTurn 检查点每次都会执行,但压缩只在”活还没干完 + 已到阈值”时才发生。 回合已经交付(needs_follow_up = false)时走的是收尾分支——PostTurn 判定就内联在这个分支里,而不是一个独立的第三检查点。
顺带说明,PostTurn 用的重注入策略是 DoNotInject(不重注入),MidTurn 用的是 BeforeLastUserMessage——对应了 §4.2 里那条重注入规则。
而且”进了 PostTurn 判定”也不等于”一定压缩”,它还有五道门槛:model_post_turn_compact_threshold_percent > 0(默认 0,即默认关闭)、非 TokenBudget 模式、回合末阈值达到、无排队输入、未被取消。
一句话总结:压缩是 agent loop 的旁路兜底路径,不是每回合必经的步骤。 这也解释了为什么”任务进行中到 90% 就压”和”答完了才考虑压”是两种不同的行为。
三、压什么、留什么:预算制保留
新历史不是”只有摘要”,而是筛选后的原文消息 + 摘要。筛选规则(is_retained_for_remote_compaction_v2):
| 历史项类型 | 是否原文保留 |
|---|---|
| 用户消息 | ✅ 全部保留 |
| 助手消息 | 部分:非子代理进度、非最终回答、且单条 ≤ 10k tokens |
| 工具调用 / 工具输出 | ❌ 全部坍缩进摘要 |
| 推理(reasoning)items | ❌ 不保留,但参与了服务端摘要生成 |
然后过预算:筛出的消息装进一个 64,000 token 的共享池,从最新往最旧装,装不下的截断、更旧的直接丢弃。
几个数字放一起看更有感觉:
- 共享池 64k,单条助手消息上限 10k → 最多 6 条满额助手消息能幸存,其余全进摘要。
- 本地摘要路径的池子小得多:用户消息 20,000 tokens,且助手消息不保留。
- 工具输出在记录时就已经逐条截断到 10,000 bytes——这一步发生在压缩之前,是更早的一层降压。
为什么用户消息优先级最高?因为用户消息是意图的原文。转述过的意图会失真,而失真的意图在几十轮之后会被模型当成一个模糊的方向去猜。中间过程(工具输出、推理)本质上是”怎么做的”,转述成”做了什么”损失相对小——所以它们全部进摘要。
还有一个设计叫 BodyAfterPrefix 作用域:默认的 Total 统计全部活跃 token,而它只统计压缩窗口前缀之后的增量。目的是避免那种”会话开头带了个巨大前缀,导致每次算增量都超阈值,反复触发压缩”的死循环。
四、摘要怎么来:来源、位置、提示词
4.1 三条来源路径
| 实现 | 摘要从哪来 | 何时用 |
|---|---|---|
| Remote V2 | 全量历史(含推理 items)发给 Responses API,服务端生成后返回恰好一个 Compaction 条目 | OpenAI provider,首选 |
| Local | harness 自己跑一轮摘要请求,取最后一条助手消息 | 其他 provider 兜底 |
| TokenBudget | 不摘要,直接开新窗口 | Feature 开关,实验 |
三条路径共享同一套外围生命周期(hooks、turn item、遥测、持久化),差别只在”摘要从哪来”。
值得注意的是 Remote V2:推理历史会被完整带去参与摘要生成,但替换后的历史里不保留推理本身。推理的结论进了摘要,体积不占窗口——官方博客的数据是,仅调整 retained reasoning 与 context compaction 两项,GPT-5.6 Sol 在 ARC-AGI-3 上从 13.3% 提升到 38.3%,输出 token 减少约 6 倍。
4.2 放置位置:永远在末尾
Compaction 摘要项总是 append 到历史最后,原因写在代码注释里——模型训练时期望这样看到压缩结果。如果摘要出现在历史中间,模型可能把它当成”过程中的一条记录”而不是”当前状态的权威描述”。
配套的初始上下文重注入分两种:MidTurn 压缩插到最后一条真实用户消息之前(任务还要继续跑,模型需要立刻知道”我在哪”),PreTurn 和手动压缩则不注入(DoNotInject),留给下一轮自然重建。
4.3 关键提示词
本地路径的提示词(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
关键词是 handoff summary——目标不是”总结聊天记录”,而是”写给接手模型的交接文档”。配套还有一个前缀:
Another language model started to solve this problem and produced a summary of its thinking process… Here is the summary produced by the other language model, use the information in this summary to assist with your own analysis:
这个前缀的作用是给模型一个解释框架:这是接手任务时的交接状态,不是用户随口聊的内容。听起来琐碎,但摘要项在历史里的位置和普通用户消息不同,明确框架能减少模型把它当成”要回复的指令”的概率。
五、几个自愈细节
压缩本身就是一次”超大的请求”,所以它自己也会撞窗口,很容易形成”压缩请求自己被窗口拒绝”的死锁。Codex 的处理分两条路:
| 场景 | 处理 |
|---|---|
| 本地摘要请求自身超窗 | 逐条丢弃最老的历史项重试,直到塞得下 |
| Remote V2 请求自身超窗 | 发起前先从新往旧修剪旧的工具调用/输出 |
其余几条降级:
- 流中断:每通道重试上限 2(压缩请求比普通回合长,预算收紧)
- 模型不兼容:用
fallback_step_context以当前模型重试一次 - PostTurn 失败:静默保留已给出的答案(fail-soft)
- PreTurn 失败:保留用户 prompt,下一轮重试
- MidTurn 失败:才上报为回合错误
- Hooks 拒绝:
TurnAborted,压缩不发生
最后一条值得单说:MidTurn 失败会上报为错误是整套机制的兜底保证。压缩失败后历史保持膨胀态,下一个模型步会再次撞 ContextWindowExceeded——如果这时还是 fail-soft,用户就会陷入”一直转圈一直失败”的死亡螺旋。所以”任务进行中的压缩必须成功或明确失败”是有意设计的。
压缩完成后还会钉住 ReasoningEffortPin::Compacted,保证压缩后那一轮的推理力度不被降档——否则”摘要有损 + 力度降档”就是双重衰减。
六、官方自己承认它是有损的
多次压缩会累积精度损失。压缩成功后本地路径会打一句提示:
Heads up: Long threads and multiple compactions can cause the model to be less accurate. Start a new thread when possible…
内部代号 Memento(遥测里策略字段就是这个值)。所以长任务建议尽早开新线程,而不是靠反复压缩续命。
顺带一个容易误解的点:/compact 只释放工作记忆(上下文),不返还订阅配额。两者是不同的账本。
参考资料
- Unrolling the Codex agent loop — OpenAI Engineering,2026-01-23。agent loop 中 context 组装与压缩的三代演进
- Codex as a platform: build on the open agent harness — 2026-08-19。harness 开源公告,含 ARC-AGI-3 数据
- Codex CLI Context Compaction: Architecture, Configuration, and Managing Long Sessions — 社区最系统的架构综述,含已知故障模式
- Codex 上下文压缩机制(中文拆解) — 远端 v1 / v2 协议差异讲得最清楚
阅读避坑:社区文章里常出现的 “20,000 tokens” 是本地摘要路径的数字,别和远端 V2 的 64k 共享池混淆;DeepWiki 上 thegovind/codex 的 Context Management 词条分析的是 TypeScript 旧版,和现行 Rust 版不是一回事。