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

会话存储:保存、恢复与分支

用 JSONL 会话树保存可恢复的运行记录,设计会话标识、恢复入口与损坏记录的处理策略。

会话存储:保存、恢复与分支

跑到一半的智能体会话要不要能接着跑?能的话,存什么、存在哪、怎么找回来、文件坏了怎么办,这四件事必须一次定清楚。

Pi 的会话存储格式是 JSONL——每行一个 JSON 对象,所有行组成一棵树。这篇讲清这棵树的结构,然后把它变成你能依赖的恢复入口。

想象这样一个场景:用户今天让 Pi 修了一半类型错误,第二天回来想接着修。会话文件就是那个"接着修"的数据源。

读它需要能读 JSON、知道文件系统基本概念,并且用过至少一个 JSONL 格式的日志或记录文件。读完你能找到 Pi 为当前目录建的会话文件、从中恢复上下文、给损坏行设计兜底策略。

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

最终要跑通什么

  1. 找到 Pi 为当前工作目录创建的会话文件,读出它的树结构。
  2. 用 SessionManager 打开一个会话,确认上下文能被正确重建。
  3. 制造一个损坏行,确认解析不会整体崩溃。
  4. 从已有会话派生一个新会话,两个文件互不影响。

第一步:会话是什么

一个持久会话是一个 JSONL 文件,每个会话目录下按工作目录分组。默认位置是 ~/.pi/agent/sessions/--<path>--/<timestamp>_<session-id>.jsonl,其中 <path> 是工作目录去掉开头分隔符、再把 /、\、: 换成 - 的结果。

文件里每行的类型由 type 字段区分。文件第一行永远是会话头,它只有元数据,不参与树结构:

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

这行里的 version 是格式版本(当前是 3,历史版本加载时自动迁移),id 是会话标识,cwd 是创建会话时的工作目录——它决定这个会话归到哪个分组目录,也决定恢复时按哪份工作目录的上下文文件装配。type 之外的字段都不参与模型上下文。

树结构由其余条目的 id 和 parentId 两个字段表达:

typescript
// 会话条目的公共字段:所有非会话头的条目都继承这一组
interface SessionEntryBase {
  type: string;
  id: string;           // 通常是 8 字符十六进制 ID,可能退化为完整 UUID
  parentId: string | null;  // 父条目 ID,根条目为 null
  timestamp: string;    // ISO 时间戳
}

id 与 parentId 一起构成树:parentId 为 null 的是根,当前条目(leaf)标识活动分支——从 leaf 沿 parentId 回溯到根的那条路径,就是下一次模型请求看到的历史。

会话文件有版本号,当前是 3。历史版本会自动迁移:v1 是线性条目序列(legacy),v2 引入树结构,v3 把 hookMessage 角色改名为 custom。

第二步:条目类型

会话文件里不只有对话消息。搞清楚每种条目"参不参与模型上下文"很关键:

条目类型作用进入模型上下文计入 token 与费用
message
对话消息(user / assistant / toolResult / system)
是
视消息而定
compaction
压缩摘要 + 保留区起点
是,替代更早的消息
摘要的 usage 计入
branch_summary
离开分支时生成的摘要
是
usage 计入
custom_message
扩展注入的消息
是
是
context_edit
追加式修改某个早期条目的上下文贡献
本身不产生消息
否
model_change / thinking_level_change
切换模型或思考级别
否
否
usage
非助手消息的用量记录(例如缓存预热)
否
是
custom / label / session_info
扩展状态、书签、会话显示名
否
否

有一条规则值得单独记住:系统提示和工具声明不单独存一份状态。第一个请求会持久化一条带完整提示段和工具声明的 system 消息,之后的变更以 system 消息记录,按名字 patch sections(null 表示删除某段),并列出 toolsAdded / toolsRemoved。顺序重放这些消息就能得到当前的提示与工具状态。

usage 条目有个细节:官方要求消费者把未知的 kind 值当作普通用量处理,而不是拒绝它。

第三步:会话目录与优先级

