Skip to content

Latest commit

 

History

History
262 lines (196 loc) · 10.7 KB

File metadata and controls

262 lines (196 loc) · 10.7 KB

第十二章 · Compaction 内部机制

第七章讲了压缩"为什么"和"大致流程"。这一章拆开引擎盖: 切点怎么找、一个巨型 turn 怎么办、摘要长什么样、文件操作怎么累积。


12.1 触发条件

自动压缩在这个条件成立时触发:

contextTokens > contextWindow - reserveTokens

reserveTokens 默认 16384(为 LLM 的回复留出空间),keepRecentTokens 默认 20000(保留最近这么多 token 不压缩)。都可在 ~/.pi/agent/settings.json 或项目的 .pi/settings.json 配置:

{
  "compaction": {
    "enabled": true,
    "reserveTokens": 16384,
    "keepRecentTokens": 20000
  }
}

也可手动 /compact [instructions],可选的 instructions 用来引导摘要重点。

注:第七章示例里的 reserveTokens: 40000 是旧默认值;当前源码默认是 16384。以本章为准。

12.2 五步流程

@startuml
skinparam backgroundColor transparent
skinparam ActivityBackgroundColor #f8f9fa
skinparam ActivityBorderColor #dee2e6

start
:1. 找切点\n从最新消息倒着走,累加 token\n直到达到 keepRecentTokens;
:2. 抽取待压缩消息\n从上个保留边界(或会话起点)到切点;
:3. 生成摘要\n用结构化格式调 LLM\n有上次摘要则作为迭代上下文传入;
:4. 追加 CompactionEntry\n带 summary 和 firstKeptEntryId;
:5. 重载\n用 摘要 + firstKeptEntryId 之后的消息;
stop
@enduml

一张图看清切点:

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

LLM 实际看到:
  ┌────────┬─────────┬─────┬─────┬──────┬──────┬─────┬──────┐
  │ system │ summary │ usr │ ass │ tool │ tool │ ass │ tool │
  └────────┴─────────┴─────┴─────┴──────┴──────┴─────┴──────┘
       ↑         ↑      └──────── firstKeptEntryId 起的消息 ────────┘
    prompt   来自 cmp entry

重复压缩的关键处理:第二次压缩时,待摘要区间的起点是上一次压缩的保留边界firstKeptEntryId),而不是上一个 compaction entry 本身。这样上次压缩后幸存的消息,会在这次摘要里再被覆盖一遍,不会丢。Pi 还会在写新 CompactionEntry 前,从重建的会话上下文重新计算 tokensBefore,让 token 数反映真正被替换掉的上下文。

12.3 Split Turn:一个 turn 就撑爆预算

一个 "turn" 从一条 user 消息开始,包含之后所有 assistant 回复和工具调用,直到下一条 user 消息。正常情况下压缩在 turn 边界切——不会把半个 turn 留下。

但如果单个 turn 就超过了 keepRecentTokens,切点会落在 turn 中间的某条 assistant 消息上。这就是 split turn:

一个巨型 turn 超过预算:

  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 = []           ← 前面没有完整 turn 可摘要
  turnPrefixMessages = [usr, ass, tool, ass, tool, tool]

这时 Pi 生成两段摘要并合并

  1. History summary:之前的上下文(如果有)
  2. Turn prefix summary:这个被切开的 turn 的前半部分

12.4 切点规则:绝不切在工具结果上

