在自主编码智能体(Coding Agent)的实际使用中,一个复杂的工程任务往往需要经历数十轮甚至上百轮的持续交互。随着工具调用的不断累积,会话消耗的 Token 数量会呈线性甚至超线性上升。
在Pi Agent 如何管理上下文:从 System Prompt 构建、工具输出截断到分支经验传递中,已建立上下文工程的全景视角:通过构建 System Prompt 保持前缀缓存稳定、通过双向截断控制单次工具输出的体积。然而,面对长时间跨度的交互,仅仅依靠单次输出的截断无法阻止多轮历史消息的整体累积。当会话逼近大语言模型的物理上下文窗口(Context Window)时,系统必须拥有一种机制:在不破坏代码状态、不丢失核心目标的前提下,将旧历史替换为结构化摘要。
在学术界与工业界的上下文工程实践中,这种操作通常被称为 Compaction(上下文压缩) 或 Context Pruning。如果处理不当,压缩极易引发两种严重问题:
- 语义切断(Broken Context):在错误的节点(如
toolCall与toolResult之间)强行切断,导致模型接收到不符合协议规范的消息序列; - 代码状态幻觉(State Hallucination):单纯依赖大模型自行概括历史,极易遗漏曾经修改或读取过的关键文件路径,导致后续编辑出现路径偏差。
本文基于开源 Agent 框架 pi(包含 pi-coding-agent 与底层 pi-agent-core 运行时)的实现源码,深入拆解其 Compaction 系统的核心架构与算法细节。
一、 全景视角:Compaction 的核心处理管线
整个压缩生命周期由 5 个高度解耦的处理阶段组成:
- 阶段 1:阈值检测(
shouldCompact):计算混合 Token 水位,判断是否突破contextWindow - reserveTokens安全阈值(如 184K); - 阶段 2:切分点计算(
findCutPoint):从后向前累加keepRecentTokens(默认保留 20K 近期消息),严格避开toolResult节点以保证协议合法性,并检测是否命中单轮中间以标记isSplitTurn; - 阶段 3:提取文件操作记录(
extractFileOperations):静态扫描待压缩消息中的read、write、edit工具调用参数,结合前序状态计算出确定性的readFiles与modifiedFiles清单,杜绝路径幻觉; - 阶段 4:调用模型生成摘要(
compact):采用 6 小节结构化模板输出高密度摘要(若命中 Turn 分割则结合局部前缀摘要),复用服务端 KV 缓存并附加文件清单 XML 标签; - 阶段 5:写入 Session Tree 与请求组装(Append-Only):以 Append-Only 方式在磁盘持久化
CompactionEntry,发请求时通过buildContextEntries仅动态组装生效的[CompactionEntry, ...retainedMessages]。
二、 触发时机与 Token 水位计算
系统何时应该触发压缩?过早压缩会导致不必要的模型调用开销并过早损失原始上下文细节;过晚压缩则可能在单次长工具返回时直接突破上下文上限导致请求崩溃。
1. 动态阈值判定(shouldCompact)
在 pi-coding-agent 的压缩模块中,shouldCompact 采用动态阈值公式进行判定:
export function shouldCompact(
contextTokens: number,
contextWindow: number,
settings: CompactionSettings
): boolean {
if (!settings.enabled) return false;
return contextTokens > contextWindow - settings.reserveTokens;
}
默认配置项 DEFAULT_COMPACTION_SETTINGS 包含两个关键参数:
| 参数名称 | 默认值 | 作用与说明 |
|---|---|---|
reserveTokens | 16384(16K) | 安全预留空间:为当前轮次模型的思考输出、工具调用参数以及生成摘要本身预留的 Token 余量。 |
keepRecentTokens | 20000(20K) | 近期保留预算:触发压缩后,系统希望在最新上下文末尾完整保留的原生未压缩消息总量。 |
以常见的 200K 上下文窗口模型(如 Claude 3.7 Sonnet)为例,当当前会话累积 Token 超过 时,系统就会在当前交互轮次结束后触发压缩。
2. 混合 Token 估算(estimateContextTokens)
大模型的 Token 计算如果每次都通过本地分词器(Tokenizer)重新对数十万字符的长对话进行分词,会带来不可忽视的 CPU 计算开销与响应延迟。
在 pi-coding-agent 中,estimateContextTokens 采用了一种混合估算策略(Hybrid Token Estimation):
- 精确基准点(
usageTokens):从后向前查找最后一条由模型返回的AssistantMessage,直接提取其携带的官方usage.totalTokens(包含 input、output、cacheRead 与 cacheWrite)。这是服务商在上一轮调用时返回的真实硬件计数,具有绝对的准确性。 - 增量启发式累加(
trailingTokens):对于最后一条模型消息之后新产生的工具返回(ToolResultMessage)或用户新输入,通过estimateTokens按照字符启发式规则(约每 4 个字符计为 1 个 Token,图片按固定 4800 字符估算)进行增量累加。
针对中文场景的启发式低估与工程考量
这里有一个值得深入探讨的工程细节:每 4 字符计为 1 个 Token(chars / 4)的启发式规则,在中文等 CJK 语系下会被严重低估。
- 英文与代码:在标准 BPE 分词器(如 OpenAI 的
cl100k_base、o200k_base或 Anthropic 的分词器)中,英文单词通常由 3~4 个字符组成 1 个 Token,Math.ceil(chars / 4)是一个合理的经验法则。 - 中文/CJK 文本:汉字在 UTF-8 编码下占用 3 个字节,在绝大多数主流大模型分词器中,1 个汉字通常占用 1 ~ 2 个 Token(视分词字典覆盖率而定)。如果简单采用
chars / 4,1000 个汉字(实际消耗约 1000~1500 Token)只会被估算为 250 Token,存在 4 到 6 倍的低估偏差。
为什么 Pi 采用这种简化的估算规则在实际运行中依然稳定可靠?
- Coding Agent 的数据特征:在终端编码场景中,会话上下文 90% 以上的数据由英文系统指令、代码源文件、JSON 工具参数、文件路径和 Bash 执行日志组成,这些内容由 ASCII 字符构成,天然吻合
chars / 4的分布; - 仅作用于尾部增量(Trailing Delta):启发式规则只计算最后一条模型消息之后的少量新增消息(通常只有 1 条工具返回或 1 条用户提问,体积在几百到几千字符),前面数十万 Token 的主体历史全部由 100% 精确的
usage.totalTokens锚定; - 安全预留空间(
reserveTokens = 16384)提供了充足余量:系统预留了整整 16K 的安全余量,即便尾部输入了数百字中文造成少量估算漂移,也完全被包含在预留空间的容差范围之内。
但对于重度使用中文编写长需求或处理包含大量中文注释代码的场景,了解这一估算偏差依然有助于更好地理解水位的微小波动。
三、 切分点选择算法:如何安全划分新旧历史(findCutPoint)
确定要执行压缩后,核心问题是:从会话历史的哪一个节点开始切断?哪些保留,哪些折叠进摘要?
在 Session Tree 线性路径上,压缩算法将整个历史划分为两个逻辑区域:
- 待压缩历史区(Compacted History):从初始需求到切分点前的全部多轮探索(包括早期代码读取、编辑与单元测试等),将被浓缩为结构化摘要并从活跃上下文中剥离;
- 近期原生保留区(Retained Recent Messages):切分点(
firstKeptEntryId)之后的最新交互(保留约 20K Token),保持未经压缩的对话原貌以维持最新任务与排查的连贯性。
1. 从后向前的反向累积机制
在 findCutPoint 函数中,算法从会话路径的最末端(endIndex - 1)向前遍历:
- 逐条累加消息的预估 Token 数;
- 当累积的 Token 数达到或刚超过
keepRecentTokens(20K)时停止; - 以此时所在的位置作为基准切分候选点。
这种从后向前的收集方式,保证了模型在压缩完成后,依然能够看到最近几轮完整的对话原貌,保持当前任务的连贯性。
2. 协议语义安全性检查(findValidCutPoints)
大模型的消息协议对工具调用的上下文连续性有严格约束:ToolResultMessage 必须紧跟在发起该调用的 AssistantMessage 之后。如果在 toolCall 和 toolResult 之间强行切断,或者保留了 toolResult 却把前面的 assistant 压缩掉,发送给服务商时会直接触发 400 Bad Request 协议错误。
为此,findValidCutPoints 在筛选切分候选节点时执行严格的类型检查:
// 简化自 pi-coding-agent 中的切分点合法性判断
function isCutPointMessage(message: AgentMessage): boolean {
switch (message.role) {
case "user":
case "assistant":
case "bashExecution":
case "custom":
case "branchSummary":
case "compactionSummary":
return true;
case "toolResult":
// 绝不允许在 toolResult 处切断!
return false;
}
}
- 如果切分点落在
user节点:后续消息是一个完整的 Turn,安全; - 如果切分点落在
assistant节点:该节点发起的toolCall及其紧随其后的toolResult均位于切分点之后,完整保留在近期上下文中,安全; - 绝不允许在
toolResult节点处切断。
四、 极端场景处理:单轮超长与 Turn 分割(Turn Splitting)
在编码场景中经常存在一种极端情况:用户提了一个复杂的重构需求,Agent 在单轮(Turn)交互中连续调用了 30 次工具,产生了数万行的执行日志。这导致单次 Turn 本身的数据量就超过了 keepRecentTokens(20K)预算。
此时,算法从后向前寻找切分点时,无法回退到该 Turn 起始的 user 消息处,切分点不得不落在当前 Turn 中间的某个 assistant 节点上。
为了防止把当前轮次腰斩导致模型丢失最初的用户目标,Pi 引入了 Turn Splitting(轮次分割) 机制。
1. 判定与拆解(isSplitTurn)
当 findCutPoint 发现切分点不是 user 消息时,它会通过 findTurnStartIndex 向上找到发起当前轮次的那条 user 消息索引(turnStartIndex),并将状态标记为 isSplitTurn = true。
此时,历史记录被清晰地解构为三段:
- 历史完整轮次(
0 ~ turnStart):调用标准摘要 Prompt 生成全局 6 小节总结; - 当前 Turn 的前半段(
turnStart ~ cutIndex):调用局部前缀 Prompt 生成 3 小节的 TurnPrefix 摘要; - 当前 Turn 的后半段(
cutIndex ~ 最新):100% 完整保留原生未压缩消息,维持最新排查现场。
2. 双重摘要融合算法(generateTurnPrefixSummary)
在 compact() 执行时,系统会发起两次有针对性的摘要生成并将其融合成一体:
- 历史主线摘要:对
turnStart之前的所有历史完整轮次,生成标准的全会话结构化摘要; - Turn 前缀摘要:使用专用的
TURN_PREFIX_SUMMARIZATION_PROMPT对当前轮次被截掉的前半段生成 3 小节的局部摘要:## Original Request:用户在这一轮最初到底提了什么要求;## Early Progress:在切分点之前,已经完成了哪些前序步骤;## Context for Suffix:理解接下来保留的那些最新工具输出所必须的前置条件。
最终合并生成的摘要结构如下:
[前序所有轮次的历史总结...]
---
**Turn Context (split turn):**
## Original Request
重构认证模块并修复所有测试用例。
## Early Progress
- 已完成 auth.ts 的加密算法替换;
- 已修复单元测试中的 mock 数据。
## Context for Suffix
接下来正在运行端到端集成测试,当前正在排查第 3 个失败用例。
通过这种设计,即便在单轮交互极为庞大的极端场景下,模型在压缩后依然能清楚地知道“这一轮开始时用户要什么”以及“当前执行进行到了哪一步”。
五、 结构化摘要生成与增量更新机制
摘要的质量直接决定了后续交互的智商上限。如果摘要写得像散文,模型在后续推理时提取有效信息的成本就会急剧上升。
1. 6 小节摘要结构(SUMMARIZATION_PROMPT)
在 pi-coding-agent 中,系统要求模型必须严格遵循一套标准化的 6 小节 Markdown 结构输出摘要:
+-------------------------------------------------------------------------+
| SUMMARIZATION_PROMPT: 6 小节结构化模板 |
+-------------------------------------------------------------------------+
| ## Goal |
| [用户试图完成的核心目标] |
| |
| ## Constraints & Preferences |
| - [用户明确提出的约束条件、技术偏好或命名规约] |
| |
| ## Progress |
| ### Done |
| - [x] [已经完成的修改和验证项] |
| ### In Progress |
| - [ ] [当前正在进行的修改] |
| ### Blocked |
| - [当前遇到的阻碍或报错问题] |
| |
| ## Key Decisions |
| - **[决策名称]**: [简短的原因与决策依据] |
| |
| ## Next Steps |
| 1. [按优先级排序的下一步操作清单] |
| |
| ## Critical Context |
| - [继续工作所必需的关键变量名、具体错误日志或配置参数] |
+-------------------------------------------------------------------------+
该模板强制保留精确的文件路径、函数名和未解决的错误信息,严禁使用模糊的概括性词汇(如“修复了某些问题”)。
2. 多次压缩时的增量更新(UPDATE_SUMMARIZATION_PROMPT)
当一个长时间会话经历第 2 次、第 3 次甚至第 N 次压缩时,系统不会从头重读全量历史。
在 prepareCompaction 中,系统会提取上一次压缩生成的 previousSummary,并使用 UPDATE_SUMMARIZATION_PROMPT 指导模型进行增量更新:
- 保留旧摘要中依然有效的
Goal与Constraints; - 更新
Progress:将之前处于In Progress且在本段已完成的任务勾选移动到Done; - 移除已经解决的
Blocked障碍项; - 追加新的
Key Decisions和更新后的Next Steps。
这种增量更新机制不仅显著降低了后续压缩时的 Token 消耗,而且使得会话的核心记忆能够在整个生命周期中像版本日志一样平滑递进。
六、 自动提取文件读写记录:代码状态不丢的确定性保障
在大模型应用开发中,“不要让大模型去猜可以通过代码确定性计算出的事实” 是一条核心准则。
如果仅靠大模型在写摘要时自觉回忆“之前读过哪些文件、修改过哪些文件”,随着会话拉长,模型极易产生幻觉(例如漏掉某个重要的配置文件,或者把只读文件记成已修改文件)。
1. 静态扫描与状态提取(extractFileOperations)
在 pi-coding-agent 的文件跟踪实现中,系统通过静态扫描工具调用提取读写记录:
export function extractFileOpsFromMessage(message: AgentMessage, fileOps: FileOperations): void {
if (message.role !== "assistant") return;
for (const block of message.content) {
if (block.type !== "toolCall") continue;
const path = block.arguments?.path;
if (!path) continue;
switch (block.name) {
case "read":
fileOps.read.add(path);
break;
case "write":
fileOps.written.add(path);
break;
case "edit":
fileOps.edited.add(path);
break;
}
}
}
算法通过静态扫描待压缩消息列表中的所有 toolCall 块,精确提取工具参数中的 path 字段:
- 将所有
read操作记录入read集合; - 将所有
write与edit操作记录入written和edited集合; - 继承上一次压缩节点
details中记录的文件列表,确保跨压缩周期的文件状态不丢失。
2. 确定性计算与注入(computeFileLists)
通过集合运算,系统计算出两份互斥的清单:
readFiles:在整个压缩历史中仅读取过但从未修改过的文件;modifiedFiles:在历史中发生过编辑或写入的文件。
<read-files>
src/config.ts
src/types.ts
</read-files>
<modified-files>
src/auth.ts
test/auth.test.ts
</modified-files>
这两份清单由程序直接格式化为 XML 标签,强制追加在模型生成的摘要正文之后,并以 CompactionDetails 结构化对象保存在持久化节点的 details 字段中。后续模型在阅读摘要时,能够以 100% 的准确度掌握代码库的文件变更底表。
七、 提示词缓存友好性优化(Cache-Friendly Compaction)
执行 Compaction 本身也是一次大模型调用,通常需要向模型输入数万 Token 的待压缩历史。如果在执行压缩时完全从头构造 Prompt,这次调用本身就会产生昂贵的计算成本和较高的延迟。
在 pi-coding-agent 的压缩实现中,系统设计了 Cache-Friendly Compaction 机制:
// 优先复用当前活跃会话在服务端的 KV 缓存前缀
let sourceContext = cacheFriendly?.sourceContext;
if (sourceContext && cacheFriendlyContextFits(model, sourceContext, maxTokens)) {
// 直接复用已有上下文前缀,仅在末尾追加指令
basePrompt = previousSummary
? SOURCE_CONTEXT_UPDATE_SUMMARIZATION_PROMPT
: SUMMARIZATION_PROMPT;
} else {
// 降级策略: 独立序列化历史文本 (裁剪过长的工具输出至 2000 字符)
const llmMessages = convertToLlm(currentMessages);
const conversationText = serializeConversation(llmMessages);
promptText = `<conversation>\n${conversationText}\n</conversation>\n\n`;
}
- 缓存命中优先:如果当前会话在服务端的 KV 缓存依然有效,系统直接沿用现有的
sourceContext,只在末尾追加一条提取摘要的指令。这样执行压缩调用时,绝大部分输入 Token 都能直接命中服务商的前缀缓存(Prefix Caching),大幅降低计费与等待时间; - 容量保护与降级:通过
cacheFriendlyContextFits进行安全校验。如果复用现有缓存前缀加上摘要输出预算会超出模型的上下文窗口,系统自动回退到独立序列化模式(serializeConversation),在此模式下主动将工具输出截断至 2000 字符(TOOL_RESULT_MAX_CHARS),优先确保压缩调用能够成功执行。
八、 Session Tree 存储与运行时上下文构建(buildContextEntries)
在Pi Agent 的 Session Tree(上):会话历史如何保存中,我们强调过 Pi 的核心存储哲学:Append-Only Log(只追加,不修改、不删除)。
Compaction 同样遵循这一哲学:执行压缩绝不会物理删除或截断磁盘 JSONL 文件中的任何历史记录。
整个机制的核心在于“存储视图与运行时视图的分离”:
- 磁盘存储层(Append-Only):按时间序列持续追加,旧历史
e1..e34永久全量留痕,压缩节点c1仅记录指向近期消息起点的firstKeptEntryId: "e35"; - 运行时上下文层(Context View):发请求时通过
buildContextEntries动态拼装,跳过已被折叠的旧消息,仅提取[c1 摘要节点] + [e35..e50 保留消息] + [e51 最新输入]。
1. 压缩节点的磁盘记录(CompactionEntry)
压缩完成后,系统在当前叶子节点后追加一个类型为 compaction 的新 Entry:
{
"type": "compaction",
"id": "c1",
"parentId": "e50",
"timestamp": "2026-08-26T16:00:00.000Z",
"summary": "## Goal\n重构认证系统...\n\n<read-files>\nsrc/config.ts\n</read-files>",
"firstKeptEntryId": "e35",
"tokensBefore": 185200,
"details": {
"readFiles": ["src/config.ts"],
"modifiedFiles": ["src/auth.ts"]
}
}
2. 发送请求时的上下文组装(buildContextEntries)
当系统为后续的新交互组装发给模型的上下文时,buildContextEntries 会根据当前路径上的 CompactionEntry 提取出实际生效的消息序列:
// 简化自 pi-coding-agent 中的 buildContextEntries 实现
export function buildContextEntries(entries: SessionEntry[], leafId?: string): SessionEntry[] {
const path = buildSessionPath(entries, leafId);
// 1. 定位路径上最后一条生效的 CompactionEntry
const compaction = path.findLast((e): e is CompactionEntry => e.type === "compaction");
if (!compaction) return path;
const compactionIdx = path.findIndex((e) => e.id === compaction.id);
const contextEntries: SessionEntry[] = [compaction];
// 2. 在压缩节点之前的历史中,仅保留 firstKeptEntryId 及其之后的近期消息
let foundFirstKept = false;
for (let i = 0; i < compactionIdx; i++) {
const entry = path[i];
if (entry.id === compaction.firstKeptEntryId) {
foundFirstKept = true;
}
if (foundFirstKept) {
contextEntries.push(entry);
}
}
// 3. 压缩节点之后新追加的 Entry 全部保留
contextEntries.push(...path.slice(compactionIdx + 1));
return contextEntries;
}
最终送入模型的上下文结构为:
[ c1 (摘要节点) ] + [ e35 ... e50 (保留的近期原始消息) ] + [ e51 (新输入) ]
这种“磁盘只追加不删改历史,发请求时只拼接摘要与近期消息”的设计带来了极大的架构优势:
- 安全可审计:磁盘文件完整记录了任务发生过的每一步真实细节,未丢失任何信息;
- 无损分支回退:如果用户未来通过 Session Tree 回退到
e20分支,由于该节点的祖先链路上没有c1压缩节点,系统能够无缝重现当时的完整未压缩上下文。
九、 总结
通过对 Pi 压缩系统的全流程剖析,我们可以提炼出面向自主编码 Agent 的 4 条核心上下文压缩设计准则:
- 切分点必须尊重协议与语义完整性:绝对避开
toolResult节点,并通过 Turn Splitting 机制化解单轮交互过长与保留首要目标之间的矛盾; - 通过结构化模板和增量更新保留核心信息:通过 6 小节模板提取关键决策,并通过增量更新让会话记忆随开发进度平滑递进;
- 通过扫描工具调用提取确定的文件修改记录:对于文件读写等关键工程事实,采用静态扫描
toolCall的方式程序化生成清单,消除模型幻觉; - 只追加记录不删改历史,按需拼装请求上下文(Append-Only 与 buildContextEntries):压缩只作为新节点追加到日志中,不破坏原始历史与多分支回退能力;真正发给模型时,再根据切分点只拼接摘要与近期消息。