上下文压缩 Compaction
长会话的生存机制——CC 用三档微压缩 + LLM 全量摘要 + 指针手术落盘,Codex 用服务端压缩 + 全量快照落盘
上下文压缩 Compaction
结论
Compaction 是「上下文逼近窗口上限时,用摘要替换旧历史」的生存机制,两个 runtime 在生成与落盘两端都选了相反的设计:
- 生成端:Claude Code 在动用 LLM 全量摘要之前有一整套零/低成本「微压缩」分层(缓存编辑删旧 tool_result / 时间触发清空 / 复用 session memory),Codex 没有这层,但 Codex 有 CC 没有的服务端压缩(OpenAI/Azure 走
/compact端点,不耗本地采样)和 turn 中途压缩(MidTurn相位)。 - 落盘端:CC 做「指针手术」——transcript 里插一条
parentUuid:null的 boundary 截断消息链,摘要以普通 user 消息明文落盘,O(1) 增量;Codex 做「全量快照」——rollout 里 append 一条CompactedItem,内嵌压缩后的完整replacement_history,O(history) 冗余换 resume 简单性。 - 对外通道:CC 的
PostCompacthook 把摘要全文直接推给外部(compact_summary字段);Codex 没有任何推送摘要全文的通道,外部只能挖 rollout 文件。
上层产品(harness)两件事必须做对:拿摘要走 CC 的 hook / Codex 的 rollout 解析(注意远程压缩 message 为空的坑);判压缩后会话视图不要自己按 parentUuid 重建链(CC 保留段消息在磁盘上的 parentUuid 是「错」的)。
研究问题
- 什么时候触发压缩?阈值如何计算、能否关闭?
- 摘要怎么生成(本地 LLM / 服务端 / prompt 形态)?
- 压缩在磁盘上如何编码?外部进程如何感知并拿到摘要全文?
- 重启 / resume 后压缩态如何重建?
各 Agent 设计与实现
Claude Code
触发 [一手源码]:阈值 = 有效上下文窗口 − 13K buffer(services/compact/autoCompact.ts:62 AUTOCOMPACT_BUFFER_TOKENS = 13_000);每个 query 入口检查。带熔断:连续失败 3 次停(autoCompact.ts:67,注释记录了真实生产事故——盲目重压曾全球每天浪费 ~25 万次 API 调用)。可禁用:DISABLE_COMPACT(全禁)/ DISABLE_AUTO_COMPACT(仅自动)。手动 /compact [自定义指令]。
微压缩三档(动 LLM 之前的零/低成本层,文档零提及) [一手源码] services/compact/microCompact.ts:253 按优先级分流:
- 缓存编辑(
:305):用 API 原生cache_edits在传输层删旧 tool_result(白名单:Read/Bash/Grep/Glob/WebSearch/WebFetch/Edit/Write),本地消息不动、缓存前缀不失效,零 LLM 调用; - 时间触发(
:422):距上条 assistant 消息超 60 分钟(对齐缓存 TTL),旧 tool_result 替换为[Old tool result content cleared]占位,保留最近 5 个; - Session Memory:复用独立进程预抽取的
.claude/session_memory.md,不临时召唤摘要模型。
全量摘要生成 [一手源码]:本地 LLM 调用,9 段式 handoff prompt(Primary Request / Key Concepts / Files / Errors / All user messages / Pending Tasks / Current Work / Next Step,services/compact/prompt.ts:19)。
落盘编码(指针手术) [一手源码]:
// utils/sessionStorage.ts:1040
parentUuid: isCompactBoundary ? null : effectiveParentUuid, // 物理截断消息链
logicalParentUuid: isCompactBoundary ? parentUuid : undefined, // 留一根"逻辑脐带"boundary 携带 compactMetadata: { trigger, preTokens, preservedSegment?: {headUuid, anchorUuid, tailUuid} }(utils/messages.ts:4530-4555)。摘要本身是一条普通 user 消息(isCompactSummary: true, isVisibleInTranscriptOnly: true,compact.ts:613-624),全文明文在 JSONL 里。旧历史不删,文件 append-only。
部分压缩的保留段:被保留的尾部消息在磁盘上保持压缩前的旧 parentUuid 不动,重链只发生在加载时内存里的 applyPreservedSegmentRelinks(sessionStorage.ts:1839-1956,加载入口 :3704)。多个 boundary 只认绝对最后一个的 preservedSegment(:1846-1870)。
对外同步 [一手源码] [一手文档]:
PreCompacthook 可阻断压缩、可注入自定义指令(docs hooks.md);PostCompacthook 收到{ trigger, compact_summary: <摘要全文> }(utils/hooks.ts:4044-4049)——外部拿摘要最省事的通道;- SDK / stream-json 收到
SDKCompactBoundaryMessage(只有compact_metadata,无摘要文本,QueryEngine.ts:935-941;公开文档 agent-sdk__agent-loop.md); ~/.claude/sessions/<pid>.json状态文件不带压缩信号。
Resume [一手源码]:parse 全量 → 内存重链 → buildConversationChain 沿 parentUuid 回溯,到 boundary 的 parentUuid:null 自然停(sessionStorage.ts:2069-2094)。>5MB 大文件直接在 fd 级跳过压缩前字节(SKIP_PRECOMPACT_THRESHOLD = 5MB,sessionStoragePortable.ts:480;可用 CLAUDE_CODE_DISABLE_PRECOMPACT_SKIP 关闭)。
Codex CLI
触发 [一手源码]:model_auto_compact_token_limit,且计 token 口径可配(core/src/session/turn.rs:707-745):total(全上下文)或 body_after_prefix(只算上次 carry-forward 之后新增——避免反复压缩已携带上下文,CC 没有的精细度)。三种相位:PreTurn / MidTurn(turn 进行中、模型还要 follow-up 时压缩——CC 只在 query 边界压) / StandaloneTurn(手动);另有模型降档触发(CompactionReason::ModelDownshift)。关闭 = 把阈值设极大。
摘要生成:压缩 prompt 是「上下文检查点」语气(prompts/templates/compact/prompt.md),比 CC 的 9 段式短得多。摘要以 SUMMARY_PREFIX 包装后伪装成 user 消息注回历史(core/src/compact.rs:292)。服务端路径(Codex 独有):OpenAI/Azure provider 调 /compact 端点(compact_remote.rs / compact_remote_v2.rs,V2 流式、64K 保留预算),门槛 supports_remote_compaction()(model-provider-info/src/lib.rs:399);其他 provider 退回本地。本地压缩保留最近 20K token 的 user 消息。
落盘编码(全量快照) [一手源码] protocol/src/protocol.rs:2826-2840:
pub struct CompactedItem {
pub message: String, // 摘要文本(本地压缩才有!)
pub replacement_history: Option<Vec<ResponseItem>>, // 压缩后的完整新历史
}持久化白名单显式放行(rollout/src/policy.rs:10-13)。远程 v2 压缩 message 是空串(compact_remote_v2.rs:301-304),摘要藏在 replacement_history 的 ResponseItem 里——解析器必须兜底。旧历史同样不删,rollout append-only。
对外同步 [一手源码]:压缩作为 turn item 走 ItemStarted/ItemCompleted 事件(TurnItem::ContextCompaction,compact.rs:201/317;v1 的 ContextCompactedEvent 已弃用)——事件不带摘要文本。压缩完成排队一个 SessionStart(source="compact") hook(session/mod.rs:2678 + hooks/src/events/session_start.rs:27),payload 无摘要字段,下一 turn 才触发。没有 PostCompact 等价物。 另发 Warning 事件提醒多次压缩降准确率,trace 开启时写 rollout-trace checkpoint(压缩前后双份历史)。
Resume [一手源码] core/src/session/rollout_reconstruction.rs:96-128:倒扫 rollout,最新带 replacement_history 的 Compacted 即基底,其后条目向前重放;多次压缩天然 newest-wins,与 ThreadRolledBack 联动(回滚范围内的快照不作基底)。
差异矩阵
| 维度 | Claude Code | Codex CLI |
|---|---|---|
| 自动触发阈值 | 窗口 − 13K 固定 buffer | model_auto_compact_token_limit,口径可配(total / body_after_prefix) |
| 触发时机 | query 边界 | PreTurn / MidTurn(turn 中途) / 手动 / 模型降档 |
| LLM 前的微压缩层 | ✅ 三档(缓存编辑 / 时间触发 / session memory) | ❌ 无独立层 |
| 服务端压缩 | ❌ | ✅ OpenAI/Azure /compact 端点(V2 流式,64K 保留) |
| 磁盘编码 | 指针手术:boundary + 摘要 user 消息,O(1) 增量 | 全量快照:CompactedItem 内嵌新历史,O(history) |
| 摘要全文在磁盘 | ✅ JSONL user 消息明文 | ⚠️ 本地在 message;远程 v2 message:"" 须挖 replacement_history |
| Hook | PreCompact(可阻断)+ PostCompact(含摘要全文) | SessionStart(source=compact)(无摘要,延迟到下一 turn) |
| 协议/SDK 事件 | SDKCompactBoundaryMessage(metadata only) | ItemStarted(ContextCompaction)(metadata only) |
| Resume 重建 | 内存指针重链 + parentUuid 回溯;>5MB fd 级跳读 | 倒扫取最新快照 + 重放后缀 |
| 熔断/降级 | 连续失败 3 次熔断 | 无熔断;非 OpenAI provider 退回本地压缩 |
| 可禁用 | DISABLE_COMPACT / DISABLE_AUTO_COMPACT | 阈值设极大近似关闭 |
最小复现
# CC:手动压缩 + hook 拿摘要全文
# .claude/settings.json 挂 PostCompact hook: jq .compact_summary 即摘要
claude> /compact 重点保留 API 设计决策
# transcript 验证:boundary + 摘要两行
grep -c '"subtype":"compact_boundary"' ~/.claude/projects/<slug>/<session>.jsonl
# Codex:rollout 里找压缩快照
grep '"type":"compacted"' ~/.codex/sessions/$(date +%Y/%m/%d)/rollout-*.jsonl | head -1 | \
python3 -c "import json,sys; d=json.loads(sys.stdin.read()); p=d['payload']; print(p['message'][:200] or '[REMOTE COMPACT: summary in replacement_history]')"Harness 接入建议(Yoda 实践)
- 拿摘要全文:CC 首选
PostCompacthook(全文直推 stdin,零解析);被动方案 watch transcript,匹配compact_boundary行 → 读下一条isCompactSummaryuser 消息。Codex 只能 tail rollout,匹配"type":"compacted",payload.message为空时进replacement_history找SUMMARY_PREFIX开头的 user 消息。 - 压缩后会话视图:不要自己按 parentUuid 重建 CC 的链——保留段消息磁盘上的 parentUuid 指向已截断的旧链,必须复刻
applyPreservedSegmentRelinks的指针手术,否则把保留段判成孤儿。Codex 侧照搬「倒扫取最新快照」即可,逻辑简单得多。 - 压缩感知信号:Yoda 的 session 状态管线可把 CC 的
compact_boundary(SDK 事件或 JSONL 行)与 Codex 的ItemStarted(ContextCompaction)统一映射成「会话被压缩」badge,提示用户上下文已瘦身、模型可能丢细节。 - 自研 runtime 取舍:摘要同步学 CC(hook 直推全文);持久化学 Codex(自包含快照,外部重建零指针手术)。两者不冲突,最优组合 = 快照落盘 + 事件/hook 推全文。
- 隐私提醒:两边「压缩掉」的旧历史都还在磁盘明文里(CC 默认 30 天清理兜底,Codex 无自动清理)——做日志展示/导出功能时按完整文件处理。
失效条件
- CC 源码快照为 2026-03-31,microcompact 的 GrowthBook 默认值(如 time-based
enabled:false)可能已在线上变更; - CC 若把摘要从「user 消息明文」改为独立条目类型,外部解析器需跟进;
- Codex
compact_prompt自定义仍带experimental_前缀,迭代中;远程 V2 受Feature::RemoteCompactionV2门控; - Codex 若给
SessionStart(compact)hook 或协议事件补上摘要字段,本章「无全文推送通道」结论过期。
参考资料
- 深度报告:
agent-research/output/手工川-会话实时摘要机制-2026-06-09-v0.1.md(生成侧全量证据) - 深度报告:
agent-research/output/手工川-compact-summary-sync-cc-vs-codex-2026-06-11-v0.1.md(落盘/同步侧全量证据) - CC docs:
hooks.md(PreCompact/PostCompact)、agent-sdk__agent-loop.md(compact_boundary SDK 事件)、costs.md - Codex source:
core/src/compact.rs、compact_remote_v2.rs、rollout/src/policy.rs、core/src/session/rollout_reconstruction.rs