合法的切点只有这几种消息:

  • User 消息
  • Assistant 消息
  • BashExecution 消息
  • Custom 消息(custom_messagebranch_summary

永远不切在工具结果(tool result)上——工具结果必须和它对应的工具调用待在一起。否则 LLM 会看到一个"孤儿"工具结果,或者一个没有结果的工具调用,两者都会让 Provider 报错(回想第二章的"孤立工具调用修复")。

12.5 两种检查点:firstKeptEntryId vs retainedTail

这是第十一章埋的伏笔。CompactionEntry 有两种记录"保留了什么"的方式:

interface CompactionEntry<T = unknown> {
  type: "compaction";
  id: string;
  parentId: string;
  timestamp: number;
  summary: string;
  firstKeptEntryId: string;   // 旧方式:指针,指向保留区间的起点
  tokensBefore: number;
  retainedTail?: AgentMessage[]; // 新方式:直接内嵌保留的尾部消息
  usage?: Usage;              // 生成摘要消耗的 token(计入会话总量)
  fromHook?: boolean;         // 是否由扩展生成(历史遗留字段名)
  details?: T;                // 实现特定数据
}
方式 机制 重建时
firstKeptEntryId 指针,指向树中某个 entry 需回溯:从该指针走到 compaction
retainedTail 内嵌完整的 AgentMessage[] 自包含:无需读取更早的 entry

新版 harness 生成的压缩会内嵌 retainedTail,使 compaction entry 成为一个自包含检查点——重建上下文时不必再走到压缩点之前的旧 entry。firstKeptEntryId 保留只是为了兼容旧会话。这正是第十一章 buildContextEntries 里那个分支判断的由来。

12.6 结构化摘要格式

压缩和分支摘要用同一套结构化格式——不是随意的自然语言总结,而是固定小节:

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

结构化的意义:让 Agent 压缩后仍记得目标、约束、已做的决策、以及碰过哪些文件——而不是丢失一切只剩一句模糊总结。

消息序列化:别让模型"接着聊"

摘要前,消息会先被 serializeConversation() 转成带角色标签的纯文本:

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

为什么要序列化成这种"剧本"格式?防止模型把它当成一段需要继续的对话,而是当成需要总结的材料。

一个关键预算控制:工具结果在序列化时被截断到 2000 字符,超出部分替换为标记(注明截断了多少字符)。因为 readbash 的工具结果通常是上下文里最大的贡献者,不截断的话摘要请求本身就会超预算。

12.7 文件操作的累积追踪

默认压缩会从被摘要的消息里提取文件操作,存进 details

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

关键词是累积。生成摘要时,文件操作来自两个源:

  1. 被摘要消息里的工具调用
  2. 上一个 compaction / branch summary 的 details(如果有)

所以经过多次压缩或嵌套分支摘要,读过/改过的文件列表会一路累积下来,不会因为压缩而"忘记"早期碰过的文件。

12.8 扩展可接管压缩

压缩不是黑箱——扩展可以拦截并自定义(回想第九章的钩子)。两个事件:

// 自动压缩或 /compact 前触发
pi.on("session_before_compact", async (event, ctx) => {
  const { preparation, reason, willRetry, signal } = event;
  // reason: "manual" | "threshold" | "overflow"

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

  // 用自己的模型生成摘要:
  const text = serializeConversation(
    convertToLlm(preparation.messagesToSummarize)
  );
  const { summary, usage } = await myModel.summarize(text);
  return {
    compaction: {
      summary,
      firstKeptEntryId: preparation.firstKeptEntryId,
      tokensBefore: preparation.tokensBefore,
      usage,   // 可选,计入会话总量
    },
  };
});

// /tree 分支导航前触发
pi.on("session_before_tree", async (event, ctx) => {
  // 可取消导航,或在 userWantsSummary 时提供自定义摘要
});

reason 里的 "overflow" 值得注意:它表示上下文已经溢出、当前 turn 被中断,压缩后 Pi 会(据 willRetry)重试那个被中断的 turn——这是溢出恢复路径,区别于主动的 "threshold" 触发。

12.9 本章检查清单

  • 记得触发公式 contextTokens > contextWindow - reserveTokens 和两个默认值
  • 理解正常切点在 turn 边界,split turn 为何出现、如何双摘要合并
  • 说得出为什么绝不能切在 tool result 上
  • 讲清 firstKeptEntryId(指针)与 retainedTail(自包含)的区别
  • 知道摘要为何序列化成"剧本"格式、工具结果为何截断到 2000 字符
  • 理解文件操作为什么要跨压缩累积
  • 知道扩展通过哪两个事件接管压缩与分支摘要

上一章第十一章 · Session 树与上下文构建
下一章第十三章 · SDK 与嵌入集成 — 把 Pi 嵌进你自己的应用