第 5 章 压缩:上下文装满之后¶
第 4 章讲的是"怎么省"——拼装上下文时精打细算,让每一 token 都值回票价。但省出来的预算,终究会花完:任务一长,对话历史只增不减,再大的窗口也有撑满的一天。
这一章的问题是:窗口快满了,怎么办? Anthropic 把压缩称为长程一致性的"第一个杠杆(the first lever)"——它是每个生产级 harness 都绕不开的一道续命机制。
引子:省出来的预算,终究会花完¶
第 4 章我们学了各种"省":稳定前缀吃缓存、工具输出先减压、手册换地图。这些手段能把"窗口爆炸"这一天往后推,但推不没——一个跑了几个小时的长任务,工具结果、中间推理、文件片段会一路累积,曲线单调向上,迟早顶到天花板。
更微妙的是,问题不只是"装不下"。Anthropic 在《Effective context engineering for AI agents》里提出过上下文腐烂(context rot):上下文越长,模型性能本身会下降,因为 Transformer 里每个 token 都要"注意"到其他每个 token,注意力预算被摊得越薄。就算窗口物理上还塞得下,塞太满本身也会让模型变笨。
所以"窗口快满了怎么办"不是边缘场景,而是主循环里必须常驻的判定。答案有两条路——压缩和重置——这就是本章第一节;第二节拆 Codex 把压缩工程化到极致的真实代码;第三节轮到你自己动手:先亲眼触发一次压缩,再把这个机制移植进你自己的 loop。
一、压缩 vs 重置:两种"腾地方"的办法¶
最直接的问题:窗口快满了怎么办?有两条路。
压缩(compaction):把前面的对话历史就地总结成一段更短的摘要,替换掉原来冗长的记录,同一个 agent 接着往下干。Anthropic 把它定义为长程一致性的"第一个杠杆(the first lever)"——"高保真地蒸馏一个上下文窗口的内容,让 agent 几乎不掉性能地继续(distills the contents of a context window in a high-fidelity manner)"。好处是连续性强(agent 没"换人");代价是摘要不可能完美,可能丢细节。
上下文重置(context reset):更激进——清空整个窗口,开一个全新的 agent,只把一份"交接工件"传给它。好处是新 agent 拿到的是一张干净的白纸 + 清晰的交接,没有历史包袱;代价是交接工件必须足够完整,否则新 agent 真的会"失忆"。
为什么有时候压缩还不够、非得重置?Anthropic 给了一个很微妙的理由——"上下文焦虑(context anxiety)":"某些模型在接近自己以为的上下文上限时,会开始提前收尾(begin wrapping up work prematurely as they approach what they believe is their context limit)"。压缩并不能消除这种焦虑(agent 还在同一个会话里,仍觉得"我快满了");而重置给了它一张真正的白纸,焦虑就没了。具体到模型:"Claude Sonnet 4.5 的上下文焦虑强到光靠压缩撑不住长任务"——所以在那一代模型上,重置是必需的。
但这里有个极其重要的"反框架"教训:到了 Opus 4.5,模型自己就基本不再有上下文焦虑了,于是 Anthropic 直接把重置这套机制从 harness 里删掉了。 原文:「Opus 4.5 largely removed that behavior on its own, so I was able to drop context resets from this harness entirely.」 同一篇文章里他还把这条提炼成了一句方法论金句——"a harness 里的每个组件,都编码了一条关于'模型自己做不到什么'的假设,而这些假设值得被反复压力测试(every component in a harness encodes an assumption about what the model can't do on its own, and those assumptions are worth stress testing)"。
这正是本书反复强调的那条魂:模型变强,harness 的假设就要被重新审视、甚至拆除。 压缩 vs 重置怎么选,不是教条,是跟着模型能力走的。理解了这条,你才看得懂下面 Codex 的代码——它把"压缩"工程化到了极致,但没有重置子系统,因为它对接的是已经不再"焦虑"的前沿模型。
〔配图 1:压缩 vs 重置〕——左"压缩":一长条历史被折叠成一小段摘要,同一个 agent 小人继续坐在原位干活,标"同一个 agent,连续但可能丢细节"。右"重置":整块历史被清空(一个垃圾桶),一个全新的 agent 小人入座,手里只拿着一张"交接卡",标"全新 agent,干净但靠交接卡"。中间一行:"选哪个,跟着模型能力走——Opus 4.5 后 Anthropic 删掉了重置。"
二、Codex 的压缩:焊进主循环,但触发条件远比你想的精¶
还记得第 3 章 run_turn 主循环吗?很容易想当然地以为压缩的触发逻辑是朴素的 if token_limit_reached { compact },但真实代码并非如此。真实代码在 codex-rs/core/src/session/turn.rs 的采样循环里,触发条件是两个信号的与:
// session/turn.rs,每轮采样返回后
let has_pending_input = sess.input_queue.has_pending_input(&sess.active_turn).await;
let needs_follow_up = model_needs_follow_up || has_pending_input;
let token_status = auto_compact_token_status(sess.as_ref(), turn_context.as_ref()).await;
if token_status.token_limit_reached && needs_follow_up {
run_auto_compact(/* …, */ CompactionReason::ContextLimit, CompactionPhase::MidTurn).await?;
continue;
}
为什么要带上 needs_follow_up?因为压缩本身是有代价的(要多发一次模型请求去做总结)。如果这一轮模型已经讲完、也没有排队的输入,那就不需要继续了——既然要停,何必再花一次压缩? 只有"既爆了、又还得接着干(token_limit_reached && needs_follow_up)"时,压缩才划算。needs_follow_up 里那个 has_pending_input,正是第 3 章 RegularTask 外层"有 pending input 就续跑"那条逻辑在压缩判定里的回响——腾地方这件事,是为"接着干"服务的,不是为腾而腾。
源码里紧挨着这段还有一行注释,点破了为什么这里不怕死循环:「as long as compaction works well in getting us way below the token limit, we shouldn't worry about being in an infinite loop(只要压缩能把用量压到远低于上限,就不用担心无限循环)」。换言之,压缩的有效性本身是循环安全的前提。
auto_compact_token_status:不是"窗口快满了"这一个信号¶
更值得深挖的是怎么判断"爆了"。乍看像是"窗口快满了"这一个信号,但 auto_compact_token_status 返回的是一个多字段的结构体,至少综合了这些信号:
struct AutoCompactTokenStatus {
active_context_tokens: i64, // 完整活动上下文用量
auto_compact_scope_tokens: i64, // 计入当前"压缩配额作用域"的用量
auto_compact_scope_limit: i64, // 该作用域的限额
full_context_window_limit: Option<i64>,
full_context_window_limit_reached: bool,
token_limit_reached: bool,
// …还有 window ordinal / prefill 等
}
关键在 model_auto_compact_token_limit_scope 这个作用域开关,它有两种取值:
Total:拿"完整活动上下文用量"直接和模型的 auto-compact 限额比——朴素的"总量快满了"。BodyAfterPrefix:先从一个"窗口快照(auto_compact_window_snapshot)"里取出prefill_input_tokens作为基线,只统计"前缀之后的正文"(active_context_tokens.saturating_sub(baseline)),再去和限额比;同时还单独盯着"完整上下文窗口上限"是否触顶(full_context_window_limit_reached)。
为什么要分作用域?因为系统提示、初始上下文这类"前缀"是每轮都要重新注入的固定开销,把它们算进"该不该压缩"会误判——你压缩的是对话正文,不是那个雷打不动的前缀。BodyAfterPrefix 让触发判定盯住"真正能压的那部分增长",同时用 full_context_window_limit_reached 兜底防止物理窗口被撑爆。最终 token_limit_reached = scope_tokens >= scope_limit || full_context_window_limit_reached——是"配额作用域满"或"物理窗口满"任一成立,而不是单一阈值。这就是"多信号判定"的真实含义。
三条路径:inline / remote / remote-v2,以及 should_use_remote_compact_task¶
这是本章最该深挖的工程点。run_auto_compact 并不是只有一种压缩实现,它在内部分发到三条路径:
// session/turn.rs::run_auto_compact 的分发骨架
if should_use_remote_compact_task(turn_context.provider.info()) {
if turn_context.features.enabled(Feature::RemoteCompactionV2) {
run_inline_remote_auto_compact_task_v2(/* … */).await?; // 路径③ remote v2
} else {
run_inline_remote_auto_compact_task(/* … */).await?; // 路径② remote v1
}
} else {
run_inline_auto_compact_task(/* … */).await?; // 路径① 本地 inline
}
选路的闸门是 should_use_remote_compact_task(provider),而它的判据极简,落在 codex-rs/model-provider-info/src/lib.rs:
pub fn supports_remote_compaction(&self) -> bool {
self.is_openai() || is_azure_responses_provider(&self.name, self.base_url.as_deref())
}
翻译:只有 OpenAI 官方 provider 和 Azure Responses provider 才走 remote 压缩,其他一律走本地 inline。 三条路径各自的存在理由:
- ① 本地 inline(
compact.rs::run_inline_auto_compact_task):拿一份压缩提示词(turn_context.compact_prompt(),模板在codex-rs/prompts/templates/compact/prompt.md)当成一条普通用户消息,让当前这个模型自己把历史总结成摘要。这是通用兜底——任何 provider 都能用,因为它只依赖"模型会总结"这一个能力。模板开头就写明意图:「You are performing a CONTEXT CHECKPOINT COMPACTION. Create a handoff summary for another LLM that will resume the task.(你在做一次上下文检查点压缩,为另一个将接手任务的 LLM 写一份交接摘要。)」——注意"另一个 LLM"这个措辞:压缩在语义上就是给"下一班"写交接,哪怕物理上还是同一个会话。 - ② remote v1(
compact_remote.rs):对 OpenAI/Azure,Codex 不在客户端本地跑总结,而是调一个专用的服务端压缩端点(model_client.compact_conversation_history,对应/responses/compact)。好处是压缩这件重活由服务端承担、对客户端是一次原子调用;坏处是它和正常的采样流是两套代码路径。 - ③ remote v2(
compact_remote_v2.rs,feature flagRemoteCompactionV2):把远程压缩收敛进统一的 Responses 流式传输(ResponsesStreamRequest::RemoteCompactionV2),用和普通推理同一套流式/重试基础设施,只是带一个更紧的重试预算(MAX_REMOTE_COMPACTION_V2_STREAM_RETRIES = 2,因为压缩可能跑得比普通 turn 久)和一个"保留消息预算"RETAINED_MESSAGE_TOKEN_BUDGET = 64_000。源码注释直说 v2 是为了"在服务端实现仍是参照标准(reference implementation)的同时,让传输层归一"。
这三条路径同时存在,本身就是"harness 随模型/平台演化"的活标本:inline 是面向任意模型的最小公分母,remote v1 是为 OpenAI 平台争取的服务端能力,remote v2 是把这条能力往统一传输架构上收的过渡态(所以还在 feature flag 后面)。你在生产里看到的,是三代实现的地层叠压。
还有一处 CompactionPhase 的细节值得记:mid-turn 压缩(turn 中途触发)用的注入策略是 InitialContextInjection::BeforeLastUserMessage,而 pre-turn/手动压缩用 DoNotInject——因为模型被训练成"mid-turn 压缩后,摘要应是历史里的最后一项",所以初始上下文必须注入到最后一条真实用户消息之上,而不是简单清空。压缩不是"删一段、贴一段"那么糙,它讲究摘要在历史里的位置语义。
〔配图 2:压缩三条路径〕——一个菱形判断框
should_use_remote_compact_task?,左出口("非 OpenAI/Azure")连到"① 本地 inline:自己总结,模板 compact/prompt.md";右出口("OpenAI/Azure")再分一个菱形RemoteCompactionV2?,分别连到"② remote v1:专用 /responses/compact 端点"和"③ remote v2:统一 Responses 流(flag 后面,retain 64k)"。底部:"同一个杠杆,三代实现的地层叠压——harness 随平台演化。"
三、动手:亲眼看到一次压缩¶
机制讲完了,现在轮到你自己复现。两步:先在真工具里触发一次,再把判定逻辑移植进自己的 loop。
第一步:在 Codex 里手动触发一次。 装好 Codex CLI,随便让它干点活(读几个文件、改两行代码),然后在 TUI 里输入 /compact——这是它内置的手动压缩命令,自我介绍就一句话:「summarize conversation to prevent hitting the context limit(总结对话,防止撞上上下文上限)」。你会看到冗长的历史被折叠成一段摘要,agent 接着往下干。
观察点有两个:压缩前,先问它"我们刚才都做了什么、下一步是什么";压缩后,再问一遍同样的问题。答案大体还在(摘要保住了主干),但某些细节——比如某个具体报错原文——可能就丢了。亲手体会一次"摘要保真但不完美",比读十遍定义都管用。
第二步:把"两信号与"移植进你自己的 loop。 第二节那段 Rust,剥掉语法后只有七行,任何语言都能写:
def maybe_compact(history, tokens, limit, needs_follow_up):
if tokens >= limit and needs_follow_up: # ① 两个信号缺一不可
summary = llm.summarize(history) # ② 压缩要多花一次模型调用
return [summary] # ③ 摘要整段替换历史
return history # ④ 要收工了?那就别压
对照着读:① 就是 token_limit_reached && needs_follow_up——快满了还不够,得"还得接着干"才值得压;② 提醒你压缩不是免费操作;③ 对应"摘要替换历史",真实实现里还讲究注入位置(BeforeLastUserMessage);④ 是大多数人第一次实现时会漏掉的分支——没有 follow-up 就压缩,纯属浪费一次调用。把这个判定焊进你的采样循环(每轮模型返回后检查一次),你的 harness 就有了生产级压缩的骨架。
想观察自动触发?给 agent 一个长任务(比如"给这个仓库的每个模块写 README"),盯着 token 用量一路爬升,到顶时你就能看到一次 mid-turn compact 发生——怎么把用量"看见",是第 14 章遥测要讲的。
本章小结¶
- "省"逃不掉"满":第 4 章的预算技巧只能推迟窗口爆炸;任务一长,压缩就是主循环里的常驻判定,何况还有 context rot——塞太满本身会让模型变笨。
- 压缩 vs 重置:压缩就地浓缩历史、同一 agent 续跑;重置清空 + 交接、全新 agent 上场(治"上下文焦虑")。选哪个跟着模型能力走——Sonnet 4.5 需要重置,Opus 4.5 后重置被删掉,印证"harness 的每个组件都编码了模型当下的短板,值得反复压力测试"。
- Codex 的压缩远比"if 满了就压"精:触发是
token_limit_reached && needs_follow_up;判定靠auto_compact_token_status多信号(作用域 token、完整窗口、prefill 基线);实现分 inline / remote v1 / remote v2 三路,由should_use_remote_compact_task(只 OpenAI/Azure 走 remote)选路——这是"harness 随平台演化"的活标本。 - 压缩是可以亲手复现的:
/compact手动触发一次体会"摘要保真但丢细节";"两信号与"的判定剥到底只有七行,值得焊进你自己的 loop。
压缩管的是"同一个班次内"的续命。但如果任务长到必须"换班"——跨会话、跨窗口接力——光压缩就不够了,还需要目标、记忆和能交接的进度工件。那是第 11 章的主场。而下一章我们先回到同心层往外走一层:模型能"想"了,怎么让它能"动手"——工具层。
参考来源¶
解剖标本(codex 源码,HEAD = ad2012d6)
codex-rs/core/src/session/turn.rs— 压缩主循环与触发判定codex-rs/core/src/compact.rs— inline 压缩与选路codex-rs/core/src/compact_remote.rs— remote v1 压缩codex-rs/core/src/compact_remote_v2.rs— remote v2 压缩codex-rs/model-provider-info/src/lib.rs—supports_remote_compactioncodex-rs/prompts/templates/compact/— 压缩模板codex-rs/tui/src/slash_command.rs—/compact手动压缩命令
方法论
- Anthropic — Effective harnesses for long-running agents
- Anthropic — Effective context engineering for AI agents
注:上下文焦虑被删除的模型版本以 Anthropic 原文为准,是 Opus 4.5。压缩三路仍有更多细节(如 remote v2 的流式重试预算),本章聚焦触发判定与选路主干;符号/路径以 HEAD=ad2012d6 为准。