从零开始拆解Pi系列——(8)compaction 机制

一、引言:为什么需要 compaction

前七篇文章讲了 pi agent 的完整核心:调 LLM、流式事件、循环、工具、hook、skills、Extension API。这些机制都建立在一个隐含前提上------context window 够用

但 context window 是有限的。GPT-4o 是 128k token,Claude Sonnet 是 200k。听起来很大,但 agent 跑几十轮后就可能耗尽:

arduino 复制代码
turn 1:  user("帮我重构这个模块")           → ~100 token
turn 1:  assistant("好的,我先读一下文件")    → ~200 token
turn 1:  toolResult(read file.ts)            → ~5000 token
turn 2:  assistant("我来修改")              → ~300 token
turn 2:  toolResult(edit file.ts)            → ~2000 token
...
turn 30: 累积 messages 总量                  → ~100000 token

每轮调 LLM 都要把完整的 context.messages 历史发过去。agent 调的工具越多、读的文件越大,context 膨胀越快。

context rot------不只是"满了才出问题"

context window 不只是"满了会报错"------在接近上限时,LLM 的表现已经开始退化。

Anthropic 把这个现象称为 context rot 。在官方文档中明确写道:

As token count grows, accuracy and recall degrade, a phenomenon known as context rot. This makes curating what's in context just as important as how much space is available.

Chroma 的 Context Rot 技术报告量化验证了这一点------测试覆盖 18 个模型(GPT-4.1 / Claude 4 / Gemini 2.5 / Qwen3 等),无一例外:随 input token 数增加,模型从 context 中准确召回信息的能力持续下降,即使任务复杂度被控制不变。

更关键的是,Anthropic 的 prompting 最佳实践文档明确指出:

Claude may sometimes naturally try to wrap up work as it approaches the context limit.

即:模型在接近 context 上限时会自然倾向于提前收尾------主动性下降,不愿调工具、不愿探索、倾向于直接给答案而非继续工作。官方甚至给出了反制 prompt:"do not stop tasks early due to token budget concerns..."

这不是某个模型的 bug,而是所有 LLM 的共性。所以 compaction 不能等"快满了才做"------要在 context rot 明显影响表现之前就压缩。

compaction 的核心思路

  1. 保留最近的消息不动(最近的工作 LLM 还需要)
  2. 把旧消息发给 LLM 生成一段结构化摘要
  3. 用摘要替换掉旧消息------context 从"100 条完整消息"变成"1 条摘要 + 10 条近期消息"

这不是无损压缩------摘要会丢失细节。但 pi 的设计目标是"保留继续工作所需的关键信息"------Goal、Progress、Key Decisions、Next Steps、Critical Context。这些信息让 LLM 在 compaction 后仍然知道"我在做什么、做到哪了、接下来做什么"。

本章拆解 pi 的 compaction 机制:什么时候触发、在哪切割、怎么生成摘要、怎么持久化。这是文章 3 讲的 prepareNextTurn 钩子在真实场景里最重要的应用------compaction 就发生在 turn 结束后的间隙。

二、触发机制

compaction 不是凭空触发的------它需要判断"context 快满了"。pi 用两个函数做这个判断。

流程图

flowchart TD A([turn_end]) --> B[估算 context token 总量<br/>estimateContextTokens] B --> C{shouldCompact?<br/>tokens > contextWindow - reserveTokens} C -->|否| D([继续下一轮]) C -->|是| E[AgentHarness.compact] E --> F[prepareCompaction<br/>找切割点 + 提取要压缩的消息] F --> G[generateSummary<br/>发给 LLM 生成摘要] G --> H[session.appendCompaction<br/>持久化] H --> I([下一轮用压缩后的 context])

compaction 在每轮 turn 结束后检查。如果 token 超阈值,走完整压缩流程;否则继续下一轮。

1. token 估算

estimateContextTokenscompaction.ts:165-193)估算当前 context 的 token 总量:

typescript 复制代码
// compaction.ts:165-193
export function estimateContextTokens(messages: AgentMessage[]): ContextUsageEstimate {
  const usageInfo = getLastAssistantUsageInfo(messages);  // 找最近一次 assistant 消息的 usage

  if (!usageInfo) {
    // 没有 usage 信息------纯字符估算(每条消息按 chars/4 估算)
    let estimated = 0;
    for (const message of messages) estimated += estimateTokens(message);
    return { tokens: estimated, usageTokens: 0, trailingTokens: estimated, lastUsageIndex: null };
  }

  // 有 usage 信息------用 provider 报告的 token 数 + 后续消息的估算
  const usageTokens = calculateContextTokens(usageInfo.usage);   // provider 报的真实 token
  let trailingTokens = 0;
  for (let i = usageInfo.index + 1; i < messages.length; i++) {
    trailingTokens += estimateTokens(messages[i]);               // usage 之后的消息按估算
  }

  return { tokens: usageTokens + trailingTokens, usageTokens, trailingTokens, lastUsageIndex: usageInfo.index };
}

两层策略:

  • 有 provider usage :LLM 每次回复都带 usage.totalTokens(文章 1 讲过),这是 provider 报告的真实 token 数。用这个数 + 后续消息的估算 = 当前总量。这是精确的------provider 自己算的 token 数最准。
  • 没有 usage (如刚启动还没有 assistant 回复):纯字符估算------每条消息按 chars / 4 估算 token 数(英文约 4 字符 1 token)。

estimateTokenscompaction.ts:220-260)按消息类型分别估算:

typescript 复制代码
function estimateTokens(message: AgentMessage): number {
  let chars = 0;
  switch (message.role) {
    case "user":
      chars = estimateTextAndImageContentChars(message.content);
      return Math.ceil(chars / 4);
    case "assistant":
      // text + thinking + toolCall 的参数
      for (const block of assistant.content) {
        if (block.type === "text") chars += block.text.length;
        else if (block.type === "thinking") chars += block.thinking.length;
        else if (block.type === "toolCall") chars += block.name.length + JSON.stringify(block.arguments).length;
      }
      return Math.ceil(chars / 4);
    case "toolResult":
      chars = estimateTextAndImageContentChars(message.content);
      return Math.ceil(chars / 4);
  }
}

