首页 / pi-agent 入门教程 / 上下文压缩:当对话太长

pi-agent 入门教程

上下文压缩:当对话太长

本教程共 30 篇 · 第 24 篇 · 更新于 2026-08-10 · 约 16 分钟阅读

pi-agent上下文压缩compactiontokencontext

本节目标:搞懂 pi 怎么在对话超出上下文窗口时”压缩”历史消息——自动和手动两种方式的触发逻辑、底层工作原理、压缩对后续对话的影响,以及如何调配置让压缩不丢关键信息。

用过一段 pi 之后你大概率会遇到这个场景:一个会话连着聊了几十上百轮、读了十几个文件、改了一堆代码,突然 pi 的回复质量开始下降——好像是”忘了”前面聊过什么、或者重复做已经做完的事。看状态栏,上下文占用快满了。

这不是 pi 出了 bug。这是所有 LLM 对话系统的共性挑战:上下文窗口有限,但对话可以无限长。

pi 的解决方案叫 compaction(上下文压缩)。这一章会深入解释它怎么工作——不只是一句”自动总结”,而是把触发条件、消息选择、摘要生成、条目结构都拆开了讲。

本章基于 pi v0.84.1


为什么需要上下文压缩

每个 LLM 都有一个上下文窗口(context window)——模型一次能”看到”的最大 token 数量。Claude Sonnet 4 的窗口是 200K,GPT-4o 是 128K。听起来很大,但实际用起来:

  • pi 的每条系统提示词(AGENTS.md、skills、扩展)吃一部分
  • 每条用户消息和 AI 回复吃一部分
  • 每次工具调用和结果吃一部分——read 一个 500 行的文件就吃掉几千 token
  • bash 跑编译输出几百行,又吃掉几千

一个复杂任务跑上十几轮,吃掉十万 token 是常事。窗口一旦满了,模型只能看到最近的对话,早期的重要决策和用户指令在它的”记忆”之外——质量下降是必然的。

上下文压缩要解决的就是:在不丢失关键信息的前提下,把旧消息占用的 token 释放出来。


两个触发路径:自动和手动

自动压缩

pi 在每次发送消息前检查当前上下文占用。触发条件是:

当前 token 数 > 上下文窗口上限 - 预留 token 数

默认预留 16384 token。也就是说——如果窗口还剩 16384 token 以内,pi 判定”快满了”,自动触发压缩。

预留这些 token 不是随便设的。你想——压缩之后 pi 马上就要调用 LLM 生成回复,如果预留太少,生成的回复本身就撑爆窗口,等于白压缩。16384 默认值大致够一次中等长度的工具调用 + 回复。

手动压缩

你也可以主动触发:

/compact

还可以带指令:

/compact 重点关注数据库 schema 变更和 API 接口修改

带指令的压缩会让 LLM 在生成摘要时特别关注你指定的方面——想想你做了半天前端调整,但接下来要讨论后端逻辑,那么让摘要侧重 API 相关的上下文就很合理。


压缩怎么工作:分步拆解

压缩不是简单的”把旧消息删了”。它是一个多步流水线,每一步都有讲究。

第一步:找切点

pi 从最新一条消息出发,倒着走,逐条累加 token 估算,直到累计量达到 keepRecentTokens(默认 20000)。从这里往前的消息是”要保留的”,再往前的消息是”要总结的”。

原始消息序列(9 条):

  entry:  0     1     2     3      4     5     6      7      8
        ┌─────┬─────┬─────┬─────┬──────┬─────┬──── ─┬──────┬─────┐
        │ hdr │ usr │ ass │ tool │ usr │ ass │ tool │ tool │ ass │
        └─────┴─────┴─────┴──────┴─────┴─────┴──────┴──────┴─────┘
                └────────┬───────┘ └──────────────┬──────────────┘
              要总结的消息(0-3)              保留的消息(4-9)

                          firstKeptEntryId = 4

切点的选择不是乱切的,有严格的规则:只能在用户消息(user)、AI 回复(assistant)、bash 执行(BashExecution)或自定义消息处切。绝对不在工具结果处切——工具结果必须和触发它的工具调用保持在一起,否则 LLM 看到了结果却不知道是谁调的,整个语义就乱了。

第二步:处理轮次边界

pi 以”轮次”为单位组织消息。“一轮”从用户消息开始,包含这期间所有的 AI 回复和工具调用,到下一个用户消息之前为止。

正常情况下,压缩在轮次边界切——该切的轮次全切,不拆散任何一轮。

