开源编程 Agent pi 的上下文压缩:源码级拆解触发时机与保留策略

2026-07-29

本文基于 pi v0.82.1(仓库 commit 027a584,2026-07-28)梳理,该项目仍在快速迭代,具体行为以官方文档 https://pi.dev/docs/latest 与仓库最新代码为准。

pi 的上下文压缩不是一个”省钱功能”,它的第一目标是让会话在撞上上下文上限之前先自己活下来,省下来的开销只是副产品。 想明白这一点,源码里那些看起来别扭的判断——为什么摘要请求要新开一个路由会话 ID、为什么工具结果宁可截断也不整段送进摘要、为什么切分点绝对不能落在 toolResult 上——就都能对上号了。

站内已经有两篇讲通用方法论的文章:上下文预算怎么分 讲的是任何 Agent 都该有的额度分配思路,Agent 记忆的分层设计 讲的是短期、工作、长期三层记忆各自的职责。本篇不重复那些判断,只做一件事:把 pi 这个具体项目的压缩实现摊开,看它把方法论落成了哪几行判断、哪几个常量。pi 采用 MIT 许可证,主仓库在 https://github.com/earendil-works/pi ,截至 2026-07 在 GitHub 上约有 8 万 star,代码你可以自己打开对照着读。

一、先看它在解决什么问题

一次编程会话的上下文是单向膨胀的:读一个文件、跑一次测试、翻一遍日志,每一步的工具结果都会永久留在消息列表里。到某个点,下一次请求就会超出模型能接受的输入长度,整轮直接失败。

pi 的处理方式是:在还没撞墙之前,把靠前的那段历史交给模型压成一段结构化摘要,摘要替代原文进入下一次请求,靠后的近期消息原样保留。这段逻辑集中在 packages/agent/src/harness/compaction/compaction.ts,摘要文本的组织格式、切分点的选取、文件轨迹的累积全在这一个文件里。

关键是”靠前”和”靠后”怎么划。pi 没有按消息条数划,也没有按时间划,而是按 token 预算从最新往回数——这个选择决定了后面所有的行为。

二、三个触发入口,一条不等式

判断是否该压缩的函数,函数体只有两行:

export function shouldCompact(contextTokens: number, contextWindow: number, settings: CompactionSettings): boolean {
	if (!settings.enabled) return false;
	return contextTokens > contextWindow - settings.reserveTokens;
}

reserveTokens 是给模型回复留出的余量,默认值写在同一个文件的 DEFAULT_COMPACTION_SETTINGS 里:enabled: truereserveTokens: 16384keepRecentTokens: 20000。官方文档说明这三项可以在 ~/.pi/agent/settings.json<project-dir>/.pi/settings.jsoncompaction 段里覆盖。

contextTokens 从哪来?estimateContextTokens() 的做法很务实:从后往前找最近一条带有效 usage 的 assistant 消息,拿服务商真实报的 token 数作为基数,再把这条之后的消息用字符启发式估出来加上去。启发式本身是 estimateTokens(),按字符数除以 4 向上取整,图片按常量 ESTIMATED_IMAGE_CHARS(4800 字符)折算。也就是说,只有”最后一次真实回包之后新增的那一小截”是估的,前面全是服务商的实数。

真正调用 shouldCompact() 的地方在 packages/coding-agent/src/core/agent-session.ts_checkCompaction()。读那段代码会发现触发口不止一个,事件里的 reason 字段有三个取值:"manual""threshold""overflow"manual 对应斜杠命令 /compact(在 slash-commands.ts 里注册为”Manually compact the session context”),threshold 就是上面那条不等式,overflow 是模型已经报了上下文溢出、或者报回来的 usage 已经超过配置窗口——这时候压缩是抢救而不是预防。

overflow 分支里有两个细节值得记住。一是模型比对:只有当出错的那条消息的 provider 和 model 与当前模型一致时才认这个溢出,注释里写得明白,用户从小窗口模型切到大窗口模型之后,旧模型的溢出错误不该拖着新模型一起压缩。二是重试只做一次,源码里有 _overflowRecoveryAttempted 这个开关,第二次失败会直接抛出提示,让你自己减少上下文或换模型。另外还有一层保护:如果这条 assistant 消息的时间戳早于最近一次压缩条目,直接跳过检查,避免压缩刚做完就被一条陈旧的 usage 再次触发。

三、切在哪里,哪些内容优先保留

findCutPoint() 的流程是三步走。

