第13章 上下文压缩与持久化管理
2026.07.29上下文窗口有上限,但 session 可以无限增长。压缩不是选项——是生存需求。
模式层
13.1 压缩是生存需求
上下文窗口的大小在不断增长——从几年前的 4K token,到现在的 1M token。但不管窗口多大,一个持续运行的 agent session 可以把它塞满。
一个典型的场景:agent 在 8 小时内执行了 100 次工具调用。每次工具调用(含输入和输出)平均约 2,000 token——一天产生 200K token 的上下文。加上 system prompt 和用户消息,一天内轻松超过 100K token。一周内的 session 可以达到 1M token。
如果不加压缩,后果是被动溢出:最早的消息被新消息推出窗口,没有任何优先级判断。最早的但最重要的信息(用户的第一条需求约束)和最早的最不重要的信息(第一次读目录返回的 200 条文件列表)被同等对待——一起被推出窗口。
主动压缩 vs 被动溢出的区别在于:主动压缩有优先级策略——知道什么先丢、什么后丢、什么不能丢。被动溢出没有策略——谁先来谁先走。
主动压缩不是"删除"信息——是将信息从"完整的上下文"降级为"摘要",释放 token 预算给新信息。被压缩的内容仍然保留在持久化的 Session 文件中,可以随时恢复。
13.2 派生 vs 改写的不变量
压缩和持久化是两种不同的操作,需要区分清楚。
持久化是"改写"——原始记录被完整保存到磁盘上的 Session 文件中。Session 文件是追加写入的,不删除不截断。压缩不影响持久化——Session 文件始终包含完整的操作历史。
压缩是"派生"——从完整的上下文中生成一个摘要,替换原始内容的位置。被压缩的内容从上下文窗口中被移除,但它的原始内容仍然在 Session 文件中。
这个不变量意味着:压缩后的上下文窗口不是完整的 session 记录——它是一个工作集。 工作集包含了当前需要的信息,但不包含完整的操作历史。如果需要完整历史,可以读 Session 文件的原始内容。
| 持久化(Session 文件) | 压缩(上下文窗口) | |
|---|---|---|
| 内容 | 完整消息历史 | 最近消息 + 摘要 |
| 写入方式 | 追加写入 | 替换/丢弃 |
| 恢复方式 | 从文件恢复 | 不可恢复,需要重新生成摘要 |
| 生命周期 | 永久 | 当前 session |
13.3 两级压缩策略
CodeCoder 的压缩策略分为两级:
tier-1:丢弃 + 占位化。
tier-1 在上下文达到阈值时触发。它做两件事:
丢弃 Reasoning token:LLM 生成的思考过程。在 CodeCoder 项目的使用观测中通常占据上下文的数成(约三到五成,随任务类型波动),信息密度最低。如果 agent 需要之后复现推理过程,它应该在 Memory 中显式保存推理结论
占位化旧的 ToolResult 正文:将较早的 ToolResult 的冗长正文替换为摘要 + 文件路径。保留"做了什么"但丢弃"返回了什么细节"
tier-1 的修改是可逆的——被占位化的 ToolResult 正文在 Session 文件中完整保留。如果 agent 需要回头看,可以重新读 Session 文件。
tier-2:结构化摘要。
tier-1 后仍超阈值时触发 tier-2。对最早的对话段落做结构化摘要:
[Summary]
Goal: Refactor module A's interface
Constraints: Maintain backward compatibility
Progress: Interface extraction complete, tests passing
Key Decisions: Abandoned generics approach; using trait objects instead
Next Steps: Implement module B's interface in new token
摘要模板的五个字段:
- 目标:当时正在做什么
- 约束:用户明确的要求
- 进展:已经做了什么
- 关键决策:做了什么设计选择
- 下一步:接下来应该做什么
tier-2 的摘要是迭代式合并的——每次只摘要增量部分,并累积文件追踪信息附在摘要末尾:
[File Tracking]
Read: src/mod.rs, src/interface.rs
Modified: src/interface.rs, tests/interface_test.rs
案例层
13.4 tier-1:丢弃 Reasoning + 占位化 ToolResult
tier-1 压缩的具体实现:
fn compact_tier1(messages: &mut Vec<Message>) -> usize {
let mut freed_tokens = 0;
for message in messages.iter_mut() {
// 1. Discard Reasoning tokens
let reasoning_count = message.items.iter()
.filter(|item| matches!(item, MessageItem::Reasoning(_)))
.count();
message.items.retain(|item| !matches!(item, MessageItem::Reasoning(_)));
freed_tokens += reasoning_count * AVG_TOKEN_PER_REASONING;
// 2. Placeholder-ize old ToolResult bodies
// Only keep the most recent N ToolResults in full
let recent_count = 10; // Keep the 10 most recent
let tool_results: Vec<_> = message.items.iter_mut()
.filter_map(|item| {
if let MessageItem::ToolResult(tr) = item {
Some(tr)
} else {
None
}
})
.collect();
let total = tool_results.len();
for (i, tr) in tool_results.iter_mut().enumerate() {
if i < total.saturating_sub(recent_count) {
// Placeholder-ize: replace content with summary
let summary = tr.content.iter()
.map(|c| match c {
ContentItem::Text(s) => s.chars().take(200).collect::<String>(),
_ => "[binary]".to_string(),
})
.collect::<Vec<_>>()
.join(" ");
tr.content = vec![ContentItem::Text(format!(
"[TRUNCATED: {} bytes, {} chars]",
tr.original_size, summary.len()
))];
freed_tokens += tr.original_size / AVG_BYTES_PER_TOKEN;
}
}
}
freed_tokens
}
tier-1 的关键设计点:
- Anchor 保护:
recent_count = 10确保最近的 N 个 ToolResult 不被占位化——即使它们也属于"较早"的消息。Anchor 是"当前推理所需的最小上下文" - Reasoning 全部丢弃:不做选择——所有 Reasoning token 都被丢弃
- 占位化保留少量信息:被占位化的 ToolResult 仍保留"文件路径 / 结果摘要"(前 200 个字符),不完全是空白
13.5 tier-2:结构化摘要
tier-2 压缩在 tier-1 之后仍超阈值时触发:
fn compact_tier2(messages: &mut Vec<Message>, context: &Context) -> Result<usize> {
// 1. Find the earliest segment (contiguous message block eligible for compaction)
let span = find_compressible_span(messages)?;
// 2. Call the LLM to generate a structured summary
let summary = summarize_span(&span, context)?;
// 3. Replace the segment with the summary
let summary_tokens = estimate_tokens(&summary);
let original_tokens = span.iter().map(|m| m.tokens).sum::<usize>();
replace_span_with_summary(messages, &span, &summary);
Ok(original_tokens - summary_tokens)
}
fn summarize_span(span: &[Message], context: &Context) -> Result<String> {
let prompt = format!(
"Please summarize the following conversation segment into a structured format.\
\nFields: Goal, Constraints, Progress, Key Decisions, Next Steps.\
\n\nConversation content:\n{}",
format_messages(span)
);
context.llm_complete(&prompt)
}
摘要结构:
[Summary]
Goal: Refactor module A's interface, extract into an independent trait
Constraints: Maintain backward compatibility, do not modify module B
Progress: Interface extraction complete, 3 test cases written
Key Decisions: Using trait objects rather than generics (to avoid impacting module B's compile time)
Next Steps: Begin module B's interface adaptation
[File Tracking]
Read: src/mod.rs, src/interface.rs, tests/interface_test.rs
Modified: src/interface.rs, tests/interface_test.rs
迭代式合并:tier-2 不会每次都对所有历史摘要重新生成。它只摘要增量部分——从上次摘要的位置到当前时间。然后合并到上一版的摘要中:
fn merge_summaries(old: &Summary, new: &Summary) -> Summary {
Summary {
goal: if new.goal.is_empty() { old.goal.clone() } else { new.goal.clone() },
constraints: merge_items(&old.constraints, &new.constraints),
progress: merge_items(&old.progress, &new.progress),
key_decisions: merge_items(&old.key_decisions, &new.key_decisions),
next_steps: new.next_steps.clone(), // Keep only the latest next steps
file_tracking: merge_file_tracking(&old.file_tracking, &new.file_tracking),
}
}
13.6 Session 持久化格式与迁移链
Session 文件的 JSON 格式:
{
"schema_version": 5,
"session_id": "cc-session-20260715-a3b2c1",
"created_at": "2026-07-15T10:00:00Z",
"messages": [
{
"role": "user",
"items": [{"type": "text", "text": "Refactor module A for me"}],
"id": 1
},
{
"role": "assistant",
"items": [
{"type": "text", "text": "Sure, let me analyze it"},
{"type": "tool_call", "id": "call_1", "name": "read_file", "args": {"path": "src/mod.rs"}}
],
"id": 2
},
{
"role": "tool",
"tool_call_id": "call_1",
"items": [{"type": "tool_result", "content": "// module A ..."}],
"id": 3
}
]
}
schema_version 字段用于迁移。当 Session 格式变更时,系统在加载时检测版本号,自动迁移到新版本:
fn migrate_session(session: &mut Session) -> Result<()> {
match session.schema_version {
1 => migrate_v1_to_v2(session),
2 => migrate_v2_to_v3(session),
3 => migrate_v3_to_v4(session),
4 => migrate_v4_to_v5(session),
5 => Ok(()), // Current version
_ => Err("unknown schema version"),
}
}
迁移链保证了向后兼容性:旧版本的 Session 文件在加载时会被自动更新到当前版本。迁移是幂等的——如果迁移过程失败,session 文件不会损坏(系统在迁移前创建备份)。
13.7 自动解压
当 session 恢复到更大的模型窗口(如从 32K 窗口切换到 128K 窗口)时,系统可以自动解压——将 tier-1 中被占位化的 ToolResult 恢复为完整内容。
fn decompress(compacted: &mut Session, full_records: &Session) -> Result<()> {
for message in compacted.messages.iter_mut() {
for item in message.items.iter_mut() {
if let MessageItem::ToolResult(tr) = item {
if tr.content.iter().any(|c| matches!(c, ContentItem::Text(t) if t.starts_with("[TRUNCATED:"))) {
// Restore from the full record
if let Some(original) = find_original(tr.tool_call_id, full_records) {
*tr = original.clone();
}
}
}
}
}
Ok(())
}
自动解压的前提是 full_records 可用——即 Session 文件中保留了完整的原始记录。如果 Session 文件本身也被压缩了(长时间运行导致 Session 文件超出存储限制),解压可能不完整。
ADR 深度阅读
从无压缩到两级压缩(ADR 0023)
CodeCoder 最初没有压缩机制。上下文窗口满了就满了——行为退化是"可接受的"。
第一个转折点发生在 headless 模式中(以下为教学重组案例,非逐字实录):一个 headless session 长时间运行后,上下文持续增长。agent 开始出现"重复决策"行为——做过的决定在一段时间后又被重新考虑。分析发现,上下文中的早期消息已经在模型的有效利用范围之外——agent 不是因为"没记住"而重复,而是因为"最早的决策已经不可见了"。这一现象与公开研究中"模型对长上下文中部信息的利用显著弱于首尾位置"的发现方向一致(如 Lost in the Middle: How Language Models Use Long Contexts,Liu 等,TACL 2023,多文档问答与键值检索任务上的位置偏置实验);本书案例为教学重组,不声称与该研究样本同源。
第一个压缩方案很简单:窗口满了就丢弃最早的消息。但丢弃后 agent 失去了"为什么做这个决定"的上下文——它知道"现在在做什么",但不知道"为什么开始做这个"。
第二个方案(最终采用的方案)是两级压缩:tier-1 丢弃低价值内容(Reasoning),tier-2 用结构化摘要替代最早的消息。结构化摘要保留了"为什么"的信息,同时大幅减少了 token 占用。
ADR 0023 还记录了"摘要压缩失败时的降级策略":如果 tier-2 的 LLM 摘要调用失败(API 超时、网络错误、模型不可用),系统降级回 tier-1 压缩,不尝试 tier-2。降级不是失败——按 CodeCoder 项目内的使用观测,tier-1 至少能释放三到五成的 token,tier-2 的释放率更高(约七到八成),tier-1 虽不及 tier-2 有效,但足够让 session 继续运行。
第 4 编结束。下一编进入工程实践——daemon-client 架构、可观测性、测试策略。