法不净空,觉无性也。

第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 在上下文达到阈值时触发。它做两件事:

  1. 丢弃 Reasoning token:LLM 生成的思考过程。在 CodeCoder 项目的使用观测中通常占据上下文的数成(约三到五成,随任务类型波动),信息密度最低。如果 agent 需要之后复现推理过程,它应该在 Memory 中显式保存推理结论

  2. 占位化旧的 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 架构、可观测性、测试策略。