返回博客列表

Agent 越聊越笨?90k 星的 Pi 是这样压缩上下文的

2026-08-14T22:00:00+08:00
PiAgentCompaction上下文工程Context EngineeringLLM

Agent 越聊越笨?90k 星的 Pi 是这样压缩上下文的

用 Claude Code 或者 Codex 聊到半天突然发现它忘了你是谁?这篇文章讲讲这个问题最优雅的解法之一。

你一定经历过这个场景:跟 AI 编程助手聊了两个小时,改了十几个文件,突然它开始"失忆"——问你早就说过的需求,重复读早就看过的文件,甚至把之前的修改又改回去。

这不是模型变笨了,是上下文窗口满了。旧对话被粗暴截断,模型失去了记忆。

上下文压缩(compaction)就是解决这个问题的。最近我读了 Pi(GitHub 90k+ 星的极简 Agent harness)的 compaction 文档,说实话,这是我把这个机制讲得最清楚的一份实现。它不只是"调个 LLM 总结一下",而是把切断点选择、turn 完整性、缓存友好、文件追踪这些细节全部想透了。

这篇文章带你拆解它的完整设计。

本文提纲

  1. Pi 是什么:一个"只给原语"的 Agent
  2. 触发条件:一行公式和两个数字
  3. 五步压缩流程
  4. 切断点规则:为什么不能在 tool result 处切断
  5. 单个 turn 超长怎么办:Split Turns
  6. 结构化摘要格式:压缩的真正精髓
  7. 分支摘要:/tree 导航时的记忆保护
  8. 用扩展接管压缩:session_before_compact
  9. 配置方法与调优建议

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)。这个设计很实用:临近收尾的任务,你可以手动压缩一次并告诉它"重点保留待办事项",给最后的冲刺腾出空间。

五步压缩流程

压缩的核心流程分五步,每一步都有明确的设计考量:

flowchart TB
    A[Trigger: threshold or /compact] --> B[Step 1: Find cut point
walk back until keepRecentTokens] B --> C[Step 2: Extract messages
from kept boundary to cut point] C --> D[Step 3: Generate summary
with previous summary as context] D --> E[Step 4: Append CompactionEntry
summary + firstKeptEntryId] E --> F[Step 5: Rebuild context
summary + kept messages] style A fill:#FF6B6B,color:#000000 style B fill:#4ECDC4,color:#000000 style C fill:#45B7D1,color:#000000 style D fill:#96CEB4,color:#000000 style E fill:#FFEAA7,color:#000000 style F fill:#DDA0DD,color:#000000

逐步拆解:

第一步,找切断点。 从最新消息往回走,累计 token 估算值,直到达到 keepRecentTokens(默认 20,000)。也就是说,最近这 2 万 token 的对话原封不动保留,更早的才进入压缩候选。

第二步,提取消息。 从上一次压缩的保留边界(或会话起点)到切断点之间的消息,就是要被总结的内容。注意这里有个细节:多次压缩时,被总结的区间从上一次压缩的保留边界开始,而不是从压缩条目本身开始——这样每次压缩幸存下来的消息不会在下一轮被重复总结。

第三步,生成摘要。 调 LLM 生成结构化摘要。如果之前已有摘要,会把它作为迭代上下文传入,新摘要是"旧摘要 + 新消息"的合并升级,而不是每次从零开始。

第四步,追加 CompactionEntry。 摘要以特殊条目的形式写入会话文件,结构如下:

interface CompactionEntry {
  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_messagebranch_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 会生成两份摘要并合并

  1. history summary:turn 开始之前的所有历史上下文
  2. 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


(读过的文件列表)



(改过的文件列表)

这个格式解决了自由文本摘要的三大顽疾:

第一,信息密度可控。 "Progress" 强制分成 Done / In Progress / Blocked 三栏,模型恢复上下文时一眼看清任务状态,不用从叙述文里自己猜。

第二,文件操作不丢失。 <read-files><modified-files> 是独立于 LLM 摘要的硬数据——从工具调用记录里机械提取,LLM 忘了写也丢不了。而且这个追踪是累积的:新一轮压缩会合并上一轮 compaction 记录里的文件列表,多次压缩后文件历史依然完整。Agent 最常见的失忆就是"忘了自己改过哪个文件",这里从机制上根除。

第三,给摘要 LLM 的输入也经过精心设计。 Pi 用 serializeConversation() 把消息序列化成带标签的文本行——[User]:[Assistant thinking]:[Assistant tool calls]:。文档特别解释:这"防止模型把它当成要继续的对话"。摘要模型收到的是资料,不是聊天记录,角色定位完全不同。同时工具结果被截断到 2,000 字符并加截断标记——毕竟 readbash 的输出是上下文膨胀的罪魁祸首。

另外一个小但重要的细节:压缩请求使用全新的 routing session ID,并禁用 prompt cache 写入。因为压缩 prompt 几乎不可能被复用,写入缓存只会污染。缓存友好的设计贯穿始终。

分支摘要:/tree 导航时的记忆保护

Pi 的会话是树形的。当你用 /tree 从分支 A 跳回主干 B 时,A 上做的工作就"悬空"了——如果不处理,B 上下文里完全不知道 A 发生过什么。

Branch summarization 解决这个问题。跳转时 Pi 会询问你是否摘要被放弃的工作,流程是:

  1. 找到新旧两个位置的最深公共祖先
  2. 从旧分支叶子往回走到祖先,收集消息(从新到旧,在 token 预算内)
  3. 生成摘要,以 BranchSummaryEntry 的形式追加到跳转点
interface BranchSummaryEntry {
  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,这套设计也值得抄作业:

  1. 切断点合法性规则——tool result 永远跟 tool call 绑定,这是消息流完整性的底线
  2. 结构化摘要模板——Goal / Progress / Key Decisions / Next Steps 的固定骨架,比自由文本可靠一个量级
  3. 文件操作硬追踪——从工具调用机械提取,不依赖 LLM 的"自觉",且跨压缩累积
  4. 压缩不删除——只改 LLM 视角,不动存储,历史永远可回溯
  5. 缓存隔离——一次性请求禁用 cache 写入,别让压缩污染你的 prompt cache

上下文工程(Context Engineering)这个词火了一年多,但多数讨论还停留在"prompt 怎么写"。Pi 的 compaction 实现展示了工程层面的下半场:切断点、turn 边界、缓存、token 记账,每一个都是实实在在的设计决策。想深入的同学,源码在 pi-mono 仓库(MIT 协议),核心文件就五个:compaction.tsbranch-summarization.tsutils.tssession-manager.tsextensions/types.ts,一个下午能读完。

参考文档与链接


作者: itech001 来源: 公众号:AI人工智能时代 网站: https://www.theaiera.cn/ 每日分享最前沿的AI新闻资讯和技术研究。

本文首发于 AI人工智能时代,转载请注明出处。

分享给朋友