但有一种特殊场景:单轮太长。比如你让 pi 跑了一个超大型重构,一轮内就有三十次工具调用,token 量直接超过 keepRecentTokens。这时候必须切成”裂轮”(split turn):

裂轮(一轮太大,超越预算):

  entry:  0     1     2      3     4      5      6     7      8
        ┌─────┬─────┬─────┬──────┬─────┬──────┬──────┬─────┬──────┐
        │ hdr │ usr │ ass │ tool │ ass │ tool │ tool │ ass │ tool │
        └─────┴─────┴─────┴──────┴─────┴──────┴──────┴─────┴──────┘
                ↑                                     ↑
         轮次开始 = 1                          firstKeptEntryId = 7
                │                                     │
                └── 轮次前缀(1-6)──────────────────┘
                                                      └── 保留(7-8)

  isSplitTurn = true
  要总结的消息 = []  (前面没有完整的轮次)
  轮次前缀消息 = [usr, ass, tool, ass, tool, tool]

裂轮时,pi 生成两份摘要再合并:

  1. 历史摘要:之前的所有内容(如果有的话)
  2. 轮次前缀摘要:当前轮次被切掉的前半部分

这样被拆散的轮次,其前半部分的关键信息也不会丢失。

第三步:序列化消息

生成摘要前,pi 先把消息转成纯文本。但这个”转文本”不是直接把对话扔给 LLM——那会让模型以为要继续这段对话。

pi 会把每条消息打上标签,格式类似:

[User]: 帮我重构 src/utils.ts 里的所有工具函数
[Assistant thinking]: 需要先理解现有代码结构...
[Assistant]: 我来先读取文件...
[Assistant tool calls]: read(path="src/utils.ts")
[Tool result]: export function formatDate...
Note

工具结果在序列化时被截断到 2000 字符。因为 readbash 的输出往往是上下文中最大的单一来源——读取整个文件可能几千行,编译输出可能几百行。完整保留会导致摘要请求本身就占用大量 token,本末倒置了。截断用标记注明跳过了多少字符。

这种格式化防止了一个常见的坑:LLM 看到对话格式的文本,可能会”接话”、补充工具调用结果、甚至开始回答问题。而打了标签之后,模型清楚这是历史记录,不是对话。

第四步:生成摘要

pi 把序列化后的文本发给 LLM,用一套结构化的格式要求它生成摘要:

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

这个格式有几个巧妙的设计:

  • Goal 和 Key Decisions 确保 pi 不会忘记用户为什么在这、做过哪些重要判断
  • Done / In Progress / Blocked 给 pi 一个清晰的状态地图,减少”我已经做过但还是重复做”的问题
  • Next Steps 给 pi 一个方向感,重新开始对话时不至于像个无头苍蝇
  • read-files / modified-files 是累积追踪的——每次压缩都会合并上一次压缩的文件记录。这意味着即使在多轮压缩后,pi 仍然知道整个会话中哪些文件被读过、哪些被改过

第五步:重建上下文

摘要生成后,pi 把它作为一个 CompactionEntry(压缩条目)追加到会话的末尾。下一次发送 LLM 请求时,上下文变成:

系统提示词 → 摘要 → 从 firstKeptEntryId 开始的新近消息

被总结的旧消息不再发给 LLM。它们还在会话文件里(可以导出),但不占用活跃上下文。

重复压缩时的特殊处理

一次会话可能触发多次压缩。第二次压缩时,要总结的消息范围从上次压缩的保留起点开始,而不是从压缩条目本身。这确保上一轮保留下来但最终也需要被总结的消息不会”掉进夹缝”里。

pi 还会在生成摘要前把 tokensBefore 重新计算一次——根据实际重建后的上下文,而不是估算值。这保证 token 计数精确,下一轮触发压缩的时机也准确。


分支摘要:切换会话树时的压缩

pi 的会话树功能(/tree)让你在任意节点分叉出新对话。切换分支时,你”离开”的那个分支上的工作信息会丢失——新分支不知道旧分支上做过什么。

pi 提供了一个可选的分支摘要(branch summarization)来解决这个问题:

  1. /tree 导航到目标分支时,pi 问你:“要不要总结你刚才的工作?”
  2. 你选”要”,pi 找到旧分支和新分支的公共祖先节点
  3. 从旧分支叶子倒着走到公共祖先,收集这段时间的所有消息
  4. 生成一份结构化摘要,注入到新分支当前位置
树结构:

         ┌─ B ─ C ─ D (旧叶子,要离开了)
    A ───┤
         └─ E ─ F (目标)

