Skip to main content

Compaction 与分支摘要

Pi 把上下文压缩这件事做成了一等公民:自动触发、结构化摘要、可扩展、可手动控制。它同时承担两种角色——compaction 在「上下文快爆了」时收尾,branch summary 在「切到不同分支」时存档。两套机制共用同一套文件跟踪与摘要格式。

两种机制一个套路

机制触发目的
Compaction上下文超阈值 或 /compact把旧消息摘要掉以释放空间
Branch summarization/tree 导航切分支把离开的分支摘要成一段上下文

两者的差异是触发点和写入位置:compaction 写在「old messages → firstKeptEntry」之间,branch summary 写在「从旧 leaf 切到新 leaf」的节点上。共用同一套结构化摘要格式与文件跟踪累积机制。

何时自动触发

自动 compaction 的条件非常直白:

contextTokens > contextWindow - reserveTokens
  • reserveTokens 默认 16384 tokens,给 LLM 响应留空间
  • 配置在 ~/.pi/agent/settings.json<project>/.pi/settings.json
{
"compaction": {
"enabled": true,
"reserveTokens": 16384,
"keepRecentTokens": 20000
}
}
字段默认含义
enabledtrue是否启用自动 compaction
reserveTokens16384给 LLM 响应保留的 tokens
keepRecentTokens20000摘要后保留的最近 tokens

enabled 设成 false 可以关掉自动 compaction,但 /compact 仍然可用。

压缩流程

Pi 的 compaction 不是一个简单的「截断前 N 条」,而是一段端到端的流程:

  1. 找切割点。从最新消息向前回溯,按 token 估算累加,直到累加到 keepRecentTokens(默认 20k)停止。切割点之前的是要摘要的「历史」,之后是「保留」。
  2. 提取消息。从「上一保留边界」到切割点之间所有 entry,构造出 messagesToSummarize
  3. 生成摘要。调用 LLM,把上一段 summary 作为迭代上下文,让它生成新 summary(增量式而非每次重头)。
  4. 追加 CompactionEntry。包含 summaryfirstKeptEntryIdtokensBefore
  5. 重载 session。会话上下文用 summary + 从 firstKeptEntryId 起的消息重建。

关键设计:重复 compaction 从上一 compaction 的保留边界开始,而不是从 compaction 节点本身。这样 N 次压缩后,磁盘上其实只有「第一段历史摘要 + 第二段增量摘要 + 保留段」三块,而不是 N 段独立摘要。每次写入新 CompactionEntry 之前,Pi 从重建后的上下文重新计算 tokensBefore——这是个容易漏掉的细节,作用是「压缩后的总 token 数」始终反映真实状态。

Split Turn:单轮超过 keepRecentTokens 时

如果一个 user→assistant 轮次本身就超过 keepRecentTokens,切割点不能落在 user/tool result 上(这两种 entry 不能作为摘要边界),只能落在该轮次中间的 assistant 消息上。这就是 split turn。Pi 生成两个摘要

  1. History summary:本轮之前的所有上下文
  2. Turn prefix summary:本轮已发生的早期部分

LLM 收到的实际输入是 [history_summary] + [turn_prefix_summary] + [本轮剩余消息]——三条摘要化的连续段拼起来。

摘要格式

Pi 把「摘要」写成了一个结构化的 schema,让 LLM 输出可被解析的字段:

## Goal
[用户目标]

## Constraints & Preferences
- [用户需求]

## Progress
### Done
- [x] [已完成]

### In Progress
- [ ] [进行中]

### Blocked
- [阻塞项]

## Key Decisions
- **[决策]**: [原因]

## Next Steps
1. [下一步]

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

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

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

<read-files><modified-files> 是从被摘要的消息里提取的工具调用,累积跨多次 compaction。这意味着即使经过多轮压缩,最终摘要里仍然完整保留了「本会话读过/改过哪些文件」的清单——这对 coding agent 来说是高价值信息,因为模型不需要重新 grep 整个仓库就能知道已经碰过哪些路径。

消息序列化

把对话喂给 LLM 摘要前,Pi 用 serializeConversation() 把消息数组转成文本:

[User]: ...
[Assistant thinking]: ...
[Assistant]: ...
[Assistant tool calls]: read(path="..."); edit(...)
[Tool result]: ...

