原文标题: How Compaction Works in Pi
只要在 Pi、Claude Code 或 Codex 里连续写过一段时间的代码,大概率都见过一个词:
Compaction。
中文可以把它理解成“上下文压缩”。
很多人第一次看到这个提示,可能会以为,Agent 是不是聊不动了,准备把前面的记录直接删掉。
其实没那么粗暴。
至少在 Pi 里,Compaction 更像一次“换班交接”:把前面已经发生过的事情整理成一份摘要,保留最近还在处理的内容,然后让接下来的模型继续干活。
为什么需要这么做?
得先从一次 LLM 对话是怎么越聊越长说起。
一场对话,是怎么越聊越“胖”的
大型语言模型,也就是 LLM,都有自己的上下文窗口。
所谓上下文窗口,可以简单理解成:模型在生成回答时,一次最多能够“看到”多少内容。
这个限制来自 LLM 使用的 Transformer 架构。模型能够处理的输入长度不是无限的,超过上限之后,请求就会被直接拒绝。
放到编程 Agent 里,这件事会更明显。
因为一次 Agent 会话里,不只有使用者发出的消息,还包括系统提示词、工具定义、加载进来的文件,例如 AGENTS.md,以及之前所有的对话、工具调用和工具返回结果。
这些内容,会随着任务推进不断累积。
一次编程 Agent 的首个请求,大概是这样的:
request 1:
[system][tools][user]
这里会开启一轮交互。
模型收到请求后,可能不会立刻给出最终回答,而是先返回一条包含工具调用的 assistant 消息。
Agent 程序执行工具,把工具结果重新塞进上下文,再向模型发送一个新的请求。模型看到完整的对话历史后继续处理,直到输出最终结果,这一轮交互才算结束。
after request 1:
[system][tools][user][assistant: tool call][tool result][assistant]
<-------------------> ^ <--------->
LLM 返回 | LLM 返回
|
Agent 执行工具
接着,使用者又发来一条新消息。
下一次请求就变成了这样:
request 2:
[system][tools][user][assistant: tool call][tool result][assistant][user]
^
新用户消息
看起来只是多聊了一轮。
但在模型眼里,前面的系统提示词、工具定义、用户消息、模型回答、工具调用和工具结果,一个都不能少,全都要重新带上。
于是,每多进行一轮交互,整个上下文就会再长一点。
代码任务一旦复杂起来,Agent 反复读文件、搜索代码、执行命令、查看报错,再根据结果修改方案,上下文增长得会非常快。
最后,整段历史超过模型的上下文窗口,下一次请求就会直接报错:
[system][tools][user][assistant][....][tool result][user]
^
超出上下文窗口
常见的错误提示就是:
Request exceeds the maximum size
翻译一下就是:装不下了。
上下文装不下之后,怎么办?
当原来的会话已经无法继续时,理论上有两种选择。
第一种,直接开启一个全新的空白会话。
这样确实干净,所有上下文瞬间归零。但之前已经做出的决定、排查过的问题、修改到一半的代码,以及还没有解决的任务,也会一起消失。
当然,重新开会话不一定是坏事。
有研究指出,随着上下文不断变长,LLM 的输出性能也可能逐渐下降,这种现象通常被称为 Context Rot,也就是“上下文腐化”。
但对于一个正在进行中的编程任务来说,直接清空通常还是太激进了。
于是就有了第二种方案:
把原来的对话压缩一下。
不是全部丢掉,而是把已经发生过的内容,转换成一个更短、更适合继续使用的版本。
这就是 Compaction。
Compaction 到底做了什么?
理论上,上下文压缩可以有很多实现方式。
例如写一个固定的程序,只保留某些消息,把其他内容直接删除;或者只保存用户消息,丢掉大部分工具调用记录。
但在实际的编程 Agent 中,Compaction 通常不是简单粗暴地截断历史,而是额外调用一次 LLM,让模型总结之前的会话。
压缩完成之后,原来的部分历史会被一段摘要替代:
[system][tools][compaction result][user]
^
新消息
前面的内容变短了,上下文窗口自然也就腾出了新的空间。
Agent 可以继续接收消息、调用工具、修改文件,而不必马上开启一场全新的会话。
Pi 是怎么实现上下文压缩的?
接下来具体看看 Pi 的压缩实现。
当会话越来越长,开始接近模型上下文窗口的上限时,Pi 会自动触发 Compaction。
除了自动触发,使用者也可以手动输入:
/compact
主动压缩当前会话。
正常情况下,Pi 会在一轮交互结束之后检查是否需要自动压缩。
在这一轮结束之前,每一次模型请求仍然会沿用原来的完整提示词。这样做有一个好处:请求之间可以继续复用已经缓存的前缀。
不过,如果任务还没执行完,就在中途遇到了上下文溢出错误,Pi 也可能在当前交互过程中直接触发压缩。
压缩之前,上下文大概是这样的:
before compaction:
[system + tools][older turns][recent retained messages]
这里有一个很重要的细节。
Pi 不会把整段对话全部总结掉,而是会保留一部分最近的消息,让它们继续以原始形式存在。
至于保留多少,不是按照固定的消息条数计算,而是根据一个可配置的 Token 预算决定。
按照原文发布时 Pi 的默认配置,这部分预算是 2 万 Token,大约相当于最近 5 到 20 轮对话。
之所以只能给出一个范围,是因为不同消息的长度差异很大。
一轮简单问答可能只有几百个 Token,一轮包含大量代码、日志和工具结果的操作,则可能占掉几千甚至上万个 Token。
找到切分位置之后,Pi 会把切分点之前的所有消息提取出来,进行序列化,然后交给 LLM 生成摘要。
Pi 的压缩提示词,也和普通对话不一样
压缩并不是让模型随便来一句“前情提要”。
对编程 Agent 来说,一份好的压缩摘要,应该像两班工程师交接工作时留下的说明:
当前要解决什么问题,已经做了什么,哪些方案被否定了,代码改到了哪里,接下来还需要完成什么。
所以,Pi 在执行 Compaction 时,发送的并不是普通对话请求,而是一套专门用于总结上下文的请求。
它和正常请求主要有三点不同。
1. 系统提示词变了
普通对话中,模型可能会被告知:
你是一名专业的编程助手。
到了压缩阶段,Pi 使用的系统提示词则会把模型定义为:
你是一名上下文总结助手。
对应实现可以在 Pi 的源代码中看到。
这时模型的任务不再是继续写代码,而是把已经发生的事情整理清楚。
2. 用户提示词也变了
压缩请求中的用户消息,会要求模型:
为之后重新进入该会话分支,生成一份结构化的对话摘要。
这部分提示词同样可以在 Pi 的实现代码中找到。
提示词里还明确规定了摘要结构,包括任务目标、当前进度和关键决策等内容。
也就是说,Pi 要的不是一段泛泛而谈的总结,而是一份能够让后续模型立刻接手任务的工作交接记录。
3. 这是一次独立的模型请求
Compaction 请求不会直接沿用原来的会话历史,而是一次单独发起的请求。
这样做意味着,Pi 可以为压缩任务选择不同的 LLM,而不需要把整段原始上下文再次带入普通对话模型,产生不必要的成本。
压缩完成后,生成的摘要会作为一条 Compaction 记录写入 Pi 的会话。
新的上下文就会变成这样:
after compaction:
[system][tools][summary][recent turns][new user message]
前面冗长的历史,被一段更短的摘要替代。
最近几轮对话仍然保留原样,新用户消息也可以继续追加。
上下文一下又有空间了。
为什么 Pi 要把摘要保存成纯文本?
Pi 会把压缩结果以纯文本形式保存在会话中。
这个设计看起来很普通,实际上很重要。
首先,纯文本是可读的。
开发者可以直接打开会话记录,查看模型究竟保留了哪些信息,而不是面对一段不可解释的内部状态。
其次,纯文本也更容易迁移。
即使中途切换了模型,新的模型依然能够读懂这份摘要,然后继续处理任务。
这也是 Pi 强调会话可移植性的一部分。
换句话说,这份摘要不只是在给当前模型看。
它更像一份通用的工作交接文档,换一个模型,也能接着往下干。
Compaction 会打断 Prompt Cache
上下文压缩虽然解决了“窗口装不下”的问题,但也会带来一个直接影响:
原来的 Prompt Cache 会失效。
很多 LLM 服务商都会提供 Prompt Caching,也就是提示词缓存。
在一次持续进行的编程会话中,很多请求的前半部分都是完全一样的。
例如系统提示词、工具定义,以及此前已经出现过的对话历史。
服务商可以缓存这些重复内容,从而减少后续请求的计算量和费用。
压缩前的缓存大概是这样:
cached before compaction:
[system][tools][older history][recent retained turns]
<-------------------- cached prefix -------------------->
但完成压缩后,原来的历史已经被摘要替换:
first request after compaction:
[system][tools][summary][recent retained turns][new user message]
<-- 可复用 -->^
|
首个变化的 Token
|
+-- 从这里开始,后面的内容都需要重新计算
Prompt Cache 依赖的是完全一致的前缀匹配。
一旦中间出现一个不同的 Token,后面的缓存就无法继续复用。
虽然压缩后保留的最近几轮对话,内容和 Token 都没有变化,但它们前面原本接的是完整历史,现在接的是一段新的摘要。
前缀变了。
所以,这些内容之前生成的缓存状态也就不能继续使用。
不过,这种影响只发生在压缩后的新前缀建立阶段。
等后续请求继续基于新的摘要和对话历史展开,Prompt Cache 又可以重新生效。
说到底,Compaction 就是一次上下文交接
所以,Pi 的 Compaction 并不是什么特别神秘的机制。
它做的事情,可以概括成几步:
先保留最近仍然重要的几轮消息,再把更早的历史交给 LLM,生成一份包含目标、进度和关键决策的结构化摘要。
然后,Pi 用这份摘要替换原来的长对话,把最近消息重新接在后面,继续执行任务。
这样一来,长时间运行的编程任务不需要彻底清空重来,也不至于因为上下文窗口耗尽,直接卡死在半路。
当然,压缩也有明确的代价。
由于提示词前缀发生了变化,压缩后的第一次请求无法完整复用旧的 Prompt Cache,需要重新计算上下文。
但相比直接丢掉整段任务历史,这个代价显然更容易接受。
还可以自己改一套压缩机制
Pi 本身具有很强的扩展性。
如果默认的 Compaction 机制不符合需求,也可以自己替换。
例如修改压缩提示词,要求模型重点保留代码改动、待办事项、报错信息,或者采用完全不同的摘要结构。
想测试新的压缩方式,可以直接让 Pi 创建一个带有自定义 Compaction Prompt 的扩展。
也就是说,Pi 不只是把上下文压缩做成了一个内置功能。
它还把这部分能力留给了开发者。
至于怎样压缩最合适,应该保留多少最近消息,摘要里最重要的内容究竟是什么,就可以根据不同的任务继续折腾了。