从源码看 pi 的上下文管理:原始数据永久保留,模型视角按需裁剪

18 阅读12分钟

本文基于 pi 源码分析其上下文管理机制。核心结论一句话:pi 把对话历史当作一棵 append-only(只追加)的树持久化,原始数据永不删除、永不覆盖;而发送给模型的消息列表,是一套在读取时从这棵树投影(projection)出来的视图。投影决定模型「看到什么」,存储决定「有什么可以看」。这两件事被彻底解耦。


一、问题:模型上下文窗口是稀缺资源

大语言模型的上下文窗口有限,而一次真实的编码会话会持续产生海量信息:用户指令、助手回复、工具调用与结果、bash 输出、读写的文件内容……直接把全部历史塞进上下文,很快就会被撑爆,甚至触发 API 错误。

常见的简单做法是「截断」:只保留最近 N 条消息,把更早的直接丢掉。但这会付出巨大代价:

  1. 目标丢失:最早的用户目标、约束被丢弃后,模型在后面几轮会「忘记自己本来要干什么」。
  2. 决策丢失:早期做过的技术选型、解决过的报错,被丢掉后模型可能重复犯错。
  3. 可追溯性丢失:一旦历史被真正删除,就无法回看、无法回退、无法 fork。

pi 的设计目标非常明确:既要控制进入模型的上下文大小,又不能让任何一条历史信息真正消失。它给出的答案是「事件溯源(event sourcing)+ 投影」——这两条原则贯穿整个源码。


二、核心原则之一:原始数据永久保留

2.1 append-only 的树,而非可变的列表

pi 的会话不是一条会被覆盖的消息线,而是一棵 append-only 的树。这一点在 SessionManager 类的文档注释里写得很直白(packages/coding-agent/src/core/session-manager.ts):

Manages conversation sessions as append-only trees stored in JSONL files.

每个会话持久化为一个 .jsonl 文件,一行一个 SessionEntry。每个 entry 都有 id 和 parentId,通过 parentId 指向父节点,从而构成一棵树:

export interface SessionEntryBase {
  type: string;
  id: string;
  parentId: string | null;
  timestamp: string;
}

关键的设计约束是:写入永远只追加,绝不重写或删除已有的行。看 _appendEntry 与 _persist 的实现:

private _appendEntry(entry: SessionEntry): void {
  this.fileEntries.push(entry);
  this.byId.set(entry.id, entry);
  this.leafId = entry.id;
  this._persist(entry);
}

首次落盘用 openSync(this.sessionFile, "wx") 建文件,之后的一切写入都走:

appendFileSync(this.sessionFile, `${JSON.stringify(entry)}\n`);

也就是说,任意一条消息、任意一次压缩、任意一次 branching,都是「往文件末尾追加一行 JSON」,从不动既有内容。历史一旦写入,就物理上不可变。

2.2 一棵树,而不是一条线

为什么必须是「树」而不是「列表」?因为会话支持分支(branching)。用户随时可以回到某个历史节点,从那里岔出一条新对话。用树就可以做到「新增一条分支」而完全不动旧分支:

[user msg] ─── [assistant] ─── [user msg] ─── [assistant] ─┬─ [user msg]   ← 当前叶子
                                                            │
                                                            └─ [branch_summary] ─── [user msg]   ← 另一分支

appendMessage 会生成新 id,并把 parentId 设为当前叶子,再把叶子指针推进:

appendMessage(message: Message | CustomMessage | BashExecutionMessage): string {
  const entry: SessionMessageEntry = {
    type: "message",
    id: generateId(this.byId),
    parentId: this.leafId,      // 挂在当前叶子下
    timestamp: new Date().toISOString(),
    message,
  };
  this._appendEntry(entry);
  return entry.id;
}

树的节点类型不止消息,SessionEntry 是一个联合类型,包含多种 entry。

2.3 各类 entry 一览

Entry 类型作用是否进入模型上下文
message原始对话消息(user/assistant/toolResult/system 等)是(见第三节)
compaction一次压缩产生的「检查点」,存摘要 + firstKeptEntryId是(产出摘要消息)
branch_summary分支切换时对被放弃分支的摘要是
custom_message扩展注入的、要进入上下文的自定义消息是
context_editappend 式的「改写指令」,声明替换/删除某条消息的 content否(作为指令参与投影)
usage模型用量记录否
custom扩展私有数据,不参与上下文否
model_change / thinking_level_change会话中的模型/思考级别切换否(但还原视图时抽取)
label / session_info用户书签 / 会话显示名否