第一步,findValidCutPoints() 扫一遍范围内的条目,把合法的切分位置列出来。合法的是:user、assistant、bashExecution、custom、branchSummary、compactionSummary 这几类消息,以及 branch_summarycustom_message 类型的条目。toolResult 明确被排除——工具结果必须跟着它的工具调用走,切在中间会留下一个没有对应结果的调用,模型那边直接是非法输入。

第二步,从最后一条往前累加 estimateTokens(),累到大于等于 keepRecentTokens 就停,然后在合法切分点里找第一个不早于当前位置的,那就是保留区的起点。

第三步是一个容易被略过的收尾:while 循环把切点往前挪,跳过那些非消息、非压缩条目(比如模型切换、思考等级变更这类记录),让它们跟着后面的保留区一起走,不至于被摘要吃掉。

切完之后还有一个判断。pi 把”从一条 user 消息开始,到下一条 user 消息之前”称为一轮(turn),正常情况下切点会落在轮次边界上。但如果单独一轮就超过了 keepRecentTokens——比如一次让它改二十个文件——切点只能落在轮中间的某条 assistant 消息上,isSplitTurn 置真,同时用 findTurnStartIndex() 回溯出这一轮的起点。

分裂轮次的麻烦在于:保留下来的后半截会显得没头没尾,模型看到的是一堆工具调用,却不知道用户最初要求的是什么。pi 的解法是生成两份摘要再拼起来。前半截走一个专门的提示词,产出的格式只有三节:Original Request、Early Progress、Context for Suffix,字面意思就是”用户原本要什么、前半截做了什么、要看懂后半截你得知道什么”。这份摘要的 maxTokens0.5 * reserveTokens 与模型自身上限的较小值,比常规摘要的 0.8 * reserveTokens 更紧,因为它只是补一个引子。最后拼成的形式是历史摘要、一条分隔线、再加上标着 **Turn Context (split turn):** 的轮次前缀摘要。

摘要本身的格式是固定的结构化模板,六节:Goal、Constraints & Preferences、Progress(下分 Done / In Progress / Blocked)、Key Decisions、Next Steps、Critical Context。提示词末尾特意强调一句 Preserve exact file paths, function names, and error messages——这是”哪些内容优先保留”最直白的回答:路径、函数名、报错原文一个字都不许改写。

多次压缩不是简单叠加。prepareCompaction() 会往回找上一个压缩条目,取它的 summary 作为 previousSummary 传给摘要请求,这时用的是另一套提示词,规则第一条就是 PRESERVE all existing information from the previous summary,并要求把 Progress 里已完成的条目从 In Progress 挪到 Done。同时,这一轮要摘要的范围起点不是上一个压缩条目本身,而是它记录的 firstKeptEntryId——上次留下来的那批消息,这次会被重新纳入摘要范围,等于给了它们一次”降级但不消失”的过渡。

文件轨迹是另一条独立累积的线。utils.ts 里的 extractFileOpsFromMessage() 只认三个工具名:readwriteedit,从它们的 path 参数收集路径。computeFileLists() 会把改动过的文件从只读列表里剔除,最后 formatFileOperations()<read-files><modified-files> 两个标签追加到摘要末尾。上一次压缩条目 details 里的两个列表也会被读回来合并进来,所以这份清单是跨越多次压缩累积的。

组成部分它负责什么仓库位置你什么时候会碰到它
shouldCompact() / estimateContextTokens()判断是否越过阈值、估算当前上下文占用packages/agent/src/harness/compaction/compaction.tsreserveTokens、排查”为什么它老是压缩”时
findCutPoint() / findTurnStartIndex()选切分点、识别分裂轮次同上发现保留的近期消息比预期多或少时
generateSummaryWithUsage() 与几段提示词常量生成/迭代结构化摘要同上想知道摘要为什么长这样、/compact 附加指令去哪了
serializeConversation() / extractFileOpsFromMessage()会话文本化、文件轨迹提取packages/agent/src/harness/compaction/utils.ts摘要里丢了工具输出细节、改过的文件没被记上时
collectEntriesForBranchSummary() / generateBranchSummary()分支摘要的收集与生成packages/agent/src/harness/compaction/branch-summarization.ts/tree 切分支时
_checkCompaction() / _runAutoCompaction()三种触发原因的编排、溢出抢救packages/coding-agent/src/core/agent-session.ts排查自动压缩没触发或触发两次时

