第 07 章:上下文压缩(compaction)

每一轮对话我们都在全量重发历史,而模型的上下文窗口是有限的——长会话总有一天撞墙。这一章实现 pi 的解法:压缩(compaction)——把较旧的消息摘要成一段文字,保留最近的消息原样;摘要进入上下文,原始历史永远留在会话文件里

pi 的压缩策略

pi 的实现(core/compaction/compaction.ts,docs/compaction.md)有几个关键决策,我们逐一对照:

决策pi 的做法mini-pi 的简化
何时触发contextTokens > contextWindow - reserveTokens(reserve 默认 16384);溢出报错后也会恢复性压缩;/compact 手动估算超过 MINI_PI_COMPACT_TOKENS(默认 16000)就触发,每个 turn 前检查
保留多少从新消息往回累积,保留约 keepRecentTokens(默认 20k)的最近消息保留最近 4 条
在哪下刀turn 边界;绝不在 toolResult 处切(它必须和自己的 toolCall 待在一起)切分点向前对齐到 user 消息(天然满足同一约束)
摘要怎么生成调 LLM,结构化格式,且把上一次摘要作为迭代上下文传入调 LLM,一段式摘要 prompt
摘要放哪CompactionEntry 落盘(含 firstKeptEntryId),上下文 = 摘要 + 保留消息同款:compaction 记录落盘

pi 还处理了一些边角:单个 turn 就超预算的"split turn"(切成两段分别摘要再合并)、二次压缩从上次的保留边界继续。mini-pi 不做这些——思想一致即可,粒度差异见文末。

估算与触发

没有官方 tokenizer 时,字符数 / 4 是个够用的粗估(英文偏保守,中文偏乐观;触发判断不需要精确):

src/main.ts(新增)
const KEEP_RECENT = 4;

function estimateTokens(systemPrompt: string, messages: Message[]): number {
  let chars = systemPrompt.length;
  for (const m of messages) {
    chars += m.content?.length ?? 0;
    for (const tc of m.tool_calls ?? []) chars += tc.function.arguments.length;
  }
  return Math.ceil(chars / 4);
}

压缩的实现

src/main.ts(新增)
const COMPACT_PROMPT = `You are compacting the conversation history of a coding agent. Summarize the conversation so far, preserving everything the agent needs to continue: the user's goals, decisions made, files read or modified (with paths), commands run and their outcomes, the current state, and agreed next steps. Be concise but complete. Write in the same language as the conversation.`;

async function maybeCompact(systemPrompt: string, session: Session, messages: Message[]): Promise<void> {
  if (estimateTokens(systemPrompt, messages) < CONFIG.compactTokens) return;

  // 切分点向前对齐到一条 user 消息,保证保留部分以 user 开头(协议要求:
  // tool 消息必须紧跟它的 assistant tool_calls,不能孤悬在切分边界上)
  let cut = Math.max(1, messages.length - KEEP_RECENT);
  while (cut < messages.length && messages[cut].role !== "user") cut++;
  if (cut <= 0 || cut >= messages.length - 1) return;

  const oldMessages = messages.slice(0, cut);
  const kept = messages.slice(cut);
  const summary = await chatOnce([
    { role: "system", content: COMPACT_PROMPT },
    { role: "user", content: JSON.stringify(oldMessages, null, 2) },
  ]);

  const firstKeptId = kept[0] ? session.entryIds.get(kept[0]) : undefined;
  session.append({ type: "compaction", summary, compactedCount: oldMessages.length, firstKeptId });

  messages.length = 0;
  messages.push({ role: "user", content: `[Earlier conversation summary]\n${summary}` }, ...kept);
  console.log(
    `\x1b[2m[compacted ${oldMessages.length} older messages; full history stays in the session file]\x1b[0m`,
  );
}

注意三件事:

  1. 摘要 prompt 是写给"接手者"的交接信:目标、决定、动过的文件、跑过的命令、当前状态、下一步。这类"交接清单"比"总结一下上文"的摘要质量高得多——这也是 pi 的摘要 prompt 采用结构化格式的原因。
  2. 摘要本身不落盘为普通消息,而是 compaction 记录;上一章的 rebuildContext 已经知道怎么用它:插入摘要消息、跳过被压缩的旧消息(skipUntilId 逻辑,现在填坑完毕)。
  3. 压缩发生在每个 turn 开始前(runAgent 的 while 顶部调用 maybeCompact),是"主动压缩";pi 除此之外还有"溢出后恢复"这条路。

验收

把阈值调小,人为制造长会话:

MINI_PI_COMPACT_TOKENS=2000 npx tsx src/main.ts

聊几轮(让它读几个文件),观察:

  1. 某一轮开始前打印 [compacted N older messages; …];之后问 "我们最开始要做什么?"——它能根据摘要答出来。
  2. cat 会话文件:compaction 记录躺在 JSONL 里,被压缩的旧消息一条不少。压缩是有损的,但只损"给模型看的上下文",不损"事实记录"——这是 pi 会话树设计的另一半好处,配合 /tree 随时可以回到压缩前的完整历史。
  3. --continue 恢复,输出 N messages, M older compacted,接着聊——重建的上下文正是"摘要 + 保留消息"。

与 pi 的差距(以及去哪看)

  • pi 按 token 预算往回找切点(保留约 20k),我们按固定条数;改成按 estimateTokens 累积即可,逻辑同构。
  • pi 有 split turn、二次压缩从上次保留边界继续、摘要带累计的文件操作清单(读/写/编辑过哪些文件);这些都写在 docs/compaction.md 里,建议通读。
  • pi 的压缩可通过扩展完全替换(custom-compaction 示例),这正是"核心极简、扩展激进"的又一例,见设计思想:harness 层

本章要点

  • 上下文窗口是硬约束;压缩 = 摘要旧消息 + 保留最近消息,触发要留出回复余量。
  • 切分必须遵守协议约束:toolResult 不能和它的 toolCall 分家。
  • 摘要 prompt 写成"交接信";摘要落盘为特殊记录,原始历史永不删除。
  • 至此 mini-pi 拥有了 pi 的全部核心能力:loop、四工具、流式、系统提示词、会话树、压缩——裸写部分完成。

下一章是里程碑:第 08 章:用 pi 的官方积木重构 mini-pi