理解「永久保留」的关键,是要看清 压缩(compaction)和改写(context_edit)都不会删除数据:

  • compaction 是一个新追加的 entry,它只是「标记」:从它这里开始,更早的消息在投影时被折叠进摘要。
  • context_edit 也是一个新追加的 entry,它声明「targetId 那条消息的 content 要替换 / 要从上下文删除」,但它不碰那条原始消息本身。

原始消息 entry 始终物理存在于文件里,随时可按需重新读取(这正是 fork、回退、导出 JSONL 等功能的根基)。


三、核心原则之二:模型视角按需裁剪

「原始数据永久保留」只解决了「存」,真正关键的是「取」。pi 在读取阶段做了四段式投影,把整棵树还原成一条发给模型的消息列表。

3.1 总览:入口是一连串纯投影

export function buildSessionContext(
  entries: SessionEntry[],
  leafId?: string | null,
  byId?: Map<string, SessionEntry>,
): SessionContext {
  const { messages, thinkingLevel, model } = buildSessionProjection(entries, leafId, byId);
  return { messages, thinkingLevel, model };
}

buildSessionContext 是整个还原过程的入口,它返回一个 SessionContext,包含:

  • messages: AgentMessage[] —— 直接发给模型的消息序列
  • thinkingLevel / model —— 从会话路径上推算出的当前生效设置

它的完整链路是四个阶段:

SessionEntry[] ──① buildSessionPath──▶ path[] ──② buildContextEntries──▶ contextEntries[]
                                                                              │
                                                                          ③ 投影 + context_edit
                                                                              ▼
                              AgentMessage[] ◀──④ sessionEntryToContextMessages

3.2 阶段①:从当前叶子回溯到根(选路径)

会话是树,而模型上下文必须是一条线性序列。第一步就是确定「当前在哪条分支上」,即 buildSessionPath:

function buildSessionPath(entries, leafId?, byId?): SessionEntry[] {
  const index = buildEntryIndex(entries, byId);
  // leafId 为空回退到最后一条;leafId === null 显式表示空路径
  let leaf = leafId ? index.get(leafId) : undefined;
  leaf ??= entries[entries.length - 1];

  const path: SessionEntry[] = [];
  let current = leaf;
  while (current) {
    path.push(current);
    current = current.parentId ? index.get(current.parentId) : undefined;
  }
  path.reverse();
  return path;
}

它从当前叶子沿 parentId 一路爬到根,再反转成正序,得到的 path 就是「当前视角下的线性历史」。后续一切投影都发生在这个 path 上,而不是在整个树的全部节点上——这就是第一层裁剪:只保留当前分支。

3.3 阶段②:应用压缩折叠(按需裁减的核心)

buildContextEntries 是「按需裁减」的灵魂。它不删任何东西,只决定 path 上的哪些 entry 进入 contextEntries:

export function buildContextEntries(entries, leafId?, byId?): SessionEntry[] {
  const path = buildSessionPath(entries, leafId, byId);
  let compaction: CompactionEntry | null = null;

  // 取路径上「最新」的 compaction
  for (const entry of path) {
    if (entry.type === "compaction") compaction = entry;
  }
  if (!compaction) return path;   // 没有压缩过:整条路径都进上下文

  const compactionIdx = path.findIndex((e) => e.id === compaction.id);
  const contextEntries: SessionEntry[] = [compaction];   // 最新的压缩检查点排最前

  // 压缩之前的保留尾巴:从 firstKeptEntryId 到 compaction 之间
  let foundFirstKept = false;
  for (let i = 0; i < compactionIdx; i++) {
    const entry = path[i];
    if (entry.id === compaction.firstKeptEntryId) foundFirstKept = true;
    if (foundFirstKept && !(entry.type === "message" && entry.message.role === "system")) {
      contextEntries.push(entry);
    }
  }
  // 压缩之后的所有新消息
  contextEntries.push(...path.slice(compactionIdx + 1));
  return contextEntries;
}

这里体现了「裁剪」的三个层次:

  1. 早期历史被折叠:firstKeptEntryId 之前的那段消息,一整段从视图里消失了,取而代之的是压缩检查点里的摘要。
  2. 保留近期的尾巴:firstKeptEntryId 到 compaction 之间的消息(压缩时就决定要保留的近期工作)完整保留。
  3. 压缩之后的新消息原样进入:压缩发生之后的对话照常全量保留,直到再次触发压缩。

