上下文装不下以后:Pi Compaction 怎样压缩历史,又会丢掉什么
一个 Coding Agent 已经连续工作两小时:读过几十个文件,尝试过三条修复路线,跑了多轮测试,还记着用户最早说的"不能改公开 API"。此时上下文接近上限,系统自动生成摘要,然后继续工作。
API 没有再报溢出,Agent 却把"不能改公开 API"压成了"保持兼容",把失败方案的原因只记成"测试未通过",又把计划中的迁移写进了 Done。压缩在传输层成功了,任务状态却已经发生漂移。
这正是 Compaction 最容易被误解的地方:它解决的是"下一次模型调用装不下",不是"如何无损保存长期记忆"。Pi v0.82.1 的实现很适合拆解这条边界,因为它同时保留完整 Session、结构化摘要和近期原文,也明确暴露 Cut Point、Split Turn、文件轨迹与 Extension Hook。
本文只有一条主线:Compaction 是有损的上下文投影;不可丢的执行状态必须脱离自然语言摘要,进入可验证的数据层。

一、先把三种状态分开
长会话至少包含三个不同对象:
text
完整 Session JSONL:原始 Entry 与分支仍在磁盘
当前 Context:本轮真正发给模型的消息集合
外部执行状态:文件、Git、进程、数据库、审批与远端任务
Pi 的 Compaction 不会缩小 JSONL,也不会回滚外部环境。它在当前分支追加一个 compaction Entry,后续构建 Context 时先放入 Summary,再接上保留的近期消息。
因此:
text
历史仍存在 ≠ 模型仍逐字看见历史
摘要提到文件 ≠ 模型仍知道文件内容
摘要写成 Done ≠ 工具与环境已经验证完成
PI-07 讨论 Session Tree 怎样选择活动路径;本篇进一步讨论这条路径过长以后,哪些内容被摘要,哪些原文继续留下,以及损失怎样被发现。
二、触发条件其实在分配两种预算

固定版本文档给出的自动触发条件是:
text
contextTokens > contextWindow - reserveTokens
默认 reserveTokens 为 16384,keepRecentTokens 为 20000。两个参数不能混为一谈:
reserveTokens给下一次模型输出留空间;keepRecentTokens决定压缩后尽量保留多少近期输入原文。
系统既可以在估算值接近上限时主动压缩,也可以在 Provider 已返回 Context Overflow 后压缩并重试。前者减少失败请求,后者处理本地估算与 Provider 真实计数不一致。
这种不一致并不罕见。Tool Schema、System Prompt、图片、Provider 包装、Reasoning 字段和缓存计数都可能影响真实占用。把本地 Token 估算当绝对事实,会让系统在不同 Provider 上表现不稳定。
三、Cut Point 不是"删掉最旧的 80%"

Pi 会从最新消息向前累计估算 Token,寻找能满足近期预算的合法切点。关键规则是:不在 Tool Result 上切开。
一个正常 Turn 可能是:
text
User
→ Assistant 发起 read
→ Tool Result
→ Assistant 发起 edit
→ Tool Result
→ Assistant 解释结果
如果保留 Tool Result 却丢掉对应 Tool Call,模型无法知道结果来自哪里;如果只保留 Tool Call,则会把已经完成的调用误认为没有结果。固定实现允许在用户消息或 Assistant 消息处切分,并让 Assistant 后续的 Tool Result 一起进入保留区。
这说明 Cut Point 的目标不是得到最精确的 Token 数,而是保护消息关系。自研 Harness 若只按数组长度或字符数截断,很容易制造孤立调用、失去来源的结果和错误的执行状态。
四、正常压缩怎样形成下一轮 Context
默认流程可以还原为六步:
- 从当前活动路径构建压缩前 Context,并估算占用;
- 若已有上一次 Compaction,读取其 Summary 与
firstKeptEntryId; - 从后向前寻找本轮合法 Cut Point;
- 将旧消息转换并序列化为待总结文本;
- 生成结构化 Summary,追加新的 Compaction Entry;
- 用 Summary、从
firstKeptEntryId开始的近期消息和压缩后新增消息重建 Context。
固定版本的结果核心字段包括:
typescript
interface CompactionResult {
summary: string;
firstKeptEntryId: string;
tokensBefore: number;
estimatedTokensAfter?: number;
usage?: Usage;
details?: unknown;
}
反复压缩时,新摘要会携带旧 Summary,并吸收这一轮新增的旧消息。这样不必每次重新处理 Session 最早部分,但也形成"摘要的摘要":第一次漏掉的约束,第二次往往没有原文可以补回。
五、一个 Turn 自己就超长时,必须处理 Split Turn

