第 16 章
第 3 周:上下文快满了,Codex 怎么办?
读 Codex 源码看上下文压缩与会话恢复:4 字节 1 token 的估算,窗口 / 95% 可用窗口 / 90% 压缩阈值三个数,turn 前与 turn 中两个压缩时机,交接摘要 prompt 与本地压缩的 2 万 token 用户消息额度,压缩请求超窗时删最旧,rollout JSONL 与从最后一个检查点重放。一段讲解视频,一个上下文窗口模拟器,一个只读统计本机 rollout 的脚本。
第 2 周写的 agent loop,token 用到预算就停。可一个真实的编码任务动辄几十轮工具调用,读文件、跑测试、看日志,历史很快就把上下文窗口撑满。停下来不行,硬塞进去会被服务端拒绝。Codex 的做法是压缩:快满的时候让模型写一份"交接摘要",用摘要换掉大部分历史,然后接着干。这一章读它的源码,回答四个问题:token 怎么数、什么时候压、压完留下什么、会话中断后怎么恢复。最后从测开的角度看:压缩是有损的,该怎么测。
下文的源码引用都基于 openai/codex commit 7993248(2026-10-01),路径省略前缀 codex-rs/,链接指向这个 commit 的固定版本。
讲解视频
互动演示
一个上下文窗口模拟器。每点一次"下一步"是一次采样请求:每个 turn 先来一条用户消息,然后若干步,每步追加助手回复和工具输出。上下文涨到阈值就按源码的规则压缩,下方显示压缩前后保留了什么(固定前缀、摘要、最近的用户消息)、丢了什么。可以调窗口大小、配置的阈值、每步增长、用户消息长度、摘要长度和 turn 后压缩阈值;四个场景分别演示正常压缩、压缩请求本身超窗、超长用户消息让请求直接超窗、小窗口压完仍在线上。第 1 轮用户消息里埋了一条"关键约束",可以看它压缩后是原文还在、被截断、只剩摘要转述,还是彻底丢了。页面底部有自动判分的练习。
Codex 怎么估 token
第 2 周的 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
}
text.len()是 UTF-8 字节数,不是字符数,相当于 Python 的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 差多少,本章没有实测。
三个数:窗口、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
}
逐行看:Option<i64> 是"可能没有值的整数”,相当于 Python 的 int | None;.or(...) 是"左边没有就用右边";map_or(默认, 函数) 是"没配置就用默认,配置了就取 min"。
| 名字 | 算法 | 窗口 272,000 时 | 含义 |
|---|---|---|---|
窗口 resolved_context_window | 模型的 context_window,没有就用 max_context_window | 272,000 | 模型元数据给的总窗口 |
可用窗口 usable_context_window | 窗口 × effective_context_window_percent / 100,这个百分比默认 95 | 258,400 | 给系统提示、工具开销和模型输出留余量;客户端看到的 model_context_window 就是它 |
自动压缩阈值 auto_compact_token_limit() | 窗口 × 9 / 10,写死在代码里;配置了 model_auto_compact_token_limit 就取两者较小值 | 244,800 | 到这里触发自动压缩 |
所以(默认 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
)。源码里的单测把三个数一次断言完(
openai_models.rs:1871-1889
):窗口 272,000、配置 250,000 时,结果是 (272_000, 258_400, 244_800)。
真正判断"该压缩了"的地方在
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;
即"当前 token ≥ 压缩阈值"或"当前 token ≥ 可用窗口"。默认 90% < 95%,所以总是 90% 先触发,95% 是兜底。buffered_ 那个缓冲只在实验特性 token_budget 打开时非零,这个特性默认关闭、标记为开发中(
features/src/lib.rs:1759-1764
),这里不展开。
我本机 rollout 里的 token_count 事件(2026-10-01 统计,随本机会话增长而变),记录的 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%)“相符,但只是相符,不是证明。
什么时候压缩: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 也让长任务跑下去。那句注释也值得记住,作者承认这里依赖"压缩足够有效":如果压完还在线上,下一步会再压一次(互动演示的"小窗口"场景就是这种情况)。
两个容易漏掉的细节:
- 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)。 - 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
)。
压缩怎么做:交接摘要 + 最近的用户消息
先分清两种实现(
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 */);
新历史 = [挑出来的用户消息(按时间顺序)] + [摘要]。举个例子:三条用户消息从旧到新约 12,000、6,000、5,000 token,先放 5,000(剩 15,000),再放 6,000(剩 9,000),最旧那条放不下,保留开头和结尾、截掉中间,压到约 9,000 token(中间插一个 …N tokens truncated… 标记,
truncate.rs:15-36
),然后停止。
助手的回复、工具调用和工具输出一条都不留,它们的信息只能靠摘要转述。旧的摘要也不会被当成用户消息再挑一遍(is_summary_message 过滤,
compact.rs:565-585
),但它在被压缩的历史里,所以第二次压缩时的摘要是从"旧摘要 + 之后的新内容"里写出来的。turn 中压缩还会把初始上下文(开发者指令、环境信息等)插回最后一条真实用户消息之前(
compact.rs: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 自己也承认,压缩是有损的,压得越多越不准。
压缩请求本身也超窗了怎么办
压缩请求要把整个历史发出去,可历史已经快满了。如果最后几步的工具输出特别大,这个请求本身就会超窗。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 额度内,原文仍然会留下来。互动演示的"大工具输出"场景就是这样:第 1 轮用户消息没进压缩请求,但原文还在新历史里。
Codex 用 mock 模型服务器测这个分支(
core/tests/suite/compact.rs:4068-4163
):第一次压缩请求返回 context_length_exceeded,第二次返回摘要;断言一共 3 个请求、重试请求的 input 恰好少一条、而且第一条变了(删的是最旧的)。这就是《Agent 到底是什么?一个 while 循环》里说的"用脚本化的假模型把每条路径确定地跑一遍"。
rollout 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。
回滚这部分只对旧 rollout 有意义:ThreadRolledBack 在当前协议里被标为 legacy 标记,“Retained for replay of existing rollouts; live rollback operations are unsupported”(
protocol.rs:1402-1404
),新版本不再产生它。
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."(
rollout_reconstruction.rs:138-139
)。检查点本身就是完整的历史快照,前面的条目不会再改变结果。好处有两个:恢复的工作量只和检查点之后的长度有关;恢复出来的模型上下文和中断前一样,同样是压缩后的版本,而不是把原始的几十万 token 又塞回去撑爆窗口。老格式的 compacted 没有 replacement_history,就用当时的用户消息加 message 里的摘要现场重建一遍,并把初始上下文重新注入到恢复后会话的末尾,源码注释承认这种 prompt 形状"暂时不同于正常分布”(
rollout_reconstruction.rs:438-458
)。
注意这里有两份历史。rollout 文件是只追加的完整日志,压缩前的原始条目一条不少,适合人看、做回放评测和错误分析(第 6 周会用);模型看到的是压缩后的历史。评测时别把两者混为一谈:从 rollout 能看到"当时发生了什么",但模型压缩后"还记得什么"要看 replacement_history。
动手实验:统计本机的 rollout
下面的脚本只读扫描 $CODEX_HOME/sessions(没设 CODEX_HOME 时是 ~/.codex/sessions),统计条目类型、事件子类型和压缩检查点,只输出类型名和数字,不打印任何对话内容,也不改任何文件。本机没有数据时加 --demo,它会按源码里的格式造一个虚构样例再统计。
"""只读统计 Codex 会话记录(rollout JSONL):条目类型、事件子类型、压缩事件。
不打印任何对话内容,只输出类型名和数字。不修改任何文件。
用法:
python3 rollout_stats.py # 扫 $CODEX_HOME/sessions(默认 ~/.codex/sessions)
python3 rollout_stats.py 某个目录 # 扫指定目录
python3 rollout_stats.py --demo # 本机没有数据时:按源码里的格式造一个样例文件再统计
格式依据(openai/codex@7993248):
每行一个 RolloutLine:{"timestamp", "ordinal"?, "type", "payload"}(history/src/lib.rs:361-367)
type 取值见 RolloutItemWire(history/src/rollout_payload.rs:30-72)
"""
import json
import os
import statistics
import sys
import tempfile
from collections import Counter
from pathlib import Path
def sessions_dir() -> Path:
home = os.environ.get("CODEX_HOME")
return (Path(home) if home else Path.home() / ".codex") / "sessions"
def token_total(payload: dict):
"""token_count 事件里最近一次请求的 total_tokens;没有就返回 None。"""
info = payload.get("info") or {}
last = info.get("last_token_usage") or {}
return last.get("total_tokens"), info.get("model_context_window")
def scan_file(path: Path, st: dict) -> None:
last_tokens = None # 压缩前最近一次 token_count
waiting = False # 刚遇到 compacted,等它后面的第一个 token_count
before = None # 这个检查点之前最近一次 token_count(可能没有)
had_compaction = False
with path.open(encoding="utf-8", errors="replace") as f:
for raw in f:
st["lines"] += 1
try:
line = json.loads(raw)
except json.JSONDecodeError:
st["bad_lines"] += 1 # 正在写入的会话,最后一行可能还不完整
continue
kind = line.get("type")
payload = line.get("payload") or {}
st["types"][kind] += 1
if kind == "event_msg":
st["events"][payload.get("type")] += 1
if payload.get("type") == "token_count":
total, window = token_total(payload)
if total is not None:
st["windows"][window] += 1
if waiting:
st["pairs"].append((before, total))
waiting = False
last_tokens = total
elif kind == "response_item":
st["items"][payload.get("type")] += 1
elif kind == "compacted":
had_compaction = True
history = payload.get("replacement_history") or []
st["replacement_len"].append(len(history))
# 远程压缩的摘要是加密的 compaction 条目,message 为空;本地压缩 message 是摘要原文
st["remote_like"] += 1 if not payload.get("message") else 0
for item in history:
st["kept"][f'{item.get("type")}/{item.get("role", "-")}'] += 1
waiting, before = True, last_tokens
st["files"] += 1
st["files_with_compaction"] += had_compaction
def report(root: Path) -> None:
st = {
"files": 0, "lines": 0, "bad_lines": 0, "files_with_compaction": 0, "remote_like": 0,
"types": Counter(), "events": Counter(), "items": Counter(), "windows": Counter(),
"kept": Counter(), "replacement_len": [], "pairs": [],
}
for path in sorted(root.rglob("rollout-*.jsonl")):
scan_file(path, st)
if st["files"] == 0:
print(f"{root} 下没有 rollout-*.jsonl。可以加 --demo 用样例数据跑一遍。")
return
print(f"文件 {st['files']} 个,{st['lines']:,} 行,解析失败 {st['bad_lines']} 行")
print("\n[顶层 type]")
for k, v in st["types"].most_common():
print(f" {k:<36}{v:>8,}")
print("\n[event_msg 子类型,前 8]")
for k, v in st["events"].most_common(8):
print(f" {k:<36}{v:>8,}")
print("\n[response_item 子类型]")
for k, v in st["items"].most_common():
print(f" {k:<36}{v:>8,}")
print("\n[token_count 里的 model_context_window]")
for k, v in st["windows"].most_common(5):
print(f" {k!s:<36}{v:>8,}")
n = len(st["replacement_len"])
print(f"\n[压缩检查点] {n} 个,分布在 {st['files_with_compaction']} 个文件;"
f"message 为空(摘要加密、疑似远程压缩){st['remote_like']} 个")
if n:
print(f" replacement_history 条数:中位数 {statistics.median(st['replacement_len'])},"
f"最少 {min(st['replacement_len'])},最多 {max(st['replacement_len'])}")
print(" replacement_history 里保留了什么(type/role):")
for k, v in st["kept"].most_common():
print(f" {k:<34}{v:>6,}")
no_before = sum(1 for b, _ in st["pairs"] if b is None)
with_before = [(b, a) for b, a in st["pairs"] if b is not None]
dropped = [(b, a) for b, a in with_before if a < b]
flat = sum(1 for b, a in with_before if a == b)
rose = sum(1 for b, a in with_before if a > b)
print(f" 后面跟着 token_count 的 {len(st['pairs'])} 个:下降 {len(dropped)},持平 {flat},"
f"上升 {rose},之前没有 token_count {no_before}")
if dropped:
ratios = [a / b for b, a in dropped]
print(f" 下降的那些:压缩前中位数 {statistics.median(b for b, _ in dropped):,.0f},"
f"压缩后中位数 {statistics.median(a for _, a in dropped):,.0f},"
f"压缩后 / 压缩前 中位数 {statistics.median(ratios):.1%}")
def write_demo(directory: Path) -> None:
"""按源码里的格式造一个最小样例:两轮对话,中间发生一次本地压缩。内容都是虚构的。"""
ts = "2026-10-01T10:00:00.000Z"
def msg(role, text):
kind = "input_text" if role == "user" else "output_text"
return {"type": "message", "role": role, "content": [{"type": kind, "text": text}]}
def usage(total, window=258_400):
last = {"input_tokens": total - 200, "cached_input_tokens": 0, "cache_write_input_tokens": 0,
"output_tokens": 200, "reasoning_output_tokens": 0, "total_tokens": total}
return {"type": "token_count", "rate_limits": None,
"info": {"total_token_usage": last, "last_token_usage": last, "model_context_window": window}}
prefix = ("Another language model started to solve this problem and produced a summary of its thinking "
"process. ...") # SUMMARY_PREFIX 的开头,原文见 prompts/templates/compact/summary_prefix.md
rows = [
("session_meta", {"id": "demo-thread", "timestamp": ts, "cwd": "/demo", "originator": "demo",
"cli_version": "0.0.0", "source": "cli", "model_provider": "demo"}),
("event_msg", {"type": "task_started", "turn_id": "t1"}),
("turn_context", {"turn_id": "t1", "cwd": "/demo", "model": "demo-model"}),
("response_item", msg("user", "(虚构)修一下登录页的 bug,不要改公开 API")),
("response_item", {"type": "function_call", "name": "shell", "arguments": "{}", "call_id": "c1"}),
("response_item", {"type": "function_call_output", "call_id": "c1", "output": "(虚构)很长的日志 ..."}),
("event_msg", usage(246_000)), # 超过 272,000 × 90% = 244,800
("compacted", {"message": prefix + "\n(虚构)摘要:已定位到 lockout.py ...",
"replacement_history": [msg("user", "(虚构)修一下登录页的 bug,不要改公开 API"),
msg("user", prefix + "\n(虚构)摘要 ...")],
"window_number": 1}),
("event_msg", usage(3_100)),
("response_item", msg("assistant", "(虚构)已修复")),
("event_msg", {"type": "task_complete", "turn_id": "t1"}),
]
path = directory / "2026" / "10" / "01" / "rollout-2026-10-01T10-00-00-demo-thread.jsonl"
path.parent.mkdir(parents=True)
with path.open("w", encoding="utf-8") as f:
for i, (kind, payload) in enumerate(rows):
f.write(json.dumps({"timestamp": ts, "ordinal": i, "type": kind, "payload": payload},
ensure_ascii=False) + "\n")
def main() -> None:
args = sys.argv[1:]
if args == ["--demo"]:
with tempfile.TemporaryDirectory() as tmp:
write_demo(Path(tmp))
print("(样例数据,按源码格式虚构)")
report(Path(tmp))
return
report(Path(args[0]).expanduser() if args else sessions_dir())
if __name__ == "__main__":
main()
我本机 2026-10-01 的运行结果(会话由多个 Codex 版本写成,节选)。脚本默认读 $CODEX_HOME/sessions,没设才读 ~/.codex/sessions;我本机的 CODEX_HOME 指向另一个运行目录,那里的 sessions 是 ~/.codex/sessions 截至 2026-09-30 的子集(硬链接),所以是 277 个文件。第 6 周错误分析一章显式扫 ~/.codex/sessions,截至 2026-10-01 18:00(北京时间)文件数是 297:
$ python3 rollout_stats.py
文件 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
[event_msg 子类型,前 8]
item_completed 34,022
token_count 21,153
task_started 1,279
task_complete 1,030
thread_settings_applied 355
turn_aborted 104
thread_goal_updated 36
[token_count 里的 model_context_window]
828400 11,383
258400 5,769
251750 2,090
353400 1,839
950000 32
[压缩检查点] 37 个,分布在 22 个文件;message 为空(摘要加密、疑似远程压缩)24 个
replacement_history 条数:中位数 21,最少 5,最多 67
replacement_history 里保留了什么(type/role):
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)。 - 在 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、compact_remote_v2.rs:562-586)。我本机数据里的message/assistant来自旧版本写的 rollout,不能当作当前行为。 message/developer是 turn 中压缩时插回去的初始上下文。- 37 个检查点后面都跟着
token_count:下降 22 个、持平 11 个、上升 0 个,另有 4 个之前没有token_count。11 个没有下降(持平),原因没有核实。 - 这组数字是 2026-10-01 统计的,随本机会话增长而变。本机配置改过窗口和阈值,会话又来自多个版本,所以不能用这组数据反推默认阈值,它只用来看格式和量级。
--demo 的输出(样例数据,按源码格式虚构):
$ python3 rollout_stats.py --demo
(样例数据,按源码格式虚构)
文件 1 个,11 行,解析失败 0 行
...
[压缩检查点] 1 个,分布在 1 个文件;message 为空(摘要加密、疑似远程压缩)0 个
replacement_history 条数:中位数 2,最少 2,最多 2
replacement_history 里保留了什么(type/role):
message/user 2
后面跟着 token_count 的 1 个:下降 1,持平 0,上升 0,之前没有 token_count 0
下降的那些:压缩前中位数 246,000,压缩后中位数 3,100,压缩后 / 压缩前 中位数 1.3%
测开视角:压缩怎么评测
压缩是有损的,而且损失在哪由模型决定。评测分两层。
确定性的部分:不调模型,直接断言。
| 测什么 | 怎么断言 |
|---|---|
| 阈值算术 | 窗口 272,000 → (272,000, 258,400, 244,800);配置值超过 90% 时被钳住(源码的单测就是这么写的) |
| 用户消息额度 | 构造若干条用户消息,断言保留总量不超过 20,000、顺序是时间顺序、摘要在最后、超额的那条被截断且带截断标记(compact_tests.rs 里有对应用例) |
| 压缩请求超窗 | mock 服务器第一次返回 context_length_exceeded,断言重试请求恰好少了最旧的一条 |
| 恢复 | 写一个带两个检查点的 rollout,断言重建结果 = 第二个检查点 + 之后的条目(core/tests/suite/compact_resume_fork.rs 的 compact_resume_after_second_compaction_preserves_history,
compact_resume_fork.rs:354
);旧 rollout 里带回滚标记的情况,断言被回滚的 turn 被删掉(core/src/session/rollout_reconstruction_tests.rs,如
rollout_reconstruction_tests.rs:579
、
rollout_reconstruction_tests.rs:1119
) |
有损的部分:用指标衡量。
- 关键信息保留率。在对话早期埋入 N 条关键事实(约束"不要改公开 API"、文件路径、已经做过的决定、具体数字),把阈值调低强制压缩(Codex 自己的测试就是把
model_auto_compact_token_limit设小),压缩后逐条探测。保留率 = 压缩后还能正确用到的条数 / N。按类别、按埋入位置(早 / 晚)、按压缩次数分开统计:最早的内容最容易在超窗时被删,多次压缩会叠加损失。 - 压缩前后的任务成功率。同一批任务,一组用大窗口(不触发压缩),一组用小阈值(必然压缩),比较通过率。两组是同一批任务,属于配对数据,用《新 prompt 从 82% 涨到 86%,是真的变好了吗?》那一章的 McNemar 检验;样本少时报 Wilson 区间(《30 次挂了 2 次,失败率在什么范围?》),不要只报一个百分比。
- 压缩后的行为指标:重复劳动(压缩后又去读已经读过的文件)、违反早期约束的次数、多花的 token。这些从 rollout 里就能算。
别只测"压缩成功了"。压缩请求返回 200、新历史变短,只说明机制在工作;该测的是压缩以后任务还做不做得对。功能测试全绿、质量却在悄悄下降,这正是测开在 Agent 方向最能发挥的地方。
常见错误说法
- "
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 粗估,源码注释自称粗略下界。
- “压缩成功了就说明没问题”:压缩是有损的,要测关键信息保留率和压缩前后的任务成功率。