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

会话树到模型上下文:沿分支和压缩记录重建

拆开从会话文件到模型请求的完整重建链路,说明活动分支如何选消息、压缩摘要如何替换旧消息,以及为什么直接改消息数组不算恢复上下文。

会话树到模型上下文:沿分支和压缩记录重建

一个实践里最常见的误解是"我改了消息数组,所以上下文变了"。改数组和重建上下文是两件事——前者动的是内存里的一份副本,后者是每次发请求前从会话树重新算出来的结果。

这个方向容易被跳过,因为大家关注的是"怎么产生摘要",而不是"摘要怎么变回模型实际收到的那串消息"。但后者才是决定"模型为什么好像忘了"的那一环。

这篇把这条链路拆开:五个阶段、三层函数各管什么、以及一个能自己跑起来的离线重建脚本。

读它需要能读 TypeScript,理解 JSONL 与父子引用构成的树,并且用过至少一个提供"会话历史"概念的框架或库。读完你能预测下一次请求会带什么消息,并能写出一个离线脚本验证自己的猜测。

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

最终要跑通什么

  1. 说得出"模型这次收到哪些消息"这个问题由哪几个函数回答,以及它们的先后顺序。
  2. 写出一个离线脚本,从一个真实的 .jsonl 会话文件打印出模型将看到的消息列表。
  3. 演示一次"改了 session.agent.state.messages 但下次请求没变",并解释原因。
  4. 用 prepareRequest 把外部状态接进每次请求的上下文。

第 2 点是验收点。它不需要连模型,也不需要 API key——会话文件本身就是输入。

第一步:重建链路有五段

模型看到的消息不是从内存里那份 messages 数组直接读出来的。它是每次发请求前重新算出来的。官方会话格式文档把这条链路描述成三步,但真正跑起来有五段:

text
# 从会话文件到模型请求的重建链路:五个阶段各由一个固定函数负责
会话文件 (.jsonl)
  → 解析条目 (parseSessionEntries)        每行一个 JSON,坏行跳过
  → 取活动分支 (getBranch)                从当前叶子沿 parentId 回溯到根
  → 应用压缩 (buildContextEntries)         摘要替换更早的条目
  → 应用编辑 (buildSessionProjection)      context_edit 覆盖或省略目标
  → 转成模型消息 (buildSessionContext)     条目类型决定产出什么消息

三段核心函数对应的职责:

函数回答的问题输入输出
buildContextEntries()
哪些条目参与上下文
全部条目 + 当前叶子
考虑压缩后的条目列表
buildSessionProjection()
这些条目最终长什么样
上一层的条目列表
每条目的模型可见消息 + 来源条目
buildSessionContext()
模型这次收到什么
全部条目 + 当前叶子
{ messages, thinkingLevel, model }

这三段函数的叶子参数是可选的。省略时,它退回到数组的最后一个条目作为叶子。SessionManager 的同名方法会传入自己跟踪的真实叶子,所以日常用 manager 就够了;直接调导出函数做离线检查时,要意识到这个默认值——文件里最后写入的条目不一定是当前活动分支的末端。

buildSessionProjection() 的返回值叫"投影"。它不修改原始条目——原始历史、UI 显示、导出、计费全都看原始条目,投影只影响模型上下文这一层。这是"编辑历史但不篡改记录"的基础。

buildSessionContext() 是最外层的包装:它内部调 buildSessionProjection(),再额外从整条路径上取出当前模型和思考级别。所以模型和思考级别来自路径,不是来自活动分支的可见部分——被压缩掉的段落里如果有 model_change 条目,那个设置依然生效。

第二步:活动分支决定投喂哪些消息

会话文件是一棵树。哪个条目是"当前叶子",决定了哪条路径被选中。

getBranch() 的逻辑很简单:从 leafId 开始,沿 parentId 一路回溯到根,把收集到的条目反转,得到一条从根到叶的路径。这条路径就是活动分支。

typescript
// branch.ts
import { SessionManager } from "@earendil-works/pi-coding-agent";

const manager = SessionManager.open("~/.pi/agent/sessions/--srv-my-project--/2024-12-03T14-00-00_abc123.jsonl");

// 活动分支:从根到当前叶子
for (const entry of manager.getBranch()) {
	console.log(entry.type, entry.id, entry.parentId);
}