最难的场景不是有很多轮对话,而是单个 User Turn 里连续读取仓库、运行命令和处理巨量日志,直到近期预算也装不下。
此时 Cut Point 可能落在 Assistant 消息处。Pi 将其标为 Split Turn,并分别处理:
text
完整旧 Turn → History Summary
当前超长 Turn 的前缀 → Turn Prefix Summary
切点后的消息 → Recent Raw Tail
Turn Prefix Summary 被要求保留 Original Request、Early Progress,以及理解后缀所需的 Context。否则,保留区可能只剩几段 Tool Result,却没有用户为什么发起任务、前面做过什么和当前结果对应哪一步。
这是一条很有工程价值的规则:保留近期 Token 不等于保留近期语义。只要切点进入一个尚未结束的 Turn,就必须为尾部补上可解释的前缀状态。
六、结构化 Summary 仍不是事实数据库

默认摘要包含 Goal、Constraints & Preferences、Progress、Key Decisions、Next Steps、Critical Context,以及 <read-files>、<modified-files>。
结构化格式能降低遗漏率,也便于后续程序读取,但不会让模型输出自动变真。Summary 仍可能:
- 把计划写成 Done;
- 丢掉"不要""除非""仅限"等否定条件;
- 合并来自用户、源码和模型推断的不同结论;
- 记住文件路径,却忘记关键函数和精确错误;
- 把 UNKNOWN 改写成已确认。
文件列表尤其容易制造安全感。知道曾读过 src/auth.ts,不等于仍知道其当前内容;知道修改过它,也不等于工作区仍处于当时版本。继续任务前,关键文件仍应重新读取,Git Diff 与测试状态仍应重新验证。
七、序列化阶段已经主动丢弃一部分内容

Pi 在摘要调用前先将 Agent Message 转成模型消息,再由 serializeConversation() 变成带标签的文本,例如 [User]、[Assistant thinking]、[Assistant tool calls] 与 [Tool result]。这样可把历史声明为待处理数据,而不是让总结模型直接续写原对话。
一个不能忽略的固定实现细节是:Tool Result 在序列化时最多保留 2000 个字符,超出部分被替换为截断提示。
因此,日志或大型文件的关键证据若位于 2000 字符之后,可能在 Summary 模型看到之前就已经消失。Prompt 边界也不能消除注入风险;旧文件和 Tool Result 中的恶意指令仍属于不可信输入。
生产实现至少应做到:
- 对外部文本标记信任来源;
- 由程序写入错误码、退出码、测试结论和产物 SHA;
- 对摘要执行 Schema 与状态一致性检查;
- 对关键约束做压缩前后回归,而不是只检查 Summary 非空。
八、文件轨迹与迭代摘要解决了什么,又没解决什么

默认 Compaction 和 Branch Summary 会从 Tool Call 以及上一轮 details 中累计已读、已改文件。这有助于跨多轮压缩保留代码范围,也能提示接手者从哪里重新核对。
但文件轨迹只是索引:
text
readFiles → 应优先重新读取的范围
modifiedFiles → 应优先检查 Diff 与版本的范围
它既不是内容缓存,也不是工作区快照。可靠系统应同时记录文件版本或内容 SHA、Git 基线、测试证据与环境检查点,避免把"曾经修改"当成"当前仍正确"。
九、文档与默认源码在 retainedTail 上并不完全一致

