会话树:分叉、回退与并线
本教程共 30 篇 · 第 25 篇 · 更新于 2026-08-10 · 约 18 分钟阅读
本节目标:搞懂 pi 怎么用树结构存储对话——不是”一串消息”,而是一棵可以分叉、回退、并线的树。你会看到 JSONL 会话文件里每一行的真实格式,理解 leaf 指针怎么决定模型读到的内容,以及这种设计为什么比线性聊天更强大。
你用过 ChatGPT 的话,一定遇到过这个痛点:聊了 30 轮,突然想到第 5 轮时还有一个思路没试。但你回不去——要么删掉后面的消息重来,要么开一个新对话重新描述一遍上下文。
pi 的会话存储不走这条路。
它不仅让你回到之前任意位置继续聊,而且旧的那条路还在——开出来的新路和旧路可以同时存在。这背后的数据结构很简单:不是一条线,是一棵树。
本章基于 pi v0.84.1;JSONL 格式和源码细节锚定 echovic 源码手册的 v0.83.0。
聊天的本质是一棵树
先忘掉代码。想一个场景。
你在和 pi 讨论怎么重构一个模块:
- 你描述了需求 → pi 给出方案 A
- 你说”方案 A 有性能问题” → pi 给了优化后的方案 A’
- 你说”开始实现吧” → pi 开始写代码
写到一半你突然想——如果当初选了方案 B 呢?方案 A’ 虽然优化了,但整体思路可能不是最优的。你想回到第 1 步结束的地方,重新走。
在传统线性聊天里,“回到第 1 步”意味着删掉第 2 步和第 3 步的内容。它们永远消失了。
pi 的做法是:不删任何东西,在你想分叉的地方长一个新枝。
u1(描述需求)
└─ a1(给出方案 A)
├─ u2(指出性能问题)
│ └─ a2(优化后的方案 A')
│ └─ u3(开始实现吧)
│ └─ a3(写代码中...) ← 旧分支叶子
│
└─ 你想回到这里 ↴
a1'
└─ u2'(试试方案 B 怎么样)
└─ a2'(方案 B 在这里...) ← 新分支叶子
u2-a2-u3-a3 这条旧路径没有消失。只要你想,随时可以切回去看。这和 Git 的分支很像——main 分支继续往前跑,experiment 分支从某个 commit 长出来,互不干扰。
节点、父指针与当前叶
要把上面的图存进文件,pi 只需要三个概念。
每个 entry 是一个节点
会话中的每一”步”都是一个 entry(条目)。不只是用户消息——AI 回复、工具结果、模型切换、压缩摘要,全都是 entry。每个 entry 有几个公共字段:
| 字段 | 说明 |
|---|---|
type | 类型——"message"、"compaction"、"model_change" 等 |
id | 8 位十六进制 ID,全局唯一 |
parentId | 指向父节点的 id,第一个节点的 parentId 为 null |
timestamp | ISO 时间戳 |
type: "message" 的 entry 还带一个 message 字段,里面就是你知道的 user/assistant/toolResult 消息。
parentId 画出树枝
每个 entry 通过 parentId 声明”我的上一句是谁”。上图中 a1 的 parentId 是 u1 的 id;u2 的 parentId 是 a2 的 id。把这些指针连起来,就得到一条从叶子到根的路径。
源码依据:packages/coding-agent/src/core/session-manager.ts,SessionEntry 联合类型定义,v0.83.0 / 845d6ff1。
leaf 是当前位置的游标
树有很多节点,但 pi 只在一个”当前位置”工作——这个位置叫 leaf(叶子指针)。新增的 entry,parentId 自动设为当前 leaf 的 id,新 entry 再变成新的 leaf。
把 leaf 理解成”光标”:你在哪个节点,下一次输入就从那里往下长。leaf 移到谁,谁就是新的根。
Tipleaf 不是 entry 自己的属性字段,而是 SessionManager 维护的运行时变量。文件里用
type: "leaf"的 entry 记录 leaf 移动历史,后面会详细讲。
JSONL:一行一个 JSON,连起来是树
pi 把会话存成 JSONL 文件。JSONL(JSON Lines)就是每行一个 JSON 对象,用换行符分隔。文件放在:
~/.pi/agent/sessions/--<项目路径>--/<时间戳>_<uuid>.jsonl
路径里 <项目路径> 把 / 替换成 -。比如你的项目在 /home/user/my-project,会话文件路径大概长这样:
~/.pi/agent/sessions/---home--user--my-project--/20241203T140000_a1b2c3d4.jsonl
文件第一行:session header
header 管身份和版本,不作为树的节点:
{"type":"session","version":3,"id":"uuid","timestamp":"2024-12-03T14:00:00.000Z","cwd":"/home/user/my-project"}
version 字段说明格式版本:
- Version 1:旧格式,线性 entry序列,加载时自动迁移到 v3
- Version 2:引入
id/parentId树结构 - Version 3:当前版本,
hookMessage改名为custom
Note会话文件头也有
parentSession字段。它记录的是一份会话”fork 自”哪份会话文件,和树内 parentId 是两回事。
文件其余行:一个完整对话示例
假设你在终端做了以下操作:输入 "Hello",pi 回复 "Hi!",然后你用 /tree 回到第一条消息,输入 "换个方式问",pi 回复 "好的,试试这样..."。
文件内容会是这样(省略多余字段):
{"type":"session","version":3,"id":"s1","timestamp":"2024-12-03T14:00:00.000Z","cwd":"/project"}
{"type":"message","id":"u1","parentId":null,"timestamp":"2024-12-03T14:00:01.000Z","message":{"role":"user","content":"Hello"}}
{"type":"message","id":"a1","parentId":"u1","timestamp":"2024-12-03T14:00:02.000Z","message":{"role":"assistant","content":[{"type":"text","text":"Hi!"}],"provider":"anthropic","model":"claude-sonnet-4-5","usage":{...},"stopReason":"stop"}}
{"type":"message","id":"u2","parentId":"a1","timestamp":"2024-12-03T14:00:05.000Z","message":{"role":"user","content":"帮我看看这个文件"}}
{"type":"message","id":"a2","parentId":"u2","timestamp":"2024-12-03T14:00:06.000Z","message":{"role":"assistant","content":[{"type":"text","text":"文件内容如下..."}],"provider":"anthropic","model":"claude-sonnet-4-5","usage":{...},"stopReason":"stop"}}
{"type":"leaf","id":"nav1","parentId":"a2","timestamp":"2024-12-03T14:00:07.000Z","targetId":"u1"}
{"type":"branch_summary","id":"bs1","parentId":"u1","timestamp":"2024-12-03T14:00:08.000Z","fromId":"a2","summary":"用户问了 Hello 和文件内容,现在想换种方式提问"}
{"type":"message","id":"u3","parentId":"bs1","timestamp":"2024-12-03T14:00:09.000Z","message":{"role":"user","content":"换个方式问"}}
{"type":"message","id":"a3","parentId":"u3","timestamp":"2024-12-03T14:00:10.000Z","message":{"role":"assistant","content":[{"type":"text","text":"好的,试试这样..."}],"provider":"anthropic","model":"claude-sonnet-4-5","usage":{...},"stopReason":"stop"}}
来逐行解读这段 JSONL:
第 2-3 行:正常的对话——u1 → a1。一开始 leaf 是 null,u1 的 parentId 就是null。a1 的 parentId 是 u1。leaf 现在指向 a1。
第 4-5 行:继续对话——u2 → a2。parentId 依次指向前一个。leaf 现在指向 a2。到这里为止,和一般聊天记录没什么区别。
第 6 行是转折点:type: "leaf",一条导航记录。targetId: "u1" 的意思是”把 leaf 移到 u1”。leaf entry 自己不参与对话,但它记录了”谁动了光标”。
第 7 行:type: "branch_summary"。fromId: "a2" 指向被离开的旧分支末尾,summary 是 LLM 生成的旧分支摘要。这条 entry 以 u1 为 parent,成为新分支的第一个节点。
第 8-9 行:新分支的对话——u3 → a3。parentId 从 branch_summary 开始链下去。
如果按文件行号顺序读,行 6-9 在行 4-5 之后。但按 parentId 回溯:当前 leaf 是 a3,沿 parentId 往上走是 u3 → bs1 → u1。u2-a2 这条旧路在祖先路径之外,模型看不见——但它们还在文件里。
不只消息,所有”发生了什么”都在树上
pi 的会话树不只有 message。配置变更、压缩摘要、标签、会话名称——它们都是树上的节点。看看完整的 entry 类型:
| 类型 | 说明 | 进入模型上下文? |
|---|---|---|
message | 用户/AI/工具消息 | ✅ |
model_change | 中途切换模型 | ❌ |
thinking_level_change | 调整思考深度 | ❌ |
compaction | 上下文压缩摘要 | ✅(摘要本身) |
branch_summary | 离开旧分支的摘要 | ✅(摘要本身) |
custom | 扩展持久化数据 | ❌ |
custom_message | 扩展注入的消息 | ✅ |
label | 用户打的标签/书签 | ❌ |
session_info | 会话显示名称 | ❌ |
leaf | 光标移动记录 | ❌ |
custom 和 custom_message 容易混淆。前者给扩展存自己的恢复数据(比如快捷键计数器),明确不进 LLM 上下文;后者是扩展注入给模型看到的消息(比如”当前天气是 28°C”)。两者都在树上,都推进 leaf。
注意 model_change 属于树的分支配置——从更早的节点开新分支,不会继承原分支后面才发生的模型切换。每条分支有自己独立的模型历史。
源码依据:packages/coding-agent/src/core/session-manager.ts,SessionEntry 联合类型,v0.83.0。
分叉:回到过去,走另一条路
分叉(forking)的操作逻辑很简单:把 leaf 指向一个旧节点,然后发新消息。新消息自动成为旧节点的子节点——不会覆盖任何已有内容。
在终端里分叉
交互模式下用 /tree 打开分叉界面。你会看到一棵可视化的会话树,按方向键选择想要回到的节点,回车确认。pi 会调用 branchWithSummary():
old leaf: a2
moveTo(u1) → leaf 变成 u1
生成 branch summary → 追加 bs1(parent: u1)
leaf 变成 bs1
然后你输入新消息,parentId 就是 bs1 的 id,新分支自然生长。
有摘要和无摘要
moveTo() 收到 summary(默认为 LLM 自动生成)时,leaf 先移到目标节点,再追加 branch_summary entry。这个摘要成为新分支的起点。没有 summary 时 leaf 就停在目标节点本身——新消息的 parentId 直接是目标。
摘要有价值:模型在新分支开头就知道”刚才那条路做了什么”。不然你得自己重新解释一遍上下文。
内存里也在分叉,文件里也在分叉
SessionManager.branch(entryId) 是内存级导航:leaf 变了,下一条 append 就成为新分支的节点。AgentSessionRuntime.fork() 是产品级动作:从当前会话文件复制根到目标叶子的路径,生成新的会话 id 和文件,新 header 带着 parentSession 指向来源。fork 比 branch 重,但让两个分支独立成可恢复的会话。
源码依据:packages/coding-agent/src/core/session-manager.ts,branch() 和 createBranchedSession(),v0.83.0。
回退与切换:leaf 是一棵树的游标
你可能会想:那些旧分支还在文件里,我重新打开会话时,pi 怎么知道我在哪条分支上?
答案在 type: "leaf" entry。
leaf entry 是怎么工作的
Session.moveTo() 调用 storage 的 setLeafId(entryId) 时,JSONL backend 做了两件事:
- 构造一条
type: "leaf"的 entry,parentId指向移动前的 leaf,targetId指向目标节点 - 把这条 entry append 到文件末尾
重新打开文件时,loader 从第一行扫描到最后一行。对每个 entry:如果是普通 entry,leaf 变成它的 id;如果是 leaf entry,leaf 变成它的 targetId。扫描完最后一行的 leaf 就是打开会话时的当前位置。
文件行顺序扫描:
line 1: session header → leaf = null
line 2: message u1 → leaf = "u1"
line 3: message a1 → leaf = "a1"
line 4: message u2 → leaf = "u2"
line 5: message a2 → leaf = "a2"
line 6: leaf entry → leaf = targetId = "u1" ← 移回去了
line 7: branch_summary → leaf = "bs1"
line 8: message u3 → leaf = "u3"
line 9: message a3 → leaf = "a3" ← 最终位置
Note这就是为什么 leaf entry 比”只在 header 里改一个 activeLeafId 字段”多写了一行。多出来的这一行保留了什么时候发生的导航——下次打开文件时能按时间线还原 leaf 位置。
源码依据:packages/agent/src/harness/session/jsonl-storage.ts,setLeafId() 和 leafIdAfterEntry 解析逻辑,v0.83.0。
恢复、分叉、新建是三条不同的路
回到之前的章节,你可能记得 pi 支持 /resume、/new 和会话内分叉。它们在代码层面是不一样的:
| 操作 | 做什么 | 换 runtime 吗 |
|---|---|---|
/tree 分叉 | 同文件内移动 leaf | 不换 |
/resume | 打开旧文件的完整 runtime | 换,teardown 旧的 |
/new | 新建空会话 | 换,teardown 旧的 |
fork() | 从当前路径复制出新文件 | 换,teardown 旧的 |
为什么 resume/new/fork 都要换 runtime?因为每个会话绑定了项目 cwd,而 settings、资源发现、扩展、工具和 system prompt 都依赖这个 cwd。只换一棵消息树而保留旧的这些服务,会串上下文。
源码依据:packages/coding-agent/src/core/agent-session-runtime.ts,switchSession() 和 fork(),v0.83.0。
模型看到的是什么:三种”视图”
树结构带来了一个重要区分:文件里存了什么 ≠ 模型看到什么。
pi 内部有三种层次的”读取”:
| 视图 | API | 包含旧分支吗 | 模型可见吗 |
|---|---|---|---|
| 完整日志 | getEntries() | ✅ 包含 | ❌ 不直接给模型 |
| 当前分支路径 | getBranch() | ❌ 只含祖先路径 | ❌ 还要再投影 |
| 上下文消息 | buildContext() | ❌ 来自当前路径 | ✅ 经过投影和转换 |
getEntries() 返回的是 JSONL 里全部记录——你可能会看到旧分支的 u2-a2 也在里面。但 getBranch() 从当前 leaf 沿 parentId 往回走,只得到 a3 → u3 → bs1 → u1 这一条链。
buildContext() 在 chain 上再做处理:
- compaction entry 截断路径:compaction 之前的旧消息只保留摘要,不保留原始消息体(除非有
retainedTail) - branch_summary entry 变成一条摘要消息
- 非消息 entry(label、leaf、model_change 等)被过滤掉
- custom entry 除非注册了
entryProjector,否则不产生消息
最后,buildSessionContext() 把选出来的 entry 转成 AgentMessage[],还原当前 model 和 thinking level,交给底层 LLM 调用。
JSONL 文件(10 行)
↓ getBranch()
当前祖先路径(5 个 entry)
↓ buildContext()
过滤 + 投影后的 message 列表(3 条消息)
↓ convertToLlm()
LLM provider 格式的消息数组
Tip如果你在做 pi 扩展开发,用
getEntries()画树状图、用getBranch()做导航、用buildContext()理解当前会话状态。不要把全量 entry 当上下文喂给模型——旧分支对当前会话没有意义,还浪费 token。
源码依据:packages/coding-agent/src/core/session-manager.ts,getBranch()、buildContextEntries()、buildSessionContext(),v0.83.0。
为什么树比线好
现在可以回答开篇的问题了:为什么 pi 不把对话存成线性列表?
1. 历史不会被覆盖
线性存储要”回去”就只能删后面的消息。pi 的树结构是 append-only(只追加不修改)——文件里的行只增不减。你永远不会丢失任何对话内容。
2. 一个会话可以有多条探索路径
同一个问题,你可以试方案 A,回到分叉点,再试方案 B。两条路径并存,你可以随时对比。这在重构、debug、方案讨论场景里特别有用——不是所有对话都只有一个”正确”的走向。
3. 配置属于分支
模型切换、thinking level 变更都是树上的节点。从早期节点开分支,自然不继承后来才发生的配置变更。每条分支有自己独立的运行上下文。
4. 压缩可以有边界
compaction entry 是树上的一个”屏障”——它之前的消息被摘要替代,它之后的消息是新的快照。有了树结构,压缩不是”剪掉一段”,而是”在某个节点插入一个摘要屏障”。retainedTail 甚至允许压缩之后的 entry 自包含,不需要回看压缩前的内容。
5. 扩展数据有机融入
custom entry 和 custom_message entry 让扩展的数据有标准化的存储位置——不存在独立的 SQLite 数据库、不存在放在别处的 JSON 配置文件、不在 header 上打补丁。一切对话相关的东西都在同一棵树上。
用一个比喻收尾
把 pi 的会话想象成一棵真实的树。
- 树干是主对话线
- 树枝是你探索过的不同方向
- **树叶(leaf)**永远只有一个——你当前所在的位置
- 年轮是时间线——文件行按时间追加,无论你移到哪根树枝
- 根系是 session header——它告诉你是谁、在哪
你可以顺着树干往上爬,在任何分叉点停下来,往另一个方向长出新枝条。旧枝条不会枯萎。下次回来,pi 翻一遍年轮就知道你最后站在哪片树叶上。
这就是会话树——比”一连串消息”多了一整个维度。
下一章,我们跳出具体功能,站到高处看一眼 pi 整个项目的内部架构:四个包各自干什么、一个请求从输入到模型返回要经过哪些模块。