// 全部条目:包含被放弃的分支
console.log("全部条目数:", manager.getEntries().length);
console.log("活动分支条目数:", manager.getBranch().length);

两个数字通常不一样。差值就是"你放弃的那些路"。它们还在文件里,但模型看不到。

一个容易记错的细节:getBranch() 默认不带参数,用的是当前叶子。传 fromId 可以取任意条目的路径——context_edit 的合法性检查就用这个:目标条目不在当前分支上时,直接抛错。

第三步:压缩摘要在重建时替换旧消息

压缩不是删除旧消息,是往树上追加一个 compaction 条目。真正的替换发生在重建时。

buildContextEntries() 的处理顺序(源码事实):

  1. 沿路径收集所有条目。
  2. 如果路径上有压缩条目,取最新的那一个。
  3. 输出顺序是:压缩条目本身 → 从 firstKeptEntryId 到压缩条目之前(不含)的非系统条目 → 压缩条目之后的全部条目。

第 3 步里"非系统条目"这个限定值得注意。压缩条目自带一个完整的系统检查点(systemMessage),保留区里的系统消息会被折叠进去而不是重复出现。

保留区之前那些被摘要掉的条目,在这一步就被排除在外了。它们没有被删——getEntries() 照样返回它们——只是不再出现在模型上下文里。

firstKeptEntryId 标的就是保留区的起点。如果一次压缩决定一条都不保留("retain-none"),这个字段会填压缩条目自己的 ID,于是下次重建时保留区为空。

落到模型消息这一步,各条目类型的产出是这样的:

条目类型产出的模型消息
message
存储的 AgentMessage 本身
compaction
完整的系统检查点(如果有)+ 压缩摘要消息
branch_summary
分支摘要消息
custom_message
自定义消息
context_edit
不产生自己的消息(它是覆盖指令)
usage / custom
不产生消息

最后两行解释了"扩展状态为什么不进上下文":pi.appendEntry() 写进去的 custom 条目是给扩展自己存状态的,模型永远看不到。

第四步:直接改消息数组不被当作持久化恢复

这是本篇的核心,也是实践里最容易踩的坑。

官方 SDK 文档的原话:

SessionManager is authoritative for finalized model context. Restore external history by constructing the session with a manager containing those entries. Assigning session.agent.state.messages does not replace persisted context.

翻译过来:直接给 session.agent.state.messages 赋值,不会替换持久化的上下文。

Agent Core 文档补了一条技术细节:赋值时会先复制顶层数组再存储。所以赋值这个动作本身是合法的,agent.state.messages 确实会变成你给的那份。但这只是 Agent 内部状态,和持久化上下文从此分叉。

下一次请求时会发生什么?Pi 在 AgentSession 里装了一个投影钩子:每次发 provider 请求前,把请求上下文的消息替换成 sessionManager.buildSessionProjection().messages。你改的那份数组被投影覆盖掉了。

所以正确的做法是构造一个带有你想要的条目的 manager:

typescript
// restore.ts
import { createAgentSession, SessionManager, type FileEntry } from "@earendil-works/pi-coding-agent";

// 外部历史条目
const externalEntries: FileEntry[] = [
	{
		// 会话头:type 为 session 的那条不参与树结构
		type: "session",
		version: 3,
		id: "restored-session",
		timestamp: new Date().toISOString(),
		cwd: process.cwd(),
	},
	{
		// 根条目:parentId 为 null,下一个条目要挂在它下面
		type: "message",
		id: "m0000001",
		parentId: null,
		timestamp: new Date().toISOString(),
		message: { role: "user", content: "继续修复类型错误", timestamp: Date.now() },
	},
];

const { session } = await createAgentSession({
	// 第三个参数是初始条目数组
	sessionManager: SessionManager.inMemory(process.cwd(), undefined, externalEntries),
});

try {
	await session.prompt("从刚才的进度继续");
	console.log(session.getLastAssistantText());
} finally {
	session.dispose();
}

SessionManager.inMemory() 的第三个参数就是初始条目数组。这是"恢复外部历史"的官方途径。

反例放在一起对比更清楚:

typescript
// anti-pattern.ts
import { createAgentSession, SessionManager } from "@earendil-works/pi-coding-agent";

const { session } = await createAgentSession({
	sessionManager: SessionManager.inMemory(),
});

