跳至内容

上下文压缩

创建于 2025-07-16·更新于 2026-07-16

LLM 的上下文窗口有限。当对话过长时,pi 使用压缩(compaction)来总结较早的内容,同时保留近期工作。本文档涵盖自动压缩和分支总结。

源文件 (pi-mono):

如需在你的项目中使用 TypeScript 定义,请查看 node_modules/@earendil-works/pi-coding-agent/dist/

概览 Overview

Pi 有两种总结机制:

机制 Mechanism触发条件 Trigger目的 Purpose
压缩 Compaction上下文超出阈值,或 /compact总结旧消息以释放上下文空间
分支总结 Branch summarization/tree 导航切换分支时保留上下文

两者使用相同的结构化总结格式,并累积跟踪文件操作。

压缩 Compaction

触发时机 When It Triggers

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

contextTokens > contextWindow - reserveTokens

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

你也可以通过 /compact [instructions] 手动触发,其中可选参数 instructions 用于聚焦总结内容。

工作原理 How It Works

  1. 查找切割点:从最新消息向前遍历,累加 token 估计值,直到达到 keepRecentTokens(默认 20k,可在 ~/.pi/agent/settings.json<project-dir>/.pi/settings.json 中配置)
  2. 提取消息:收集从上一个保留边界(或会话开始)到切割点之间的消息
  3. 生成总结:调用 LLM 以结构化格式进行总结,如果存在之前的总结,则将其作为迭代上下文传入
  4. 追加条目:保存包含总结和 firstKeptEntryIdCompactionEntry
  5. 重新加载:会话重新加载,使用总结 + 从 firstKeptEntryId 开始的消息
压缩前:

  entry:  0     1     2     3      4     5     6      7      8     9
        ┌─────┬─────┬─────┬─────┬──────┬─────┬─────┬──────┬──────┬─────┐
        │ hdr │ usr │ ass │ tool │ usr │ ass │ tool │ tool │ ass │ tool│
        └─────┴─────┴─────┴──────┴─────┴─────┴──────┴──────┴─────┴─────┘
                └────────┬───────┘ └──────────────┬──────────────┘
               messagesToSummarize            kept messages
                                   ↑
                          firstKeptEntryId (entry 4)

压缩后(追加了新条目):

  entry:  0     1     2     3      4     5     6      7      8     9     10
        ┌─────┬─────┬─────┬─────┬──────┬─────┬─────┬──────┬──────┬─────┬─────┐
        │ hdr │ usr │ ass │ tool │ usr │ ass │ tool │ tool │ ass │ tool│ cmp │
        └─────┴─────┴─────┴──────┴─────┴─────┴──────┴──────┴─────┴─────┴─────┘
               └──────────┬──────┘ └──────────────────────┬───────────────────┘
                 not sent to LLM                    sent to LLM
                                                         ↑
                                              starts from firstKeptEntryId

LLM 看到的内容:

  ┌────────┬─────────┬─────┬─────┬──────┬──────┬─────┬──────┐
  │ system │ summary │ usr │ ass │ tool │ tool │ ass │ tool │
  └────────┴─────────┴─────┴─────┴──────┴──────┴─────┴──────┘
       ↑         ↑      └─────────────────┬────────────────┘
    prompt   from cmp          messages from firstKeptEntryId

在重复压缩时,被总结的范围开始于上一次压缩的保留边界(firstKeptEntryId),而不是压缩条目本身;如果该保留条目在路径中找不到,则回退到上一次压缩之后的下一个条目。这确保了那些在上一次压缩中保留下来的消息能够在接下来的总结过程中也被包含在内。Pi 还会在写入新的 CompactionEntry 之前,根据重建的会话上下文重新计算 tokensBefore,从而使 token 计数反映被替换的实际压缩前上下文大小。

分割回合 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) ───────┘
                                                      └── kept (7-8)

  isSplitTurn = true
  messagesToSummarize = []  (之前没有完整回合)
  turnPrefixMessages = [usr, ass, tool, ass, tool, tool]

对于分割回合,pi 生成两个总结并合并:

  1. 历史总结:之前的上下文(如果有)
  2. 回合前缀总结:分割回合的前半部分

切割点规则 Cut Point Rules

有效的切割点包括:

  • 用户消息
  • 助手消息
  • BashExecution 消息
  • 自定义消息(custom_message、branch_summary)

切勿在工具结果处切割(它们必须与其工具调用保持在一起)。

CompactionEntry 结构

定义于 session-manager.ts