Tool result 截断到 2000 字符——超过部分用标记替代,避免一个超长输出把整个摘要 prompt 撑爆。

CompactionEntry 结构

interface CompactionEntry<T = unknown> {
type: "compaction";
id: string;
parentId: string;
timestamp: number;
summary: string;
firstKeptEntryId: string;
tokensBefore: number;
fromHook?: boolean; // true = 扩展生成
details?: T; // 扩展可存任意 JSON
}

interface CompactionDetails {
readFiles: string[];
modifiedFiles: string[];
}

details 字段允许扩展塞入额外数据(实际压缩用 CompactionDetails,但 schema 是开放的)。

扩展点:session_before_compact

这是最重要的自定义 hook。Pi 在执行 compaction 之前触发它,让扩展可以完全替换摘要生成

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

// preparation 字段:
// - messagesToSummarize 待摘要的消息
// - turnPrefixMessages split turn 时本轮早期部分
// - previousSummary 上一段 summary(用于增量)
// - fileOps 文件操作累积
// - tokensBefore 压缩前 token 数
// - firstKeptEntryId 保留段起始
// - settings 当前 compaction 配置

// reason: "manual" | "threshold" | "overflow"
// willRetry: 溢出恢复时是否重试

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

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

最实用的用法是用自己的模型摘要——比如用本地小模型、或者专门的 summary 模型:

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

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

const conversationText = serializeConversation(
convertToLlm(preparation.messagesToSummarize)
);

const summary = await myModel.summarize(conversationText);

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

官方在 examples/extensions/custom-compaction.ts 给出了完整例子。

分支摘要:切分支时不丢上下文

/tree 切到一个不同分支时,Pi 默认让你选:

  1. 不摘要
  2. 用默认 prompt 摘要
  3. 用自定义指令摘要

流程

  1. 找共同祖先——新旧位置共享的最深节点
  2. 收集从旧 leaf 回溯到共同祖先的 entries
  3. 在 token 预算内选消息(最新优先)
  4. 调 LLM 生成 summary
  5. 追加 BranchSummaryEntry 到导航点

BranchSummaryEntry 结构跟 CompactionEntry 类似:

interface BranchSummaryEntry<T = unknown> {
type: "branch_summary";
id: string;
parentId: string;
timestamp: number;
summary: string;
fromId: string;
fromHook?: boolean;
details?: T;
}

interface BranchSummaryDetails {
readFiles: string[];
modifiedFiles: string[];
}

切换后,新分支上 LLM 看到的输入 = 「旧分支的 branch_summary + 新分支的实际消息」。历史不会被丢掉,只是被压缩进了左分支——磁盘上完整保留,将来想回旧分支只需 branch() 切回去。

累积文件跟踪

两种机制都做了「累积式文件跟踪」——读/改过的文件清单从以下来源累加:

  • 本次摘要消息中的 tool 调用
  • 之前 compaction 或 branch summary 的 details 字段

跨多次 compaction 或嵌套 branch summary 累积,最终 summary 里保留完整的 read/modified 文件历史。这避免了一个常见 bug:压缩 5 次后,模型已经忘了最初读过哪些文件,需要重新 grep。

扩展点:session_before_tree

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

// preparation 字段:
// - targetId /tree 目标
// - oldLeafId 当前 leaf
// - commonAncestorId 共同祖先
// - entriesToSummarize 即将离开的分支
// - userWantsSummary 是否需要摘要

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

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

关键源码位置

功能文件
Auto-compaction 主逻辑packages/coding-agent/src/core/compaction/compaction.ts
Branch summarizationpackages/coding-agent/src/core/compaction/branch-summarization.ts
共享工具(文件跟踪、序列化)packages/coding-agent/src/core/compaction/utils.ts
Entry 类型定义packages/coding-agent/src/core/session-manager.ts
扩展事件类型packages/coding-agent/src/core/extensions/types.ts
完整自定义示例examples/extensions/custom-compaction.ts

下一章讲 Extensions。Pi 的扩展机制是整个架构的核心——所有「核心不内置但用户又能拿到」的能力,都是通过 TypeScript 扩展装配的。理解 extensions 才能理解 Pi 的设计哲学。