try {
	// 反例:改了 agent 内部状态,但持久化上下文没变
	session.agent.state.messages = [
		{ role: "user", content: "这条消息不会进入持久化上下文", timestamp: Date.now() },
	];

	// 投影出来的权威上下文里没有它:两份状态从此分叉
	console.log("投影消息数:", session.sessionManager.buildSessionProjection().messages.length);
	console.log("agent 状态消息数:", session.agent.state.messages.length);
} finally {
	session.dispose();
}

两个数字不一样,就说明状态和权威已经分叉了。要真正注入历史,用上面的 appendMessage 或构造 manager 条目。

第五步:用 prepareRequest 重建权威上下文

理解了投影之后,第三个钩子的定位就清楚了。

transformContext 和 convertToLlm 在 Agent Core 内部工作,处理的是已经被投影响后的消息数组。而 prepareRequest 的位置更靠后:它在每次对话型 provider 请求之前运行,包括第一次。它拿到的是完整的请求上下文,可以整体替换。

Agent Core 文档给的形态(文档里的 loadModelContext() 是示意写法,coding-agent SDK 里对应的实际操作是 sessionManager.buildSessionProjection()):

typescript
// Agent Core 文档给的形态。文档里的 loadModelContext() 是示意写法,
// coding-agent SDK 里对应的实际操作是 sessionManager.buildSessionProjection()
agent.prepareRequest = async ({ context }) => ({
	context: { ...context, messages: await session.loadModelContext() },
});

注意文档那句提醒:prepareRequest 不轮询队列。运行期间排进来的 steering 消息要等下一次正常的 steering 轮询才会被处理。

在 coding-agent 里,AgentSession 自己已经装了一个 prepareRequest,它做的正是"用投影替换消息"。所以你要加东西,应该在它的基础上叠加,而不是覆盖它:

typescript
// prepare-request.ts
import { createAgentSession, SessionManager, type AgentMessage } from "@earendil-works/pi-coding-agent";

const { session } = await createAgentSession({
	sessionManager: SessionManager.inMemory(),
});

// 保留原有的 prepareRequest,先跑它再叠加我们的逻辑
const previousPrepareRequest = session.agent.prepareRequest;

session.agent.prepareRequest = async (request, signal) => {
	const result = await previousPrepareRequest?.(request, signal);
	const context = result?.context ?? request.context;

	// 权威上下文:会话投影 + 当前的外部状态
	// 用投影而不是 session.messages
	const projection = session.sessionManager.buildSessionProjection();
	const projectState = await loadProjectState(signal);

	const messages: AgentMessage[] = [
		...projection.messages,
		{
			// 用 XML 标签包住注入内容,让模型能分辨它不是用户说的
			role: "user",
			content: `<project-state>\n${projectState}\n</project-state>`,
			timestamp: Date.now(),
		},
	];

	// 必须返回 { context }:返回 undefined 表示保持原上下文不变
	return { ...result, context: { ...context, messages } };
};

async function loadProjectState(signal?: AbortSignal): Promise<string> {
	// 实际项目里这里读数据库、读配置文件或调用内部 API
	return "分支: feature/fix-types\n已修改文件: src/types.ts";
}

try {
	await session.prompt("继续修复类型错误");
} finally {
	session.dispose();
}

这段代码有两个要点。

第一,previousPrepareRequest 不能省。 少了它,你的实现会顶掉 Pi 自己的投影钩子,请求上下文里就没有权威消息了。

第二,用投影而不是 session.messages。 session.messages 返回的是 agent.state.messages,那是投影的产物加上你可能改过的东西。sessionManager.buildSessionProjection() 才是权威。

离线重建脚本

上面这些函数都不需要模型。把它们拼起来就是一个可以反复跑的检查工具:

typescript
// inspect-context.ts
import { readFileSync } from "node:fs";
import {
	buildContextEntries,
	buildSessionProjection,
	migrateSessionEntries,
	parseSessionEntries,
	type FileEntry,
	type SessionEntry,
} from "@earendil-works/pi-coding-agent";

const path = process.argv[2];
if (!path) {
	console.error("用法: tsx inspect-context.ts <session.jsonl>");
	process.exit(1);
}

// 1. 解析:每行一个 JSON,坏行被跳过
const entries: FileEntry[] = parseSessionEntries(readFileSync(path, "utf-8"));

// 2. 迁移:把 v1 / v2 的旧会话升级到当前版本(就地修改)
migrateSessionEntries(entries);

