piagent_
← ~/guides

上下文管理:压缩与分支摘要

最后更新: 2026年8月17日

$ pi –compact

TL;DR。 Pi 在对话变长时跑两套摘要机制:自动压缩contextTokens > contextWindow - reserveTokens 时触发,把较早的消息总结成一条 CompactionEntry,并保留最近的 keepRecentTokens(默认分别为 16384 与 20000);分支摘要/tree 切分支时触发,从旧叶回溯到公共祖先,把总结追加为新叶上的 BranchSummaryEntry。两者共用同一套结构化摘要(Goal / Constraints & Preferences / Progress / Key Decisions / Next Steps / Critical Context + <read-files> / <modified-files>),并在多次压缩之间累计文件操作。扩展可通过 session_before_compactsession_before_tree 事件拦截其中任一流程。压缩与分支摘要请求使用全新的路由 session id,并在 provider 支持时关闭 prompt-cache 写入——这些是一次性提示,不会被复用。

本指南是官方 Compaction 文档的深度配套。它把 packages/coding-agent/docs/compaction.mdpackages/coding-agent/src/core/compaction/ 中的实现规则汇总到一处,方便一次性读完。上游文档如有变更,本指南会滞后——动手前请始终核对官方版本。

何时触发压缩

自动压缩在会话 token 数超过上下文窗口减去预留量时触发:

contextTokens > contextWindow - reserveTokens

reserveTokens 默认 16384,是留给 LLM 响应的余量;可在 ~/.pi/agent/settings.json<project-dir>/.pi/settings.json 中调整。任何时候都可以用 /compact [instructions] 手动触发——可选的 instructions 用于聚焦摘要方向(「聚焦测试失败」「保留 API 契约」「压缩 agent 的探索过程但保留每一条用户决策」)。

压缩也可以在会话中途作为 溢出恢复 触发:单次请求即将超过上下文窗口时,被中止的回合在压缩之后被重试(session_before_compact 事件上的 willRetry: true)。

压缩如何遍历历史

阈值触发后,pi 对会话条目跑下面这套流程:

  1. 找切点。 从最新条目往回遍历,累加 token 估算,直到累计 keepRecentTokens(默认 20000)。
  2. 抽取待摘要消息。 收集从会话起点(或上一次压缩的 firstKeptEntryId)到切点之间的所有条目。
  3. 生成结构化摘要。 用下面的格式调用 LLM;如已有上一次摘要,作为迭代上下文传入。
  4. 追加 CompactionEntry 写入 summary、firstKeptEntryIdtokensBefore
  5. 为下一条请求重建上下文。 会话重建为「system prompt + summary + 从 firstKeptEntryId 起的后续条目」。

重复压缩时,「待摘要区间」的起点是上一次压缩的 kept boundary,而不是上一次压缩条目本身;如果那个 kept 条目已经不在当前路径上,则回退到上一次压缩之后的条目。这样设计是为了把上一次保留下来的消息继续纳入下一次摘要,而不是把它们当成已经处理过的内容丢弃。Pi 还会基于重建后的会话上下文重新计算 tokensBefore,再写入新的 CompactionEntry,所以 token 数始终反映「正在被替换掉的真实前置上下文」。

切点规则

合法的切点是用户消息、assistant 消息、BashExecution 消息、自定义消息(custom_messagebranch_summary)。绝不能在工具结果处切——它们必须和触发它们的工具调用放在一起,否则 LLM 会看到一条没有返回结果的工具调用。

切分回合

正常情况下压缩在回合边界处切(用户消息及其后到下一条用户消息之间的全部内容)。当单个回合本身就超过 keepRecentTokens 时,切点落在回合内部的 assistant 消息上,该条目被标记 isSplitTurn: true。对切分回合,pi 会生成两份摘要并合并:

  1. 历史摘要:覆盖回合之前的上下文(若有)。
  2. 回合前缀摘要:覆盖被切分的回合的前半段。

合并结果作为单条 CompactionEntry 的 summary 写入。

结构化摘要格式

压缩与分支摘要共用同一套格式。摘要块用 markdown 围栏包裹;read-filesmodified-files 用尖括号标签分隔,以便 pi 栈的后续环节能够确定性地抽取:

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

在消息交给 LLM 之前,serializeConversation() 会先把它们压平成文本:

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

这样能防止模型把摘要提示当成一段对话继续往下写。序列化期间,工具结果会被截断到 2000 字符;超过的部分用一段标记替换,告知实际截断长度。截断是必要的:工具结果(尤其是 readbash)通常是上下文的最大来源,加上限才能保证摘要请求本身不超出预算。

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 usage
  fromHook?: boolean;  // 由扩展提供时为 true(兼容旧字段名)
  details?: T;         // 实现自定义数据
}