图片消息按固定 4800 字符估算(ESTIMATED_IMAGE_CHARS = 4800)------因为图片是 base64 编码,字符数和 token 数的关系和文本不同。

2. 触发判断

shouldCompactcompaction.ts:196-199)判断要不要压缩:

typescript 复制代码
export function shouldCompact(
  contextTokens: number,         // 当前 token 总量
  contextWindow: number,         // 模型的 context window(如 128000)
  settings: CompactionSettings,  // 压缩配置
): boolean {
  if (!settings.enabled) return false;
  return contextTokens > contextWindow - settings.reserveTokens;
}

逻辑很简单:当前 token 数 > context window - 预留 。预留是给模型回复留的空间------如果 context window 是 128k,reserveTokens 是 16384,那么 token 数超过 128000 - 16384 = 111616 就触发 compaction。

3. 配置

typescript 复制代码
// compaction.ts:112-116
export const DEFAULT_COMPACTION_SETTINGS: CompactionSettings = {
  enabled: true,
  reserveTokens: 16384,      // 给模型回复预留 16k token
  keepRecentTokens: 20000,   // 保留最近 20k token 不压缩
};

三个参数:

  • enabled:开关。关了就不压缩------长对话会撞上 context window 上限
  • reserveTokens:给模型回复预留的空间。压缩后 context 必须留出这个空间给 LLM 生成回复
  • keepRecentTokens:保留最近多少 token 不压缩。这部分消息原样保留,不进摘要

4. 触发时机

compaction 不在 runLoop 内部触发------它发生在 AgentHarness 层,每轮 turn 结束后检查。harness.compact()agent-harness.ts:708-757)是一个显式调用------先发 session_before_compact 事件(Extension 可以 cancel),然后调 prepareCompaction + compact 生成摘要,最后持久化。

这意味着 compaction 不会打断正在进行的 LLM 调用或工具执行------它只在 turn 结束后、下一轮开始前的间隙做。agent 不会在工具执行到一半时突然压缩历史。

三、切割点查找

触发 compaction 后,第一个问题是:在哪切割? 保留哪些消息、压缩哪些消息?

1. 切割策略

pi 的策略是"从尾部保留最近 N token,前面的全部压缩":

sql 复制代码
context.messages:
┌──────────────────────────────────────────────────────┐
│ 旧消息(压缩成摘要)               │ 近期消息(保留不动)    │
│ user → assistant → toolResult → ...   │ user → assistant → ... │
└────────────────────────────────────────┴──────────────┘
                                         ← keepRecentTokens (20000)

但切割点不能在任意位置------必须在 turn 边界上切。一个 turn 是"user 消息 → assistant 回复 → toolResult → ... → 下一个 user 消息"的完整序列。如果从中间切断(如 assistant 和 toolResult 之间),LLM 下一轮会看到一个没有上下文的孤立 toolResult。

2. 合法切割点

伪代码(compaction.ts:261-298):

python 复制代码
function findValidCutPoints(entries, startIndex, endIndex):
    cutPoints = []
    for entry in entries[startIndex..endIndex]:
        if entry.type == "message":
            switch entry.message.role:
                case "user":          # user 消息------新 turn 的开始,合法
                case "assistant":     # assistant 消息------合法
                case "custom":
                case "branchSummary":
                case "compactionSummary":
                    cutPoints.push(entry.index)
                case "toolResult":    # toolResult 不能当切割点
                    # 必须和前面的 assistant(含 toolCall)连在一起
        if entry.type == "branch_summary" or entry.type == "custom_message":
            cutPoints.push(entry.index)
    return cutPoints

关键规则:toolResult 不能当切割点。toolResult 必须和它前面的 assistant 消息(包含 toolCall)连在一起------LLM 下一轮需要看到"assistant 请求调工具 → 工具返回了什么"的完整序列。如果把 toolResult 切到摘要里、assistant 留在保留区,LLM 会看到一个请求了工具调用但没收到结果的 assistant 消息------这会让 LLM 困惑。

3. findCutPoint

伪代码(compaction.ts:329-377):

ini 复制代码
function findCutPoint(entries, startIndex, endIndex, keepRecentTokens):
    cutPoints = findValidCutPoints(entries, startIndex, endIndex)
    if cutPoints.length == 0:
        return { firstKeptEntryIndex: startIndex, isSplitTurn: false }

    # 从尾部往前累积 token,直到达到 keepRecentTokens 预算
    accumulatedTokens = 0
    cutIndex = cutPoints[0]

    for i = endIndex - 1 down to startIndex:
        accumulatedTokens += estimateTokens(entries[i])
        if accumulatedTokens >= keepRecentTokens:
            # 达到预算------找一个 >= i 的合法切割点
            cutIndex = first cutPoint where cutPoint >= i
            break

    # 检查切割点是不是 user 消息(turn 开始)
    isUserMessage = entries[cutIndex].message.role == "user"
    turnStartIndex = isUserMessage ? -1 : findTurnStartIndex(...)

    return {
        firstKeptEntryIndex: cutIndex,
        turnStartIndex,
        isSplitTurn: not isUserMessage and turnStartIndex != -1,
    }

逻辑:

  1. 从尾部往前累积 token,直到达到 keepRecentTokens(20000)预算
  2. 在累积到的位置往后找最近的合法切割点
  3. 检查切割点是不是 user 消息(turn 开始)------如果不是,说明切在了一个 turn 的中间

4. split turn------切割点落在 turn 中间

如果切割点不是 user 消息(如切在 assistant 消息上),说明切割点落在一个 turn 的中间------这个 turn 的前半部分会被压缩,后半部分会保留。这叫 split turn

css 复制代码
turn 5: user("修改 bug")
        → assistant("我来读文件")
        → toolResult(read bug.ts)
        → assistant("我找到了 bug")
        → toolResult(edit bug.ts)
        → assistant("修好了")
                    ↑ 切割点在这里(assistant 消息)

切割点前半部分(压缩成摘要):
  user("修改 bug") + assistant("我来读文件") + toolResult(read bug.ts)

切割点后半部分(保留不动):
  assistant("我找到了 bug") + toolResult(edit bug.ts) + assistant("修好了")