// 3. 全部条目(含被放弃的分支)。会话头不参与树结构,要先滤掉。
const allEntries = entries.filter((e): e is SessionEntry => e.type !== "session");
console.log(`文件里的条目总数:${allEntries.length}`);

// 4. 考虑压缩后的上下文条目
// 注意:直接调导出函数时叶子参数是可选的,默认取数组最后一个条目
const contextEntries = buildContextEntries(allEntries);
console.log(`参与上下文的条目数:${contextEntries.length}`);

// 5. 投影:应用 context_edit 之后的模型可见消息
const projection = buildSessionProjection(allEntries);
console.log(`模型可见消息数:${projection.messages.length}`);

for (const message of projection.messages) {
	const role = message.role;
	const text = "content" in message ? JSON.stringify(message.content).slice(0, 80) : "";
	console.log(`- ${role}: ${text}`);
}

// 6. 压缩条目单独看一眼
for (const entry of contextEntries) {
	if (entry.type === "compaction") {
		console.log(`压缩摘要保留起点:${entry.firstKeptEntryId}`);
		console.log(`压缩前 token 数:${entry.tokensBefore}`);
	}
}

跑法:

bash
# 用 tsx 直接跑这个离线脚本
npx tsx inspect-context.ts ~/.pi/agent/sessions/--srv-my-project--/2024-12-03T14-00-00_abc123.jsonl

这个脚本的价值在于离线。压缩是否生效、分支是否选对、摘要是否覆盖了你以为保留的消息——这些问题都能在不发一次请求的情况下回答。

有一个坑要注意:buildContextEntries(allEntries) 和 buildSessionProjection(allEntries) 直接调导出函数时,叶子参数是可选的,默认取数组最后一个条目。而 manager.getLeafId() 跟踪的才是真正的活动分支末端。想要精确对齐,用 manager 的同名方法而不是导出函数。

谁负责什么

职责由谁承担说明
解析会话文件、跳过坏行
Pi
parseSessionEntries()
决定哪条路径是活动分支
Pi
getBranch(),基于当前叶子
应用压缩与 context_edit
Pi
buildContextEntries() / buildSessionProjection()
产出模型消息
Pi
buildSessionContext()
恢复外部历史
宿主
构造带条目的 SessionManager
请求前注入外部状态
宿主
叠加 prepareRequest,保留原有钩子
上下文体积的最终约束
宿主
模型窗口是硬边界

你现在能做什么

  • 把上面那个离线脚本跑在一个真实的会话文件上,对比它打印的"模型可见消息数"和你在 Pi 界面里看到的消息条数是否一致。
  • 在脚本里把 buildContextEntries(allEntries) 换成 manager.buildContextEntries()(用 manager 而不是导出函数),看两者的条目数是否不同——如果不同,说明你那个文件的最后写入条目不是活动分支末端。
  • 跑那个反例,打印投影消息数和 agent 状态消息数,确认两个数字不同,把"赋值不生效"这件事变成可观察的。
  • 用 prepareRequest 叠加一段外部状态(当前分支名、最近改过的文件),跑一个多轮任务,确认每次请求都带着这段状态——这比把它写进上下文文件更动态。

排障速查

现象检查点处理
改了 agent.state.messages 没效果
是否直接赋值
SessionManager 是权威。构造带条目的 manager,或用 sessionManager.appendMessage()
模型看到了不想要的旧消息
是否在活动分支上
用 getBranch() 和 getEntries() 对比条数,差值是放弃的分支
压缩后模型忘了任务
firstKeptEntryId
保留区起点太靠前会让关键消息被摘要。用离线脚本打印投影结果
压缩后原始消息不见了
是否误以为会删条目
压缩只写摘要条目,原始条目仍在文件里,getEntries() 照样返回
prepareRequest 里注入的内容没进请求
返回值形态
必须返回 { context };返回 undefined 表示保持不变
注入的内容让请求体积暴涨
是否重复注入
prepareRequest 每次请求都跑,检查是否每轮都追加同样的内容
steering 消息没被立刻处理
prepareRequest 特性
它不轮询队列;steering 要等下一次正常轮询
恢复后模型不记得上次进度
构造 manager 的方式
条目要带正确的 parentId 链,否则活动分支选不到
覆盖 prepareRequest 后模型失忆
是否保留了原钩子
少了 previousPrepareRequest 会顶掉 Pi 的投影钩子

参考资料