会话树与压缩:上下文如何重建
拆开会话条目的树结构,说明活动分支如何决定模型上下文,分支摘要与压缩摘要各自解决什么问题,以及版本变化下持久化格式的阅读方法。
会话树与压缩:上下文如何重建
同一个仓库、同一个任务,你可能走过三条路:
- 保留历史——任务做了三小时,五十轮对话,你不想丢任何一步。
- 切换分支——方案 A 试到一半,想回到方案 B 的起点重来。
- 减少上下文——历史太长,模型上下文塞不下了。
这三件事在 Pi 里是三套不同的机制,共用同一个存储结构。把它们混为一谈,就会出现"我用 /compact 压缩了,结果分支也没了"这类事故。
这篇讲清会话树怎么构成、活动分支怎么决定模型看到什么、两种摘要分别在什么时候出现,以及拿到一个陌生版本的会话文件时该怎么读。
读它需要能读 JSONL 文件、知道父子引用构成的树是什么,并且用过至少一个带分支的版本控制工具(Git 分支、SVN 分支之类)。读完你能打印一棵会话树、定位活动分支,并区分两种摘要各自的用途。
Pi 迭代很快,接口可能变化。动手前先记录 pi --version,并按你安装的版本复核参数。
最终要跑通什么
- 打印一个会话的完整树,画出当前的活动分支。
- 制造一次分支,确认离开的分支仍在文件里,模型上下文只走新分支。
- 触发一次压缩,确认摘要条目写进了树,而原始条目还在。
会话是一棵树
持久会话是一个 JSONL 文件,每行一个 JSON 对象。第一行是会话头,只有元数据,不参与树结构:
{"type":"session","version":3,"id":"uuid","timestamp":"2024-12-03T14:00:00.000Z","cwd":"/path/to/project"}其余每行都是一个条目,都继承同一组基础字段:
// 会话条目的公共字段:树结构完全由 id 与 parentId 表达
interface SessionEntryBase {
type: string;
id: string; // 通常是 8 字符十六进制 ID,可能退化为完整 UUID
parentId: string | null; // 父条目 ID,根条目为 null
timestamp: string; // ISO 时间戳
}parentId 为 null 的是根。从根到"当前条目"的那条路径,就是活动分支。官方工作原理文档的一句话概括很准:"Each path through that tree is a branch. The branch ending at the current entry is the active branch and supplies the history for the next model request."
# 会话树的形状
[user msg] ─── [assistant] ─── [user msg] ─── [assistant] ─┬─ [user msg] ← 当前条目(活动分支)
│
└─ [branch_summary] ─── [user msg] ← 另一条分支关键点:树不是只能有一个分支。 从任意早期条目继续,会长出新的一条路径,旧路径原封不动留在文件里。
上下文重建的三步
模型上下文不是直接读文件,而是走一条三步流水线:
第三步里,条目类型对应不同的产出:message 产出存储的 AgentMessage;compaction 产出系统检查点 + 压缩摘要;branch_summary 产出分支摘要消息;custom_message 产出 CustomMessage;context_edit、usage 和 custom 自身不产生消息。
usage 和 custom 不进上下文但计入 token 与费用总额。 它们影响账单,不影响模型视野。
context_edit 是分支相对的。 官方文档写明"navigating to a point before the edit reveals the target's original contribution again"——导航回编辑发生之前,被省略的条目会重新出现。
两种摘要,两种用途
这是本篇最重要的区分。
两者都用结构化摘要格式,都跟踪文件操作,但服务的目标完全不同。
压缩摘要
压缩的触发条件是一个不等式:
# 自动压缩的触发条件:上下文 token 数超过窗口减去预留量
contextTokens > contextWindow - reserveTokensreserveTokens 默认 16384,keepRecentTokens 默认 20000。流程是:向前回溯累积 token 估算直到 keepRecentTokens → 收集待摘要的消息 → 调 LLM 生成摘要 → 追加 CompactionEntry → 之后重建上下文时用摘要替换更早的消息。
{
"type": "compaction",
"id": "f6g7h8i9",
"parentId": "e5f6g7h8",
"timestamp": "2024-12-03T14:10:00.000Z",
"summary": "User discussed X, Y, Z...",
"firstKeptEntryId": "c3d4e5f6",
"tokensBefore": 50000,
"systemMessage": { "role": "system", "content": "You are a coding assistant.", "toolsAdded": [], "timestamp": 1733235000000 }
}firstKeptEntryId 是必填的:它标出保留区从哪里开始。重建上下文时,比它更早的条目被摘要替换,从它开始的条目保留。systemMessage 保存压缩边界的完整提示与工具状态,重建时它成为压缩后上下文的首个系统消息。summary 是模型生成的摘要正文,tokensBefore 记录压缩前的上下文 token 数,parentId 则指向压缩条目在树上的位置——它自己也是树上的一个节点。
一个必须记住的事实:压缩不删除原始条目。 官方工作原理文档明确说 "The original entries remain in the session tree." 压缩只是让模型看不到它们了。
分支摘要
分支摘要在你用 /tree 导航到另一条分支时出现。官方会话文档的描述是:"When you leave a branch, Pi can summarize it and attach that summary to the branch you enter."
流程是:找新旧位置共享的最深节点(公共祖先)→ 从旧条目回溯到公共祖先收集要摘要的条目 → 按 token 预算从最新往回收集 → 调 LLM 生成摘要 → 在导航点追加 BranchSummaryEntry。
{
"type": "branch_summary",
"id": "g7h8i9j0",
"parentId": "a1b2c3d4",
"timestamp": "2024-12-03T14:15:00.000Z",
"fromId": "f6g7h8i9",
"summary": "Branch explored approach A..."
}parentId 是新分支从哪继续,fromId 是被放弃的旧条目。摘要作为一条消息进入新分支的上下文——模型因此知道"另一条路上试出了什么",但看不到那边的完整对话。分支摘要条目没有 firstKeptEntryId:它不替换任何消息,只是在新分支上多挂一条说明。
两种摘要的格式差异
两者共用 Goal、Constraints & Preferences、Progress、Key Decisions、Next Steps 五个小节。压缩摘要多一段 Critical Context,分支摘要到 Next Steps 就结束。文件列表(<read-files> / <modified-files>)追加在末尾。
默认压缩和分支摘要都累积跟踪文件操作。但扩展生成的摘要(fromHook: true)不会自动继承文件列表。
三种分支方式
/tree、/fork、/clone 经常被混用,但它们的产物完全不同。
/tree 的交互细节:选中一条用户消息会把它的文本放回编辑器,你修改并提交后创建另一条分支;选中助手回复或其它条目则在它之后继续。
源码里对应的三个操作:
// 1) 在当前文件内移动当前条目,下一次 append 会创建该条目的子节点
manager.branch(branchFromId: string): void;
// 2) 移动当前条目并追加一条 branch_summary 摘要
manager.branchWithSummary(
branchFromId: string | null,
summary: string,
details?: unknown,
fromHook?: boolean,
usage?: Usage,
): string;
// 3) 把从根到指定条目的路径抽成一个新的会话文件
manager.createBranchedSession(leafId: string): string | undefined;branch() 的注释说得很清楚:"Moves the leaf pointer to the specified entry. The next appendXXX() call will create a child of that entry, forming a new branch. Existing entries are not modified or deleted."
createBranchedSession() 抽路径时会过滤掉 label 条目并重建父子链——因为标签是真实的树条目,后续条目可能是标签的子节点,直接删标签会留下孤儿子树。
SessionManager.forkFrom() 则是跨项目的派生:源会话文件的全部历史复制到目标工作目录下的新会话文件,头部记 parentSession 指向源文件。
恢复入口
/session 会显示当前会话文件、ID、消息数、token 用量和费用——这是排查"上下文为什么这么大"的第一站。
版本变化下的持久化格式阅读方法
会话文件有版本号,当前是 3。历史版本在加载时自动迁移。
源码里的迁移函数是 migrateSessionEntries(entries),它就地修改传入的数组。SessionManager._loadEntries() 在加载后调用它,检测到有迁移发生就重写文件。
拿到一个陌生会话文件时,按这个顺序读:
第一步,看头部确认版本。 第一行的 version 字段告诉你这是哪种结构。version 缺失按 v1 处理。
第二步,用 id/parentId 画树,不要按行序读。 这是最容易出错的地方——JSONL 的行序不等于树序。文件里的条目是追加写的,但树的形状由 parentId 决定。
下面这个脚本可以整体复制运行。它打开一个会话文件、画出整棵树、标出活动分支,结束时释放会话:
import { createAgentSession, SessionManager } from "@earendil-works/pi-coding-agent";
// 自己定义的树节点形状:只保留打印需要的字段
interface TreeNode {
entry: { id: string; type: string };
children: TreeNode[];
label?: string;
}
// 递归打印:缩进表示深度,命中活动分支就打标记
function printNode(node: TreeNode, depth: number, activeIds: Set<string>): void {
const marker = activeIds.has(node.entry.id) ? " ← 活动分支" : "";
const label = node.label ? ` [${node.label}]` : "";
console.log(`${" ".repeat(depth)}${node.entry.type} ${node.entry.id}${label}${marker}`);
for (const child of node.children) {
printNode(child, depth + 1, activeIds);
}
}
const sessionFile = process.argv[2];
if (!sessionFile) {
console.error("用法: tsx inspect-tree.ts <session.jsonl>");
process.exit(1);
}
// open 按路径打开:恢复的上下文就是文件里的活动分支
const { session } = await createAgentSession({
sessionManager: SessionManager.open(sessionFile),
});
try {
// 活动分支:当前叶子回溯到根的那条路径
const activeIds = new Set(session.sessionManager.getBranch().map((e) => e.id));
// getTree() 返回树的防御性拷贝,children 按时间戳排序
for (const root of session.sessionManager.getTree() as TreeNode[]) {
printNode(root, 0, activeIds);
}
} finally {
// 释放:中止在跑的工作、断开 agent 事件订阅、清空监听器
session.dispose();
}第三步,确认活动分支。 当前条目(leaf)标识活动分支,getBranch() 从它回溯到根并按路径顺序返回——这一段才是下一次模型请求看到的历史。
第四步,用投影看模型真正收到什么。 这一步能把"文件里有"和"模型看到"分开:
// 投影:每条目的模型可见消息
const projection = manager.buildSessionProjection();
console.log("模型可见消息数:", projection.messages.length);
console.log("当前条目:", manager.getLeafId());
// 哪些条目被压缩或编辑隐藏了
for (const entry of projection.entries) {
// messages 为空数组 = 该条目不进模型上下文
if (entry.messages.length === 0) {
console.log("不进上下文:", entry.sourceEntry.type, entry.sourceEntry.id);
}
}buildSessionProjection() 返回的每项都带 sourceEntry(原始条目)和 messages(模型可见的部分)。messages 为空数组的条目就是被上下文编辑省略掉的。
第五步,看压缩与分支摘要条目。 这两类条目不在 projection.messages 里以原形出现,而是被转成了摘要消息:
// 压缩与分支摘要:投影里只剩摘要消息,原形在 getEntries() 里
for (const entry of manager.getEntries()) {
// firstKeptEntryId 之前的条目就是被摘要掉的那一段
if (entry.type === "compaction") {
console.log(`压缩于 ${entry.timestamp},压缩前 ${entry.tokensBefore} token,保留区从 ${entry.firstKeptEntryId} 开始`);
}
// fromId 指向被放弃的旧条目
if (entry.type === "branch_summary") {
console.log(`分支摘要,来自 ${entry.fromId},父条目 ${entry.parentId}`);
}
}原始消息条目仍然在 getEntries() 里。要把"这次压缩前的完整对话"找回来,就是过滤出 firstKeptEntryId 之前的那批条目。
三种目标的区别
回到开头那三件事。这是全篇最该记住的一张表:
三点要强调:
第一,压缩不删数据,但模型确实"忘了"。 原始条目还在文件里,但下一轮请求里没有它们。要找回细节,得自己读会话文件。
第二,分支摘要不是压缩。 它不减少上下文体积,只把被放弃分支的结论带进新分支。
第三,/fork 和 /clone 会产生新文件。 这是"切换分支"和"减少上下文"的分野:前者改的是"看哪条路径",后者改的是"路径上带多少细节"。
压缩的阈值配置
配置写在 ~/.pi/agent/settings.json 或项目的 .pi/settings.json:
{
"compaction": {
"enabled": true,
"reserveTokens": 16384,
"keepRecentTokens": 20000,
"modelOverrides": {
"some-provider/big-model": {
"reserveTokens": 400000
}
}
},
"branchSummary": {
"reserveTokens": 16384,
"skipPrompt": false
}
}关掉 compaction.enabled 只停用自动压缩,/compact 手动命令照常可用。
modelOverrides 的键是大小写敏感的完整 provider/modelId,包括模型 ID 里的斜杠。每个值独立回退:模型覆盖 → 普通设置 → 内置默认。enabled 是全局的,不参与模型覆盖。
压缩失败怎么办
压缩要调模型生成摘要,模型不可用就会失败。官方会话文档的处理方式很朴素:"Correct the provider problem and run /compact again."
扩展层有两个事件可以观测:
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
export default function (pi: ExtensionAPI) {
pi.on("session_before_compact", async (event) => {
const { preparation, reason, willRetry } = event;
// reason: "manual"(/compact) | "threshold"(超阈值) | "overflow"(溢出恢复)
// preparation.messagesToSummarize —— 待摘要的消息
// preparation.firstKeptEntryId —— 保留区起点
// preparation.tokensBefore —— 压缩前的上下文 token 数
// preparation.previousSummary —— 上一次的压缩摘要
// willRetry —— 这次中止的轮次之后是否会重试
// 阈值压缩但 token 还不算多,先不压
if (reason === "threshold" && preparation.tokensBefore < 50_000) {
return { cancel: true };
}
return undefined;
});
pi.on("session_compact_failed", async (event) => {
// reason / errorMessage / aborted / willRetry / fromExtension
console.error(`压缩失败:${event.reason}`, event.errorMessage ?? "(已中止)");
});
}session_compact_failed 在手动和自动压缩失败或被中止时都会触发。aborted: true 表示是被取消的,errorMessage 在非中止失败时才有值。
自动压缩失败时不会写 CompactionEntry——会话树保持原样,历史没丢,只是没被压缩。provider 上下文溢出或提前的 stopReason: "length" 会触发一次"压缩并重试"的恢复尝试;恢复压缩失败或被取消时,Pi 保留省略编辑、不追加压缩条目、也不安排内部重试。
谁负责什么
会话文件是 JSONL,官方解析函数 parseSessionEntries() 有容错处理——单行损坏会被跳过,不会让整个文件读不出来。但如果解析出的条目数量为 0 且文件非空,SessionManager 会抛出 Session file is not a valid pi session: <path>(pi 是默认的应用名),不会覆盖原文件。
你现在能做什么
- 打开一个真实的会话文件,用
getTree()打印整棵树,在旁边标出getLeafId()指向的节点,确认活动分支是你以为的那条路径。 - 用
buildSessionProjection()打印projection.messages.length,再对比getEntries().length,两个数字的差值就是"模型看不到但文件里还在"的条目数。 - 用
manager.branch(某个早期条目ID)把当前条目移到过去,然后追加一条消息,确认文件里多出一条分支,而原来的分支一条没少。 - 触发一次压缩(
/compact或把compaction.reserveTokens调小),然后用getEntries()找到那个compaction条目,读它的firstKeptEntryId,再把该 ID 之前的所有条目过滤出来——你会拿到"压缩前的完整对话"。