Codex 上下文压缩:Agent 装不下长记忆时,它到底扔了什么


Agent 的循环天然会让上下文无限膨胀,而塞进去的东西基本都不该扔——你不知道十轮之后会需要哪一条报错信息。Codex 的解法不是滑窗,而是用一份新历史整体替换旧历史。

这篇记六个我关心的点:

问题简短结论
压缩到底改变了什么换一份新历史,不是裁剪旧历史
什么时候压90%/95% 双阈值,四个检查点,但到阈值不一定压
什么值得留预算制:用户消息优先原文,从新往旧装
摘要从哪来远端服务端 / 本地请求 / 不摘要三种路径
摘要放哪永远置于历史末尾
代价是什么有损,多次压缩累积精度损失

一、核心视角:换历史,不是裁剪

普通做法是”保留最近 N 条”,这在对话场景够用,但在 agent 场景有两个致命问题:一是丢掉的早期内容里往往有最初的任务目标(“把登录逻辑迁到 JWT”),二是压缩后剩下的历史仍然是连续的长对话,模型容易在中间细节里打转。

Codex 的做法是构造一份全新的历史来替换旧的:

旧历史(膨胀) ──压缩──▶ 新历史 = 初始上下文(重注入) + 预算内原文消息 + 一条摘要

三个组成部分:

  • 初始上下文重注入:AGENTS.md、环境信息、当前计划等 world state 重新放回历史头部,让接手模型重新知道”我在哪、我是谁”。
  • 预算内原文消息:少量消息保留原文(不转述),主要保真。
  • 一条摘要:所有工具调用、工具输出、推理过程、被挤掉的消息,全部坍缩成一条交接摘要。

也就是说,压缩后模型读到的不是”一段被截断的对话”,而是”一份简报 + 少量原文 + 关键状态”。

这个视角的差别在于:它把”什么值得留”建模成了预算问题,而不是规则问题。 规则制要预先定死”哪类消息该保留”,预算制只问”预算还剩多少”,然后从新往旧装——对不同类型的消息一视同仁,代价可调、行为可预测。


二、什么时候压:两个阈值,四个检查点

2.1 两级阈值

阈值默认值作用
auto_compact_token_limitmin(配置值, 窗口 × 90%)常规触发线。只能收紧不能放宽
effective_context_window_percent95%硬顶,碰到必压

95% 这个硬顶是安全网,防止某种原因(配置写错、模型返回超长内容)绕过 90% 触发线直接把请求打到服务端被拒。

还有一个默认关闭的 model_post_turn_compact_threshold_percent(默认 0 = 不判定),用来在回合交付完成后做后台压缩。

2.2 四个检查点

相位时机说明
StandaloneTurn手动 /compact唯一到点必压的入口
PreTurn新回合发出前模型切换、上下文哈希变化等
MidTurnagent 循环中每个模型步之后活还没干完才压,压完继续跑
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,首选
Localharness 自己跑一轮摘要请求,取最后一条助手消息其他 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 只释放工作记忆(上下文),不返还订阅配额。两者是不同的账本。


参考资料

  1. Unrolling the Codex agent loop — OpenAI Engineering,2026-01-23。agent loop 中 context 组装与压缩的三代演进
  2. Codex as a platform: build on the open agent harness — 2026-08-19。harness 开源公告,含 ARC-AGI-3 数据
  3. Codex CLI Context Compaction: Architecture, Configuration, and Managing Long Sessions — 社区最系统的架构综述,含已知故障模式
  4. Codex 上下文压缩机制(中文拆解) — 远端 v1 / v2 协议差异讲得最清楚

阅读避坑:社区文章里常出现的 “20,000 tokens” 是本地摘要路径的数字,别和远端 V2 的 64k 共享池混淆;DeepWiki 上 thegovind/codex 的 Context Management 词条分析的是 TypeScript 旧版,和现行 Rust 版不是一回事。