会话存放位置的三个配置入口,官方文档明确了优先级:

入口形式优先级
--session-dir
CLI 选项
最高
PI_CODING_AGENT_SESSION_DIR
环境变量
中
sessionDir
配置文件项
最低

CLI 文档的原话是:--session-dir 覆盖存储与查找,它优先于 PI_CODING_AGENT_SESSION_DIR 和 sessionDir 设置。

有个例外值得记住:sessionDir 是在解析项目信任之前读取的。官方安全文档说明了原因——Pi 需要先知道去哪里找会话,才能决定加载哪个会话。项目信任控制的是"要不要加载这个目录提供的设置和资源",但它拦不住这一次目录查找。同样地,--session-dir 指定的目录不按工作目录分组。

第四步:四种会话入口

一次运行要用哪种会话,取决于它是一次性的还是要能恢复的:

入口命令 / API行为是否可恢复
临时运行
--no-session
用内存会话,不落盘
否,退出后无法恢复
继续最近
--continue / SessionManager.continueRecent(cwd)
打开当前工作目录最近的会话
是
指定会话
--session <path|id>
按文件路径、完整 ID 或部分 ID 打开;先搜当前项目,跨项目匹配时提示 fork
是
派生新会话
--fork <path|id> / SessionManager.forkFrom(...)
从已有会话派生一个属于当前项目的新会话
是,两个文件互不影响

CLI 还有几条约束:--fork 不能和 --session、--continue、--resume、--no-session 组合;--session-id 不能和 --session、--continue、--resume 组合,但可以和 --fork 一起指定新 ID。会话 ID 必须以字母或数字开头结尾,只允许字母、数字、.、_、-。

代码路径和 CLI 一一对应:

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

const cwd = process.cwd();

// 临时运行:不落盘
const { session: ephemeral } = await createAgentSession({
	sessionManager: SessionManager.inMemory(),
});
// sessionFile 对内存会话是 undefined,所以这里要兜底
console.log("临时会话文件:", ephemeral.sessionFile ?? "(无)");
ephemeral.dispose();

// 新建持久会话:文件落在 sessions/ 下按工作目录分组的目录里
const { session: fresh } = await createAgentSession({
	sessionManager: SessionManager.create(cwd),
});
console.log("新会话文件:", fresh.sessionFile);
fresh.dispose();

// 继续当前目录最近的会话:没有就新建一个
const { session: continued } = await createAgentSession({
	sessionManager: SessionManager.continueRecent(cwd),
});
console.log("继续的会话:", continued.sessionFile);
continued.dispose();

// 列出并打开指定会话
// list(cwd) 只列当前工作目录的会话;listAll() 扫全部目录,慎用
const sessions = await SessionManager.list(cwd);
for (const info of sessions.slice(0, 3)) {
	console.log(`${info.id.slice(0, 8)} — ${info.firstMessage.slice(0, 40)}`);
}

if (sessions.length > 0) {
	const { session: opened } = await createAgentSession({
		// open 按路径打开:恢复的上下文就是文件里的活动分支
		sessionManager: SessionManager.open(sessions[0].path),
	});
	console.log("打开的会话 ID:", opened.sessionId);
	opened.dispose();
}

SessionManager.list(cwd, sessionDir?, onProgress?, signal?) 返回的 SessionInfo 包含 path、id、cwd、name、parentSessionPath、created、modified、messageCount、firstMessage、allMessagesText。扫描大目录时可以用 onProgress 报告进度,用 signal 取消。

第五步:三种分支方式

Pi 把条目存成树,所以回到之前的某个点不会抹掉你离开的那条分支。三种方式看起来相似,作用完全不同:

操作结果什么时候用
/tree
在当前会话文件内移动
相关备选方案应该放在一起
/fork
从更早的用户消息创建一个新会话
备选方案应该成为独立的工作
/clone
把活动分支复制成一个新会话
你想要当前状态的独立副本

