跳到主内容
创建时间:最后更新:作者:白川

会话树与压缩:上下文如何重建

拆开会话条目的树结构,说明活动分支如何决定模型上下文,分支摘要与压缩摘要各自解决什么问题,以及版本变化下持久化格式的阅读方法。

会话树与压缩:上下文如何重建

同一个仓库、同一个任务,你可能走过三条路:

  • 保留历史——任务做了三小时,五十轮对话,你不想丢任何一步。
  • 切换分支——方案 A 试到一半,想回到方案 B 的起点重来。
  • 减少上下文——历史太长,模型上下文塞不下了。

这三件事在 Pi 里是三套不同的机制,共用同一个存储结构。把它们混为一谈,就会出现"我用 /compact 压缩了,结果分支也没了"这类事故。

这篇讲清会话树怎么构成、活动分支怎么决定模型看到什么、两种摘要分别在什么时候出现,以及拿到一个陌生版本的会话文件时该怎么读。

读它需要能读 JSONL 文件、知道父子引用构成的树是什么,并且用过至少一个带分支的版本控制工具(Git 分支、SVN 分支之类)。读完你能打印一棵会话树、定位活动分支,并区分两种摘要各自的用途。

Pi 迭代很快,接口可能变化。动手前先记录 pi --version,并按你安装的版本复核参数。

最终要跑通什么

  1. 打印一个会话的完整树,画出当前的活动分支。
  2. 制造一次分支,确认离开的分支仍在文件里,模型上下文只走新分支。
  3. 触发一次压缩,确认摘要条目写进了树,而原始条目还在。

会话是一棵树

持久会话是一个 JSONL 文件,每行一个 JSON 对象。第一行是会话头,只有元数据,不参与树结构:

json
{"type":"session","version":3,"id":"uuid","timestamp":"2024-12-03T14:00:00.000Z","cwd":"/path/to/project"}

其余每行都是一个条目,都继承同一组基础字段:

typescript
// 会话条目的公共字段:树结构完全由 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."

text
# 会话树的形状
[user msg] ─── [assistant] ─── [user msg] ─── [assistant] ─┬─ [user msg] ← 当前条目(活动分支)
                                                            │
                                                            └─ [branch_summary] ─── [user msg] ← 另一条分支

关键点:树不是只能有一个分支。 从任意早期条目继续,会长出新的一条路径,旧路径原封不动留在文件里。

上下文重建的三步

模型上下文不是直接读文件,而是走一条三步流水线:

步骤函数做什么
一
buildContextEntries()
从当前条目沿 parentId 回溯到根,得到活动分支;若路径上有压缩条目,用最新的那个替换更早的消息
二
buildSessionProjection()
在上一步结果上应用 context_edit:最新一次针对某条目的编辑生效
三
buildSessionContext()
把选中的条目转成模型消息,并从完整路径里取出当前 model 与 thinking level

第三步里,条目类型对应不同的产出: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"——导航回编辑发生之前,被省略的条目会重新出现。

两种摘要,两种用途

这是本篇最重要的区分。

机制触发时机解决的问题条目类型
压缩(compaction)
上下文超阈值,或手动 /compact
历史太长,塞不进模型上下文
compaction
分支摘要(branch summary)
用 /tree 切换分支时
离开的分支上有值得保留的结论
branch_summary

两者都用结构化摘要格式,都跟踪文件操作,但服务的目标完全不同。

压缩摘要

压缩的触发条件是一个不等式:

text
# 自动压缩的触发条件:上下文 token 数超过窗口减去预留量
contextTokens > contextWindow - reserveTokens

reserveTokens 默认 16384,keepRecentTokens 默认 20000。流程是:向前回溯累积 token 估算直到 keepRecentTokens → 收集待摘要的消息 → 调 LLM 生成摘要 → 追加 CompactionEntry → 之后重建上下文时用摘要替换更早的消息。

