上下文快满了,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):

  1. 精确值:上一次模型响应里服务端返回的 total_tokens。
  2. 估算值:在那之后才追加进历史的条目(工具输出、新的用户消息等),用字节数粗估。

估算规则只有一行: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_window272,000模型元数据给的总窗口
可用窗口 usable_context_window窗口 × effective_context_window_percent / 100,这个百分比默认 95258,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);
}
  1. 删最旧的一条,再发一次。remove_first_item 会把配对的另一半一起删掉(删了工具调用,就删掉它的输出),保证调用和结果仍然成对(history.rs:650-662)。
  2. retries = 0:超窗不算网络重试,重试计数清零。所以这是一个"删一条、试一次"的循环,直到放得下。
  3. 只剩压缩 prompt 自己(turn_input_len 为 1)还超窗,就放弃:把 token 计数记为满,返回错误。

为什么删最旧的?注释给了两个理由:保住前缀缓存、保住最近的消息。我的理解是:系统指令和工具定义每次都在请求最前面,删最旧的历史条目不影响这段前缀;而最近的工具输出往往是摘要最需要的内容。代价是:被删掉的最旧条目,摘要就看不到了。不过挑"保留的用户消息"时用的是会话的完整历史,不是删过的这份(compact.rs:348-362),所以最早的用户消息如果还在 2 万 token 额度内,原文仍然会留下来。

Codex 用 mock 模型服务器测这个分支(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),分两遍:

  1. 倒着扫:从文件末尾往前找,找到最新的、没有被回滚掉的、带 replacement_history 的 compacted。途中遇到 thread_rolled_back 事件就记下"要跳过最近 N 个用户 turn"。
  2. 正着放:历史先设成那份 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),新版本不再产生它。

两份历史:rollout 文件是只追加的完整日志,压缩前的原始条目一条不少,适合人看、做回放评测和错误分析(第 6 周会用);模型看到的是压缩后的历史。评测时别把两者混为一谈:从 rollout 能看到"当时发生了什么",但模型压缩后"还记得什么"要看 replacement_history。

▶互动演示:上下文窗口模拟器

每点一次"下一步"=一次采样请求。每个 turn:一条用户消息,然后若干步(每步追加助手回复 + 工具输出),最后一步是最终回答。按本地压缩(非 OpenAI / Azure provider)的规则:阈值、95% 可用窗口、turn 前 / turn 中 / turn 后三个时机、2 万 token 用户消息额度、压缩请求超窗时删最旧的条目,都按源码的规则算。token 直接按设定值累加(不再做字节估算);固定前缀代表系统指令、工具定义和初始上下文,始终保留。第 1 轮的用户消息里埋了一条"关键约束",看它压缩后还剩什么。

场景
当前上下文
生效阈值 = min(配置, 窗口×90%)
可用窗口(95%)
turn / 采样步
压缩次数(前 / 中 / 后)
关键约束(第 1 轮用户消息)
固定前缀 用户消息 助手回复 + 工具输出 压缩摘要 超出窗口
折线:每次采样后的上下文大小。竖直下落 = 一次压缩(橙色 turn 前、蓝色 turn 中、绿色 turn 后),红点 = 请求超窗。

最近一次压缩:保留了什么

还没有压缩。

事件日志

✎练习

题 1(算):一个模型的窗口是 400,000,没有改任何配置,effective_context_window_percent 是默认值。自动压缩阈值 auto_compact_token_limit() 是多少?
token
400,000 × 9 / 10 = 360,000。95% 那个是可用窗口 380,000,不是压缩阈值。如果你填了 380,000,就是把 effective_context_window_percent 当成了压缩阈值。
题 2(算):一段 1,000 个汉字的中文(UTF-8 每字 3 字节,不含其他字符),Codex 的 approx_token_count 估成多少 token?
token
3,000 字节,(3000 + 3) // 4 = 750。按的是字节数,不是字符数。
题 3(算):压缩时历史里有三条用户消息,从旧到新分别约 12,000、6,000、5,000 token(都是纯文本)。按 20,000 的额度从新往旧挑,最旧那条会被截到大约多少 token?
token
先放最新的 5,000(剩 15,000),再放 6,000(剩 9,000),最旧的 12,000 放不下,保留开头和结尾、截掉中间,压到剩余的 9,000 后停止(truncate.rs:15-36)。三条都在,但最旧那条只剩一部分(中间被截掉,截断文本里带有截断标记)。
题 4(行为):本地压缩时,压缩请求本身返回了 context_length_exceeded。Codex 怎么做?
compact.rs:316-328。A 是其他错误的处理方式;超窗不算可重试的网络错误。B 方向反了:注释说要保住前缀缓存和最近的消息。代价是被删的最旧条目,摘要看不到了。
题 5(恢复):一个 rollout 里先后有两个 compacted 检查点(都带 replacement_history,没有回滚)。codex resume 重建出来的模型历史是什么?
最新的、未被回滚的检查点就是完整的历史基底,更早的条目不影响结果(rollout_reconstruction.rs:138-139)。A 会把已经压缩掉的内容塞回去,恢复后第一个请求就可能超窗。

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)

有损的部分:用指标衡量

  1. 关键信息保留率:在对话早期埋入 N 条关键事实(约束"不要改公开 API"、文件路径、已做的决定、具体数字),把阈值调低强制压缩(Codex 自己的测试就是把 model_auto_compact_token_limit 设小),压缩后逐条探测。保留率 = 还能正确用到的条数 / N。按类别、按埋入位置(早 / 晚)、按压缩次数分开统计,因为最早的内容最容易被删、多次压缩会叠加损失。
  2. 压缩前后任务成功率:同一批任务,一组用大窗口(不触发压缩),一组用小阈值(必然压缩),比较通过率。两组是同一批任务,所以是配对数据,用第 1 周的 McNemar 检验;样本少时报 Wilson 区间,不要只报一个百分比。
  3. 压缩后的行为指标:重复劳动(压缩后又去读已经读过的文件)、违反早期约束的次数、多跑的 token。这些从 rollout 里就能算。
别只测"压缩成功了"。压缩请求返回 200、新历史变短了,只说明机制在工作;该测的是压缩后任务还做不做得对。这是测开在 Agent 方向最有价值的一类用例:功能测试全绿,质量却在悄悄下降。

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,压缩检查点存了完整的新历史,恢复时只重放最后一个检查点之后的内容。

追问准备

  1. 为什么阈值是 90% 而不是 100%?源码没有写 90 这个数的理由。95% 那条线的注释写的是给系统提示、工具开销和模型输出留余量;我的理解是 90% 再多留一截,因为压缩请求本身要把整个历史加一段 prompt 发出去,还要让模型写出摘要。
  2. 为什么只留用户消息,不留助手回复和工具输出?源码同样没有注释说明。我的理解:用户消息是"要求",体积小、原文最不能丢;工具输出体积大、过时快,交给摘要概括。代价是工具输出里的细节只能靠摘要。
  3. 压缩和截断工具输出有什么区别?截断是每条工具输出写进历史时就按策略砍掉中间(history.rs:546-555,和挑用户消息时用的是同一个 truncate_text);压缩是整个历史快满时换成摘要。前者每条输出都可能发生,后者整段对话偶尔发生一次。
  4. 你会怎么测压缩?确定性部分用 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 源码,交互部分不受影响。