12  上下文压缩

12.1 概述

LLM 的上下文窗口是有限的。当对话变得过长时,Pi 使用压缩(Compaction)机制来摘要旧内容,同时保留最近的工作。本章涵盖自动压缩和分支摘要两种机制。

12.1.1 压缩 vs 分支摘要

Pi 有两种摘要机制:

机制 触发条件 目的
压缩(Compaction) 上下文超过阈值,或 /compact 命令 摘要旧消息以释放上下文
分支摘要(Branch Summarization) /tree 导航 切换分支时保留上下文

两种机制使用相同的结构化摘要格式,并累积跟踪文件操作。压缩和分支摘要请求使用全新的路由会话 ID,并在提供商支持时禁用 Prompt 缓存写入——因为这些一次性提示不太可能被重用。

12.2 自动压缩

12.2.1 触发条件

当满足以下条件时触发自动压缩:

contextTokens > contextWindow - reserveTokens

默认情况下,reserveTokens 为 16384 Token(可在 ~/.pi/agent/settings.json.pi/settings.json 中配置)。这为 LLM 的响应预留了空间。

你也可以通过 /compact [instructions] 手动触发压缩,其中可选的指令用于聚焦摘要内容。

Tip

/compact 命令的 [instructions] 参数让你可以自定义压缩的重点。例如:/compact Focus on the database schema decisions 会生成一个侧重于数据库模式的摘要。

12.2.2 压缩原因类型

自动压缩的触发原因分为三种:

  • "manual":通过 /compact 命令手动触发
  • "threshold":上下文 Token 超过阈值
  • "overflow":上下文溢出,需要压缩后重试

12.3 工作原理

压缩过程分为五个步骤:

12.3.1 步骤一:寻找切割点

从最新消息开始向前回溯,累积 Token 估计直到达到 keepRecentTokens(默认 20k Token)。

压缩前:

 entry: 0   1    2    3     4    5    6     7     8    9
 ┌─────┬─────┬─────┬──────┬─────┬─────┬──────┬──────┬─────┬─────┐
 │ hdr │ usr │ ass │ tool │ usr │ ass │ tool │ tool │ ass │ tool│
 └─────┴─────┴─────┴──────┴─────┴─────┴──────┴──────┴─────┴─────┘
 └────────┬───────┘ └──────────────────┬──────────────────┘
   待摘要消息            保留消息
                        ↑
                   firstKeptEntryId (entry 4)

12.3.2 步骤二:提取消息

从前一个保留边界(或会话开始处)收集到切割点之间的消息。

12.3.3 步骤三:生成摘要

调用 LLM 使用结构化格式摘要提取的消息。如果存在前一次的摘要,会作为迭代上下文传入。

12.3.4 步骤四:保存条目

将带有摘要和 firstKeptEntryIdCompactionEntry 追加到会话中。

压缩后(追加新条目):

 entry: 0   1    2    3     4    5    6     7     8    9    10
 ┌─────┬─────┬─────┬──────┬─────┬─────┬──────┬──────┬─────┬─────┬─────┐
 │ hdr │ usr │ ass │ tool │ usr │ ass │ tool │ tool │ ass │ tool│ cmp │
 └─────┴─────┴─────┴──────┴─────┴─────┴──────┴──────┴─────┴─────┴─────┘
 └──────────┬──────────┘ └──────────────────────┬───────────────────┘
   不发送给 LLM              发送给 LLM
                             ↑
                        从 firstKeptEntryId 开始

12.3.5 步骤五:重新加载会话

会话重新加载,使用摘要 + 从 firstKeptEntryId 开始的消息。

LLM 实际看到的:

 ┌────────┬─────────┬─────┬─────┬──────┬──────┬─────┬──────┐
 │ system │ summary │ usr │ ass │ tool │ tool │ ass │ tool │
 └────────┴─────────┴─────┴─────┴──────┴──────┴─────┴──────┘
   ↑        ↑         └──────────────────┬──────────────────┘
   系统提示  来自 cmp      从 firstKeptEntryId 开始的消息
Note

在重复压缩时,摘要范围从前一次压缩的保留边界(firstKeptEntryId)开始,而不是从压缩条目本身开始。如果找不到保留的条目,则从前一次压缩之后的条目开始。这确保了在前一次压缩中存活的消息也会被包含在下一次摘要中。

12.4 分割轮次(Split Turns)

一个”轮次”从用户消息开始,包含直到下一个用户消息的所有助手响应和工具调用。通常,压缩在轮次边界切割。

当单个轮次超过 keepRecentTokens 时,切割点会落在轮次中间的助手消息上。这被称为分割轮次

