JSONL 树形 session:append-only + 两种 fork
本文以 earendil-works/pi-mono 为样本,分析其会话存储设计。所引用的代码位于
packages/agent/src/harness/session/、packages/coding-agent/src/core/session-manager.ts。
一、为什么不是一条线性消息列表
大多数 chat 应用的会话存储是这样的:
{
"id": "chat-123",
"messages": [
{ "role": "user", "content": "..." },
{ "role": "assistant", "content": "..." },
{ "role": "user", "content": "..." },
...
]
}
够用,但有几个先天问题:
- 不能精确回放历史的某一节点。如果用户在第 5 条消息时反悔,想从那里再问一个不同的问题,要么覆盖第 5 条之后的所有内容,要么复制整个会话。
- 不能并行探索分支。给 LLM 同一个上下文问两个不同问题,结果应该并存比较,但线性结构里没办法表达。
- 崩溃恢复粒度大。整个文件需要事务写入,部分成功部分失败时难以处理。
- 多端编辑容易冲突。两个客户端同时往同一会话追加消息,简单 append 也能撞车。
pi 的解法:会话文件是一棵 append-only 的事件树。每条 entry 携带 id 和 parentId,逻辑上构成树;物理上仍然按时间顺序追加到同一个 .jsonl 文件。“当前活跃位置” 用一个独立的 leaf entry 显式标记。
下文从文件格式开始,逐步展开它的两种 fork 与上下文构建。
二、文件格式
2.1 路径与命名
~/.pi/agent/sessions/<encoded-cwd>/<timestamp>_<uuid>.jsonl
<encoded-cwd>是把工作目录绝对路径编码后的子目录名(这样不同项目的会话天然隔离);- 每个会话一个
.jsonl文件,不可变更名; - 文件名带时间戳便于排序,带 UUID 防止冲突。
2.2 行格式
文件每一行是一个 JSON 对象,第一行是 header,之后每行是一个 entry:
{"type":"session","version":3,"id":"<uuid>","timestamp":"...","cwd":"/path","parentSession":"/abs/path/to/parent.jsonl"}
{"type":"model_change","id":"<8hex>","parentId":null,"timestamp":"...","provider":"...","modelId":"..."}
{"type":"message","id":"<8hex>","parentId":"<8hex>","timestamp":"...","message":{"role":"user","content":"..."}}
{"type":"message","id":"<8hex>","parentId":"<8hex>","timestamp":"...","message":{"role":"assistant","content":[...],...}}
{"type":"message","id":"<8hex>","parentId":"<8hex>","timestamp":"...","message":{"role":"toolResult","toolCallId":"...","content":[...]}}
{"type":"compaction","id":"<8hex>","parentId":"<8hex>","timestamp":"...","summary":"...","firstKeptEntryId":"<8hex>","tokensBefore":N}
{"type":"label","id":"<8hex>","parentId":"<8hex>","timestamp":"...","targetId":"<8hex>","label":"v1 attempt"}
{"type":"leaf","id":"<8hex>","parentId":"<8hex>","timestamp":"...","targetId":"<8hex>"}
{"type":"session_info","id":"<8hex>","parentId":"<8hex>","timestamp":"...","name":"refactor auth flow"}
类型一览:
type | 用途 |
|---|---|
session | 文件 header(仅第一行) |
message | 用户 / assistant / toolResult 消息 |
model_change | 切换模型记录 |
thinking_level_change | 切换 thinking 等级记录 |
compaction | 历史折叠摘要 |
branch_summary | 分支被放弃前的摘要 |
label | 给某个 entry 打的人类可读标签 |
leaf | 当前活跃叶子指针(可多次写) |
session_info | 显示名等元信息 |
custom_message / custom | 扩展自定义消息 / 数据 |
每个 entry 必有四个字段:type、id(在文件内唯一)、parentId(指向上一条逻辑前驱)、timestamp(ISO string)。
2.3 树结构示例
下面这段对话产生的 entry 树(id 简化为字母):
[session header]
A: model_change (parentId: null)
B: message (parentId: A, role: user, "改一下登录")
C: message (parentId: B, role: assistant, "好的,先看一下文件")
D: message (parentId: C, role: assistant, toolCall: read auth.ts)
E: message (parentId: D, role: toolResult, ...)
F: message (parentId: E, role: assistant, "用 X 方案改")
leaf: { targetId: F }
物理上是 7 行 JSON。逻辑上是一条从 A 到 F 的线性链,leaf 指向 F 表示当前活跃位置。
三、append-only 与 leaf 指针
整个 session 文件只追加,不修改。这是 pi 设计中最强的不变量。
3.1 写入只走 appendFile
存储后端实现里只有 appendEntry 方法做写入:
// packages/agent/src/harness/session/jsonl-storage.ts
async appendEntry(entry: SessionTreeEntry): Promise<void> {
getFileSystemResultOrThrow(
await this.fs.appendFile(this.filePath, `${JSON.stringify(entry)}\n`),
...
);
this.byId.set(entry.id, entry);
updateLabelCache(this.labelsById, entry);
this.currentLeafId = leafIdAfterEntry(entry);
}
没有 updateEntry、没有 deleteEntry。session 一旦写入就是历史。
3.2 leaf 是一种独立的 entry 类型
"当前我们在哪一条 message 上"这种状态如果存在内存里,崩溃后就丢了。pi 的做法是:leaf 也是一条持久化的 entry:
// packages/agent/src/harness/session/jsonl-storage.ts
async setLeafId(leafId: string | null): Promise<void> {
if (leafId !== null && !this.byId.has(leafId)) {
throw new SessionError("not_found", `Entry ${leafId} not found`);
}
const entry: LeafEntry = {
type: "leaf",
id: generateEntryId(this.byId),
parentId: this.currentLeafId,
timestamp: new Date().toISOString(),
targetId: leafId,
};
getFileSystemResultOrThrow(
await this.fs.appendFile(this.filePath, `${JSON.stringify(entry)}\n`),
`Failed to append session leaf ${entry.id}`,
);
this.entries.push(entry);
this.byId.set(entry.id, entry);
this.currentLeafId = leafId;
}
每次切换叶子(用户回退、navigate 到旧分支)都会 append 一个 leaf entry。重开文件时只需读到最后一个 leaf 就能恢复出"上次活跃在哪":
function leafIdAfterEntry(entry: SessionTreeEntry): string | null {
return entry.type === "leaf" ? entry.targetId : entry.id;
}
// loadJsonlStorage 加载时:
let leafId: string | null = null;
for (let i = 1; i < lines.length; i++) {
const entry = parseEntryLine(lines[i]!, filePath, i + 1);
entries.push(entry);
leafId = leafIdAfterEntry(entry); // ← 普通 entry 推进 leaf;leaf entry 显式跳转
}
普通消息 entry 写入会让 leaf 推进到自己;只有 leaf entry 是显式的"跳过去"。
3.3 上层包装:SessionManager
coding-agent 在 storage 之上加了一层 SessionManager,把 entry 创建的细节封住:
// packages/coding-agent/src/core/session-manager.ts
appendMessage(message: AgentMessage): string {
const entry: MessageEntry = {
type: "message",
id: generateId(this.byId),
parentId: this.leafId, // ← 当前 leaf 作为 parent
timestamp: new Date().toISOString(),
message,
};
this.fileEntries.push(entry);
this.byId.set(entry.id, entry);
this.leafId = entry.id; // ← 自动推进 leaf
this._persist(entry);
return entry.id;
}
appendCompaction<T>(summary: string, firstKeptEntryId: string, tokensBefore: number, details?: T): string {
const entry: CompactionEntry = {
type: "compaction",
id: generateId(this.byId),
parentId: this.leafId,
timestamp: new Date().toISOString(),
summary,
firstKeptEntryId,
tokensBefore,
details,
};
// ... 同样 append
}
SessionManager 还提供 branch(branchFromId) 来切换 leaf 到一个旧 entry:
// packages/coding-agent/src/core/session-manager.ts
branch(branchFromId: string): void {
if (!this.byId.has(branchFromId)) {
throw new Error(`Entry ${branchFromId} not found`);
}
this.leafId = branchFromId;
}
切完 leaf 后下一次 appendMessage 写入的 entry,parentId 就是 branchFromId——这样多个孩子共享同一个 parentId,树就长出新分支了。
四、两种 fork:fork 与 navigateTree
pi 提供两种"分叉",底层数据结构相同,但物理位置不同,用法也不同。
4.1 fork:复制路径到新文件
SessionManager.createBranchedSession(leafId) 把从根到指定 leaf 的路径复制到一个新的 .jsonl 文件:
// packages/coding-agent/src/core/session-manager.ts
createBranchedSession(leafId: string): string | undefined {
const path = this.getBranch(leafId);
if (path.length === 0) {
throw new Error(`Entry ${leafId} not found`);
}
// 创建新 session 文件,header 里 parentSession 指回原文件
// 然后把 path 上的所有 entry 重新 append(id 不变)
...
}
效果:
原文件 a.jsonl 新文件 b.jsonl
───────────────── ─────────────────
session header (parentSession: -) session header (parentSession: a.jsonl)
A: model_change A: model_change ← 复制
B: message (user) B: message (user) ← 复制
C: message (asst) C: message (asst) ← 复制
D: message (asst, toolCall) leaf: { target: C }
E: toolResult
F: message (asst, "X 方案")
leaf: { target: F }
新文件从 C 之后是空的,可以独立追加完全不同的对话。两份文件完全独立,互不影响。
适用场景:
- 用户在某个用户消息处点 “Fork from here”,想从这里发起一个完全独立的探索;
- 以一个旧会话作为"模板"开新会话;
- 给团队成员分享一段对话历史让他从某点继续。
UI 上通常表现为 sidebar 树里多出一个子会话节点(通过 header 里的 parentSession 字段反查得到)。
4.2 navigateTree:在同一文件里建多分支
AgentSession.navigateTree(targetId) 不复制文件,而是把 leaf 指针跳到 targetId,下次写入就会形成树的新分支:
单个文件,leaf 跳到 C 之后再 append M1:
[session header]
A: model_change
B: message (user)
C: message (asst)
D: message (asst, toolCall, parentId=C) ← 旧分支
E: toolResult (parentId=D)
F: message (asst, parentId=E)
leaf: { target: F }
[这里调 navigateTree(C),写一个 leaf entry]
leaf: { target: C }
[现在再 prompt 一句,会 append 一个 message,parentId=C]
M1: message (user, parentId=C) ← 新分支起点
M2: message (asst, parentId=M1)
leaf: { target: M2 }
文件里同时存在两个分支:C → D → E → F 和 C → M1 → M2,共享前面的 B → C。
适用场景:
- 用户在对话里点回退到第 N 条消息,从那里换个问法;
- 比较"同一个上下文下两种问法"的效果;
- LLM 自己生成多个候选答案让用户挑(每个候选作为一个分支)。
UI 上通常表现为同一会话里的"分支选择器"(pi-web 里叫 BranchNavigator):用户能在分支间来回切,每次切都 append 一个 leaf entry 做持久化。
4.3 两种 fork 的对比
| 维度 | fork (createBranchedSession) | navigateTree |
|---|---|---|
| 物理位置 | 新文件 | 同文件 |
| 文件 header | 新文件 parentSession 反指原文件 | 不变 |
| 是否复制 entry | 是(路径上的所有 entry 重写一份) | 否(只 append 新的 leaf 跳转) |
| 元数据可继承性 | 弱(独立会话) | 强(共享所有元数据) |
| sidebar 表现 | 树里新增子节点 | 同会话内分支选择器 |
| 适用 | 完全独立的探索 | 当前对话内"换个问法" |
它们底层是同一套树结构,只是物理上的分割粒度不同。这是 pi 比许多框架更成熟的一处设计——大多数项目要么只支持一种、要么把两种概念混淆。
五、上下文构建:buildSessionContext
写入是 append-only 的,但喂给 LLM 的"对话历史"必须是线性的。buildSessionContext 把树降维成线:
// packages/agent/src/harness/session/session.ts
export function buildSessionContext(pathEntries: SessionTreeEntry[]): SessionContext {
let thinkingLevel = "off";
let model: { provider: string; modelId: string } | null = null;
let compaction: CompactionEntry | null = null;
for (const entry of pathEntries) {
if (entry.type === "thinking_level_change") {
thinkingLevel = entry.thinkingLevel;
} else if (entry.type === "model_change") {
model = { provider: entry.provider, modelId: entry.modelId };
} else if (entry.type === "message" && entry.message.role === "assistant") {
model = { provider: entry.message.provider, modelId: entry.message.model };
} else if (entry.type === "compaction") {
compaction = entry;
}
}
const messages: AgentMessage[] = [];
const appendMessage = (entry: SessionTreeEntry) => {
if (entry.type === "message") {
messages.push(entry.message as AgentMessage);
} else if (entry.type === "custom_message") {
messages.push(createCustomMessage(...));
} else if (entry.type === "branch_summary" && entry.summary) {
messages.push(createBranchSummaryMessage(...));
}
};
if (compaction) {
// 用 summary 替换 compaction 之前的所有内容(保留 firstKeptEntryId 之后的)
messages.push(createCompactionSummaryMessage(...));
const compactionIdx = pathEntries.findIndex((e) => e.type === "compaction" && e.id === compaction.id);
let foundFirstKept = false;
for (let i = 0; i < compactionIdx; i++) {
const entry = pathEntries[i]!;
if (entry.id === compaction.firstKeptEntryId) foundFirstKept = true;
if (foundFirstKept) appendMessage(entry);
}
for (let i = compactionIdx + 1; i < pathEntries.length; i++) {
appendMessage(pathEntries[i]!);
}
} else {
for (const entry of pathEntries) {
appendMessage(entry);
}
}
return { messages, thinkingLevel, model };
}
输入是 pathEntries:从 leaf 沿 parentId 回溯到根的 entry 序列。处理三件事:
- 沿路径累计配置:
thinkingLevel、model用最后一次出现的为准(assistant message 也会更新 model,因为它记录了"这条消息实际用的什么模型"); - 跳过非消息类 entry:
leaf、label、session_info、thinking_level_change、model_change都不进入 messages; - Compaction 折叠:如果路径上有
compactionentry,把它之前的内容用 summary 替换,但保留firstKeptEntryId之后的 entry(这是 compaction 算法决定要保留的近期上下文)。
注意这是纯函数——每次 turn 开始都会重算一次。pi 不缓存"对话历史",而是从 source of truth(session 文件)每次推导。这让任何对 session 的修改都能立刻反映到下一个 turn。
SessionManager 暴露的 buildSessionContext() 包了一层,从当前 leaf 开始:
buildSessionContext(): SessionContext {
return buildSessionContext(this.getEntries(), this.leafId, this.byId);
}
getBranch(fromId?: string): SessionEntry[] {
const path: SessionEntry[] = [];
const startId = fromId ?? this.leafId;
let current = startId ? this.byId.get(startId) : undefined;
while (current) {
path.unshift(current);
current = current.parentId ? this.byId.get(current.parentId) : undefined;
}
return path;
}
getBranch 沿 parentId 回溯,得到从根到 leaf 的线性路径。即使文件里有多个分支共存,只有当前 leaf 所在的那一条会被读出来。
六、和"线性消息列表"的对比总结
| 能力 | 线性 messages | pi JSONL 树 |
|---|---|---|
| 写入 | 整文件覆盖或 append | append-only |
| 回退到旧消息 | 截断后续 / 复制全部 | 写一条 leaf entry |
| 同一上下文多分支 | 不支持 | navigateTree 原生支持 |
| 跨会话分支 | 复制整个文件 | fork (createBranchedSession) |
| 历史折叠 | 重写文件 | 写一条 compaction entry |
| 崩溃恢复 | 整文件事务 | 行级,最后一行损坏只丢一个 entry |
| 多端编辑 | 易冲突 | append 天然顺序,但仍需 leader |
| 元数据(model 切换、label) | 混在 message 里 | 独立 entry 类型 |
代价:
- 文件读取要解析整棵树,复杂度比线性高(pi 的实现是把 entry 全部读进
Map<id, entry>一次构建); - 历史 entry 不能删除(pi 通过 compaction 来"逻辑删除",把折叠的内容用 summary 替换);
- 文件会持续增长,需要 compaction + 必要时手工归档。
收益:
- 任意一条 entry 都能稳定引用(id 永久有效);
- 任何"修改 session"的操作都不会破坏历史;
- 重开文件能精确恢复到上次活跃位置;
- 分支不需要新文件,单文件能表达完整对话探索过程。
七、设计中的几个关键不变量
-
session 文件 append-only。任何代码不允许 truncate 或 update 已有行(需要重写整个文件的场景,比如 cascade reparent,pi 通过完全重写新文件来实现,原文件保持只读)。
-
leaf 由 entry 承载。内存里的
leafId字段只是缓存,source of truth 是文件里最后一个leafentry(或最后一条普通 entry)。 -
id 在文件内唯一。
generateEntryId(byId)在每次 append 前确保 id 不重复,跨文件可以重复。 -
parentId 可以为 null。表示这是路径的根(通常是第一条 model_change 或 message)。
-
compaction entry 替换历史,不删除。LLM 看到的是 summary,但原始 entry 仍在文件里——如果将来需要,可以读取原始内容做审计或重新 compact。
-
buildSessionContext 是纯函数。从 entry 数组到 messages 数组的转换不依赖任何外部状态,意味着任何对 session 的改动都能在下个 turn 立即生效。
八、可借鉴的工程要点
如果你正在做需要"历史回放"或"分支探索"的对话系统,从 pi 这套设计里能直接搬走的几条:
-
每条记录配 id + parentId。即使一开始只用线性,后续要加分支也只是切 leaf。
-
当前位置作为独立 entry 持久化。运行时缓存可以丢,文件里的
leafentry 不能丢。 -
"复制文件"和"同文件分支"分别提供 API,因为它们是两种用户意图。混用会让 UI 设计变得困难。
-
元数据用独立 entry 类型(model 切换、label、session_info),不要塞 message 字段。这让文件的语义层级更清晰。
-
历史折叠用 entry 而不是重写。
compactionentry 是逻辑层的"折叠",物理层文件不变,给审计、回滚、二次处理留了空间。 -
buildContext 永远从 source 重算。不要做"增量维护一个内存对话历史"的优化,每次 turn 重算才能保证多分支切换 / compaction / extension 写入都立即生效。
写在最后
把会话历史从"线性数组"升级成"append-only 事件树",看上去只是数据结构的小调整,落到产品上是一系列能力解锁:精确回放、并行探索、历史折叠、崩溃恢复、跨会话 fork。这些能力 ChatGPT 与 Claude 在产品层都做了,但在公开的开源项目里,pi 是少有的把这套数据结构清晰暴露出来的样本。
更难得的是它的实现非常克制——核心代码不到一千行,没有引入任何额外的数据库、没有索引、没有事务系统,只用 appendFile 加几个 entry 类型就把所有能力承载起来。这是一种"用约束换能力"的工程哲学:越是对写入做严格约束(append-only),就越能稳定地暴露丰富的读取语义(历史回放、分支、折叠)。
仓库地址:https://github.com/earendil-works/pi-mono
关键文件:
packages/agent/src/harness/session/jsonl-storage.tspackages/agent/src/harness/session/session.tspackages/coding-agent/src/core/session-manager.ts
更多推荐

所有评论(0)