问题是:保留的后半部分缺少 turn 的开头------LLM 不知道这个 turn 的 user 请求是什么。pi 的解决办法是生成一个 turn prefix summary------把 turn 前半部分也摘要一下,附加到主摘要里:

ini 复制代码
function compact(preparation):
    if isSplitTurn and turnPrefixMessages.length > 0:
        # 并行生成两段摘要
        historyResult = generateSummary(messagesToSummarize)           # 主历史摘要
        turnPrefixResult = generateTurnPrefixSummary(turnPrefixMessages) # turn 前缀摘要
        summary = historyResult + "---" + "Turn Context (split turn):" + turnPrefixResult
    else:
        summary = generateSummary(messagesToSummarize)                  # 正常摘要

两段摘要拼在一起------主摘要覆盖被压缩的完整 turn,turn prefix 摘要覆盖被切断的当前 turn 的前半部分。LLM 下一轮看到的是"历史摘要 + turn 前缀摘要 + 保留的近期消息"------完整理解上下文。

turn prefix 用的 prompt 不同------更简短,只关注"这个 turn 的前半部分做了什么":

ini 复制代码
TURN_PREFIX_SUMMARIZATION_PROMPT:
  ## Original Request
  [What did the user ask for in this turn?]
  ## Early Progress
  - [Key decisions and work done in the prefix]
  ## Context for Suffix
  - [Information needed to understand the retained recent work]

三个板块:Original Request(user 要什么)/ Early Progress(前半部分做了什么)/ Context for Suffix(理解后半部分需要什么信息)。比主摘要的六板块精简------因为只需要桥接一个 turn 的前后半部分,不需要覆盖完整历史。

四、摘要生成

切割点确定后,把要压缩的旧消息发给 LLM 生成摘要。这是 compaction 的核心------摘要质量直接决定 LLM 在 compaction 后还能不能正常工作。

1. 结构化 prompt

pi 用两个固定的 prompt 让 LLM 生成摘要(compaction.ts:379-414)。

system prompt------告诉 LLM 它是摘要助手,不是对话参与者:

typescript 复制代码
export const SUMMARIZATION_SYSTEM_PROMPT = `You are a context summarization assistant.
Your task is to read a conversation between a user and an AI coding assistant,
then produce a structured summary following the exact format specified.

Do NOT continue the conversation. Do NOT respond to any questions in the conversation.
ONLY output the structured summary.`;

三行"Do NOT"很关键------LLM 读到对话内容时可能忍不住"继续对话"或"回答问题"。system prompt 明确禁止------你只负责摘要,不负责回复。

user prompt------指定摘要的格式和内容:

typescript 复制代码
const SUMMARIZATION_PROMPT = `The messages above are a conversation to summarize.
Create a structured context checkpoint summary that another LLM will use to continue the work.

Use this EXACT format:

## Goal
[What is the user trying to accomplish? Can be multiple items if the session covers different tasks.]

## Constraints & Preferences
- [Any constraints, preferences, or requirements mentioned by user]
- [Or "(none)" if none were mentioned]

## Progress
### Done
- [x] [Completed tasks/changes]

### In Progress
- [ ] [Current work]

### Blocked
- [Issues preventing progress, if any]

## Key Decisions
- **[Decision]**: [Brief rationale]

## Next Steps
1. [Ordered list of what should happen next]

## Critical Context
- [Any data, examples, or references needed to continue]
- [Or "(none)" if not applicable]

Keep each section concise. Preserve exact file paths, function names, and error messages.`;

六个板块,每个有明确职责:

Goal------用户要做什么。不知道目标就无法判断后续方向。允许多个目标(session 可能覆盖不同任务)。

Constraints & Preferences------用户提了什么限制。不能违背。如"不要用第三方库"、"测试覆盖率必须 >80%"。

Progress------做到哪了。分 Done / In Progress / Blocked 三档,用 checkbox 标记。这让 LLM 知道哪些工作已完成、避免重复。

Key Decisions------做了什么决策。保持一致性。如"用 Map 而不是 Object,因为需要保持插入顺序"------后续工作要遵循这个决策。

Next Steps------接下来做什么。直接指导 LLM 的下一步行动。

Critical Context------关键数据。文件路径、函数名、错误信息------这些硬信息不能丢失。prompt 最后一行强调"Preserve exact file paths, function names, and error messages"。

2. 增量摘要

如果之前已经做过一次 compaction(有 previousSummary),pi 不重新摘要全部历史,而是增量更新 已有摘要(compaction.ts:416-454):

typescript 复制代码
const UPDATE_SUMMARIZATION_PROMPT = `The messages above are NEW conversation messages to incorporate
into the existing summary provided in <previous-summary> tags.

Update the existing structured summary with new information. RULES:
- PRESERVE all existing information from the previous summary
- ADD new progress, decisions, and context from the new messages
- UPDATE the Progress section: move items from "In Progress" to "Done" when completed
- UPDATE "Next Steps" based on what was accomplished
- PRESERVE exact file paths, function names, and error messages
- If something is no longer relevant, you may remove it

Use this EXACT format:
[same format as SUMMARIZATION_PROMPT]`;

增量摘要在 generateSummary 里选择 prompt(compaction.ts:471):

typescript 复制代码
let basePrompt = previousSummary ? UPDATE_SUMMARIZATION_PROMPT : SUMMARIZATION_PROMPT;

previousSummary 就用增量 prompt,没有就用全量 prompt。这让多次 compaction 的摘要保持连贯------每次更新而不是重写。

3. generateSummary

generateSummarycompaction.ts:456-514)把旧消息序列化成文本,发给 LLM:

