会话树到模型上下文:沿分支和压缩记录重建
拆开从会话文件到模型请求的完整重建链路,说明活动分支如何选消息、压缩摘要如何替换旧消息,以及为什么直接改消息数组不算恢复上下文。
会话树到模型上下文:沿分支和压缩记录重建
一个实践里最常见的误解是"我改了消息数组,所以上下文变了"。改数组和重建上下文是两件事——前者动的是内存里的一份副本,后者是每次发请求前从会话树重新算出来的结果。
这个方向容易被跳过,因为大家关注的是"怎么产生摘要",而不是"摘要怎么变回模型实际收到的那串消息"。但后者才是决定"模型为什么好像忘了"的那一环。
这篇把这条链路拆开:五个阶段、三层函数各管什么、以及一个能自己跑起来的离线重建脚本。
读它需要能读 TypeScript,理解 JSONL 与父子引用构成的树,并且用过至少一个提供"会话历史"概念的框架或库。读完你能预测下一次请求会带什么消息,并能写出一个离线脚本验证自己的猜测。
Pi 迭代很快,接口可能变化。动手前先记录 pi --version,并按你安装的版本复核参数。
最终要跑通什么
- 说得出"模型这次收到哪些消息"这个问题由哪几个函数回答,以及它们的先后顺序。
- 写出一个离线脚本,从一个真实的
.jsonl会话文件打印出模型将看到的消息列表。 - 演示一次"改了
session.agent.state.messages但下次请求没变",并解释原因。 - 用
prepareRequest把外部状态接进每次请求的上下文。
第 2 点是验收点。它不需要连模型,也不需要 API key——会话文件本身就是输入。
第一步:重建链路有五段
模型看到的消息不是从内存里那份 messages 数组直接读出来的。它是每次发请求前重新算出来的。官方会话格式文档把这条链路描述成三步,但真正跑起来有五段:
# 从会话文件到模型请求的重建链路:五个阶段各由一个固定函数负责
会话文件 (.jsonl)
→ 解析条目 (parseSessionEntries) 每行一个 JSON,坏行跳过
→ 取活动分支 (getBranch) 从当前叶子沿 parentId 回溯到根
→ 应用压缩 (buildContextEntries) 摘要替换更早的条目
→ 应用编辑 (buildSessionProjection) context_edit 覆盖或省略目标
→ 转成模型消息 (buildSessionContext) 条目类型决定产出什么消息三段核心函数对应的职责:
这三段函数的叶子参数是可选的。省略时,它退回到数组的最后一个条目作为叶子。SessionManager 的同名方法会传入自己跟踪的真实叶子,所以日常用 manager 就够了;直接调导出函数做离线检查时,要意识到这个默认值——文件里最后写入的条目不一定是当前活动分支的末端。
buildSessionProjection() 的返回值叫"投影"。它不修改原始条目——原始历史、UI 显示、导出、计费全都看原始条目,投影只影响模型上下文这一层。这是"编辑历史但不篡改记录"的基础。
buildSessionContext() 是最外层的包装:它内部调 buildSessionProjection(),再额外从整条路径上取出当前模型和思考级别。所以模型和思考级别来自路径,不是来自活动分支的可见部分——被压缩掉的段落里如果有 model_change 条目,那个设置依然生效。
第二步:活动分支决定投喂哪些消息
会话文件是一棵树。哪个条目是"当前叶子",决定了哪条路径被选中。
getBranch() 的逻辑很简单:从 leafId 开始,沿 parentId 一路回溯到根,把收集到的条目反转,得到一条从根到叶的路径。这条路径就是活动分支。
// 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() 的处理顺序(源码事实):
- 沿路径收集所有条目。
- 如果路径上有压缩条目,取最新的那一个。
- 输出顺序是:压缩条目本身 → 从
firstKeptEntryId到压缩条目之前(不含)的非系统条目 → 压缩条目之后的全部条目。
第 3 步里"非系统条目"这个限定值得注意。压缩条目自带一个完整的系统检查点(systemMessage),保留区里的系统消息会被折叠进去而不是重复出现。
保留区之前那些被摘要掉的条目,在这一步就被排除在外了。它们没有被删——getEntries() 照样返回它们——只是不再出现在模型上下文里。
firstKeptEntryId 标的就是保留区的起点。如果一次压缩决定一条都不保留("retain-none"),这个字段会填压缩条目自己的 ID,于是下次重建时保留区为空。
落到模型消息这一步,各条目类型的产出是这样的:
最后两行解释了"扩展状态为什么不进上下文":pi.appendEntry() 写进去的 custom 条目是给扩展自己存状态的,模型永远看不到。
第四步:直接改消息数组不被当作持久化恢复
这是本篇的核心,也是实践里最容易踩的坑。
官方 SDK 文档的原话:
SessionManageris authoritative for finalized model context. Restore external history by constructing the session with a manager containing those entries. Assigningsession.agent.state.messagesdoes not replace persisted context.
翻译过来:直接给 session.agent.state.messages 赋值,不会替换持久化的上下文。
Agent Core 文档补了一条技术细节:赋值时会先复制顶层数组再存储。所以赋值这个动作本身是合法的,agent.state.messages 确实会变成你给的那份。但这只是 Agent 内部状态,和持久化上下文从此分叉。
下一次请求时会发生什么?Pi 在 AgentSession 里装了一个投影钩子:每次发 provider 请求前,把请求上下文的消息替换成 sessionManager.buildSessionProjection().messages。你改的那份数组被投影覆盖掉了。
所以正确的做法是构造一个带有你想要的条目的 manager:
// 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() 的第三个参数就是初始条目数组。这是"恢复外部历史"的官方途径。
反例放在一起对比更清楚:
// 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()):
// 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,它做的正是"用投影替换消息"。所以你要加东西,应该在它的基础上叠加,而不是覆盖它:
// 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() 才是权威。
离线重建脚本
上面这些函数都不需要模型。把它们拼起来就是一个可以反复跑的检查工具:
// 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}`);
}
}跑法:
# 用 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 界面里看到的消息条数是否一致。
- 在脚本里把
buildContextEntries(allEntries)换成manager.buildContextEntries()(用 manager 而不是导出函数),看两者的条目数是否不同——如果不同,说明你那个文件的最后写入条目不是活动分支末端。 - 跑那个反例,打印投影消息数和 agent 状态消息数,确认两个数字不同,把"赋值不生效"这件事变成可观察的。
- 用
prepareRequest叠加一段外部状态(当前分支名、最近改过的文件),跑一个多轮任务,确认每次请求都带着这段状态——这比把它写进上下文文件更动态。