// 默认压缩用这个 details:
interface CompactionDetails {
  readFiles: string[];
  modifiedFiles: string[];
}

details 是开放的——扩展可以在里面存任何 JSON 可序列化的负载。默认实现按累加方式跨压缩追踪文件操作:生成下一份摘要时,pi 既会从被摘要的消息里抽工具调用得到的文件操作,也会与上一次压缩的 details.readFiles / details.modifiedFiles 合并。内置实现和扩展实现的摘要都会写入 LLM usage,让会话的累计统计包含摘要工作的开销。

/tree 上的分支摘要

/tree 打开会话树导航器。当你切到另一分支时,pi 会询问是否要把放弃的一侧总结成摘要;若同意,会执行:

  1. 找到旧叶与目标之间最深的公共祖先。
  2. 从旧叶往回走到祖先。
  3. 在 token 预算内(按时间倒序)准备要总结的条目。
  4. 用同样的结构化格式调用 LLM。
  5. 在新叶上追加 BranchSummaryEntry——每次切分支只生成一条,而不是每条分支一条。

分支摘要挂在切点位置,这样新叶就能拿到被放弃一侧的上下文,而不必沿着树回走:

切分支前:

         ┌─ B ─ C ─ D(要放弃的旧叶)
    A ───┤
         └─ E ─ F(目标)

带摘要的切分支后:

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

BranchSummaryEntryCompactionEntry 结构相似,但用 fromId(切出去的源条目)替代 firstKeptEntryId,并复用同样的 details 形状(readFiles / modifiedFiles),这样累计文件追踪在嵌套分支摘要里也能正常工作。

通过扩展自定义摘要

扩展可以拦截其中任一流程。两个事件互相独立,都在对应动作之前触发。

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             - compaction 配置块

  // branchEntries  - 当前分支上的全部条目(用于自定义状态)
  // reason         - "manual"(/compact)、"threshold" 或 "overflow"
  // willRetry      - 被中止的回合是否在压缩后重试
  // signal         - AbortSignal,传递给 LLM 调用

  return { cancel: true };
  // 或返回自定义摘要:
  // return {
  //   compaction: {
  //     summary: "你的摘要...",
  //     firstKeptEntryId: preparation.firstKeptEntryId,
  //     tokensBefore: preparation.tokensBefore,
  //     usage, // 可选;纳入会话累计统计
  //     details: { /* 自定义数据 */ },
  //   },
  // };
});

如果用其他模型生成摘要,先把准备好的消息转成文本:

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

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

const { summary, usage } = await myModel.summarize(conversationText);
return {
  compaction: { summary, firstKeptEntryId: preparation.firstKeptEntryId, tokensBefore: preparation.tokensBefore, usage },
};

完整的「用别的模型做压缩」示例见 examples/extensions/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: "你的摘要...",
        usage, // 可选
        details: { /* 自定义数据 */ },
      },
    };
  }
});

配置项

~/.pi/agent/settings.json<project-dir>/.pi/settings.json 中:

{
  "compaction": {
    "enabled": true,
    "reserveTokens": 16384,
    "keepRecentTokens": 20000
  }
}
设置 默认 说明
enabled true 自动压缩的总开关
reserveTokens 16384 给 LLM 响应预留的 token(自动触发的安全余量)
keepRecentTokens 20000 压缩后保留的最近 token 数

"enabled" 设为 false 可关闭自动压缩;手动 /compact 不受影响。当前的 settings schema 还没有单独的 branchSummary.* 块——分支摘要沿用压缩配置,未来若新增每分支覆写项会落到这里。

调参经验法则:如果任务经常中途撞穿模型窗口,提高 keepRecentTokens 让 agent 保留更多原始历史,摘要覆盖的范围就更小;如果摘要丢失关键细节,提高 reserveTokens 让压缩更早触发(每次待摘要的上下文更少,LLM 有更多空间写得详尽);如果想可控成本,把 reserveTokens 调低,让压缩刚好踩点触发,再按「摘要 + 保留窗口」估算会话预算。

压缩与 prompt cache

压缩与分支摘要请求使用 全新的路由 session id,并在 provider 支持时对这次摘要调用关闭 prompt-cache 写入。原因在于:这些是一次性提示——不属于任何用户可见的对话轨迹,缓存它们只会占用 cache slot 而没有命中率来回收成本。如果你的扩展用 session_before_compact 调别的模型,同理:传一个新的 session id,并考虑在摘要请求里关闭 prompt-cache 写入。

延伸阅读