上下文压缩:当对话太长
本教程共 30 篇 · 第 24 篇 · 更新于 2026-08-10 · 约 16 分钟阅读
本节目标:搞懂 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 生成两份摘要再合并:
- 历史摘要:之前的所有内容(如果有的话)
- 轮次前缀摘要:当前轮次被切掉的前半部分
这样被拆散的轮次,其前半部分的关键信息也不会丢失。
第三步:序列化消息
生成摘要前,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 字符。因为
read和bash的输出往往是上下文中最大的单一来源——读取整个文件可能几千行,编译输出可能几百行。完整保留会导致摘要请求本身就占用大量 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)来解决这个问题:
- 用
/tree导航到目标分支时,pi 问你:“要不要总结你刚才的工作?” - 你选”要”,pi 找到旧分支和新分支的公共祖先节点
- 从旧分支叶子倒着走到公共祖先,收集这段时间的所有消息
- 生成一份结构化摘要,注入到新分支当前位置
树结构:
┌─ 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-files和modified-files是累积追踪的,压缩后还在 - 任务方向:Goal、Next Steps 这种高层信息,只要摘要本身质量过关,通常能保留
- 约束条件:用户明确说的”不改 migrations/“之类的规则,会进 Constraints & Preferences
如何缓解信息损耗
- 带指令压缩:如果知道自己接下来要关注哪方面,
/compact 重点关注...能让摘要偏重保留那部分信息 - 控制压缩频率:别把
keepRecentTokens调太小——保留的新近消息越多,压缩频率越低,信息损耗越小 - 拆分会话:与其一个会话聊 500 轮触发五六次压缩,不如完成一个子任务就开新会话
压缩策略配置
在 ~/.pi/agent/settings.json 或项目的 .pi/settings.json 中:
{
"compaction": {
"enabled": true,
"reserveTokens": 16384,
"keepRecentTokens": 20000
}
}
| 配置项 | 默认值 | 作用 |
|---|---|---|
enabled | true | 自动压缩开关。关了之后仍然可以 /compact 手动触发 |
reserveTokens | 16384 | 留给 LLM 响应的 token 空间。调大意味着压缩后响应更有空间,但压缩触发得更频繁。调小则相反 |
keepRecentTokens | 20000 | 保留多少最新 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,比怀疑底层实现更管用。