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,两套数字拼起来:

  1. 找到最后一条带 usage 的 assistant 消息,用 provider 返回的真实 usage;
  2. 它之后新增的消息(工具结果、新的用户输入)还没有真数,就用本地估算补上。

本地估算很粗暴,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") { /* ... */ }
}

这段代码有两点值得学:

  1. 先排除误判。NON_OVERFLOW_PATTERNS 用来挡掉限流之类的错误,它们的文案里也可能出现 "too many"、"limit" 这种词。用正则匹配错误信息本来就脆弱,所以加一组否定模式来加固;
  2. 静默溢出。有的 provider 不报错,悄悄截掉你的历史,然后返回一个基于残缺上下文的回答。这种最危险,因为表面上一切正常,只能靠对比 usage 和已知窗口大小来发现。

判定为溢出后,会触发一次压缩,compaction_start 事件的 reason 为 "overflow",压缩完自动重试。另外两种触发原因是 "threshold"(第一节讲的阈值触发)和 "manual"(用户手动执行 /compact)。

八、为什么不直接丢掉最老的消息

读到这里可能会有个疑问:搞这么复杂,为什么不直接用滑动窗口,丢掉最老的 N 条?

滑动窗口确实简单,零成本,保留下来的部分也完全无损。但它丢掉的是最早的内容,而最早的内容往往是最重要的:用户的原始需求、项目的约束、一开始定下的技术方案。用滑动窗口的 agent 会出现一种很典型的失败——干着干着,忘了自己要干什么。

LLM 摘要贵(多一次调用)而且有损,但它留下的是信息密度最高的部分,而不是时间上最近的部分。

pi 实际上是两者的混合:最近 20000 token 原样保留,吃到滑动窗口无损、零成本的好处;更早的压成结构化摘要,保住目标和决策。再加上机械提取的文件清单和溢出兜底,这套组合比任何单一策略都要稳。

总结

  1. 触发不等满:reserveTokens 预留 16K,给生成摘要的那次 LLM 调用留空间,否则会死锁;
  2. 切点永远不落在 toolResult 上,否则保留部分以孤儿结果开头,provider 直接拒绝;
  3. 切点落在 assistant 上会劈开一个 turn,用专门的 TURN_PREFIX 提示词把用户的原始请求捞回来;
  4. 摘要用固定的 markdown 结构,精确保留路径、函数名和错误信息,并支持多次压缩时的局部增量更新;
  5. 文件读写清单用代码机械提取,不交给 LLM——能用确定性算法保住的,不要交给概率模型;
  6. 估算会失准,最后还要靠溢出检测兜底,尤其要提防不报错的静默溢出。

延申

pi 的做法是原地改写 messages 数组,被压掉的内容只在会话文件的树结构里留痕。同一个问题还有别的解法,比如 DeepSeek Harness 用只增不减的事件日志加一条"遮蔽"消息来压缩,日志永远不删,随时可以回放到压缩前的状态;Anthropic 在长时间运行任务里讨论过的"上下文重置",则是干脆清空上下文、靠结构化的交接文件传递状态。这几种思路的对比,后面有空再单独整理一篇。

相关资料

  • pi-mono(earendil-works)源码:
    • packages/coding-agent/src/core/compaction/compaction.ts:本文主体,shouldCompact、findCutPoint、三个摘要提示词、prepareCompaction
    • packages/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