开源编程 Agent pi 的上下文压缩:长会话崩掉前它做了什么
本文基于 pi v0.82.1(仓库 commit 027a584,2026-07-28)梳理,该项目仍在快速迭代,具体行为以官方文档 https://pi.dev/docs/latest 与仓库最新代码为准。
长会话崩掉的那一刻,问题几乎从来不是模型突然变笨,而是你的上下文账本被工具输出撑爆了,而账本一爆,Agent 要么直接报错、要么把最关键的那段目标丢在了看不见的地方。 pi 处理这件事的思路很直白:与其等到塞不下再抢救,不如在还剩一段余量的时候,主动把旧消息换成一份结构化摘要,同时保证最近的工作原封不动。这篇讲的就是这套机制的触发口径、保留策略,以及它换来的代价。
站内已经有几篇讲通用方法论的文章:上下文窗口到底是什么 讲概念,Agent 上下文管理 讲通用策略,Claude Code 的上下文管理 讲另一个工具的日常用法。本篇不重复这些,只做一件事:把 pi 这个具体项目的做法落到可核对的文件和配置项上,让你能对着仓库自己验证。pi 采用 MIT 许可证,主仓库在 https://github.com/earendil-works/pi ,截至 2026 年 7 月 GitHub 上约 8 万 star,任何人都能打开对照。
一、长会话为什么会崩:账本视角
把一次编程会话当成一本流水账。你的每句话、模型的每次回复、每一次工具调用和它返回的内容,都要按顺序记在账上,下一轮请求时整本账都得重新发一遍。真正撑爆账本的往往不是你说的话,而是工具结果——读文件、跑命令返回的那几屏输出。pi 的文档在讲序列化时点得很明白:工具结果(尤其来自 read 和 bash)通常是上下文体积的最大贡献者。
账本一旦逼近上限,会出现两类难受的情况。一类是请求直接被拒,这一轮白跑;另一类更隐蔽,模型还能回,但它已经在噪音里找不到你三十轮前定下的约束条件,于是开始改你不让它改的文件、重复已经做过的事。第二类杀伤力更大,因为它不报错。
压缩要解决的就是这两类。它不是省钱功能(虽然顺带影响成本,见上下文预算怎么定),本职是在账本溢出前完成一次可控的换页。
二、pi 在什么时机动手:三个触发口径
pi 的压缩有三个入口,在会话事件里分别记作 manual、threshold、overflow(见 packages/coding-agent/src/core/agent-session.ts 中 compaction_start 事件的 reason 定义)。
第一个是阈值触发。文档给出的判定条件是一行式子:
contextTokens > contextWindow - reserveTokens
也就是说,pi 不等账本真的塞满,而是提前留出一块给模型回复用的余量,一旦当前上下文越过这条线就开压。reserveTokens 的文档默认值是 16384,含义就是”给这次回复留多少”。
第二个是溢出触发。当一轮请求确实因为超出而中断时,pi 也会走压缩路径,并且会带上一个标记表明这一轮之后是否重试——文档里在扩展事件的字段说明中写作 willRetry,用来区分”压缩完继续把刚才那轮跑完”和”压缩完就停在这”。
第三个是你手动敲 /compact,后面可以跟一段说明用来聚焦摘要方向。斜杠命令表里对它的描述是手动压缩当前会话上下文(见 packages/coding-agent/src/core/slash-commands.ts)。
三者之间的差别值得你放在心上:阈值和溢出是被动的,摘要方向由默认提示词决定;手动那次你可以给方向。如果这一段会话里有你特别不想丢的约定,主动压比等它自动压更划算。
三、切在哪、留什么:切点与拆开的一轮
触发之后,pi 要决定从哪一刀切开。文档给的顺序是:从最新的消息往回走,一路累加 token 估算,直到攒够 keepRecentTokens(文档默认值 20000),这个位置就是”保留区”的起点,记为 firstKeptEntryId。它之前的消息拿去做摘要,它之后的原样保留。
关键在于这一刀不能随便切。文档明确列出了合法切点:用户消息、助手消息、BashExecution 消息,以及自定义消息(custom_message、branch_summary)。而工具结果绝不能当切点——工具结果必须和它对应的那次工具调用待在一起,切散了模型会看到一个孤零零的结果块,不知道它是回答什么问题的。
还有一种情况值得单独说:一整轮就超预算了。pi 把”一轮”定义为从一条用户消息开始,到下一条用户消息之前的全部助手回复与工具调用。正常情况下切点落在轮的边界上;但如果单独一轮就撑过了 keepRecentTokens,切点只能落到这一轮中间的某条助手消息上,文档称之为 split turn。这时 pi 会生成两份摘要再合并:一份是更早的历史摘要,一份是这一轮被切掉的前半段的摘要。
反复压缩时还有个容易忽略的细节:第二次压缩的摘要范围,起点是上一次压缩保留下来的边界,而不是上一次那条压缩记录本身。这样做的效果是,上次幸存下来的那批消息会再被摘要覆盖一次,不会因为”上次没被摘要、这次又滑出保留区”而凭空消失。
四、这次压缩到底存下了什么
压缩不是把旧消息删掉了事,它会在会话里追加一条记录。文档给出的结构如下:
interface CompactionEntry<T = unknown> {
type: "compaction";
id: string;
parentId: string;
timestamp: number;
summary: string;
firstKeptEntryId: string;
tokensBefore: number;
usage?: Usage; // LLM usage that generated the summary
fromHook?: boolean; // true if provided by extension (legacy field name)
details?: T; // implementation-specific data
}
摘要本身不是一段自由散文,而是固定骨架:Goal、Constraints & Preferences、Progress(分 Done / In Progress / Blocked)、Key Decisions、Next Steps、Critical Context。提示词里还专门要求保留原样的文件路径、函数名和报错信息。这个骨架决定了压缩之后模型还能记住什么——目标和约束被结构化地钉住了,中间那些来回试错的过程则被合并掉。
摘要末尾还会附上两段文件清单,用 <read-files> 和 <modified-files> 包起来。文件是从工具调用里提取的:read 记为读过,write 和 edit 记为改过,改过的优先级更高(同时读过又改过的文件只出现在”改过”那一栏)。更要紧的是,这个清单跨轮累计——新一次摘要会把上一次记录里 details 中的文件也一起纳入,所以你压了三轮之后,改过哪些文件的历史仍然完整。
下面这张表是各部分的分工,前五行是仓库里的真实文件路径,最后一行是本地配置文件的位置:
| 组成部分 | 它负责什么 | 对应位置 | 你什么时候会碰到它 |
|---|---|---|---|
| 自动压缩逻辑 | 算触发条件、找切点、生成并落盘摘要 | packages/coding-agent/src/core/compaction/compaction.ts | 长会话自动压缩,或你敲 /compact 时 |
| 分支摘要 | 切换会话分支时,把要离开的那条分支总结进新分支 | packages/coding-agent/src/core/compaction/branch-summarization.ts | 用 /tree 在会话树上跳分支时 |
| 共享工具 | 文件操作提取、对话序列化与工具结果截断 | packages/coding-agent/src/core/compaction/utils.ts | 摘要里的文件清单和被截断的工具输出 |
| 记录类型定义 | CompactionEntry、BranchSummaryEntry 的字段 | packages/coding-agent/src/core/session-manager.ts | 读会话文件、自己解析历史时 |
| 扩展事件类型 | session_before_compact、session_before_tree | packages/coding-agent/src/core/extensions/types.ts | 想换模型做摘要或直接拦掉压缩时 |
| 压缩配置项 | enabled、reserveTokens、keepRecentTokens | ~/.pi/agent/settings.json 或 <project-dir>/.pi/settings.json | 调参、按项目覆盖全局配置时 |
另外还有一条并列机制:分支摘要。它的触发口径完全不同——你用 /tree 跳到另一条分支时,pi 会问你要不要把即将离开的那条分支总结一下带过去。做法是先找到两个位置的最近公共祖先,从旧叶子往回收集条目,按预算从新到旧地纳入,再生成摘要挂在导航点上。它和压缩共用同一套摘要格式和同一套文件累计逻辑,但解决的是”换思路时别把上一条路上的结论丢了”,不是”账本要满了”。
五、边界与代价:它放弃了什么
压缩是有损的,这一点必须说在前面。
被摘要掉的那段对话,原始内容不会再发给模型。它还在会话文件里,你事后能翻,但模型看不到。所以任何”埋在第 17 轮某条工具输出里、摘要没提到”的细节,压缩之后就等于不存在了。结构化骨架决定了什么会被保住:目标、约束、决策、下一步、关键数据。不在这几栏里的东西,落选是常态而不是意外。
用来做摘要的输入本身也被裁过。序列化时工具结果被截到 2000 字符,超出部分换成一个标注截断了多少字符的标记(见 utils.ts 中 TOOL_RESULT_MAX_CHARS)。这个取舍很清楚:不裁的话,摘要请求本身就可能过大。代价是如果你的关键信息躺在某条超长命令输出的尾部,它连摘要的输入都进不去。
压缩要花钱花时间。它是一次额外的模型调用,文档提到摘要请求会用独立的路由会话 ID,并且在服务商支持的前提下关掉提示缓存写入——因为这种一次性提示大概率不会被复用。生成摘要的 usage 会记进会话总账,所以你看到的 token 统计里包含这部分。
还有几件它明确不管:它不做跨会话的长期记忆,摘要只服务于当前这条会话路径;它不替你判断哪些文件重要,文件清单是按工具调用机械提取的;它也不保证摘要绝对忠实,摘要终究是模型写的,写漏了就是写漏了。如果你的信息重要到不能丢,正确做法是写进项目文件或者你自己维护的说明里,而不是指望它被摘要捞出来——这也是记忆分层那类做法存在的原因。
序列化格式还有个容易被忽视的用意。摘要输入的形态是这样的:
[User]: What they said
[Assistant thinking]: Internal reasoning
[Assistant]: Response text
[Assistant tool calls]: read(path="foo.ts"); edit(path="bar.ts", ...)
[Tool result]: Output from tool
把对话拍平成带标签的纯文本,是为了避免模型把它当成一场需要接着往下说的对话。
六、上手与避坑清单
别把 keepRecentTokens 调得过小。 为什么会踩:看到压缩省 token,就想多压一点。后果是保留区太窄,很容易让单独一轮就超过预算,直接进 split turn 路径,你当前正在做的这件事的前半段会被摘要掉,而那往往正是你最需要模型记住的部分。怎么避:把它理解成”当前这件事的完整上下文能不能装下”,宁可留宽一点。
别把 reserveTokens 调得过小。 为什么会踩:这个值看起来只是留白。但它是留给模型输出的额度,压缩判定在这条线之前触发。留得太少,会出现”刚好没触发阈值、但这轮回复放不下”的情况,只能走溢出路径补救。怎么避:如果你的任务经常产出长回复或大段补丁,把余量放大。
关掉自动压缩之前想清楚。 为什么会踩:自动压缩打断节奏,有人干脆把 enabled 设成 false。文档说明关掉之后你仍然可以用 /compact 手动压,但代价是你得自己盯着,忘了就是一次溢出。怎么避:真要关,就配合固定习惯,比如每完成一个阶段主动压一次。
配置分全局和项目两层,别只改一处就以为生效。 为什么会踩:~/.pi/agent/settings.json 是全局的,<project-dir>/.pi/settings.json 是项目级的,项目会覆盖全局,且嵌套对象是合并而非整体替换。你在全局改了、项目里又写了同一项,实际生效的是项目那个。文档里给的写法是:
{
"compaction": {
"enabled": true,
"reserveTokens": 16384,
"keepRecentTokens": 20000
}
}
怎么避:改完先确认你改的是哪一层,特别是多项目并行时。
手动压的时候顺手给指令。 为什么会踩:/compact 后面能跟一段用来聚焦摘要方向的说明,很多人一直只敲光秃秃的命令。怎么避:在压之前把”这次要保住什么”讲一句,比压完发现约束丢了再补要省事。
换分支时别习惯性拒绝摘要。 为什么会踩:/tree 跳分支时的摘要提示看着像多余的确认。但它是把旧分支上的结论带到新分支的唯一自动途径,拒掉之后新分支对你刚才那半小时一无所知。怎么避:只有当旧分支确实是废弃尝试时才拒。
需要自己控摘要时用扩展事件,别去改核心。 为什么会踩:想换个便宜模型做摘要,第一反应是改压缩代码。pi 提供了 session_before_compact 和 session_before_tree 两个事件,能取消这次操作,也能返回你自己生成的摘要;文档还给了用 convertToLlm 加 serializeConversation 把消息转成文本再交给自己模型的写法。怎么避:先读 packages/coding-agent/src/core/extensions/types.ts 里的事件类型定义。
顺带说一句服务商侧的事:摘要调用同样是一次模型请求,海外服务商官方对中国大陆存在区域限制、不支持直连,市面上有第三方中转但本文不做推荐也不背书。各家的规则不同且会调整,以官方最新说明为准。压缩相关的机制本身与服务商无关,你换模型不影响这套逻辑怎么跑。
收束:三条自检
判断你的会话该不该动手,问自己三件事。第一,这一轮的关键信息是不是躺在某条超长工具输出里?如果是,它可能连摘要的输入都进不去,趁早把结论写进文件。第二,你当前手头这件事的完整上下文,装得进保留区吗?装不下就意味着 split turn。第三,你接下来要跳分支吗?跳之前先想好旧分支的结论要不要带走。
想继续往下挖,从 packages/coding-agent/docs/compaction.md 读起,再顺着它列的源文件看 compaction.ts 里的切点查找与摘要生成,utils.ts 里的文件提取与序列化。这两个文件加起来就能把本篇讲的每一条对上号。源码层面的实现细节,另有一篇单独展开。
本篇属于一个把开源编程 Agent 项目 pi 逐层拆开讲的系列,整体地图见开源编程 Agent pi 是什么;沿着这条线往下,还可以看 开源编程 Agent pi 没有内置权限系统 和 开源编程 Agent pi 的扩展、技能、提示词模板与包该怎么选。