json
{
  "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。

json
{
  "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 个
相关方案应该放在一起
/fork
从某个早期用户消息创建新会话
2 个
这个方案要变成独立的工作
/clone
把活动分支复制成新会话
2 个
想要当前状态的一份独立副本

/tree 的交互细节:选中一条用户消息会把它的文本放回编辑器,你修改并提交后创建另一条分支;选中助手回复或其它条目则在它之后继续。

源码里对应的三个操作:

typescript
// 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 指向源文件。

恢复入口

入口命令 / API行为
继续最近
--continue / SessionManager.continueRecent(cwd)
打开当前工作目录最近的会话
会话选择器
--resume / /resume
交互式挑选
新会话
/new
开始新会话
指定会话
--session <path|id>
按路径、完整 ID 或部分 ID 打开
派生
--fork <path|id> / SessionManager.forkFrom(...)
从已有会话派生

/session 会显示当前会话文件、ID、消息数、token 用量和费用——这是排查"上下文为什么这么大"的第一站。

版本变化下的持久化格式阅读方法

会话文件有版本号,当前是 3。历史版本在加载时自动迁移。

版本结构迁移动作
1
线性条目序列(legacy)
补 id/parentId;压缩条目的 firstKeptEntryIndex 改写为 firstKeptEntryId
2
引入 id/parentId 树结构
hookMessage 角色改名为 custom
3
当前版本
—

源码里的迁移函数是 migrateSessionEntries(entries),它就地修改传入的数组。SessionManager._loadEntries() 在加载后调用它,检测到有迁移发生就重写文件。

拿到一个陌生会话文件时,按这个顺序读:

第一步,看头部确认版本。 第一行的 version 字段告诉你这是哪种结构。version 缺失按 v1 处理。

第二步,用 id/parentId 画树,不要按行序读。 这是最容易出错的地方——JSONL 的行序不等于树序。文件里的条目是追加写的,但树的形状由 parentId 决定。

下面这个脚本可以整体复制运行。它打开一个会话文件、画出整棵树、标出活动分支,结束时释放会话:

typescript
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() 从它回溯到根并按路径顺序返回——这一段才是下一次模型请求看到的历史。

第四步,用投影看模型真正收到什么。 这一步能把"文件里有"和"模型看到"分开:

typescript
// 投影:每条目的模型可见消息
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 里以原形出现,而是被转成了摘要消息:

typescript
// 压缩与分支摘要:投影里只剩摘要消息,原形在 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 之前的那批条目。

三种目标的区别

回到开头那三件事。这是全篇最该记住的一张表:

保留历史切换分支减少上下文
目标
不丢任何一步
回到某个点试另一条路
让模型塞得下
机制
会话树天然保留所有条目
/tree(同文件)/ /fork、/clone(新文件)
/compact 或自动压缩
产物条目
无
branch_summary(可选)
compaction
原始条目是否还在
一直在
一直在(旧分支不删)
一直在(只是不进上下文)
模型看到什么
活动分支全部
新分支 + 可能的分支摘要
摘要 + 保留区
对文件的影响
无
/fork、/clone 会新增文件
只追加一条条目
可逆性
—
/tree 切回即可
不可逆(模型已不记得细节)
典型场景
审计、复盘
方案对比
长任务上下文溢出

三点要强调:

第一,压缩不删数据,但模型确实"忘了"。 原始条目还在文件里,但下一轮请求里没有它们。要找回细节,得自己读会话文件。

第二,分支摘要不是压缩。 它不减少上下文体积,只把被放弃分支的结论带进新分支。

第三,/fork 和 /clone 会产生新文件。 这是"切换分支"和"减少上下文"的分野:前者改的是"看哪条路径",后者改的是"路径上带多少细节"。

压缩的阈值配置

配置写在 ~/.pi/agent/settings.json 或项目的 .pi/settings.json:

json
{
  "compaction": {
    "enabled": true,
    "reserveTokens": 16384,
    "keepRecentTokens": 20000,
    "modelOverrides": {
      "some-provider/big-model": {
        "reserveTokens": 400000
      }
    }
  },
  "branchSummary": {
    "reserveTokens": 16384,
    "skipPrompt": false
  }
}
配置项默认作用
compaction.enabled
true
自动压缩开关
compaction.reserveTokens
16384
上下文剩余多少 token 时开始压缩
compaction.keepRecentTokens
20000
保留多少近期 token 不摘要
compaction.modelOverrides
无
按 provider/modelId 覆盖 token 预算
branchSummary.reserveTokens
16384
分支摘要的 token 预算
branchSummary.skipPrompt
false
跳过分支摘要提示

关掉 compaction.enabled 只停用自动压缩,/compact 手动命令照常可用。

modelOverrides 的键是大小写敏感的完整 provider/modelId,包括模型 ID 里的斜杠。每个值独立回退:模型覆盖 → 普通设置 → 内置默认。enabled 是全局的,不参与模型覆盖。

压缩失败怎么办

压缩要调模型生成摘要,模型不可用就会失败。官方会话文档的处理方式很朴素:"Correct the provider problem and run /compact again."

扩展层有两个事件可以观测:

typescript
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 保留省略编辑、不追加压缩条目、也不安排内部重试。

谁负责什么

职责由谁承担说明
维护条目树与当前条目
Pi
SessionManager 是权威来源
决定模型看到哪条分支
Pi
当前条目(leaf)确定活动分支
应用压缩与上下文编辑
Pi
buildContextEntries() → buildSessionProjection()
生成摘要文本
模型 + Pi
Pi 负责结构化格式与切点
决定保留什么内容
宿主
通过 session_before_compact 提供自定义摘要
阈值配置
宿主
compaction.* / branchSummary.* 设置
压缩失败的兜底
宿主
修好 provider 后重试,或改用 transformContext 手动裁剪

会话文件是 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 之前的所有条目过滤出来——你会拿到"压缩前的完整对话"。

排障速查

现象检查点处理
读文件时以为顺序就是对话顺序
JSONL 行序 vs 树序
行序是追加顺序,对话顺序看 parentId 链;用 getTree()
压缩后原始消息不见了
是否误以为压缩会删条目
压缩只写摘要条目,原始条目仍在;用 getEntries() 或 firstKeptEntryId 定位
模型"忘了"之前定过的约束
摘要里有没有保留
压缩摘要含 Constraints & Preferences 与 Critical Context;必要时用 session_before_compact 自定义摘要
/compact 报错
provider 是否可用
修好 provider 再重试;失败不会写入压缩条目,历史不丢
关掉自动压缩后 /compact 也没了
误解了开关语义
compaction.enabled: false 只停自动压缩,手动命令照常
大上下文模型太早压缩
reserveTokens 对所有模型一样
用 compaction.modelOverrides 按 provider/modelId 单独调
分支摘要没生成
是否选了"不生成摘要"
branchSummary.skipPrompt 默认 false;/tree 导航时会询问
/fork 之后文件变多了
预期行为
/fork、/clone 会创建新会话文件;/tree 不会
派生出来的会话缺了标签
createBranchedSession 的过滤
抽路径时会过滤 label 条目并重建父子链

参考资料