typescript 复制代码
export async function generateSummary(
  currentMessages: AgentMessage[],
  model: Model<any>,
  reserveTokens: number,
  apiKey: string,
  ...
  previousSummary?: string,
): Promise<Result<string, CompactionError>> {
  const maxTokens = Math.min(Math.floor(0.8 * reserveTokens), model.maxTokens);

  // 序列化对话------把 AgentMessage[] 转成纯文本
  const llmMessages = convertToLlm(currentMessages);
  const conversationText = serializeConversation(llmMessages);

  // 构建 prompt
  let promptText = `<conversation>\n${conversationText}\n</conversation>\n\n`;
  if (previousSummary) {
    promptText += `<previous-summary>\n${previousSummary}\n</previous-summary>\n\n`;
  }
  promptText += basePrompt;

  // 调 completeSimple(文章 1 讲的同步调用)生成摘要
  const response = await completeSimple(
    model,
    { systemPrompt: SUMMARIZATION_SYSTEM_PROMPT, messages: [{ role: "user", content: promptText, timestamp: Date.now() }] },
    { maxTokens, apiKey, ... },
  );

  // 提取文本
  const textContent = response.content.filter(c => c.type === "text").map(c => c.text).join("");
  return ok(textContent);
}

注意:摘要生成用的是 completeSimple------文章 1 讲的同步调用,不需要流式。摘要是一次性生成的内容,不需要打字机效果。

maxTokens 限制为 0.8 * reserveTokens------摘要最多占预留空间的 80%,留 20% 给后续追加的文件操作信息。

4. serializeConversation

serializeConversationutils.ts:91-144)把消息序列化成纯文本------因为摘要 prompt 需要把对话作为文本发给 LLM:

typescript 复制代码
export function serializeConversation(messages: Message[]): string {
  const parts: string[] = [];
  for (const msg of messages) {
    if (msg.role === "user") {
      parts.push(`[User]: ${content}`);
    } else if (msg.role === "assistant") {
      if (thinkingParts.length > 0) parts.push(`[Assistant thinking]: ${thinkingParts.join("\n")}`);
      if (textParts.length > 0) parts.push(`[Assistant]: ${textParts.join("\n")}`);
      if (toolCalls.length > 0) parts.push(`[Assistant tool calls]: ${toolCalls.join("; ")}`);
    } else if (msg.role === "toolResult") {
      parts.push(`[Tool result]: ${truncateForSummary(content, 2000)}`);  // toolResult 截断到 2000 字符
    }
  }
  return parts.join("\n\n");
}

关键设计------toolResult 截断到 2000 字符TOOL_RESULT_MAX_CHARS = 2000)。toolResult 可能很长(如 read 一个大文件返回 5000 token),但摘要 prompt 不需要完整内容------只需要知道"读了什么文件、大概返回了什么"。截断防止摘要 prompt 本身爆 context。

assistant 消息分三部分序列化------thinking / text / tool calls 分开标注。这让 LLM 能区分"模型的推理过程"、"模型的回复文本"、"模型请求的工具调用"。

5. 文件操作追踪

摘要末尾追加文件操作信息(compaction.ts:697-698):

typescript 复制代码
const { readFiles, modifiedFiles } = computeFileLists(fileOps);
summary += formatFileOperations(readFiles, modifiedFiles);

extractFileOpsFromMessageutils.ts:24-51)从 assistant 消息的 toolCall 里提取文件操作:

typescript 复制代码
export function extractFileOpsFromMessage(message: AgentMessage, fileOps: FileOperations): void {
  for (const block of message.content) {
    if (block.type !== "toolCall") continue;
    const path = block.arguments.path;
    switch (block.name) {
      case "read": fileOps.read.add(path); break;
      case "write": fileOps.written.add(path); break;
      case "edit": fileOps.edited.add(path); break;
    }
  }
}

最终格式化成 XML 标签追加到摘要末尾:

xml 复制代码
<read-files>
src/types.ts
src/utils.ts
</read-files>

<modified-files>
src/index.ts
src/handlers.ts
</modified-files>

这让 LLM 在 compaction 后知道"哪些文件读过了、哪些文件改过了"------避免重复读取,也方便定位之前的修改。

6. compact 完整流程

compactcompaction.ts:627-706)把以上步骤串起来:

css 复制代码
compact(preparation, model, apiKey, ...)
  │
  ├─ if isSplitTurn:
  │    ├─ generateSummary(messagesToSummarize)          → 主历史摘要
  │    └─ generateTurnPrefixSummary(turnPrefixMessages)  → turn 前缀摘要
  │    summary = 主摘要 + "---" + turn 前缀摘要
  │
  ├─ else:
  │    └─ generateSummary(messagesToSummarize)          → 摘要
  │
  ├─ computeFileLists(fileOps)                          → 文件操作列表
  ├─ summary += formatFileOperations(readFiles, modifiedFiles)
  │
  └─ return { summary, firstKeptEntryId, tokensBefore, details }

五、Session 持久化

摘要生成后,需要持久化到 session 里------这样下次启动或切回这个 session 时,compaction 结果不会丢。理解持久化需要先了解 session 树的最小基础结构。

1. session 树最小基础

pi 的 session 是一棵树------通过 parentId 连成父子关系。树的根是 session 的第一条消息,叶节点是当前最新位置。而 entry 就是 session 树上的一个节点------记录"某一刻发生了什么"。它可以是用户发的一条消息、模型的一次回复、工具的一次执行结果,也可以是模型切换、工具集变更、compaction 摘要等"状态变更事件"。每条 entry 都有唯一 ID、父节点 ID 和时间戳。session 的完整对话历史就是从根到叶的一条路径上所有 entry 的序列。

所有 entry 类型共享一个基础结构(types.ts:334-339):

typescript 复制代码
interface SessionTreeEntryBase {
  type: string;          // "message" / "compaction" / "branch_summary" / ...
  id: string;            // 唯一 ID
  parentId: string | null;  // 父节点 ID(根节点为 null)
  timestamp: string;     // 时间戳
}

主要 entry 类型:

类型 存什么 和 compaction 的关系
message user / assistant / toolResult 消息 被压缩的主体
compaction 摘要 + firstKeptEntryId + tokensBefore 压缩结果
branch_summary 旧分支的摘要 + fromId 切分支时的摘要(第 6 章讲)
model_change provider + modelId 记录模型切换
active_tools_change activeToolNames 记录工具集切换
thinking_level_change thinkingLevel 记录推理等级切换
custom_message 自定义消息(extension 发的) extension 注入的消息
leaf targetId 标记当前叶节点(最新位置)