/tree 的交互细节:选中一条用户消息会把它的文本放回编辑器,编辑后提交就创建出另一条分支;选中助手回复或其他条目则从该条目之后继续,编辑器保持空白。

离开分支时,Pi 可以为你正在放弃的那条分支生成摘要,并把它附到你要进入的分支上。这样保留了被放弃路径里的相关工作,又不用把每条消息都带过去。

对应的 SDK API 是 session.navigateTree(targetId, options),它要求当前没有运行中的响应、也没有压缩在进行中,否则会抛错。

这三个操作都不改变"模型看到什么"的规则:模型永远只看活动分支。会话文件里保留的其他分支是给人看的存档,不进上下文。

第六步:SessionManager 是权威

这是本篇最重要的一条。官方 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.state.messages 就能控制模型看到什么,实际上下一次请求时 Pi 会用 SessionManager 重建上下文,把改动覆盖掉。正确做法是构造一个带有你想要的条目的 manager:

typescript
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({
	// inMemory 的第三个参数就是初始条目数组,这是恢复外部历史的官方途径
	sessionManager: SessionManager.inMemory(process.cwd(), undefined, externalEntries),
});

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

如果目标是"往当前会话里追加历史",对应的方法是 sessionManager.appendMessage(...),它会正确地挂到当前叶子上并更新父子关系。

还有一个容易混淆的边界:session.agent.state.messages = newMessages 本身是合法的,Agent Core 文档说赋值时会先复制顶层数组再存储。但它只影响 Agent 内部状态,不影响持久化——两份状态从此分叉。除非你有明确理由,不要碰它。

第七步:损坏记录的处理

JSONL 的好处是每行独立,坏一行不影响其他行。官方源码里 parseSessionEntries 的做法正是逐行解析、跳过解析失败的行:

typescript
// 官方源码的容错解析:逐行解析,坏行跳过,不中断整体
export function parseSessionEntries(content: string): FileEntry[] {
	const entries: FileEntry[] = [];
	// 末尾可能有多余空行,trim 后再切
	const lines = content.trim().split("\n");

	for (const line of lines) {
		// 空行直接跳过,不当作损坏
		if (!line.trim()) continue;
		try {
			const entry = JSON.parse(line) as FileEntry;
			entries.push(entry);
		} catch {
			// Skip malformed lines
		}
	}

	return entries;
}

细节有三个:空行被跳过,坏行被跳过,坏行不会中断整个解析。从文件读取时(loadEntriesFromFile)走的是同一套容错逻辑,而且会在修复文件前先校验会话头——如果第一行不是合法的会话头(type 不是 session 或 id 不是字符串),直接返回空数组。

官方文档没有明确规定宿主应该怎么应对损坏记录。 上面是源码事实,但"读到多少条算够""要不要备份原文件""要不要报警"要宿主自己定。下面是一个参考实现:

typescript
import { readFileSync } from "node:fs";
import type { FileEntry } from "@earendil-works/pi-coding-agent";

export interface RecoverResult {
	entries: FileEntry[];
	/** 因格式错误被跳过的行号(从 1 开始) */
	skippedLines: number[];
	/** 文件头是否合法;不合法时整个会话不可用 */
	headerValid: boolean;
}

/**
 * 读取会话文件并跳过损坏行。
 * 策略:保留能解析的全部条目,把坏行位置报给宿主,由宿主决定是否告警。
 */
export function recoverSessionFile(path: string): RecoverResult {
	const content = readFileSync(path, "utf-8");
	const entries: FileEntry[] = [];
	const skippedLines: number[] = [];

	// 逐行解析:尽量恢复,而不是因为一行坏 JSON 把整个文件丢掉
	const lines = content.split("\n");
	for (let i = 0; i < lines.length; i++) {
		const line = lines[i].trim();
		if (!line) continue;
		try {
			entries.push(JSON.parse(line) as FileEntry);
		} catch {
			// 记下行号(从 1 开始):静默跳过会让人误以为会话是完整的
			skippedLines.push(i + 1);
		}
	}

	const header = entries[0];
	// 头非法说明这压根不是一个会话文件:条目能解析也缺父子关系,重建不出活动分支
	const headerValid = header?.type === "session" && typeof (header as { id?: unknown }).id === "string";

	return { entries, skippedLines, headerValid };
}

