上下文快满了,Codex 怎么办?
到了上下文窗口的 90% 就压缩:让模型写一份"交接摘要",本地压缩(非 OpenAI / Azure provider)的新历史只留最近约 2 万 token 的用户消息 + 这份摘要,助手回复和工具输出全部丢掉;OpenAI 默认走远程压缩,摘要加密,保留预算 64,000 token,同样不留助手回复。整个会话一行一行写进 rollout JSONL,恢复时从最后一个压缩检查点开始重放。
压缩让长任务能一直跑下去,代价是有损:摘要漏掉的东西,模型就再也看不到了。测开要回答的问题是:压缩以后关键信息还在不在,任务还能不能做完。
1先数清楚:Codex 怎么估 token
第 2 周的 agent loop 只在模型返回后读 usage。Codex 要在发请求之前判断"会不会超",所以它把两个来源拼起来(core/src/context_manager/history.rs:904-921):
- 精确值:上一次模型响应里服务端返回的
total_tokens。 - 估算值:在那之后才追加进历史的条目(工具输出、新的用户消息等),用字节数粗估。
估算规则只有一行:4 字节算 1 个 token,向上取整(utils/string/src/truncate.rs:4、:77-80):
const APPROX_BYTES_PER_TOKEN: usize = 4;
// ...
pub fn approx_token_count(text: &str) -> usize {
let len = text.len();
len.saturating_add(APPROX_BYTES_PER_TOKEN.saturating_sub(1)) / APPROX_BYTES_PER_TOKEN
}
| Rust | 用 Python 说 |
|---|---|
text.len() | 是 UTF-8 字节数,不是字符数,相当于 len(text.encode()) |
saturating_add(3) / 4 | (n + 3) // 4,即 \(\lceil n/4 \rceil\)。saturating_ 表示溢出时停在最大值而不是回绕 |
所以 "hello world"(11 字节)估成 3 个 token;一个汉字在 UTF-8 里占 3 字节,按这个公式只算 0.75 个 token,1,000 个汉字估成 750。图片不看字节:默认按固定的 7,373 字节、约 1,844 token 估(history.rs:1044-1048);带 detail: "original" 的图片改按 32×32 像素的 patch 数估,上限 ORIGINAL_IMAGE_MAX_PATCHES = 10_000(history.rs:1050-1056、history.rs:1268-1279)。
estimate_token_count 的注释说得很清楚:This is a coarse lower bound, not a tokenizer-accurate count.(history.rs:623-624)。这句说的是"整份历史都靠字节估"的那个函数;判断要不要压缩时,字节估算只负责"追加的那一小段",大头仍是服务端给的精确值,所以误差被限制在最近一次请求之后新增的部分。中文内容按这个公式估出来的 token 和真实 tokenizer 差多少,本章没有实测。2三个数:窗口、95%、90%
"90% 就压缩"这句话里的 90%,经常被说错。源码里和窗口有关的是三个数(protocol/src/openai_models.rs:515-537):
pub fn resolved_context_window(&self) -> Option<i64> {
self.context_window.or(self.max_context_window)
}
/// Context available to inference after reserving this model's configured headroom.
pub fn usable_context_window(&self) -> Option<i64> {
self.resolved_context_window().map(|context_window| {
context_window.saturating_mul(self.effective_context_window_percent) / 100
})
}
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
}
| 名字 | 算法 | 默认值(窗口 272,000) | 含义 |
|---|---|---|---|
窗口 resolved_context_window | 模型的 context_window,没有就用 max_context_window | 272,000 | 模型元数据给的总窗口 |
可用窗口 usable_context_window | 窗口 × effective_context_window_percent / 100,这个百分比默认 95 | 258,400 | 给系统提示、工具定义和模型输出留 5% 余量。客户端看到的 model_context_window 就是它 |
自动压缩阈值 auto_compact_token_limit() | 窗口 × 9 / 10,写死在代码里;配置了 model_auto_compact_token_limit 就取两者较小值 | 244,800 | 到这里就触发自动压缩 |
逐行看:Option<i64> 是"可能没有值的整数",相当于 Python 的 int | None;.or(...) 是"左边没有就用右边";map_or(默认, 函数) 是"没配置就用默认,配置了就取 min"。所以(默认 scope Total 下)配置值只能往下调、不能超过 90%;scope 设为 BodyAfterPrefix 时配置值直接生效、不被夹到 90%,只受 95% 可用窗口兜底(context_window.rs:68-78)。字段注释也是这么写的:"When provided, core clamps it to 90% of the context window"(openai_models.rs:457-468)。effective_context_window_percent 的默认值 95 在 :390-392;272,000 是未知模型的兜底元数据(models-manager/src/model_info.rs:129-133)。源码里的单测把三个数一次断言完(:1871-1889):窗口 272,000、配置 250,000 时,结果是 (272_000, 258_400, 244_800)。
effective_context_window_percent 默认 90%"。不对:它默认 95,管的是"可用窗口";90% 是 auto_compact_token_limit() 里写死的 * 9 / 10,而且是原始窗口的 90%,不是可用窗口的 90%。真正判断"该压缩了"的地方在 core/src/session/context_window.rs:83-117,两个条件满足一个就算到线:
let full_context_window_limit = model_info.resolved_context_window().map(|context_window| {
context_window.saturating_mul(model_info.effective_context_window_percent) / 100
});
// ...
let full_context_window_limit_reached =
full_context_window_limit.is_some_and(|limit| active_context_tokens >= limit);
let token_limit_reached = buffered_auto_compact_limit
.is_some_and(|limit| auto_compact_scope_tokens >= limit)
|| full_context_window_limit_reached;
即 \(\text{当前 token} \ge \text{压缩阈值}\) 或 \(\text{当前 token} \ge \text{可用窗口}\)。默认 90% < 95%,所以总是 90% 先触发;95% 是兜底。buffered_ 那个缓冲只在实验特性 token_budget 打开时非零,这个特性默认关闭、标记为开发中(features/src/lib.rs:1759-1764),本章不展开。
我本机的 rollout 里(2026-10-01 统计,随本机会话增长而变),token_count 事件记录的 model_context_window 有 828,400、258,400、251,750、353,400、950,000 五种值。它们除以 0.95 都得到整数窗口:872,000、272,000、265,000、372,000、1,000,000。其中 272,000 和 372,000 见内置模型元数据(models.json:34、models.json:1264),其余来自我本机的配置覆盖。这和"客户端看到的是可用窗口(窗口 × 95%)"相符,但只是相符,不是证明。
3什么时候压缩:turn 前、turn 中、turn 后
先对齐术语。Codex 的一个 turn 是"用户发一条消息,到 agent 做完";一个 turn 里会有多次采样请求(调一次模型 = 第 2 周 loop 里的一圈)。压缩可以发生在三个时机:
| 时机 | 触发条件 | 源码 | 初始上下文 |
|---|---|---|---|
| turn 前(PreTurn) | 新 turn 开始、新用户消息还没写进历史时,已经到线 | turn.rs:1298-1327 | 不注入,下一次正常请求时完整重新注入 |
| turn 中(MidTurn) | 一次采样结束后,还要继续(有工具调用待回、或有排队的输入),且到线 | turn.rs:601-651 | 插回到最后一条真实用户消息之前 |
| turn 后(PostTurn) | turn 正常结束,且超过 model_post_turn_compact_threshold_percent(相对可用窗口,即 95% 那个数);开了 TokenBudget 特性时不做 | turn.rs:713-756 | 不注入 |
turn 中的那段是和第 2 周 loop 最像的地方:
let should_roll_over = needs_follow_up
&& (sess.take_new_context_window_request().await || token_limit_reached);
// ...
// as long as compaction works well in getting us way below the token limit, we shouldn't worry about being in an infinite loop.
if should_roll_over {
if let Err(err) = run_auto_compact(
// ...
InitialContextInjection::BeforeLastUserMessage { /* ... */ },
CompactionReason::ContextLimit,
CompactionPhase::MidTurn,
)
.await
{ /* 压缩失败:报错,结束这个 turn */ }
// ...
continue;
}
逐行看:needs_follow_up = model_needs_follow_up || has_pending_input(turn.rs:566),即模型还要接着干(比如有工具调用待回,类似第 2 周的 stop_reason == "tool_use")或者有排队等处理的输入;到线就压缩,然后 continue 回到循环顶部发下一次请求,任务不中断。第 2 周的 loop 碰到 token 预算是直接停;Codex 是压缩后接着跑。我的理解是:正因为有压缩兜底,Codex 才能不设 max_turns 也让长任务跑下去。那句注释也值得记住:作者承认这里依赖"压缩足够有效",如果压完还在线上,下一步会再压一次。
1)turn 后压缩默认关闭。配置项注释原文:"Omitted or zero disables turn-end compaction"(
config/src/config_toml.rs:185-189),默认值是 0(core/src/config/mod.rs:4292-4294)。所以默认只有"turn 前"和"turn 中"两个时机。turn 结束时即使已经到线,也要等下一个 turn 开头再压。打开时,这个百分比相对可用窗口(窗口 × 95%)算;开了 TokenBudget 特性时也不做 turn 后压缩(turn.rs:716)。2)turn 前压缩发生在新用户消息写入之前。源码里有一条 TODO 承认了这一点(
turn.rs:179-182):还没估算即将进来的输入。一条超长的用户消息可以让第一次请求直接超窗。这时 Codex 把 token 计数记为"满"(turn.rs:1700-1704),这个 turn 以报错结束,下一个 turn 开头一定会先压缩。另外两个也走 turn 前压缩的原因:换到了上下文更小的模型(ModelDownshift,三个条件同时满足才触发:当前 token 已超过新模型的线、模型确实换了、新窗口比旧窗口小,turn.rs:1424-1442),或者模型的压缩兼容哈希变了(CompHashChanged),见 turn.rs:1366-1464。手动压缩(Op::Compact)按同样的规则选远程或本地实现,本地走的也是 compact.rs 里同一套逻辑(core/src/tasks/compact.rs:48-75)。
4压缩怎么做:一份交接摘要 + 最近的用户消息
先分清两种实现(model-provider/src/provider.rs:461-468、turn.rs:1500-1533):provider 是 OpenAI 或 Azure Responses 时走远程压缩(服务端生成,摘要以加密的 compaction 条目返回,本地看不到原文,保留消息的预算是 RETAINED_MESSAGE_TOKEN_BUDGET = 64_000,compact_remote_v2.rs:75);其他 provider 走本地压缩(core/src/compact.rs),逻辑全部可读。下面讲本地压缩。
第一步:把整个历史 + 一段压缩 prompt 发给模型
默认的压缩 prompt 是 prompts/templates/compact/prompt.md,全文如下(配置 compact_prompt 可以替换,compact.rs:120-125):
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.
它被当成一条用户消息追加在历史末尾,整个历史连同它一起发出去。注意措辞是 handoff summary for another LLM:不是给人看的会议纪要,而是写给"接手的下一个模型"的交接单,所以要求进度、关键决定、约束、下一步和关键数据。
第二步:拼出新历史
模型返回的摘要前面会加一段固定前缀 SUMMARY_PREFIX(compact.rs:361),原文(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:
然后从历史里挑用户消息,从最新往回挑,总量不超过 COMPACT_USER_MESSAGE_MAX_TOKENS = 20_000(compact.rs:58、:668-746):
let mut remaining = max_tokens; // 20_000
for message in user_messages.iter().rev() { // 从最新的用户消息往回
if remaining == 0 { break; }
let tokens = approx_token_count(&message.message); // 4 字节 ≈ 1 token
// ...
let content = if tokens <= remaining /* 且全是文本 */ {
content.clone() // 放得下:原样保留
} else {
vec![ContentItem::InputText {
text: truncate_text(&message.message, TruncationPolicy::Tokens(remaining)),
}] // 放不下:保留头尾、截掉中间,总量≈剩余额度(带 tokens truncated 标记)
};
selected_messages.push(/* ... */);
if tokens > remaining { break; } // 截过一条就停
remaining = remaining.saturating_sub(tokens);
}
selected_messages.reverse(); // 恢复时间顺序
// ...
history.push(/* CompactionSummary:SUMMARY_PREFIX + 摘要,role 是 user */);
新历史 = [挑出来的用户消息(按时间顺序)] + [摘要]。助手的回复、工具调用和工具输出一条都不留,它们的信息只能靠摘要转述。旧的摘要也不会被当成用户消息再挑一遍(is_summary_message 过滤,:565-585)。turn 中压缩还会把初始上下文(开发者指令、环境信息等)插回最后一条真实用户消息之前(:587-653);系统指令(base instructions)不在历史里,每次请求单独带上,压缩碰不到它。
第三步:落盘,并提醒用户
新历史作为 Compacted 条目写进 rollout(下一节),然后发一条警告事件,原文是(compact.rs:403-406):
Heads up: Long threads and multiple compactions can cause the model to be less accurate. Start a new thread when possible to keep threads small and targeted.
Codex 自己也承认:压缩是有损的,压得越多越不准。
5压缩请求本身也超窗了怎么办
压缩请求要把整个历史发出去,可历史已经快满了。如果最后几步的工具输出特别大,这个请求本身就会超窗。Codex 的处理(compact.rs:316-328):
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);
}
- 删最旧的一条,再发一次。
remove_first_item会把配对的另一半一起删掉(删了工具调用,就删掉它的输出),保证调用和结果仍然成对(history.rs:650-662)。 retries = 0:超窗不算网络重试,重试计数清零。所以这是一个"删一条、试一次"的循环,直到放得下。- 只剩压缩 prompt 自己(
turn_input_len为 1)还超窗,就放弃:把 token 计数记为满,返回错误。
为什么删最旧的?注释给了两个理由:保住前缀缓存、保住最近的消息。我的理解是:系统指令和工具定义每次都在请求最前面,删最旧的历史条目不影响这段前缀;而最近的工具输出往往是摘要最需要的内容。代价是:被删掉的最旧条目,摘要就看不到了。不过挑"保留的用户消息"时用的是会话的完整历史,不是删过的这份(compact.rs:348-362),所以最早的用户消息如果还在 2 万 token 额度内,原文仍然会留下来。
core/tests/suite/compact.rs:4068-4163):第一次压缩请求返回 context_length_exceeded,第二次返回摘要;断言一共 3 个请求、重试请求的 input 恰好少一条、而且第一条变了(删的是最旧的)。这就是第 2 周说的"用脚本化的假模型把每条路径确定地跑一遍"。6rollout JSONL:会话记录与恢复
文件在哪
$CODEX_HOME/sessions/YYYY/MM/DD/rollout-YYYY-MM-DDThh-mm-ss-<thread_id>.jsonl,CODEX_HOME 默认是 ~/.codex,日期按本地时间(rollout/src/recorder.rs:1723-1745、rollout_file_name.rs:62-74、utils/home-dir/src/lib.rs:5-18)。
每行长什么样
一行一个 JSON:{"timestamp": ..., "ordinal": ..., "type": ..., "payload": {...}}(history/src/lib.rs:361-367)。type 的取值来自 RolloutItemWire(history/src/rollout_payload.rs:30-72,#[serde(tag = "type", rename_all = "snake_case")]):
| type | 里面是什么 |
|---|---|
session_meta | 会话元数据:id、cwd、版本、provider、系统指令等 |
response_item | 模型可见的历史条目:message、reasoning、function_call / function_call_output 等 |
event_msg | 事件流:task_started、task_complete、token_count、turn_aborted 等(turn 开始 / 结束在线上叫 task_started / task_complete,protocol.rs:1407-1418) |
turn_context | 这个 turn 用的模型、审批策略、沙箱、cwd 等设置 |
compacted | 压缩检查点:message(摘要文本)+ replacement_history(压缩后的完整新历史)+ 窗口编号等(history/src/lib.rs:286-306) |
| 其他 | token_usage_record、world_state、inter_agent_communication 等 |
关键是 replacement_history:压缩时直接把"压缩后的新历史"整份存下来(core/src/session/mod.rs:4122-4157),而不是只存摘要。
恢复时怎么重放
codex resume 走到 reconstruct_history_from_rollout(core/src/session/rollout_reconstruction.rs:169-536),分两遍:
- 倒着扫:从文件末尾往前找,找到最新的、没有被回滚掉的、带
replacement_history的compacted。途中遇到thread_rolled_back事件就记下"要跳过最近 N 个用户 turn"。 - 正着放:历史先设成那份
replacement_history,再把它后面的response_item依次追加;遇到回滚事件就删掉最近 N 个用户 turn。
if let Some(checkpoint) = history_checkpoint
&& let Some(items) = &checkpoint.compacted.replacement_history
{
history.replace_annotated(items.clone()); // 基底 = 压缩后的完整历史
// ...
}
let rollout_suffix =
history_checkpoint.map_or(rollout_items, |checkpoint| checkpoint.suffix);
for item in rollout_suffix { // 只重放检查点之后的条目
match item {
RolloutItem::ResponseItem(response_item) => { history.replay_annotated_item(/* ... */); }
RolloutItem::EventMsg(EventMsg::ThreadRolledBack(rollback)) => {
history.drop_last_n_user_turns(rollback.num_turns);
}
// ...
}
}
为什么只重放最后一个检查点之后?注释原话:"A surviving replacement-history compaction is a complete history base. Once we know the newest surviving one, older rollout items do not affect rebuilt history."(:138-139)。检查点本身就是完整的历史快照,前面的条目不会再改变结果。好处有两个:恢复的工作量只和检查点之后的长度有关;恢复出来的模型上下文和中断前一样(同样是压缩后的版本),而不是把原始的几十万 token 又塞回去撑爆窗口。老格式的 compacted 没有 replacement_history,就用当时的用户消息加 message 里的摘要现场重建一遍,并把初始上下文重新注入到恢复后会话的末尾,源码注释承认这种 prompt 形状"暂时不同于正常分布"(:438-458)。
回滚那部分只对旧 rollout 有意义:ThreadRolledBack 在当前协议里被标为 legacy 标记,"Retained for replay of existing rollouts; live rollback operations are unsupported"(protocol.rs:1402-1404),新版本不再产生它。
replacement_history。▶互动演示:上下文窗口模拟器
每点一次"下一步"=一次采样请求。每个 turn:一条用户消息,然后若干步(每步追加助手回复 + 工具输出),最后一步是最终回答。按本地压缩(非 OpenAI / Azure provider)的规则:阈值、95% 可用窗口、turn 前 / turn 中 / turn 后三个时机、2 万 token 用户消息额度、压缩请求超窗时删最旧的条目,都按源码的规则算。token 直接按设定值累加(不再做字节估算);固定前缀代表系统指令、工具定义和初始上下文,始终保留。第 1 轮的用户消息里埋了一条"关键约束",看它压缩后还剩什么。
最近一次压缩:保留了什么
事件日志
✎练习
effective_context_window_percent 是默认值。自动压缩阈值 auto_compact_token_limit() 是多少?approx_token_count 估成多少 token?context_length_exceeded。Codex 怎么做?compacted 检查点(都带 replacement_history,没有回滚)。codex resume 重建出来的模型历史是什么?7测开视角:压缩怎么评测
压缩是有损的,而且损失在哪由模型决定。评测要分两层:
确定性的部分:不调模型,直接断言
| 测什么 | 怎么断言 |
|---|---|
| 阈值算术 | 窗口 272,000 → (272,000, 258,400, 244,800);配置值超过 90% 时被钳住(源码的单测就是这么写的) |
| 用户消息额度 | 构造若干条用户消息,断言保留总量 ≤ 20,000、顺序是时间顺序、摘要在最后、超额的那条被截断且带截断标记(compact_tests.rs 里有对应用例) |
| 压缩请求超窗 | mock 服务器第一次返回 context_length_exceeded:断言重试请求恰好少最旧的一条 |
| 恢复 | 写一个带两个检查点的 rollout,断言重建结果 = 第二个检查点 + 之后的条目(compact_resume_after_second_compaction_preserves_history,compact_resume_fork.rs:354);旧 rollout 里带回滚标记的情况,断言被回滚的 turn 被删掉(core/src/session/rollout_reconstruction_tests.rs,如 :579、:1119) |
有损的部分:用指标衡量
- 关键信息保留率:在对话早期埋入 N 条关键事实(约束"不要改公开 API"、文件路径、已做的决定、具体数字),把阈值调低强制压缩(Codex 自己的测试就是把
model_auto_compact_token_limit设小),压缩后逐条探测。保留率 = 还能正确用到的条数 / N。按类别、按埋入位置(早 / 晚)、按压缩次数分开统计,因为最早的内容最容易被删、多次压缩会叠加损失。 - 压缩前后任务成功率:同一批任务,一组用大窗口(不触发压缩),一组用小阈值(必然压缩),比较通过率。两组是同一批任务,所以是配对数据,用第 1 周的 McNemar 检验;样本少时报 Wilson 区间,不要只报一个百分比。
- 压缩后的行为指标:重复劳动(压缩后又去读已经读过的文件)、违反早期约束的次数、多跑的 token。这些从 rollout 里就能算。
8动手实验:统计本机的 rollout
脚本只读、只输出类型名和数字,不打印任何对话内容;没有数据时加 --demo,按源码格式造一个虚构样例跑一遍。
python3 week03_Codex源码/code/rollout_stats.py # 扫 $CODEX_HOME/sessions(默认 ~/.codex/sessions)
python3 week03_Codex源码/code/rollout_stats.py --demo # 本机没有数据时
我本机 2026-10-01 的结果(节选,随本机会话增长而变;本机 CODEX_HOME 指向另一个运行目录,其中的 sessions 是 ~/.codex/sessions 截至 2026-09-30 的子集,所以是 277 个文件,第 6 周显式扫 ~/.codex/sessions、截至 2026-10-01 18:00(北京时间)得到 297 个):
文件 277 个,138,118 行,解析失败 0 行
[顶层 type]
response_item 63,729 · event_msg 57,979 · token_usage_record 10,852 · turn_context 3,148
inter_agent_communication_metadata 1,106 · world_state 880 · session_meta 387 · compacted 37
[压缩检查点] 37 个,分布在 22 个文件;message 为空(摘要加密、疑似远程压缩)24 个
replacement_history 里保留了什么:message/user 874 · message/developer 83
message/assistant 47 · compaction 24
后面跟着 token_count 的 37 个:下降 22,持平 11,上升 0,之前没有 token_count 4
下降的那些:压缩前中位数 223,871,压缩后中位数 22,058,压缩后 / 压缩前 中位数 6.1%
读法:一次压缩把上下文从二十多万 token 压到两万出头,中位数只剩约 6%,被丢掉的那 94% 只能靠摘要转述。24 个检查点的 message 为空、replacement_history 里带加密的 compaction 条目,说明这些会话走的是远程压缩(我用的是 OpenAI provider)。37 个检查点后面都跟着 token_count:下降 22 个、持平 11 个、上升 0 个,另有 4 个之前没有 token_count;11 个没有下降(持平),原因没有核实。在 7993248,远程压缩同样只保留用户消息(以及 hook prompt、客户端 developer 消息),预算 64,000 token,丢弃助手回复(compact_remote_v2.rs:587-600);另外,AgentMessage 默认保留,但排除后代的进度消息和 FINAL_ANSWER 完成消息,且单条不超过 10,000 token(MAX_RETAINED_AGENT_MESSAGE_TOKENS,:76、:562-586)。我本机数据里的 message/assistant 来自旧版本写的 rollout,不能当作当前行为。这些会话由多个 Codex 版本写成,本机配置也改过窗口和阈值,所以不能用这组数据反推默认阈值,它只用来看格式和量级。完整脚本见博客正文。
9面试要点
一句话讲清楚
Codex 用"服务端上次返回的 token 数 + 之后新增内容按 4 字节 1 token 估算"判断上下文大小;到原始窗口的 90%(或可用窗口 95%)就压缩,默认在 turn 开始前和 turn 中途两个时机。本地压缩让模型按固定模板写一份给下一个模型的交接摘要,新历史只留最近 2 万 token 的用户消息加摘要;压缩请求自己超窗就从最旧的条目删起。整个会话写进 rollout JSONL,压缩检查点存了完整的新历史,恢复时只重放最后一个检查点之后的内容。
追问准备
- 为什么阈值是 90% 而不是 100%?源码没有写 90 这个数的理由。95% 那条线的注释写的是给系统提示、工具开销和模型输出留余量;我的理解是 90% 再多留一截,因为压缩请求本身要把整个历史加一段 prompt 发出去,还要让模型写出摘要。
- 为什么只留用户消息,不留助手回复和工具输出?源码同样没有注释说明。我的理解:用户消息是"要求",体积小、原文最不能丢;工具输出体积大、过时快,交给摘要概括。代价是工具输出里的细节只能靠摘要。
- 压缩和截断工具输出有什么区别?截断是每条工具输出写进历史时就按策略砍掉中间(
history.rs:546-555,和挑用户消息时用的是同一个truncate_text);压缩是整个历史快满时换成摘要。前者每条输出都可能发生,后者整段对话偶尔发生一次。 - 你会怎么测压缩?确定性部分用 mock 模型断言(阈值、额度、超窗删最旧、恢复);有损部分用关键信息保留率和压缩前后任务成功率,配对比较用 McNemar。
常见错误说法
effective_context_window_percent 默认 90%,到 90% 就压缩":它默认 95,管可用窗口;90% 是压缩阈值里写死的 * 9 / 10,按原始窗口算。❌ "Codex 在 turn 前、turn 中、turn 后都会自动压缩":turn 后压缩默认关闭(阈值默认 0)。
❌ "压缩保留了最近几轮完整对话":本地压缩(非 OpenAI / Azure provider)只保留最近约 2 万 token 的用户消息,助手回复和工具输出全换成摘要;OpenAI 默认走远程压缩,同样不留助手回复,保留预算 64,000 token,摘要加密。
❌ "压缩超窗时删掉最新的大工具输出":删的是最旧的条目。
❌ "恢复会话就是把 JSONL 从头重放一遍":从最后一个可用的压缩检查点开始,只重放之后的条目。
❌ "token 是用 tokenizer 精确算的":大头来自服务端返回的 usage,新增部分按 4 字节 1 token 粗估,源码注释自称粗略下界。
所有源码引用都基于 openai/codex commit 7993248(2026-10-01),路径省略了前缀 codex-rs/。公式显示依赖 KaTeX(CDN),断网时公式会显示成原始 LaTeX 源码,交互部分不受影响。