getBranch()session.ts:109-112)从叶节点往上追溯到根,返回当前路径上的所有 entry------这就是要发给 LLM 的完整对话历史。

2. CompactionEntry

compaction 结果存成一个 CompactionEntrytypes.ts:362-369):

typescript 复制代码
interface CompactionEntry<T = unknown> extends SessionTreeEntryBase {
  type: "compaction";
  summary: string;               // 生成的摘要文本
  firstKeptEntryId: string;      // 保留的第一条消息的 ID
  tokensBefore: number;          // 压缩前的 token 总量
  details?: T;                   // 文件操作列表(readFiles / modifiedFiles)
  fromHook?: boolean;            // 是否来自 hook 提供的自定义摘要
}

firstKeptEntryId 是关键------它标记"从这个 ID 开始的消息保留不动,之前的全部被摘要替换"。compaction entry 本身作为树上的一个节点,挂在被压缩的消息序列之后。

3. AgentHarness.compact

AgentHarness.compactagent-harness.ts:708-757)是 compaction 的入口:

typescript 复制代码
// agent-harness.ts:708-757(简化)
async compact(customInstructions?: string) {
  this.phase = "compaction";                        // 标记 harness 状态

  // 1. 准备------找切割点、提取要压缩的消息
  const branchEntries = await this.session.getBranch();
  const preparationResult = prepareCompaction(branchEntries, DEFAULT_COMPACTION_SETTINGS);
  const preparation = preparationResult.value;

  // 2. 发 session_before_compact 事件------Extension 可以 cancel 或提供自定义摘要
  const hookResult = await this.emitHook({
    type: "session_before_compact",
    preparation,
    branchEntries,
    customInstructions,
  });
  if (hookResult?.cancel) throw new CompactionError("cancelled");

  // 3. 生成摘要------或用 hook 提供的摘要
  const provided = hookResult?.compaction;
  const compactResult = provided
    ? { ok: true, value: provided }              // hook 提供了自定义摘要
    : await compact(preparation, model, apiKey);  // 正常生成摘要

  // 4. 持久化------写入 session 树
  const entryId = await this.session.appendCompaction(
    result.summary,
    result.firstKeptEntryId,
    result.tokensBefore,
    result.details,
    provided !== undefined,                       // fromHook 标记
  );

  // 5. 发 session_compact 事件------通知 UI
  await this.emitOwn({ type: "session_compact", compactionEntry: entry, fromHook: provided !== undefined });

  this.phase = "idle";
  return result;
}

五步:准备 → hook 事件 → 生成摘要 → 持久化 → 通知 UI。

session_before_compact 事件让 Extension 有两个选择:

  • cancel:取消整个 compaction------如 Extension 认为当前任务不适合压缩
  • 提供自定义摘要hookResult.compaction------Extension 可以用自己的逻辑生成摘要,跳过 pi 的 generateSummaryfromHook: true 标记这个 entry 是 hook 提供的

4. context 重建------buildSessionContext

compaction 后,下一轮 LLM 调用时需要从 session 树重建 context。buildSessionContextsession.ts:22-80)负责这件事:

typescript 复制代码
export function buildSessionContext(pathEntries: SessionTreeEntry[]): SessionContext {
  let compaction: CompactionEntry | null = null;

  // 第一遍:找最新的 compaction entry
  for (const entry of pathEntries) {
    if (entry.type === "compaction") compaction = entry;
  }

  const messages: AgentMessage[] = [];

  if (compaction) {
    // 有 compaction------用摘要替换旧消息
    messages.push(createCompactionSummaryMessage(compaction.summary, ...));

    // 从 firstKeptEntryId 开始的消息保留
    const compactionIdx = pathEntries.findIndex(e => e.type === "compaction" && e.id === compaction.id);
    let foundFirstKept = false;
    for (let i = 0; i < compactionIdx; i++) {
      if (pathEntries[i].id === compaction.firstKeptEntryId) foundFirstKept = true;
      if (foundFirstKept) appendMessage(pathEntries[i]);    // firstKeptEntryId 到 compaction 之间的消息保留
    }
    // compaction 之后的消息全部保留
    for (let i = compactionIdx + 1; i < pathEntries.length; i++) {
      appendMessage(pathEntries[i]);
    }
  } else {
    // 没有 compaction------全部消息保留
    for (const entry of pathEntries) appendMessage(entry);
  }

  return { messages, thinkingLevel, model, activeToolNames };
}

重建逻辑分三种情况:

没有 compaction:全部 entry 转成消息,按顺序放入 messages。

有 compaction

  1. 先放一条 CompactionSummaryMessage------摘要作为特殊消息放在开头
  2. firstKeptEntryId 到 compaction entry 之间的消息保留------这些是 split turn 的前半部分或 compaction 保留区
  3. compaction entry 之后的消息全部保留------这是 compaction 之后新产生的消息

重建后的 context 长这样:

swift 复制代码
context.messages = [
  { role: "compactionSummary", summary: "## Goal\n重构模块...\n## Progress\n..." },  // 摘要
  { role: "user", content: "修改 bug" },           // 保留的近期消息
  { role: "assistant", content: [...] },
  { role: "toolResult", ... },
  { role: "assistant", content: [...] },
]

从 100 条完整消息压缩成 1 条摘要 + 10 条近期消息------token 从 ~100k 降到 ~20k,腾出 80k 空间继续跑。

CompactionSummaryMessageconvertToLlm 转成 user 消息发给 LLM------LLM 不认识 compactionSummary role,转成 user 最安全。摘要文本以 ## Goal / ## Progress 等结构化格式呈现,LLM 能理解这是历史摘要而非当前用户指令。

六、branch summarization

compaction 是"同一个 session 分支内的压缩"。branch summarization 是"切换到另一个 session 分支时的压缩"。

1. 什么时候用

pi 的 session 是树结构------用户可以在某个 turn 分叉出新分支(如"从这个点开始换个方案试试")。切回旧分支时,旧分支的消息不在当前路径上,但 LLM 需要知道旧分支做了什么。

arduino 复制代码
session 树:
  turn 1 → turn 2 → turn 3 → turn 4 (分支 A)
                └→ turn 3' → turn 4' (分支 B,当前)