这个策略的取舍是明确的:尽量恢复,而不是整体丢弃。一个会话文件里可能混着几十轮有价值的对话,为了一行坏 JSON 把整个文件丢掉不划算。但 headerValid 为 false 时要区别对待——那说明文件根本不是一个会话,条目即使能解析也缺少父子关系,无法重建出活动分支。

宿主还应该补两件官方不管的事:读之前先复制一份原文件,以及把 skippedLines 记进日志——静默跳过会让人误以为会话是完整的。

谁负责什么

职责由谁承担说明
写 JSONL、维护条目树
Pi
SessionManager 负责
决定存哪个目录
宿主
通过 --session-dir / 环境变量 / sessionDir
损坏记录的检测与恢复策略
宿主
Pi 只提供逐行解析与跳过坏行的能力
备份、保留期限、清理
宿主
Pi 不会自动删除会话文件
会话是否可丢弃
宿主
--no-session 是你的选择
模型上下文的重建
Pi
以 SessionManager 的活动分支为准

三种场景的可观察结果

场景触发方式可观察结果
成功(写入)
跑一轮带工具的任务
会话文件新增条目;session.sessionFile 指向该文件;getSessionStats() 的消息数增加
成功(恢复)
用 SessionManager.open(path) 打开
session.sessionId 与文件头一致;模型上下文包含历史消息
成功(分支)
navigateTree 到早前的条目
活动分支改变;原分支条目仍在文件里;模型只看到新分支
失败(文件缺失)
打开一个不存在的路径
抛出明确错误,不静默创建空会话
失败(部分损坏)
手动往文件里插一行坏 JSON
解析继续,坏行被跳过,其余条目可用,skippedLines 非空
失败(头部损坏)
破坏第一行
headerValid 为 false;宿主应拒绝使用而不是当作空会话
取消(恢复中断)
扫描大量会话时传 signal
list() 抛出中止错误,不返回半截结果

你现在能做什么

  • 在 ~/.pi/agent/sessions/ 下找到当前目录对应的那一格(目录名是工作目录把分隔符换成 - 的结果),打开最新的 .jsonl,数一数有多少行、能不能用 jq 逐行解析出来。
  • 跑一次 pi --continue,确认它打开的就是那个目录下的会话;再跑一次 pi --session <部分ID>,确认按部分 ID 也能命中同一个文件。
  • 复制一份会话文件,往中间插一行 {bad json,然后用 recoverSessionFile 跑一遍,确认 skippedLines 指出了那一行、其余条目仍然可用。
  • 用 SessionManager.forkFrom(源文件, 另一个目录) 派生一个会话,比较两个文件的条目数量和 parentSession 字段,确认派生是复制而不是移动。

排障速查

现象检查点处理
找不到会话文件
工作目录
会话按工作目录分组,换目录启动就是另一组会话
--continue 打不开旧会话
是否有更新的会话
--continue 打开的是最近的一个,要选特定的用 --session 或 /resume
改了 agent.state.messages 没效果
是否直接赋值了
SessionManager 才是权威,改状态不会替换持久化上下文
会话文件里有看不懂的条目
type 字段
usage / custom / label 不进上下文,是正常的元数据
上下文比预想的长
是否读了非活动分支
模型只看活动分支,检查 leaf 是否被移动过
压缩后原始消息还在
预期误解
压缩写摘要条目,不删原始条目,这是设计
恢复后模型不记得上次进度
检查重建路径
buildContextEntries() 会处理压缩;context_edit 会改变某条目的贡献
扫描会话目录很慢
是否用了 listAll
list(cwd) 只列当前目录的,listAll() 扫全部,慎用
会话文件越攒越多
保留策略
Pi 不会自动清理会话文件,删除策略要自己定

参考资料