有一个细节值得注意——旧的 system 消息会被跳过:

if (foundFirstKept && !(entry.type === "message" && entry.message.role === "system"))

原因在于:压缩时,整个 system prompt 的状态已经被完整地快照进了 compaction entry 的 systemMessage 字段(见下文 3.5),在折叠后由它重放,而不是从保留范围内重复重放旧 system 消息,避免重复。

3.4 阶段③:套用 context_edit,产出投影结果

buildSessionContext 真正调用的是 buildSessionProjection,它在前两步基础上再做两件事:抽取运行时设置、套用 context_edit 改写。

抽取运行时设置 getSessionContextSettings 在完整 path 上扫描 thinking_level_change 和 model_change,得到当前生效的 thinking level 与 model,这二者不参与消息,但作为 SessionContext 的一部分返回。

套用 context_edit:ContextEditEntry 是一种「不改原数据」的改写指令。投影时先收集每个 targetId 对应的最新编辑:

const edits = new Map<string, ContextEditEntry>();
for (const entry of contextEntries) {
  if (entry.type === "context_edit") edits.set(entry.targetId, entry);
}

然后逐 entry 套用。projectContextEntry 的规则是:

function projectContextEntry(entry, edit?): AgentMessage[] {
  const messages = sessionEntryToContextMessages(entry);
  if (!edit) return messages;
  if (edit.replacement === null) return [];   // 从模型视角删除,但数据仍在

  return messages.map((message) => {
    // 只替换 content,保留原 entry 的 role 与元数据
    return { ...message, content } as AgentMessage;
  });
}

replacement === null 表示「从模型上下文里删掉这条」,但原始 entry 并不删除。context_edit 的语义整个是 append 的:它是一条追加在后面的指令,而非对前一条的原地修改。这就是「原始数据永久保留」在改写场景下的体现——连「修改」都是用追加来表达的。

3.5 阶段④:逐 entry 转成消息

sessionEntryToContextMessages 是单个 entry → AgentMessage[] 的映射,是整个还原的最后一块拼图:

entry 类型产出
message原样返回 entry.message(并对历史/手改文件做防御:content 为 null 时补 "" 或 [])
custom_messagecreateCustomMessage(...) → role: "custom"
branch_summarycreateBranchSummaryMessage(...) → role: "branchSummary"
compactioncreateCompactionSummaryMessage(...) → role: "compactionSummary";若有 systemMessage 检查点,先产出 system 消息再产出摘要
context_edit / usage / custom / 其他[](不进入上下文)

压缩条目特别值得展开:

if (entry.type === "compaction") {
  const summary = createCompactionSummaryMessage(entry.summary, entry.tokensBefore, entry.timestamp);
  return entry.systemMessage ? [entry.systemMessage, summary] : [summary];
}

这里印证了 3.3 里跳过旧 system 消息的原因:压缩时,appendCompaction 会把当时的 system prompt 完整快照进 systemMessage:

appendCompaction(summary, firstKeptEntryId, tokensBefore, details?, fromHook?, usage?) {
  const systemMessage = getCurrentSystemMessage(this.buildSessionProjection().messages);
  const entry: CompactionEntry = {
    type: "compaction",
    // ...
    firstKeptEntryId: firstKeptEntryId ?? id,
    summary,
    ...(systemMessage ? { systemMessage: { ...systemMessage, timestamp: ... } } : {}),
  };
  this._appendEntry(entry);
}

于是压缩后,模型得到的是一条完整 system 检查点(而非散落的旧 system)+ 一条压缩摘要 + 保留尾巴 + 新消息。压缩之前的内容被「折叠」为一个可重放的检查点,而不是被丢弃。

最后,buildSessionProjection 用一个 flatMap 把所有投影结果展平成单一 AgentMessage[]:

return {
  entries: projectedEntries,
  messages: projectedEntries.flatMap((entry) => entry.messages),
  thinkingLevel,
  model,
};

3.6 一个容易被忽略的边界:旧 compaction 的折叠

buildSessionProjection 里还有一处细节,体现了设计者对「重复」的警惕:

messages:
  sourceEntry.type === "compaction" && index > 0
    ? []
    : projectContextEntry(sourceEntry, edits.get(sourceEntry.id)),

当最新 compaction 的保留范围里还躺着一条更早的 compaction时(即连续多次压缩),那些旧 compaction 的 index > 0,会被投影成空消息。因为旧摘要的内容已经合并进更新的摘要里了,再重复输出只会污染上下文、浪费 token。只有 index === 0 那条(最新的)才是当前有效的检查点。