用户从分支 B 切到分支 A:
  分支 A 的 context 不含分支 B 的 turn 3'/4'
  但 LLM 需要知道"分支 B 做了什么" → 生成 branch summary

2. collectEntriesForBranchSummary

切换分支时,先找出"旧分支独有"的 entry------即旧分支有、但目标分支路径上没有的 entry(branch-summarization.ts:69-98):

typescript 复制代码
export async function collectEntriesForBranchSummary(
  session: Session,
  oldLeafId: string | null,    // 旧分支的叶节点
  targetId: string,            // 要切到的目标节点
): Promise<CollectEntriesResult> {
  if (!oldLeafId) return { entries: [], commonAncestorId: null };

  // 旧分支的完整路径
  const oldPath = new Set((await session.getBranch(oldLeafId)).map(e => e.id));
  // 目标分支的完整路径
  const targetPath = await session.getBranch(targetId);

  // 找最近公共祖先------两个分支的分叉点
  let commonAncestorId: string | null = null;
  for (let i = targetPath.length - 1; i >= 0; i--) {
    if (oldPath.has(targetPath[i].id)) {
      commonAncestorId = targetPath[i].id;
      break;
    }
  }

  // 从旧叶节点往上走到公共祖先------这些是旧分支独有的 entry
  const entries: SessionTreeEntry[] = [];
  let current: string | null = oldLeafId;
  while (current && current !== commonAncestorId) {
    const entry = await session.getEntry(current);
    entries.push(entry);
    current = entry.parentId;
  }
  entries.reverse();   // 按时间正序排列

  return { entries, commonAncestorId };
}

逻辑:

  1. 找两个分支的最近公共祖先------分叉点。公共祖先之前的消息两个分支共享,不需要摘要
  2. 从旧叶节点往上走到公共祖先------这段路径上的 entry 是旧分支独有的,需要摘要
  3. 反转成时间正序

3. prepareBranchEntries

找到要摘要的 entry 后,prepareBranchEntriesbranch-summarization.ts:125-164)把它们转成消息,同时提取文件操作:

typescript 复制代码
export function prepareBranchEntries(entries: SessionTreeEntry[], tokenBudget: number = 0): BranchPreparation {
  const messages: AgentMessage[] = [];
  const fileOps = createFileOps();

  // 先从之前的 branch_summary 继承文件操作列表
  for (const entry of entries) {
    if (entry.type === "branch_summary" && !entry.fromHook && entry.details) {
      const details = entry.details as BranchSummaryDetails;
      for (const f of details.readFiles) fileOps.read.add(f);
      for (const f of details.modifiedFiles) fileOps.edited.add(f);
    }
  }

  // 从尾部往前收集消息,遵守 token 预算
  for (let i = entries.length - 1; i >= 0; i--) {
    const message = getMessageFromEntry(entries[i]);
    if (!message) continue;
    extractFileOpsFromMessage(message, fileOps);

    const tokens = estimateTokens(message);
    if (tokenBudget > 0 && totalTokens + tokens > tokenBudget) {
      // 超预算了------但如果是 compaction/branch_summary 就尽量保留(它们是浓缩信息)
      if (entries[i].type === "compaction" || entries[i].type === "branch_summary") {
        if (totalTokens < tokenBudget * 0.9) {
          messages.unshift(message);
          totalTokens += tokens;
        }
      }
      break;
    }
    messages.unshift(message);
    totalTokens += tokens;
  }

  return { messages, fileOps, totalTokens };
}

关键设计:

  • 从尾部往前收集 :如果超预算,保留最近的消息,丢弃最旧的------和 compaction 的 keepRecentTokens 策略一致
  • compaction/branch_summary 优先保留:如果超预算时遇到的 entry 是已有的摘要,尽量保留------摘要是浓缩信息,token 少但信息量大
  • 文件操作继承 :如果旧分支里已经有 branch_summary,从它的 details 里继承文件操作列表------避免丢失更早分支的文件操作记录

4. generateBranchSummary

generateBranchSummarybranch-summarization.ts:201-263)生成摘要:

typescript 复制代码
export async function generateBranchSummary(
  entries: SessionTreeEntry[],
  options: GenerateBranchSummaryOptions,
): Promise<Result<BranchSummaryResult, BranchSummaryError>> {
  const { messages, fileOps } = prepareBranchEntries(entries, tokenBudget);

  if (messages.length === 0) {
    return ok({ summary: "No content to summarize", readFiles: [], modifiedFiles: [] });
  }

  // 序列化对话 + 构建 prompt
  const conversationText = serializeConversation(convertToLlm(messages));
  const promptText = `<conversation>\n${conversationText}\n</conversation>\n\n${BRANCH_SUMMARY_PROMPT}`;

  // 调 completeSimple 生成摘要
  const response = await completeSimple(
    model,
    { systemPrompt: SUMMARIZATION_SYSTEM_PROMPT, messages: [{ role: "user", content: promptText }] },
    { apiKey, maxTokens: 2048, ... },
  );

  // 加前缀 + 文件操作信息
  let summary = BRANCH_SUMMARY_PREAMBLE + response.content...join("\n");
  summary += formatFileOperations(readFiles, modifiedFiles);

  return ok({ summary, readFiles, modifiedFiles });
}

branch summary 的 prompt 和 compaction 的结构一样(六板块),但有一段前缀(branch-summarization.ts:166-168):

typescript 复制代码
const BRANCH_SUMMARY_PREAMBLE = `The user explored a different conversation branch before returning here.
Summary of that exploration:

`;

这段前缀让 LLM 知道"这不是当前任务的摘要,而是用户在另一个分支探索过的内容"------LLM 可以参考但不应该把旧分支的进度当成当前进度。

maxTokens: 2048------branch summary 比 compaction summary 短。compaction 的摘要要覆盖完整历史,branch summary 只需要覆盖一个分支的探索。

5. BranchSummaryEntry

生成的摘要存成 BranchSummaryEntrytypes.ts:371-377):