公共祖先: A
要总结的条目: B, C, D

导航后:

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

分支摘要和压缩使用相同的结构化摘要格式,文件追踪也是累积的——如果你在 B-C-D 分支中压缩过,那些压缩摘要里的文件记录也会被合并到分支摘要中。


压缩对对话质量的影响

压缩不是免费的。每次压缩都在用一份”经过 LLM 加工的摘要”替代”原始对话”。这个过程必然有信息损耗。

会丢什么

  • 细节:原始对话中的具体代码行号、精确报错信息、边界情况的讨论——这些在摘要中可能被概括成”处理了错误情况”这样的模糊描述
  • 语气和意图:用户说”我总觉得这里的逻辑不太对”和”这里的逻辑有 bug”——两个意思不同,但可能被总结成”用户认为这里有问题”
  • 临时性决策:那些”先这样,后面再看”的临时妥协——很容易在摘要中被忽略

不会丢什么

  • 文件操作记录read-filesmodified-files 是累积追踪的,压缩后还在
  • 任务方向:Goal、Next Steps 这种高层信息,只要摘要本身质量过关,通常能保留
  • 约束条件:用户明确说的”不改 migrations/“之类的规则,会进 Constraints & Preferences

如何缓解信息损耗

  1. 带指令压缩:如果知道自己接下来要关注哪方面,/compact 重点关注... 能让摘要偏重保留那部分信息
  2. 控制压缩频率:别把 keepRecentTokens 调太小——保留的新近消息越多,压缩频率越低,信息损耗越小
  3. 拆分会话:与其一个会话聊 500 轮触发五六次压缩,不如完成一个子任务就开新会话

压缩策略配置

~/.pi/agent/settings.json 或项目的 .pi/settings.json 中:

{
  "compaction": {
    "enabled": true,
    "reserveTokens": 16384,
    "keepRecentTokens": 20000
  }
}
配置项默认值作用
enabledtrue自动压缩开关。关了之后仍然可以 /compact 手动触发
reserveTokens16384留给 LLM 响应的 token 空间。调大意味着压缩后响应更有空间,但压缩触发得更频繁。调小则相反
keepRecentTokens20000保留多少最新 token 不参与压缩。调大能保留更多原始对话细节,但压缩效果打折扣(能清理的旧消息变少)。调小让压缩更激进

怎么调

如果你的使用场景是长会话、大项目(一次改几十个文件),建议:

{
  "compaction": {
    "reserveTokens": 32768,
    "keepRecentTokens": 30000
  }
}

给 pi 更大的响应空间和更多的近期记忆——代价是压缩更频繁。

如果是短会话、小修改(一次改一两个文件),默认值已经够好,不用动。

如果你用的模型上下文窗口特别大(200K+),可以把两值都调高——窗口大,多留点原始对话无所谓。

Tip

不确定怎么调的时候,先用默认值。等实际遇到压缩后 pi 明显”失忆”了,再考虑加大 keepRecentTokens。别没事瞎调。


扩展介入:自定义压缩逻辑

pi 允许你通过扩展完全接管压缩过程。在 session_before_compact 事件中,你能拿到所有中间数据——要总结的消息、裂轮前缀、之前的摘要、文件操作记录——然后用自己的模型生成摘要。

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

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

  // preparation.messagesToSummarize - 要总结的消息
  // preparation.previousSummary - 上一次的压缩摘要
  // preparation.tokensBefore - 压缩前的 token 数
  // preparation.firstKeptEntryId - 保留消息的起点

  // 把消息转成文本
  const conversationText = serializeConversation(
    convertToLlm(preparation.messagesToSummarize)
  );

  // 用自己的模型生成摘要
  const { summary, usage } = await myModel.summarize(conversationText);

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

类似地,session_before_tree 可以接管 /tree 导航的分支摘要。

这个扩展点对于那些对摘要质量有极高要求的场景很有用——比如你有一个专门训练来做代码上下文摘要的模型、或者想把摘要发给一个更强的模型(即使是手动压缩也默认用的当前会话模型)。


一句话总结

上下文压缩的本质不是”删旧消息”,而是”用 LLM 重新理解旧消息,生成一个紧凑版本”。它试图在信息保真度和 token 节省之间找到平衡。实际使用中,pi 的默认配置对大多数场景已经够用。如果发现压缩后 pi 明显”忘了事”,问题通常不在压缩机制本身,而在摘要没能捕捉到你真正关心的信息——这时候用手动压缩带指令,或者调大 keepRecentTokens,比怀疑底层实现更管用。