Agent 越聊越笨?90k 星的 Pi 是这样压缩上下文的
用 Claude Code 或者 Codex 聊到半天突然发现它忘了你是谁?这篇文章讲讲这个问题最优雅的解法之一。
你一定经历过这个场景:跟 AI 编程助手聊了两个小时,改了十几个文件,突然它开始"失忆"——问你早就说过的需求,重复读早就看过的文件,甚至把之前的修改又改回去。
这不是模型变笨了,是上下文窗口满了。旧对话被粗暴截断,模型失去了记忆。
上下文压缩(compaction)就是解决这个问题的。最近我读了 Pi(GitHub 90k+ 星的极简 Agent harness)的 compaction 文档,说实话,这是我把这个机制讲得最清楚的一份实现。它不只是"调个 LLM 总结一下",而是把切断点选择、turn 完整性、缓存友好、文件追踪这些细节全部想透了。
这篇文章带你拆解它的完整设计。
本文提纲
- Pi 是什么:一个"只给原语"的 Agent
- 触发条件:一行公式和两个数字
- 五步压缩流程
- 切断点规则:为什么不能在 tool result 处切断
- 单个 turn 超长怎么办:Split Turns
- 结构化摘要格式:压缩的真正精髓
- 分支摘要:/tree 导航时的记忆保护
- 用扩展接管压缩:session_before_compact
- 配置方法与调优建议
Pi 是什么:一个"只给原语"的 Agent
先简单介绍主角。Pi 是 Earendil 公司开源的 coding agent(MIT 协议,npm 包 @earendil-works/pi-coding-agent),主打"Primitives, not features"——只提供原语,不堆功能。MCP、子 Agent、计划模式这些别家内置的东西,Pi 全部通过 TypeScript 扩展实现。
它的会话是树形结构,可以回退、分支。而 compaction(压缩)和 branch summarization(分支摘要)就是这棵树的"记忆管理器":前者在上下文快满时总结旧消息释放空间,后者在你切换分支时保留被放弃的工作上下文。
两者共享同一套结构化摘要格式和累积文件追踪。设计上的这种一致性,本身就是好品味的体现。
触发条件:一行公式和两个数字
Pi 的自动压缩触发条件是一行公式:
contextTokens > contextWindow - reserveTokens
翻译成人话:当前上下文 token 数超过"窗口大小减去预留空间"就触发压缩。默认预留 16,384 token,这是给 LLM 生成回复留的余地——总不能压缩完了,连回答的空间都没有。
除了自动触发,你也可以随时手动执行 /compact,还可以带参数:
/compact 重点保留数据库 schema 的修改记录
后面的指令会传给摘要 LLM 作为聚焦指示(focus instructions)。这个设计很实用:临近收尾的任务,你可以手动压缩一次并告诉它"重点保留待办事项",给最后的冲刺腾出空间。
五步压缩流程
压缩的核心流程分五步,每一步都有明确的设计考量:
MERMAID_BLOCK_0
逐步拆解:
第一步,找切断点。 从最新消息往回走,累计 token 估算值,直到达到 keepRecentTokens(默认 20,000)。也就是说,最近这 2 万 token 的对话原封不动保留,更早的才进入压缩候选。
第二步,提取消息。 从上一次压缩的保留边界(或会话起点)到切断点之间的消息,就是要被总结的内容。注意这里有个细节:多次压缩时,被总结的区间从上一次压缩的保留边界开始,而不是从压缩条目本身开始——这样每次压缩幸存下来的消息不会在下一轮被重复总结。
第三步,生成摘要。 调 LLM 生成结构化摘要。如果之前已有摘要,会把它作为迭代上下文传入,新摘要是"旧摘要 + 新消息"的合并升级,而不是每次从零开始。
第四步,追加 CompactionEntry。 摘要以特殊条目的形式写入会话文件,结构如下:
interface CompactionEntry<T = unknown> {
type: "compaction";
id: string;
parentId: string;
timestamp: number;
summary: string;
firstKeptEntryId: string; // 从哪条消息开始保留
tokensBefore: number; // 压缩前的 token 数
usage?: Usage; // 生成摘要消耗的 token(计入会话总量)
fromHook?: boolean; // 是否来自扩展
details?: T; // 默认: { readFiles: string[]; modifiedFiles: string[] }
}
第五步,重建上下文。 下一次请求发给 LLM 的内容变成:system prompt + 摘要 + firstKeptEntryId 之后的消息。
这里有个容易误解的点:被压缩的消息没有从会话文件里删除,只是不再发给 LLM。你回看历史记录时一切都在,树形结构的完整性不受影响。压缩只影响"模型看到什么",不影响"存储了什么"。
切断点规则:为什么不能在 tool result 处切断
切断点不是随便找的。Pi 规定合法的切断位置只有四种:
- user 消息
- assistant 消息
- BashExecution 消息
- 自定义消息(
custom_message、branch_summary)
永远不能在 tool result 处切断。文档里的原话是:"they must stay with their tool call"——工具结果必须和它的工具调用待在一起。
为什么?因为 LLM 的对话格式里,tool call 和 tool result 是严格配对的。如果把工具调用留在压缩区、结果留在保留区(或反过来),发给模型的消息序列就出现了"孤儿":有调用没结果,或者有结果没调用。轻则模型困惑,重则 API 直接报错。
这个规则看似简单,但很多自己撸 Agent 的团队都在这里踩过坑。上下文管理的第一课:消息流的语法完整性优先于 token 数学的精确性。
单个 turn 超长怎么办:Split Turns
一个 turn 从 user 消息开始,到下一个 user 消息之前结束。问题来了:如果一个 turn 内部跑了个超长任务——比如 Agent 连续调了 50 次工具,单这一个 turn 就超过了 20,000 token——切断点会落在 turn 中间的某条 assistant 消息上。
这时 Pi 会生成两份摘要并合并:
- history summary:turn 开始之前的所有历史上下文
- turn prefix summary:这个被劈开的 turn 的前半部分
为什么不干脆整个 turn 都总结掉?因为最近的对话细节对当前任务最关键。把 turn 前缀单独摘要、后半部分原文保留,既守住了 token 预算,又保住了"手头正在做什么"的最高保真度。
这个 split turn 处理是我觉得整个设计里最见功力的地方——它处理的是边角情况,但边角情况处理不好,压缩机制在生产环境里就是定时炸弹。
结构化摘要格式:压缩的真正精髓
大多数 Agent 的压缩摘要就是一段自由文本:"用户在做一个电商网站,已经完成了……"。Pi 不一样,它的摘要是一个严格的模板:
## Goal
(用户的目标是什么)## Constraints & Preferences
(约束和偏好)## Progress
### Done
### In Progress
### Blocked## Key Decisions
(关键决策及理由)## Next Steps## Critical Context<read-files>
(读过的文件列表)
</read-files><modified-files>
(改过的文件列表)
</modified-files>
这个格式解决了自由文本摘要的三大顽疾:
第一,信息密度可控。 "Progress" 强制分成 Done / In Progress / Blocked 三栏,模型恢复上下文时一眼看清任务状态,不用从叙述文里自己猜。
第二,文件操作不丢失。 <read-files> 和 <modified-files> 是独立于 LLM 摘要的硬数据——从工具调用记录里机械提取,LLM 忘了写也丢不了。而且这个追踪是累积的:新一轮压缩会合并上一轮 compaction 记录里的文件列表,多次压缩后文件历史依然完整。Agent 最常见的失忆就是"忘了自己改过哪个文件",这里从机制上根除。
第三,给摘要 LLM 的输入也经过精心设计。 Pi 用 serializeConversation() 把消息序列化成带标签的文本行——[User]:、[Assistant thinking]:、[Assistant tool calls]:。文档特别解释:这"防止模型把它当成要继续的对话"。摘要模型收到的是资料,不是聊天记录,角色定位完全不同。同时工具结果被截断到 2,000 字符并加截断标记——毕竟 read 和 bash 的输出是上下文膨胀的罪魁祸首。
另外一个小但重要的细节:压缩请求使用全新的 routing session ID,并禁用 prompt cache 写入。因为压缩 prompt 几乎不可能被复用,写入缓存只会污染。缓存友好的设计贯穿始终。
分支摘要:/tree 导航时的记忆保护
Pi 的会话是树形的。当你用 /tree 从分支 A 跳回主干 B 时,A 上做的工作就"悬空"了——如果不处理,B 上下文里完全不知道 A 发生过什么。
Branch summarization 解决这个问题。跳转时 Pi 会询问你是否摘要被放弃的工作,流程是:
- 找到新旧两个位置的最深公共祖先
- 从旧分支叶子往回走到祖先,收集消息(从新到旧,在 token 预算内)
- 生成摘要,以
BranchSummaryEntry的形式追加到跳转点
interface BranchSummaryEntry<T = unknown> {
type: "branch_summary";
id: string;
parentId: string;
timestamp: number;
summary: string;
fromId: string; // 从哪个分支跳过来
usage?: Usage;
fromHook?: boolean;
details?: T;
}
这样你切到新分支后,模型知道"刚才在另一个分支尝试过 X 方案,卡在了 Y"。探索性工作(试一条路,不行换一条)在这种设计下不再是记忆黑洞。这个能力和压缩共享同一套摘要格式和文件追踪——一套基础设施,两种记忆保护。
用扩展接管压缩:session_before_compact
Pi 的哲学是"别的 Agent 内置的功能,你可以自己建",压缩也不例外。扩展可以监听 session_before_compact 事件,完全接管摘要生成:
pi.on("session_before_compact", async (event, ctx) => {
const { preparation } = event;
const conversationText = serializeConversation(
convertToLlm(preparation.messagesToSummarize)
);
// 用你自己的模型、你自己的 prompt 生成摘要
const { summary, usage } = await myModel.summarize(conversationText);
return {
compaction: {
summary,
firstKeptEntryId: preparation.firstKeptEntryId,
tokensBefore: preparation.tokensBefore,
usage, // 报告 usage,让会话统计保持准确
}
};
});
事件里给了你需要的一切:待总结消息、split turn 前缀、上一个摘要、文件操作、token 数、切断点,以及取消整个压缩的能力(return { cancel: true })。reason 字段告诉你触发原因(manual / threshold / overflow),还带 AbortSignal 让你的 LLM 调用可以正确取消。
对应地,session_before_tree 事件可以在分支跳转时接管分支摘要。官方仓库的 examples/extensions/custom-compaction.ts 有完整可跑的例子。
什么场景会用到?比如你想用便宜的小模型做摘要省 token,或者想针对特定领域(比如法律文档、数据管道)定制摘要 prompt,或者想把摘要同步到外部记忆系统。钩子给了你完全的控制权。
配置方法与调优建议
配置文件在 ~/.pi/agent/settings.json(全局)或 <project-dir>/.pi/settings.json(项目级):
{
"compaction": {
"enabled": true,
"reserveTokens": 16384,
"keepRecentTokens": 20000
}
}
| 配置项 | 默认值 | 说明 |
|---|---|---|
enabled |
true |
是否启用自动压缩 |
reserveTokens |
16384 |
给 LLM 回复预留的空间 |
keepRecentTokens |
20000 |
最近多少 token 原文保留 |
三个实操建议:
摘要丢细节太多?调大 keepRecentTokens。 20k 是保守值,如果你的上下文窗口是 200k,把保留区提到 40k~60k 完全合理,压缩频率下降,失忆感会明显减轻。
任务收尾前手动 /compact + 指令。 比起等自动触发(时机不可控),主动压缩并指定"保留所有待办和关键决策",能让最后冲刺阶段的上下文质量高得多。
写自定义摘要器时遵守两个约定: 把事件的 AbortSignal 传给 LLM 调用;如实报告 usage。前者保证取消时干净利落,后者让会话的 token 统计包含压缩开销——压缩不是免费的,看得见才管得住。
值得自己项目借鉴的五个设计
即使你不用 Pi,这套设计也值得抄作业:
- 切断点合法性规则——tool result 永远跟 tool call 绑定,这是消息流完整性的底线
- 结构化摘要模板——Goal / Progress / Key Decisions / Next Steps 的固定骨架,比自由文本可靠一个量级
- 文件操作硬追踪——从工具调用机械提取,不依赖 LLM 的"自觉",且跨压缩累积
- 压缩不删除——只改 LLM 视角,不动存储,历史永远可回溯
- 缓存隔离——一次性请求禁用 cache 写入,别让压缩污染你的 prompt cache
上下文工程(Context Engineering)这个词火了一年多,但多数讨论还停留在"prompt 怎么写"。Pi 的 compaction 实现展示了工程层面的下半场:切断点、turn 边界、缓存、token 记账,每一个都是实实在在的设计决策。想深入的同学,源码在 pi-mono 仓库(MIT 协议),核心文件就五个:compaction.ts、branch-summarization.ts、utils.ts、session-manager.ts、extensions/types.ts,一个下午能读完。
参考文档与链接
- Pi Compaction 官方文档 - 本文主要来源,含完整的流程图、接口定义和扩展示例
- Pi GitHub 仓库 - MIT 协议开源,90k+ 星,源码含 compaction.ts 等核心实现
- Pi 官网 - "Primitives, not features" 的设计哲学与四种运行模式介绍
- npm: @earendil-works/pi-coding-agent - 安装入口,当前版本 0.84.2
- Pi 扩展示例目录 - 50+ 扩展示例,含 custom-compaction.ts 完整例子
作者: itech001
来源: 公众号:AI人工智能时代
网站: https://www.theaiera.cn/
每日分享最前沿的AI新闻资讯和技术研究。
本文首发于 AI人工智能时代,转载请注明出处。