typescript 复制代码
interface BranchSummaryEntry<T = unknown> extends SessionTreeEntryBase {
  type: "branch_summary";
  fromId: string;      // 旧分支的叶节点 ID------标记从哪个分支摘要来的
  summary: string;     // 摘要文本(含前缀 + 六板块 + 文件操作)
  details?: T;         // 文件操作列表(readFiles / modifiedFiles)
  fromHook?: boolean;  // 是否来自 hook 提供的自定义摘要
}

CompactionEntry 的差异:

CompactionEntry BranchSummaryEntry
firstKeptEntryId 有------保留的消息起点 无------整个分支都压缩
fromId 有------标记从哪个分支来
tokensBefore 有------压缩前 token 数

BranchSummaryEntry 挂在目标分支路径上------切到目标分支时,buildSessionContext 把它转成 BranchSummaryMessage 放入 messages 数组,LLM 下一轮就能看到"用户在另一个分支探索了什么"。

6. 和 compaction 的对比

compaction branch summarization
触发 context token 超阈值 切换 session 分支
压缩范围 当前分支的旧消息 被切走的分支的独有消息
保留近期 是(keepRecentTokens) 否(整个分支都压缩)
摘要格式 六板块结构化 prompt 相同(加前缀)
持久化 CompactionEntry BranchSummaryEntry
prompt SUMMARIZATION_PROMPT BRANCH_SUMMARY_PROMPT(类似但带前缀)
maxTokens 0.8 × reserveTokens 2048(更短)

两者共用 SUMMARIZATION_SYSTEM_PROMPTserializeConversationestimateTokens、文件操作追踪------摘要生成逻辑一样,只是触发场景、压缩范围和持久化结构不同。

七、Q&A

Q1:compaction 会丢失信息吗?丢失了怎么办?

会丢失。compaction 是有损压缩------摘要不可能完整保留所有细节。

pi 的设计策略是"保留继续工作所需的关键信息":Goal / Progress / Key Decisions / Next Steps / Critical Context。这些是 LLM 继续工作的最小必要信息。

丢失的主要是:

  • toolResult 的完整内容serializeConversation 把 toolResult 截断到 2000 字符。如 read 返回的完整文件内容会丢失------但摘要里会保留"读了 src/types.ts"这个信息,LLM 需要时可以重新 read
  • 对话的语气和措辞:摘要只保留事实,不保留用户的原始措辞
  • 中间尝试:如果 LLM 尝试了方案 A 失败再换方案 B,摘要可能只保留"方案 B 成功",不保留"方案 A 失败的详细原因"

如果 LLM 在 compaction 后发现缺少信息,它可以重新调工具获取------如重新 read 文件、重新 grep。compaction 丢的是"历史快照",不是"信息源"------文件还在磁盘上,随时可以重新读。

Q2:compaction 之后 LLM 的表现会变差吗?

会有一点。compaction 后 LLM 看到的 context 从"完整历史"变成"摘要 + 近期消息"。摘要不如完整历史详细------LLM 可能:

  • 重复已做过的工作(如重新读一个已经读过的文件)------但文件操作列表(<read-files> / <modified-files>)能减少这种情况
  • 对早期决策的理解不够精确------但 Key Decisions 板块保留了决策和理由
  • 丢失用户的具体偏好------Constraints & Preferences 板块尽量保留,但可能不完整

pi 的 keepRecentTokens: 20000 保证了最近的工作完整保留------当前任务的上下文不会丢。丢的是几十轮前的早期上下文,那些通常对当前工作影响较小。

Q3:可以手动触发 compaction 吗?

可以。AgentHarness.compact() 是公开方法,任何时候都可以调。pi 也提供了 /compact 命令让用户手动触发。

手动触发的典型场景:

  • 用户知道接下来要做大任务,提前压缩腾空间
  • 用户感觉 agent 回复变慢了(context 太大导致 LLM 响应慢),手动压缩
  • 用户切完分支后想压缩旧分支信息

手动 compaction 和自动 compaction 走完全相同的流程------只是触发者不同。

Q4:compaction 和 branch summarization 能同时发生吗?

理论上能,但 pi 不会让它们同时发生------两者都在 harness.phase !== "idle" 时被拒绝(compact() 开头检查 if (this.phase !== "idle") throw "busy")。

实际场景中它们是串行的:切分支时先做 branch summarization,然后在新分支上如果 token 超阈值再做 compaction。两者不会同时运行。

Q5:如何替换默认的 compaction 策略?需要实现什么?

通过 Extension API 的 session_before_compact 事件。回顾第 5 章讲的 AgentHarness.compactagent-harness.ts:723-734):

typescript 复制代码
// 2. 发 session_before_compact 事件------Extension 可以 cancel 或提供自定义摘要
const hookResult = await this.emitHook({
  type: "session_before_compact",
  preparation,
  branchEntries,
  customInstructions,
});
if (hookResult?.cancel) throw new CompactionError("cancelled");

// 3. 生成摘要------或用 hook 提供的摘要
const provided = hookResult?.compaction;
const compactResult = provided
  ? { ok: true, value: provided }              // hook 提供了自定义摘要
  : await compact(preparation, model, apiKey);  // 正常生成摘要

Extension 需要做两件事:

1. 注册 session_before_compact handler

typescript 复制代码
export default function (pi: ExtensionAPI) {
  pi.on("session_before_compact", async (event, ctx) => {
    const { preparation, customInstructions } = event;

    // 拿到 preparation------包含 messagesToSummarize / turnPrefixMessages / fileOps 等
    // 用自己的逻辑生成摘要
    const mySummary = await myCustomSummarizer(preparation.messagesToSummarize, customInstructions);

    // 返回自定义摘要------pi 跳过 generateSummary,直接用这个
    return {
      compaction: {
        summary: mySummary,
        firstKeptEntryId: preparation.firstKeptEntryId,
        tokensBefore: preparation.tokensBefore,
        details: { readFiles: [...], modifiedFiles: [...] },
      },
    };
  });
}

2. 返回 compaction 字段

handler 返回 { compaction: CompactionResult },pi 跳过默认的 generateSummary + compact 调用,直接用 Extension 提供的摘要。fromHook: true 标记存入 CompactionEntry

也可以选择 cancel