interface CompactionEntry<T = unknown> {
  type: "compaction";
  id: string;
  parentId: string;
  timestamp: number;
  summary: string;
  firstKeptEntryId: string;
  tokensBefore: number;
  fromHook?: boolean;  // 如果由扩展提供则为 true(遗留字段名)
  details?: T;         // 实现特定的数据
}

// 默认压缩使用以下 details(来自 compaction.ts):
interface CompactionDetails {
  readFiles: string[];
  modifiedFiles: string[];
}

扩展可以在 details 中存储任何 JSON 可序列化的数据。默认压缩跟踪文件操作,但自定义扩展实现可以使用自己的结构。

实现详见 prepareCompaction()compact()

分支总结 Branch Summarization

触发时机 When It Triggers

当你使用 /tree 导航到不同分支时,pi 会提供总结你即将离开的工作的选项。这将把离开分支的上下文注入到新分支中。

工作原理 How It Works

  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 (新叶子节点)

累积文件跟踪 Cumulative File Tracking

压缩和分支总结都累积跟踪文件。在生成总结时,pi 从以下来源提取文件操作:

  • 被总结消息中的工具调用
  • 之前的压缩或分支总结 details(如果有)

这意味着文件跟踪会在多次压缩或嵌套的分支总结中累积,保留读取和修改文件的完整历史。

BranchSummaryEntry 结构

定义于 session-manager.ts

interface BranchSummaryEntry<T = unknown> {
  type: "branch_summary";
  id: string;
  parentId: string;
  timestamp: number;
  summary: string;
  fromId: string;      // 我们从中导航来的条目
  fromHook?: boolean;  // 如果由扩展提供则为 true(遗留字段名)
  details?: T;         // 实现特定的数据
}

// 默认分支总结使用以下 details(来自 branch-summarization.ts):
interface BranchSummaryDetails {
  readFiles: string[];
  modifiedFiles: string[];
}

与压缩相同,扩展可以在 details 中存储自定义数据。

实现详见 collectEntriesForBranchSummary()prepareBranchEntries()generateBranchSummary()

总结格式 Summary Format

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

## 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>

消息序列化 Message Serialization

在总结之前,消息通过 serializeConversation() 序列化为文本:

[User]: 他们说的话
[Assistant thinking]: 内部推理
[Assistant]: 回复文本
[Assistant tool calls]: read(path="foo.ts"); edit(path="bar.ts", ...)
[Tool result]: 工具输出

这可以防止模型将其视为需要继续的对话。

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

通过扩展进行自定义总结 Custom Summarization via Extensions

扩展可以拦截并自定义压缩和分支总结。事件类型定义见 extensions/types.ts

session_before_compact

在自动压缩或 /compact 之前触发。可以取消或提供自定义总结。详见类型文件中的 SessionBeforeCompactEventCompactionPreparation

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 - 压缩后是否重试中止的回合(overflow 恢复)
  // signal - AbortSignal(传递给 LLM 调用)

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

  // 自定义总结:
  return {
    compaction: {
      summary: "你的总结...",
      firstKeptEntryId: preparation.firstKeptEntryId,
      tokensBefore: preparation.tokensBefore,
      details: { /* 自定义数据 */ },
    }
  };
});

将消息转换为文本 Converting Messages to Text

要使用你自己的模型生成总结,请使用 serializeConversation 将消息转换为文本:

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)
  );
  // 返回:
  // [User]: message text
  // [Assistant thinking]: thinking content
  // [Assistant]: response text
  // [Assistant tool calls]: read(path="..."); bash(command="...")
  // [Tool result]: output text

  // 现在发送给你的模型进行总结
  const summary = await myModel.summarize(conversationText);
  
  return {
    compaction: {
      summary,
      firstKeptEntryId: preparation.firstKeptEntryId,
      tokensBefore: preparation.tokensBefore,
    }
  };
});

完整示例见 custom-compaction.ts(使用不同模型)。

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: "你的总结...",
        details: { /* 自定义数据 */ },
      }
    };
  }
});

类型定义详见 SessionBeforeTreeEventTreePreparation

设置 Settings

~/.pi/agent/settings.json<project-dir>/.pi/settings.json 中配置压缩:

{
  "compaction": {
    "enabled": true,
    "reserveTokens": 16384,
    "keepRecentTokens": 20000
  }
}
设置项默认值说明
enabledtrue启用自动压缩
reserveTokens16384为 LLM 响应预留的 token 数
keepRecentTokens20000保留的近期 token 数(不被总结)

使用 "enabled": false 禁用自动压缩。你仍然可以通过 /compact 手动压缩。