分割轮次(一个巨大轮次超出预算):

 entry: 0   1    2    3     4    5     6     7    8
 ┌─────┬─────┬─────┬──────┬─────┬──────┬──────┬─────┬──────┐
 │ hdr │ usr │ ass │ tool │ ass │ tool │ tool │ ass │ tool │
 └─────┴─────┴─────┴──────┴─────┴──────┴──────┴─────┴──────┘
 ↑                              ↑
 turnStartIndex = 1             firstKeptEntryId = 7
 │                              │
 └──── turnPrefixMessages (1-6) ──────┘
                                └── 保留 (7-8)

 isSplitTurn = true
 messagesToSummarize = [] (前面没有完整轮次)
 turnPrefixMessages = [usr, ass, tool, ass, tool, tool]

对于分割轮次,Pi 生成两个摘要并合并它们:

  1. 历史摘要:之前的上下文(如果有)
  2. 轮次前缀摘要:分割轮次的前半部分

12.5 切割点规则

有效的切割点包括:

  • ✅ 用户消息
  • ✅ 助手消息
  • ✅ Bash 执行消息
  • ✅ 自定义消息(custom_messagebranch_summary

永远不能在以下位置切割:

  • ❌ 工具结果(它们必须与对应的工具调用在一起)
Warning

工具结果必须与生成它们的工具调用保持在同一切割段内,否则模型会看到没有对应工具调用的工具结果,导致上下文断裂。

12.6 CompactionEntry 结构

CompactionEntry 定义在 session-manager.ts 中:

interface CompactionEntry<T = unknown> {
  type: "compaction";
  id: string;
  parentId: string;
  timestamp: number;
  summary: string;
  firstKeptEntryId: string;
  tokensBefore: number;
  usage?: Usage;           // 生成摘要的 LLM 使用量
  fromHook?: boolean;      // 如果由扩展提供则为 true
  details?: T;             // 实现特定的数据
}

// 默认压缩的 details 结构(来自 compaction.ts):
interface CompactionDetails {
  readFiles: string[];
  modifiedFiles: string[];
}
Note

扩展可以在 details 中存储任何可 JSON 序列化的数据。默认压缩跟踪文件操作,但自定义扩展实现可以使用自己的结构。生成的和扩展提供的摘要在可用时存储其 LLM 使用量,以便会话总计包含摘要工作。

12.7 分支摘要机制

12.7.1 触发条件

当你使用 /tree 导航到不同分支时,Pi 会提供对你正在离开的工作进行摘要的选项。这将被离开分支的上下文注入到新分支中。

12.7.2 工作原理

  1. 寻找共同祖先:旧位置和新位置共享的最深节点
  2. 收集条目:从旧叶子回溯到共同祖先
  3. 预算准备:从最新开始,在 Token 预算内包含消息
  4. 生成摘要:使用结构化格式调用 LLM
  5. 保存条目:在导航点保存 BranchSummaryEntry
导航前的树:

 ┌─ B ─ C ─ D (旧叶子,将被放弃)
 A ───┤
 └─ E ─ F (目标)

共同祖先:A
待摘要条目:B, C, D

导航后(带摘要):

 ┌─ B ─ C ─ D ─ [B,C,D 的摘要]
 A ───┤
 └─ E ─ F (新叶子)

12.8 累积文件跟踪

压缩和分支摘要都以累积方式跟踪文件操作。生成摘要时,Pi 从以下来源提取文件操作:

  • 被摘要消息中的工具调用
  • 之前的压缩或分支摘要详情(如果有)
Note

这意味着文件跟踪在多次压缩或嵌套分支摘要之间累积,保留已读取和已修改文件的完整历史。

12.8.1 BranchSummaryEntry 结构

interface BranchSummaryEntry<T = unknown> {
  type: "branch_summary";
  id: string;
  parentId: string;
  timestamp: number;
  summary: string;
  fromId: string;          // 我们导航来源的条目
  usage?: Usage;           // 生成摘要的 LLM 使用量
  fromHook?: boolean;      // 如果由扩展提供则为 true
  details?: T;             // 实现特定的数据
}

// 默认分支摘要的 details 结构:
interface BranchSummaryDetails {
  readFiles: string[];
  modifiedFiles: string[];
}

12.9 摘要格式模板

压缩和分支摘要使用相同的结构化格式:

## Goal
[用户试图实现的目标]

## Constraints & Preferences
- [用户提到的要求]

## Progress
### Done
- [x] [已完成的任务]

### In Progress
- [ ] [当前进行的工作]

### Blocked
- [问题(如果有)]

## Key Decisions
- **[决策]**: [理由]

## Next Steps
1. [下一步应该做什么]

## Critical Context
- [继续工作所需的数据]

<read-files>
path/to/file1.ts
path/to/file2.ts
</read-files>

<modified-files>
path/to/changed.ts
</modified-files>
Tip

这个结构化格式确保了摘要的一致性和可读性。模型在后续工作中可以参考这个摘要来了解之前的进展和决策。

12.10 消息序列化

在摘要之前,消息通过 serializeConversation() 函数序列化为文本:

[User]: 用户说了什么
[Assistant thinking]: 内部推理
[Assistant]: 响应文本
[Assistant tool calls]: read(path="foo.ts"); edit(path="bar.ts", ...)
[Tool result]: 工具输出
Note

这种格式防止模型将其视为要继续的对话。序列化为只读的历史记录,而不是活跃的对话。

Warning

工具结果在序列化时被截断为 2000 个字符。超出限制的内容被替换为指示截断字符数的标记。这使摘要请求保持在合理的 Token 预算内,因为工具结果(尤其是来自 readbash 的)通常是上下文大小的最大贡献者。

12.11 通过扩展自定义摘要

扩展可以拦截和自定义压缩和分支摘要。

12.11.1 session_before_compact 事件

在自动压缩或 /compact 之前触发。可以取消或提供自定义摘要:

pi.on("session_before_compact", async (event, ctx) => {
  const { preparation, branchEntries, customInstructions, 
         reason, willRetry, signal } = event;

  // preparation.messagesToSummarize - 待摘要消息
  // preparation.turnPrefixMessages - 分割轮次前缀(如果 isSplitTurn)
  // preparation.previousSummary - 前一次压缩的摘要
  // preparation.fileOps - 提取的文件操作
  // preparation.tokensBefore - 压缩前的上下文 Token 数
  // preparation.firstKeptEntryId - 保留消息开始位置
  // preparation.settings - 压缩设置
  // branchEntries - 当前分支上的所有条目
  // reason - "manual"(/compact)、"threshold" 或 "overflow"
  // willRetry - 中止的轮次是否在压缩后重试
  // signal - AbortSignal(传给 LLM 调用)

  // 取消压缩:
  return { cancel: true };

  // 自定义摘要:
  return {
    compaction: {
      summary: "Your summary...",
      firstKeptEntryId: preparation.firstKeptEntryId,
      tokensBefore: preparation.tokensBefore,
      usage: summaryResponse.usage,  // 可选
      details: { /* 自定义数据 */ },
    }
  };
});

12.11.1.1 将消息转换为文本

import { convertToLlm, serializeConversation } from "@earendil-works/pi-coding-agent";

pi.on("session_before_compact", async (event, ctx) => {
  const { preparation } = event;

  // 将 AgentMessage[] 转为 Message[],然后序列化为文本
  const conversationText = serializeConversation(
    convertToLlm(preparation.messagesToSummarize)
  );

  // 使用你自己的模型生成摘要
  const { summary, usage } = await myModel.summarize(conversationText);

  return {
    compaction: {
      summary,
      firstKeptEntryId: preparation.firstKeptEntryId,
      tokensBefore: preparation.tokensBefore,
      usage,
    }
  };
});

12.11.2 session_before_tree 事件

/tree 导航之前触发。无论用户是否选择摘要都会触发。可以取消导航或提供自定义摘要:

pi.on("session_before_tree", async (event, ctx) => {
  const { preparation, signal } = event;

  // preparation.targetId - 导航目标
  // preparation.oldLeafId - 当前位置(将被放弃)
  // preparation.commonAncestorId - 共同祖先
  // preparation.entriesToSummarize - 将被摘要的条目
  // preparation.userWantsSummary - 用户是否选择摘要

  // 完全取消导航:
  return { cancel: true };

  // 提供自定义摘要(仅在 userWantsSummary 为 true 时使用):
  if (preparation.userWantsSummary) {
    return {
      summary: {
        summary: "Your summary...",
        usage: summaryResponse.usage,
        details: { /* 自定义数据 */ },
      }
    };
  }
});
Tip

完整的自定义压缩示例(使用不同模型)请参阅 custom-compaction.ts

12.12 设置

~/.pi/agent/settings.json.pi/settings.json 中配置压缩:

{
  "compaction": {
    "enabled": true,
    "reserveTokens": 16384,
    "keepRecentTokens": 20000
  }
}
设置 默认值 描述
compaction.enabled true 启用自动压缩
compaction.reserveTokens 16384 为 LLM 响应预留的 Token 数
compaction.keepRecentTokens 20000 保留的最近 Token 数(不被摘要)
Note

设置 "enabled": false 可禁用自动压缩。你仍可通过 /compact 手动触发压缩。