顺带一提,翻源码时要注意这套逻辑在仓库里同时存在两份。官方文档 packages/coding-agent/docs/compaction.md 给出的源码链接指向 packages/coding-agent/src/core/compaction/,而本文主要对照的是 packages/agent/src/harness/compaction/。两个目录都真实存在:前者是 agent-session.ts 实际引用的那一份(它的 import 写的是 ./compaction/index.ts),后者属于 harness 那一层。我把关键常量逐个比对过——reserveTokens: 16384keepRecentTokens: 20000、图片折算的 4800 字符、工具结果截断的 2000 字符、常规摘要的 0.8 * reserveTokens 与轮次前缀摘要的 0.5 * reserveTokens、分支摘要固定的 2048、拼接用的 **Turn Context (split turn):**——两份完全一致,本文讲的规则对两边都成立。差异主要在类型和函数命名两层。类型上,harness 那份的 SessionBeforeCompactEvent 没有 reason 字段,packages/coding-agent/src/core/extensions/types.ts 里的那份有;harness 的 prepareCompaction() 返回的是 Result 包装,coding-agent 那份直接返回准备结果,调用方一个判 .ok、一个判空值。命名上,下文要讲的那个摘要请求封装在 harness 里叫 completeSimpleWithRetries(),在 coding-agent 那份里承担同样职责的函数叫 completeSummarization()——你在文档链过去的目录里搜前一个名字是搜不到的,但两者做的事一致,连源码注释都是同一句。所以顺着文档链接点过去读不会跑偏,但你要给某个函数打断点、或者写扩展去接管压缩,得先确认自己的入口走的是哪一条路径。

四、分支摘要在解决另一个问题

压缩解决的是”太长”,分支摘要解决的是”分叉”。

pi 的会话是一棵树而不是一条线,/tree 命令可以切到另一个分支上去。问题随之而来:你在分支 A 上试了一套方案、读了七八个文件、确认了某条路走不通,然后切回分支 B 重来——那些结论留在 A 上,B 完全看不到,你只能自己复述一遍。

collectEntriesForBranchSummary() 做的第一件事是求两个位置的最深公共祖先:把旧叶子节点整条路径的 id 装进集合,再沿目标路径从后往前找第一个命中的。然后从旧叶子一路回溯到公共祖先,收集途中的条目并反转成时间顺序。这批条目就是”你即将离开的那段探索”。

prepareBranchEntries() 负责在预算内装消息,方向同样是从新往旧。预算是 contextWindow - reserveTokensreserveTokens 默认 16384,模型元数据里没有给出 contextWindow 时,源码里另有一个兜底常量顶上,不至于让预算算成负数。装不下就停——但有一个例外分支:如果当前这条是 compactionbranch_summary 类型的条目,且已用量还不到预算的 90%,就破例塞进去。逻辑是这类条目本身已经是压缩过的高密度信息,值得为它多花一点预算。

另一个和主压缩不同的取舍:分支摘要在取消息时直接跳过所有 toolResult,一条不留。这条路径的目标不是复现过程,而是带走结论,工具原始输出没必要进摘要。生成时 maxTokens 固定 2048,提示词格式与压缩摘要基本一致但去掉了 Critical Context 一节,最后在开头拼上一段前言,第一句是 The user explored a different conversation branch before returning here.——直接告诉接手的模型这段文字的来历,别当成当前对话的一部分。

两套机制在摘要请求的构造上是共用的。completeSimpleWithRetries() 会强制把请求的 cacheRetention 设成 "none",并生成一个新的 sessionId。源码注释解释得很清楚:摘要是一次性的独立请求,路由要隔离,写进提示缓存也不会被复用。这个细节和缓存与幂等设计里讲的取舍是同一类问题——不是所有请求都值得进缓存。

五、边界与代价:它明确不管什么

摘要是有损的,而且损在哪儿是可预测的。 serializeConversation() 会把消息转成 [User]:[Assistant thinking]:[Assistant]:[Assistant tool calls]:[Tool result]: 这样的纯文本行,其中工具结果按 TOOL_RESULT_MAX_CHARS(2000 字符)截断,超出部分替换成一行标注还剩多少字符被截掉了。也就是说,一次 read 读进来的长文件、一次 bash 打出的长日志,在进入摘要模型之前就已经只剩开头一小截。摘要模型看不到的东西,不可能出现在摘要里。

token 估算是启发式的。 字符除以 4 这个比例对英文代码大致合适,对中文、对高度压缩的符号串都会偏。图片一律按 4800 字符折算,与实际编码开销未必吻合。所以 keepRecentTokens: 20000 这个数不是精确保留量,是个量级。

文件轨迹只覆盖三个工具。bash 里的 sedmv、重定向改文件,或者通过别的工具改动,都不会进 <modified-files>。这份清单是线索不是审计记录。

分支摘要会静默丢弃装不下的老内容。 预算从新往旧填,超了就 break,没有任何降级或二次压缩。分支越长,最早那段探索越可能整段消失。