typescript 复制代码
pi.on("session_before_compact", async (event, ctx) => {
  // 某些条件下取消 compaction
  if (shouldNotCompact(event.preparation)) {
    return { cancel: true };
  }
  return undefined;  // 不 cancel 也不提供自定义摘要------走默认流程
});

需要实现什么:

如果你想 需要实现什么
完全替换摘要策略 自己的 summarizer 函数 + 返回 { compaction: { summary, firstKeptEntryId, tokensBefore, details } }
只修改 prompt 不需要 hook------AgentHarness.compact(customInstructions) 接受 customInstructions 参数,追加到默认 prompt
取消 compaction 返回 { cancel: true }
替换切割策略 不能------findCutPoint 在 hook 之前就执行了。preparation 是已切好的结果。如果要改切割,得 override AgentHarness 本身

关键限制:hook 拿到的是 preparation(已切好的结果),不是原始 entry 列表 。切割策略(findCutPoint / keepRecentTokens / split turn 检测)在 hook 之前执行,hook 不能改切割点------只能改摘要内容。

Q6:是否可以通过 Extension API 仅替换 SUMMARIZATION_SYSTEM_PROMPT?如何做?

可以,但不能只换 system prompt 不换 user prompt------得整体返回 compaction

回顾 generateSummarycompaction.ts:496-499):

typescript 复制代码
const response = await completeSimple(
  model,
  { systemPrompt: SUMMARIZATION_SYSTEM_PROMPT, messages: summarizationMessages },
  completionOptions,
);

SUMMARIZATION_SYSTEM_PROMPT 是硬编码的常量,没有参数可以覆盖它。AgentHarness.compact(customInstructions)customInstructions 只追加到 user prompt 末尾(generateSummarybasePrompt = basePrompt + "\n\nAdditional focus: " + customInstructions),不碰 system prompt。

所以只换 system prompt 有两条路径:

路径 1:用 session_before_compact hook 整体替换

注册 handler,在里面用自己的 system prompt 调 completeSimple

typescript 复制代码
export default function (pi: ExtensionAPI) {
  pi.on("session_before_compact", async (event, ctx) => {
    const { preparation } = event;
    const { messagesToSummarize, firstKeptEntryId, tokensBefore, fileOps, settings } = preparation;

    // 用自己的 system prompt + pi 默认的 user prompt
    const conversationText = serializeConversation(convertToLlm(messagesToSummarize));
    const promptText = `<conversation>\n${conversationText}\n</conversation>\n\n${SUMMARIZATION_PROMPT}`;

    const response = await completeSimple(
      ctx.model,
      {
        systemPrompt: "你是一个中文摘要助手。用中文生成结构化摘要。",  // ← 自定义 system prompt
        messages: [{ role: "user", content: promptText, timestamp: Date.now() }],
      },
      { apiKey: ctx.apiKey, maxTokens: Math.floor(0.8 * settings.reserveTokens) },
    );

    const summary = response.content.filter(c => c.type === "text").map(c => c.text).join("");
    const { readFiles, modifiedFiles } = computeFileLists(fileOps);
    const finalSummary = summary + formatFileOperations(readFiles, modifiedFiles);

    return {
      compaction: {
        summary: finalSummary,
        firstKeptEntryId,
        tokensBefore,
        details: { readFiles, modifiedFiles },
      },
    };
  });
}

但这意味着要自己调 completeSimple + 自己拼接 user prompt + 自己追加文件操作信息 ------不能只换 system prompt 让 pi 帮你干其余的活。因为 pi 的 generateSummary 是一个整体函数,不拆分成"system prompt / user prompt / LLM 调用 / 后处理"四个可独立替换的步骤。

路径 2:用 customInstructions 间接影响(不替换 system prompt)

如果只是想加一些额外指令(如"用中文"),用 customInstructions 更简单:

typescript 复制代码
// 通过 AgentHarness API 调用时传
harness.compact("用中文生成摘要");

customInstructions 会追加到 user prompt 末尾("Additional focus: 用中文生成摘要"),LLM 会遵守。这不替换 system prompt,但能达到类似效果------大部分场景够用。

总结:

需求 怎么做 代价
只加额外指令 customInstructions 参数 无------走默认流程
替换 system prompt session_before_compact hook + 自己调 completeSimple 要自己实现整个摘要生成流程
替换 user prompt 同上 同上
完全替换摘要策略 同上 同上

pi 没有提供"只换 system prompt"的细粒度接口------generateSummary 是一个不可拆分的整体。如果要改任何一部分,就得整体接管。

八、下一章预告

下一篇文章将进入 pi 的 session 管理------session 树的完整结构、分支 / fork / switch 操作、JSONL 持久化格式、session 恢复机制。本章第 5 节讲了 session 树的最小基础(entry 类型 + getBranch + buildSessionContext),下一篇展开完整的树管理------怎么从任意节点分叉、怎么在分支间切换、session 怎么存成 JSONL 文件、启动时怎么恢复。这是 compaction 和 branch summarization 的基础设施。

相关推荐
宋哥转AI36 分钟前
深入理解 AI Agent:从黑盒到全链路——生产级 Agent 的可观测性体系怎么建
人工智能·agent·ai编程
岁月宁静38 分钟前
二、《从零手撸 Agent》 — 聊聊 LLM 的失忆真相
前端·python·agent
苏灵凯44 分钟前
IT疑难杂症诊疗室:从故障定位到根治的技术实战指南
笔记·ai·域名·agent·deepseek
岁月宁静1 小时前
一、《从零手撸 Agent》 我用 10 行代码跑通了第一次大模型调用(顺便踩了 4 个坑)
前端·python·agent
武子康1 小时前
删掉邮箱后,Agent Trace 仍可能泄露什么:一条可重放脱敏流水线
人工智能·llm·agent
柒和远方1 小时前
V081:Agent 记忆管理:InMemory 短期记忆、文件持久化,与上下文截断的取舍
agent
Csvn1 小时前
第 13 章 反思 Reflection
人工智能·aigc·agent
梦想的颜色1 小时前
【AI实战】React‑Native + AI 移动端 APP 完整实战全流程|云端 / 本地大模型、Agent 集成、安装配置、避坑指南
react.js·大模型·app·agent·reactnative·expo·ai 应用开发