固定 Tag 的 session-format.md 描述了较新的 Harness-generated Compaction:Entry 可以携带 retainedTail,成为自包含 Checkpoint,不再向前读取旧 Entry。
但同一 Tag 的默认 CompactionEntry 类型、compact() 返回值和 buildContextEntries() 主路径仍以 firstKeptEntryId 为核心,源码中没有确认默认创建流程已写入 retainedTail。
所以本篇采用保守口径:
firstKeptEntryId是v0.82.1默认源码可确认的机制;retainedTail是固定版本文档描述的更新或兼容方向;- 没有进一步 Commit 与调用链证据,不能把文档描述升级为该 Tag 默认 Runtime 已完整使用。
这不是文档挑错,而是源码研究的基本方法:文档、类型、创建路径、读取路径与测试必须形成同一条证据链。
十、Compaction 自己也有成本
Summary 不是免费的本地压缩。它会增加输入、输出、延迟、Provider 成本和失败重试。固定源码将摘要调用隔离到新的 Routing Session,并在支持时关闭 Prompt Cache Write,因为一次性历史通常难以复用。
摘要最大输出预算受 reserveTokens 与模型最大输出共同限制:普通 History Summary 约取 min(0.8 × reserveTokens, model.maxTokens),Split Turn Prefix 约取 min(0.5 × reserveTokens, model.maxTokens)。
调大 reserveTokens 可能提高摘要表达空间,也会减少主 Context 可用区并影响成本。成熟配置应同时观察压缩频率、摘要费用、延迟、Continuation Success 和约束召回,而不是只追求"再也不 Overflow"。
十一、怎样验收一次压缩

只检查"成功追加 Compaction Entry"远远不够。至少需要五类指标:
| 指标 | 要回答的问题 |
|---|---|
| Constraint Recall | 用户限制、禁止项和输出合同保留了多少? |
| Decision Fidelity | 关键决策、理由和反方是否仍准确? |
| Execution State Accuracy | Done、In Progress、Blocked 是否与 Tool Event 一致? |
| Artifact Coverage | 已读、已改、已创建产物及版本是否可定位? |
| Continuation Success | 只看 Summary 与 Recent Tail,下一步能否做对? |
最有价值的测试是同任务对照:一条路线保留原始长 Context,另一条只使用压缩 Context,再比较下一步 Tool Call、约束违反、重复读取、错误率和最终产物。
Batch 08 的 Fixture 只能证明给定 JSONL 按规则投影出 compaction + retained messages,状态为 PASS-SPEC-NOT-RUNTIME。它没有运行真实 Pi,也不能证明 Summary 质量、成本或继续执行成功。
十二、生产 Harness 应把摘要拆成三层

Pi Extension 可以在 session_before_compact 查看 Preparation、取消压缩或提供自定义结果。这为更强的状态系统留下了入口,但目标不应只是换一个更长的 Prompt。
面向长周期工程任务,建议拆成三层:
text
Immutable Event Log
原始消息、Tool Event、退出码、审批、外部副作用引用
Structured Working State
goal、constraints、done、pending、blocked、artifacts、claims、versions
LLM Narrative Summary
为模型解释背景、决策关系和近期路线
Context Builder 再将 Structured State、Narrative Summary 与 Recent Raw Messages 组合给模型。这样即使自然语言摘要漏掉细节,关键约束、产物 SHA、验证等级和审批状态仍可由程序恢复。
更重要的是,Structured State 不能完全由同一个总结模型自产自证。完成状态应来自 Tool Event,文件状态来自实际字节或 Git,远端任务来自 API,事实边界来自 Claim Ledger,用户授权来自独立 Gate。
结论
Pi Compaction 的价值不是把聊天"写短",而是在固定预算里构造下一次模型调用的工作集:它寻找合法 Cut Point,总结旧历史,在超长 Turn 中补充 Prefix Summary,保留近期原文,并把结果追加回完整 Session。
它的限制同样明确:Summary 有损,多代摘要会累积误差,Tool Result 可能在序列化前截断,文件列表不等于文件内容,Session 历史也不等于外部环境状态。
因此,可靠的结论不是"换一个更强模型做摘要",而是:让摘要负责解释,让结构化状态负责恢复,让原始事件负责审计。