3.7 最后一步:把扩展角色降级为模型消息

上面产出的是 pi 内部的 AgentMessage,还带有 compactionSummary、branchSummary、bashExecution、custom 等扩展角色。真正发给模型前,还要经过 convertToLlm(packages/coding-agent/src/core/messages.ts)把它们降级为 provider 能理解的 Message:

case "compactionSummary":
  return {
    role: "user",
    content: [{ type: "text", text: COMPACTION_SUMMARY_PREFIX + m.summary + COMPACTION_SUMMARY_SUFFIX }],
    timestamp: m.timestamp,
  };
case "branchSummary":
  return {
    role: "user",
    content: [{ type: "text", text: BRANCH_SUMMARY_PREFIX + m.summary + BRANCH_SUMMARY_SUFFIX }],
    timestamp: m.timestamp,
  };

摘要和分支摘要都被包进 <summary> 标签,并以 user 角色注入。这样模型把它们当作「对话中传来的上下文」,而不是又一条 system 指令。bashExecution 则被转成一段 user 文本,包含命令、输出、退出码、截断提示。


四、压缩(compaction)本身:如何决定裁什么、留什么

「按需裁减」的「需」由压缩触发。压缩模块(packages/coding-agent/src/core/compaction/compaction.ts)是纯函数集,I/O 归 SessionManager,压缩完成后会话被重新加载。

压缩的核心产物是 CompactionResult:

export interface CompactionResult<T = unknown> {
  summary: string;
  firstKeptEntryId: string;   // 切割点:它之前的被折叠进摘要
  tokensBefore: number;
  estimatedTokensAfter?: number;
  usage?: Usage;
  details?: T;
}

其中 firstKeptEntryId 是理解整条链路的轴心——它既被 appendCompaction 写进 entry(session-manager.ts),又被 buildContextEntries 在读取时用来切分上下文(3.3)。压缩不移动、不删除任何历史条目,它只是在树的末尾追加一个「从这里起,更早的折叠进摘要」的标记。 图中可以直观理解为:

[msg1] [msg2] [msg3] [msg4] [msg5] [compaction] [msg6] [msg7]
   └──── 折叠进摘要 ────┘  └─ 保留尾巴 ─┘            └ 新消息 ─┘
                          (firstKeptEntryId = msg4)

压缩时还会做两件「保价值」的额外工作:

  1. 文件操作追踪:extractFileOperations 从工具调用和上一次压缩的 details 里累积 read / written / edited 的文件集合,存入 CompactionDetails。这样即使对话文本被压缩,模型仍知道「碰过哪些文件、改过哪些文件」——对编程 agent 这是最有价值的一块状态。

  2. 结构化摘要模板:compaction/compaction.ts 里用固定格式强制摘要保留目标、约束、进度(Done / In Progress / Blocked)、关键决策、下一步、关键上下文(精确文件路径、报错信息),而不是让模型自由发挥、丢掉早期目标。


五、一句话总结与价值

pi 的上下文管理 = append-only 的事件溯源存储 + 读取时的投影视图。

  • 原始数据永久保留:所有消息、压缩、改写、分支都以「追加一行 JSON」的方式写入一棵 append-only 树,从不删除、从不覆盖。连「删除某条消息」和「改写某条消息」这类操作,都是用追加一条 context_edit 指令来表达的,原数据始终可回溯。

  • 模型视角按需裁剪:发送给模型的消息列表,是 buildSessionPath → buildContextEntries → 投影 → sessionEntryToContextMessages → convertToLlm 五段式投影的结果。折叠点由 compaction.firstKeptEntryId 决定,早期历史被摘要取代,近期工作与压缩后的新消息完整保留。整个读取过程是纯计算,原始数据零改动。

这套设计带来的直接好处:

  1. 上下文可控:通过压缩阈值,保证进入模型的内容始终在窗口内。
  2. 信息不丢:摘要结构化地层叠保留目标/决策/文件状态,压缩不牺牲关键信息。
  3. 可回溯可分支:因为历史树完整,任意节点都可回退、可 fork,压缩后的会话也能重新展开。
  4. 也就更容易做可观测与调试:导出的 JSONL 就是完整的事件流,与模型实际看到的投影可分离对比。

如果你只记住一件事,那就是这句:存储层是「事件日志」,模型层是「投影视图」——日志永不丢,视图按需裁。