每次压缩都是一次额外的模型调用。 completeSimpleWithRetries()cacheRetention 写死成 "none",所以它既不写缓存也不复用缓存,是实打实的一次独立请求,还可能是两次(分裂轮次时历史摘要和轮次前缀摘要分别调用)。而且 _runAutoCompaction() 传给 compact() 的模型就是 this.model,也就是你当前会话正在用的那个——摘要质量因此和主模型绑在一起,你切到一个更弱的模型,它替你做取舍的水平也跟着降。摘要的输出上限是 0.8 * reserveTokens 与模型自身 maxTokens 的较小值,这两个数任意一个偏小,摘要都会被截短。

它明确不管的事: 不管跨会话的长期记忆——压缩只在当前会话树里工作;不管按需检索——它不会在你需要时把某段被摘要掉的历史再捞回来,摘要是单向的;不管你项目里的记忆文件怎么组织。这些属于上下文管理的其它环节,pi 把它们留给了别的机制和扩展。

六、上手与避坑清单

keepRecentTokens 调得过大。 为什么会踩:直觉上”多留点近期消息更安全”。实际后果是保留区吃掉大部分预算,能被摘要的历史反而所剩无几,压缩做了却几乎没释放空间,下一轮很快又触发。怎么避:改这个值时同步看压缩后实际释放了多少,如果 tokensBefore 和压缩后的占用差距很小,说明留太多了。

reserveTokens 调得过小。 为什么会踩:想让压缩晚一点触发,多用一点窗口。但这个值同时是摘要请求 maxTokens 的基数(常规摘要取 0.8 * reserveTokens),调小会连带把摘要能写的长度压下去,摘要一短,细节丢得更多。怎么避:这两个作用是绑在一起的,别为了推迟触发而动它,要推迟触发就换大窗口模型。

指望摘要里保留完整的工具输出。 为什么会踩:不知道有 2000 字符的截断。典型场景是让它跑一遍测试、输出一大段失败详情,压缩之后追问”刚才哪几个用例挂了”,它答不上来。怎么避:重要结论在压缩发生前落到文件里,或者让它当场把关键结果复述成一段短文本——短文本会完整进摘要。

用 bash 改了文件却发现没被记录。 为什么会踩:extractFileOpsFromMessage() 只匹配 readwriteedit 三个工具名。怎么避:把 <modified-files> 当提示而不是清单,真要确认改了什么,看版本控制的状态。

切换模型之后期待旧的溢出错误自动清掉。 为什么会踩:_checkCompaction() 里的模型比对会让不同模型的溢出错误不触发压缩,这是有意为之,但从用户视角看像是”没反应”。怎么避:换模型后如果上下文本身就很满,手动跑一次 /compact,别等自动触发。

在扩展里返回 cancel 而没考虑溢出场景。 为什么会踩:session_before_compact 事件允许返回 { cancel: true } 取消这次压缩,写的时候通常只想着”这种情况不用压”。但这个事件在 reason"overflow" 时同样会触发,此时取消等于放弃抢救。怎么避:任何 cancel 分支都先判断 event.reason

忘了 /compact 可以带指令。 为什么会踩:文档里写的是 /compact [instructions],方括号容易被当成占位符略过。这段自定义指令会以 Additional focus: 的形式追加到摘要提示词后面。怎么避:在压缩前明确你希望摘要重点保住什么,比如某个模块的改动脉络,直接写在命令后面。

收束

如果你要自己验证本文的每一句,建议按这个顺序读:先 compaction.ts 里的 shouldCompact()DEFAULT_COMPACTION_SETTINGS,把阈值这条线搞清楚;再看 findValidCutPoints()findCutPoint(),切分规则是整套设计的骨架;然后跳到 utils.tsserializeConversation(),摘要模型到底看到了什么全在这个函数里;最后回到 agent-session.ts_checkCompaction(),理解三种触发原因各自的编排。分支摘要那条线可以最后看,它是压缩逻辑的一次变体应用。

一句话总结这套设计的取向:宁可丢掉过程细节,也要保住目标、决策和文件路径;宁可少留几条近期消息,也不让工具调用和它的结果被切开。这两条取舍是否适合你的场景,得你自己判断——但至少现在,你知道它是怎么判断的了。

本篇属于一个把开源编程 Agent 项目 pi 逐层拆开讲的系列,整体地图见开源编程 Agent pi 是什么;沿着这条线往下,还可以看 开源编程 Agent pi 的会话存储开源编程 Agent pi 的系统提示词是怎么拼出来的

想系统学会用 AI?报名体系课或加入会员,照着学、照着用。