Coding Agent上下文装不下了怎么办——从pi-mono源码看上下文压缩:触发时机、切点、摘要与溢出兜底
最近在读 pi-mono 的源码,顺手记了不少笔记。pi-mono 是 earendil-works 用 TypeScript 写的一套 Agent 工具集,里面的 coding-agent 就是一个完整的编码 agent。整套代码里我觉得最有意思的一块,是上下文压缩。
用过 coding agent 的都知道,一个任务做久了,读过的文件、跑过的命令、改过的代码全都堆在对话历史里,模型的上下文窗口迟早要满。满了总得扔点东西,可扔掉的每一条消息,都可能正好是模型下一步要用的。
所以压缩这件事注定是有损的。所有的设计都在回答同一个问题:在必须丢信息的前提下,怎么丢得最不疼?这篇就顺着 pi 的源码,把它的做法一步步拆开看。
一、什么时候触发压缩
判定函数短得出奇,在 packages/coding-agent/src/core/compaction/compaction.ts 里:
export function shouldCompact(contextTokens: number, contextWindow: number, settings: CompactionSettings): boolean {
if (!settings.enabled) return false;
// 当前上下文 > 窗口 - 预留空间,就触发
return contextTokens > contextWindow - settings.reserveTokens;
}
export const DEFAULT_COMPACTION_SETTINGS: CompactionSettings = {
enabled: true,
reserveTokens: 16384, // 预留 16K
keepRecentTokens: 20000, // 压缩时原样保留最近 20K
};
可以看到,pi 并不是等窗口用满才动手,而是提前留出了 16K。
为什么不用满?因为压缩本身要再跑一次 LLM 调用来生成摘要,而这次调用要把待压缩的内容全部发进去。如果等到窗口真满了才开始,摘要调用自己就会超限,陷入"因为满了要压缩,因为要压缩所以更满"的死锁。reserveTokens 就是给这次自救留的空间。
这和 JVM 的 GC 是一个道理:回收本身也要消耗资源,GC 需要暂停时间,压缩需要 token。
那么 contextTokens 是怎么算出来的呢?pi 用的是 estimateContextTokens,两套数字拼起来:
- 找到最后一条带 usage 的 assistant 消息,用 provider 返回的真实 usage;
- 它之后新增的消息(工具结果、新的用户输入)还没有真数,就用本地估算补上。
本地估算很粗暴,estimate.ts 里按 CHARS_PER_TOKEN = 4 算,四个字符算一个 token。这对英文大致成立,但对中文会低估——中文一个字往往就接近一个 token。所以中文多的会话里,估算会偏乐观,压缩触发得比理想时机要晚一些。这也是后面还需要一层"溢出兜底"的原因之一。
算一笔账就有感觉了:一个 200K 窗口的模型,预留 16384,system prompt 占 5K、工具定义占 2K,留给对话消息的大约是 176K。如果每轮平均消耗 3K(一次工具调用加结果),差不多跑到第 58 轮左右就会触发压缩。
二、从哪里切:永远不切在 toolResult 上
确定要压缩之后,第一个问题是从哪里切——切点之前的丢掉换成摘要,切点之后的原样保留。
findCutPoint 的思路是从最新的消息往回走,一边走一边累加 token,累到 keepRecentTokens(默认 20000)就停,然后找"这个位置或之后"最近的一个合法切点:
for (let i = endIndex - 1; i >= startIndex; i--) {
accumulatedTokens += messageTokens;
if (accumulatedTokens >= keepRecentTokens) {
// 找到"这个位置或之后"最近的一个合法切点
for (let c = 0; c < cutPoints.length; c++) {
if (cutPoints[c] >= i) { cutIndex = cutPoints[c]; break; }
}
break;
}
}
关键在"合法切点"四个字。我们看 isCutPointMessage:
function isCutPointMessage(message: AgentMessage): boolean {
switch (message.role) {
case "user":
case "assistant":
case "bashExecution":
case "custom":
case "branchSummary":
case "compactionSummary":
return true;
case "toolResult":
return false; // 永远不能在这里切
}
}
除了 toolResult,其他角色都可以当切点。
为什么偏偏 toolResult 不行?因为 agent loop 里有一条硬约束:带 toolCall 的 assistant 消息后面必须紧跟它的 toolResult。如果切在 toolResult 上,保留下来的部分就会以一条"孤儿" toolResult 开头,前面找不到对应的 toolCall,provider 会直接拒绝这个请求。
反过来,切在带工具调用的 assistant 消息上是可以的。切点的语义是"从这里开始保留",它的 toolResult 在后面,会一起被留下,配对是完整的。源码注释写得很清楚:
Can cut at user OR assistant messages (never tool results). When cutting at an assistant message with tool calls, its tool results come after and will be kept.
光看规则有点抽象,举个例子。假设会话是这样的(编号即索引):
0 user "帮我重构认证模块"
1 assistant (含 toolCall: read auth.ts)
2 toolResult (auth.ts 的内容,很大)
3 assistant "我看到了,需要改三处"
4 user "先改第一处"
5 assistant (含 toolCall: edit)
6 toolResult (编辑结果)
7 assistant "改好了"
合法切点是 0、1、3、4、5、7,而 2 和 6 是 toolResult,不能切。假设从后往前累加,到索引 3 时刚好累够 20000,那么"3 或之后最近的合法切点"就是 3 本身,于是从 3 开始保留,0~2 被压成摘要。
如果算法允许切在 2 上,保留的部分就会以 auth.ts 的文件内容开头,模型和 provider 都不知道这是谁调用的结果,请求直接就挂了。
切点定下来之后,还有一段容易看漏的回扫:
// 往前扫,把相邻的、不产生上下文消息的元数据条目也包进来
while (cutIndex > startIndex) {
const prevEntry = entries[cutIndex - 1];
if (prevEntry.type === "compaction" || sessionEntryToContextMessages(prevEntry).length > 0) break;
cutIndex--;
}
pi 的会话文件里有些条目是纯元数据,不会变成任何上下文消息。把它们留在切点的错误一侧其实没什么实际危害,但会让会话树的结构变乱,所以顺手往前扫一下,切得更干净。
三、切开了一个 turn 怎么办
上面那个例子其实藏了一个问题:切点 3 是一条 assistant 消息,它是合法切点,但不是 turn 的开头。
pi 里还有一个函数叫 isTurnStartMessage,专门判断一条消息是不是 turn 的起点:
function isTurnStartMessage(message: AgentMessage): boolean {
switch (message.role) {
case "user": case "bashExecution": case "custom":
case "branchSummary": case "compactionSummary":
return true;
// assistant 能当切点,但不是 turn 的开头
case "assistant": case "toolResult":
return false;
}
}
对比一下就能发现,两个函数对 assistant 的判断不一样。turn 是由 user 侧的输入开启的,切在 assistant 上,等于把一个 turn 劈成了两半:前半(用户的原始请求加早期工作)被丢掉,后半(最近的工作)留下来。
什么时候会这样?最典型的就是某一个 turn 本身就超过了 20000 token,比如模型一口气连读了十几个大文件,这时候不切在 turn 中间根本没法保住最近 20K。findCutPoint 会把这种情况标记出来:
const startsTurn = isTurnStartEntry(cutEntry);
// 切点不是 turn 开头时,往回找这个 turn 真正的起点
const turnStartIndex = startsTurn ? -1 : findTurnStartIndex(entries, cutIndex, startIndex);
return { firstKeptEntryIndex: cutIndex, turnStartIndex, isSplitTurn: !startsTurn && turnStartIndex !== -1 };
回到例子里,切点 3 不是 turn 开头,往回能找到 turn 起点 0,所以 isSplitTurn 为 true。被切掉的那半里最要命的是索引 0 那句"帮我重构认证模块"——保留部分里有"需要改三处""先改第一处",却没有"用户到底要干什么",这个上下文就很别扭了。
所以 pi 为这种情况单独准备了一个摘要提示词:
const TURN_PREFIX_SUMMARIZATION_PROMPT = `This is the PREFIX of a turn that was too large to keep. The SUFFIX (recent work) is retained.
Summarize the prefix to provide context for the retained suffix:
## Original Request
[What did the user ask for in this turn?]
## Early Progress
- [Key decisions and work done in the prefix]
## Context for Suffix
- [Information needed to understand the retained recent work]
`;
第一节就是 Original Request,把被切掉那半里最关键的东西显式捞回来。
我觉得这个细节很能说明什么叫上下文工程:不是"写个 prompt 让 LLM 总结一下"就完事了,而是针对具体的信息丢失场景,设计针对性的补救。
四、摘要里该留什么
切完了,接下来是把前面那段压成摘要。主摘要提示词 SUMMARIZATION_PROMPT 规定了一个固定的结构:
## Goal
## Constraints & Preferences
## Progress
### Done / ### In Progress / ### Blocked
## Key Decisions
## Next Steps
## Critical Context
结尾还有一句约束:
Keep each section concise. Preserve exact file paths, function names, and error messages.
为什么要专门强调精确保留文件路径、函数名、错误信息?因为这些是没法重新推导的信息。模型可以从"我们在重构认证模块"推出大致的目标,但推不出 packages/auth/src/oauth/refresh.ts:142 这种具体位置。摘要的篇幅有限,就得优先保住这类信息密度高、又没有冗余的字符串。
除了 LLM 生成的摘要,pi 还在 compaction/utils.ts 里机械地提取了一份文件操作清单:
// 从单条消息里提取文件读写操作
export function extractFileOpsFromMessage(message: AgentMessage, fileOps: FileOperations): void;
// 汇总成"读过的文件"和"改过的文件"两份列表
export function computeFileLists(fileOps: FileOperations): { readFiles: string[]; modifiedFiles: string[] };
// 格式化后附在摘要后面
export function formatFileOperations(readFiles: string[], modifiedFiles: string[]): string;
这部分完全不经过 LLM,直接扫描被丢弃的消息,把 read 过的和 edit/write 过的文件路径抠出来,附在摘要后面。
为什么不交给 LLM 顺便写进摘要里?因为"我改过哪些文件"是压缩里最不能出错的信息,而 LLM 生成的摘要可能漏掉任何东西。这个要是丢了,agent 可能把同一个文件再改一遍,或者忘了某个文件还没改完。
所以这里有一条可以直接搬到自己项目里的原则:能用确定性算法保住的信息,不要交给概率模型。 设计压缩策略时,先问一句"哪些信息我能用代码 100% 准确地提取出来",那些先用代码抠出来,剩下的非结构化对话再交给模型去浓缩。
五、第二次压缩:摘要的摘要
压缩不是一次性的。长会话会压缩很多次,那第二次压缩时,第一次的摘要怎么处理?
我们看 prepareCompaction:
// 往回找上一次压缩的位置
for (let i = pathEntries.length - 1; i >= 0; i--) {
if (pathEntries[i].type === "compaction") { prevCompactionIndex = i; break; }
}
if (prevCompactionIndex >= 0) {
const prevCompaction = pathEntries[prevCompactionIndex] as CompactionEntry;
// 拿出上次的摘要
previousSummary = prevCompaction.summary;
// 边界从上次保留的第一条开始,已经摘过的不再重复摘要
boundaryStart = ...;
}
拿到 previousSummary 后,走的是另一条提示词 UPDATE_SUMMARIZATION_PROMPT,核心规则就几条:
RULES:
- PRESERVE all existing information from the previous summary
- ADD new progress, decisions, and context from the new messages
- UPDATE the Progress section: move items from "In Progress" to "Done" when completed
- UPDATE "Next Steps" based on what was accomplished
- PRESERVE exact file paths, function names, and error messages
- If something is no longer relevant, you may remove it
到这里就明白主摘要为什么一定要用固定结构了。增量更新的本质,是"把新消息合并进一个已有的结构化文档"。如果上一版摘要是一段自由发挥的散文,模型就没法可靠地"把某一项从 In Progress 挪到 Done",只能整篇理解后重写,每压缩一轮都在漂移。
固定的 markdown 小节让更新变成了局部编辑,而不是整体重写。boundaryStart 也是同样的考虑:新一轮只摘要上次压缩之后的消息,之前的部分靠 previousSummary 继承,既不重复花钱,也不重复失真。
但这仍然是有损的叠加。第 5 次压缩的时候,最早的那些对话已经被摘要了 5 次。长会话里 agent "忘事"的原因就在这儿——不是它记不住,是那段信息已经被反复蒸馏到只剩几个词了。
六、摘要怎么回到上下文
生成的摘要会变成一条 compactionSummary 类型的内部消息。pi 内部的消息类型比 LLM 认识的多,但发出去之前,全都会塌缩成 user / assistant / toolResult 三种之一。compactionSummary 的归宿是一条 user 消息:
case "compactionSummary":
// 前后缀把摘要包进 <summary> 标签,以 user 角色发给模型
return { role: "user",
content: [{ type: "text", text: COMPACTION_SUMMARY_PREFIX + m.summary + COMPACTION_SUMMARY_SUFFIX }],
timestamp: m.timestamp };
模型最终看到的是这个样子:
The conversation history before this point was compacted into the following summary:
<summary>
...
</summary>
可以看到,压缩并不是往上下文外面存了什么东西,而是用一条短消息替换掉一大段消息。对下游的代码来说(agent loop、消息转换、provider 编码),压缩是完全透明的,它们只看到 messages 数组变短了。
七、兜底:溢出重试
前面说过,估算会失准。CHARS_PER_TOKEN = 4 对中文偏低;有的 provider 实际窗口比宣称的小;还有的会静默截断,不报错。所以 pi 在 packages/ai/src/utils/overflow.ts 里又加了一层事后兜底:
export function isContextOverflow(message: AssistantMessage, contextWindow?: number): boolean {
// 情况 1:请求报错,匹配错误信息里的溢出模式
if (message.stopReason === "error" && message.errorMessage) {
// 先用否定模式排除误判
const isNonOverflow = NON_OVERFLOW_PATTERNS.some((p) => p.test(message.errorMessage!));
if (!isNonOverflow && OVERFLOW_PATTERNS.some((p) => p.test(message.errorMessage!))) return true;
}
// 情况 2:静默溢出——请求成功了,但 usage 超过了窗口
if (contextWindow && message.stopReason === "stop") { /* ... */ }
}
这段代码有两点值得学:
- 先排除误判。
NON_OVERFLOW_PATTERNS用来挡掉限流之类的错误,它们的文案里也可能出现 "too many"、"limit" 这种词。用正则匹配错误信息本来就脆弱,所以加一组否定模式来加固; - 静默溢出。有的 provider 不报错,悄悄截掉你的历史,然后返回一个基于残缺上下文的回答。这种最危险,因为表面上一切正常,只能靠对比
usage和已知窗口大小来发现。
判定为溢出后,会触发一次压缩,compaction_start 事件的 reason 为 "overflow",压缩完自动重试。另外两种触发原因是 "threshold"(第一节讲的阈值触发)和 "manual"(用户手动执行 /compact)。
八、为什么不直接丢掉最老的消息
读到这里可能会有个疑问:搞这么复杂,为什么不直接用滑动窗口,丢掉最老的 N 条?
滑动窗口确实简单,零成本,保留下来的部分也完全无损。但它丢掉的是最早的内容,而最早的内容往往是最重要的:用户的原始需求、项目的约束、一开始定下的技术方案。用滑动窗口的 agent 会出现一种很典型的失败——干着干着,忘了自己要干什么。
LLM 摘要贵(多一次调用)而且有损,但它留下的是信息密度最高的部分,而不是时间上最近的部分。
pi 实际上是两者的混合:最近 20000 token 原样保留,吃到滑动窗口无损、零成本的好处;更早的压成结构化摘要,保住目标和决策。再加上机械提取的文件清单和溢出兜底,这套组合比任何单一策略都要稳。
总结
- 触发不等满:
reserveTokens预留 16K,给生成摘要的那次 LLM 调用留空间,否则会死锁; - 切点永远不落在
toolResult上,否则保留部分以孤儿结果开头,provider 直接拒绝; - 切点落在 assistant 上会劈开一个 turn,用专门的
TURN_PREFIX提示词把用户的原始请求捞回来; - 摘要用固定的 markdown 结构,精确保留路径、函数名和错误信息,并支持多次压缩时的局部增量更新;
- 文件读写清单用代码机械提取,不交给 LLM——能用确定性算法保住的,不要交给概率模型;
- 估算会失准,最后还要靠溢出检测兜底,尤其要提防不报错的静默溢出。
延申
pi 的做法是原地改写 messages 数组,被压掉的内容只在会话文件的树结构里留痕。同一个问题还有别的解法,比如 DeepSeek Harness 用只增不减的事件日志加一条"遮蔽"消息来压缩,日志永远不删,随时可以回放到压缩前的状态;Anthropic 在长时间运行任务里讨论过的"上下文重置",则是干脆清空上下文、靠结构化的交接文件传递状态。这几种思路的对比,后面有空再单独整理一篇。
相关资料
- pi-mono(earendil-works)源码:
packages/coding-agent/src/core/compaction/compaction.ts:本文主体,shouldCompact、findCutPoint、三个摘要提示词、prepareCompactionpackages/coding-agent/src/core/compaction/utils.ts:文件操作清单的机械提取packages/ai/src/utils/overflow.ts:isContextOverflow与两组正则packages/coding-agent/docs/compaction.md:用户视角的压缩配置说明packages/coding-agent/examples/extensions/custom-compaction.ts:用扩展接管压缩逻辑的示例
- Anthropic:Harness design for long-running application development