本文基于 pi 源码分析其上下文管理机制。核心结论一句话:pi 把对话历史当作一棵 append-only(只追加)的树 持久化,原始数据永不删除、永不覆盖;而发送给模型的消息列表,是一套在读取时从这棵树投影(projection)出来的视图。投影决定模型「看到什么」,存储决定「有什么可以看」。这两件事被彻底解耦。
一、问题:模型上下文窗口是稀缺资源
大语言模型的上下文窗口有限,而一次真实的编码会话会持续产生海量信息:用户指令、助手回复、工具调用与结果、bash 输出、读写的文件内容......直接把全部历史塞进上下文,很快就会被撑爆,甚至触发 API 错误。
常见的简单做法是「截断」:只保留最近 N 条消息,把更早的直接丢掉。但这会付出巨大代价:
- 目标丢失:最早的用户目标、约束被丢弃后,模型在后面几轮会「忘记自己本来要干什么」。
- 决策丢失:早期做过的技术选型、解决过的报错,被丢掉后模型可能重复犯错。
- 可追溯性丢失:一旦历史被真正删除,就无法回看、无法回退、无法 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 指向父节点,从而构成一棵树:
ts
export interface SessionEntryBase {
type: string;
id: string;
parentId: string | null;
timestamp: string;
}
关键的设计约束是:写入永远只追加,绝不重写或删除已有的行 。看 _appendEntry 与 _persist 的实现:
ts
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") 建文件,之后的一切写入都走:
ts
appendFileSync(this.sessionFile, `${JSON.stringify(entry)}\n`);
也就是说,任意一条消息、任意一次压缩、任意一次 branching,都是「往文件末尾追加一行 JSON」,从不动既有内容。历史一旦写入,就物理上不可变。
2.2 一棵树,而不是一条线
为什么必须是「树」而不是「列表」?因为会话支持分支(branching)。用户随时可以回到某个历史节点,从那里岔出一条新对话。用树就可以做到「新增一条分支」而完全不动旧分支:
css
[user msg] ─── [assistant] ─── [user msg] ─── [assistant] ─┬─ [user msg] ← 当前叶子
│
└─ [branch_summary] ─── [user msg] ← 另一分支
appendMessage 会生成新 id,并把 parentId 设为当前叶子,再把叶子指针推进:
ts
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_edit |
append 式的「改写指令」,声明替换/删除某条消息的 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 总览:入口是一连串纯投影
ts
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------ 从会话路径上推算出的当前生效设置
它的完整链路是四个阶段:
css
SessionEntry[] ──① buildSessionPath──▶ path[] ──② buildContextEntries──▶ contextEntries[]
│
③ 投影 + context_edit
▼
AgentMessage[] ◀──④ sessionEntryToContextMessages
3.2 阶段①:从当前叶子回溯到根(选路径)
会话是树,而模型上下文必须是一条线性序列。第一步就是确定「当前在哪条分支上」,即 buildSessionPath:
ts
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:
ts
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;
}
这里体现了「裁剪」的三个层次:
- 早期历史被折叠 :
firstKeptEntryId之前的那段消息,一整段从视图里消失了,取而代之的是压缩检查点里的摘要。 - 保留近期的尾巴 :
firstKeptEntryId到 compaction 之间的消息(压缩时就决定要保留的近期工作)完整保留。 - 压缩之后的新消息原样进入:压缩发生之后的对话照常全量保留,直到再次触发压缩。
有一个细节值得注意------旧的 system 消息会被跳过:
ts
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 对应的最新编辑:
ts
const edits = new Map<string, ContextEditEntry>();
for (const entry of contextEntries) {
if (entry.type === "context_edit") edits.set(entry.targetId, entry);
}
然后逐 entry 套用。projectContextEntry 的规则是:
ts
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_message |
createCustomMessage(...) → role: "custom" |
branch_summary |
createBranchSummaryMessage(...) → role: "branchSummary" |
compaction |
createCompactionSummaryMessage(...) → role: "compactionSummary";若有 systemMessage 检查点,先产出 system 消息再产出摘要 |
context_edit / usage / custom / 其他 |
[](不进入上下文) |
压缩条目特别值得展开:
ts
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:
ts
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[]:
ts
return {
entries: projectedEntries,
messages: projectedEntries.flatMap((entry) => entry.messages),
thinkingLevel,
model,
};
3.6 一个容易被忽略的边界:旧 compaction 的折叠
buildSessionProjection 里还有一处细节,体现了设计者对「重复」的警惕:
ts
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:
ts
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:
ts
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)。压缩不移动、不删除任何历史条目,它只是在树的末尾追加一个「从这里起,更早的折叠进摘要」的标记。 图中可以直观理解为:
ini
[msg1] [msg2] [msg3] [msg4] [msg5] [compaction] [msg6] [msg7]
└──── 折叠进摘要 ────┘ └─ 保留尾巴 ─┘ └ 新消息 ─┘
(firstKeptEntryId = msg4)
压缩时还会做两件「保价值」的额外工作:
-
文件操作追踪 :
extractFileOperations从工具调用和上一次压缩的 details 里累积read / written / edited的文件集合,存入CompactionDetails。这样即使对话文本被压缩,模型仍知道「碰过哪些文件、改过哪些文件」------对编程 agent 这是最有价值的一块状态。 -
结构化摘要模板 :
compaction/compaction.ts里用固定格式强制摘要保留目标、约束、进度(Done / In Progress / Blocked)、关键决策、下一步、关键上下文(精确文件路径、报错信息),而不是让模型自由发挥、丢掉早期目标。
五、一句话总结与价值
pi 的上下文管理 = append-only 的事件溯源存储 + 读取时的投影视图。
-
原始数据永久保留 :所有消息、压缩、改写、分支都以「追加一行 JSON」的方式写入一棵 append-only 树,从不删除、从不覆盖。连「删除某条消息」和「改写某条消息」这类操作,都是用追加一条
context_edit指令来表达的,原数据始终可回溯。 -
模型视角按需裁剪 :发送给模型的消息列表,是
buildSessionPath → buildContextEntries → 投影 → sessionEntryToContextMessages → convertToLlm五段式投影的结果。折叠点由compaction.firstKeptEntryId决定,早期历史被摘要取代,近期工作与压缩后的新消息完整保留。整个读取过程是纯计算,原始数据零改动。
这套设计带来的直接好处:
- 上下文可控:通过压缩阈值,保证进入模型的内容始终在窗口内。
- 信息不丢:摘要结构化地层叠保留目标/决策/文件状态,压缩不牺牲关键信息。
- 可回溯可分支:因为历史树完整,任意节点都可回退、可 fork,压缩后的会话也能重新展开。
- 也就更容易做可观测与调试:导出的 JSONL 就是完整的事件流,与模型实际看到的投影可分离对比。
如果你只记住一件事,那就是这句:存储层是「事件日志」,模型层是「投影视图」------日志永不丢,视图按需裁。