首页 / pi-agent 入门教程 / 会话树:分叉、回退与并线

pi-agent 入门教程

会话树:分叉、回退与并线

本教程共 30 篇 · 第 25 篇 · 更新于 2026-08-10 · 约 18 分钟阅读

pi-agent会话树分叉回退JSONLsession会话管理

本节目标:搞懂 pi 怎么用树结构存储对话——不是”一串消息”,而是一棵可以分叉、回退、并线的树。你会看到 JSONL 会话文件里每一行的真实格式,理解 leaf 指针怎么决定模型读到的内容,以及这种设计为什么比线性聊天更强大。

你用过 ChatGPT 的话,一定遇到过这个痛点:聊了 30 轮,突然想到第 5 轮时还有一个思路没试。但你回不去——要么删掉后面的消息重来,要么开一个新对话重新描述一遍上下文。

pi 的会话存储不走这条路。

它不仅让你回到之前任意位置继续聊,而且旧的那条路还在——开出来的新路和旧路可以同时存在。这背后的数据结构很简单:不是一条线,是一棵树。

本章基于 pi v0.84.1;JSONL 格式和源码细节锚定 echovic 源码手册的 v0.83.0


聊天的本质是一棵树

先忘掉代码。想一个场景。

你在和 pi 讨论怎么重构一个模块:

  1. 你描述了需求 → pi 给出方案 A
  2. 你说”方案 A 有性能问题” → pi 给了优化后的方案 A’
  3. 你说”开始实现吧” → 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"
id8 位十六进制 ID,全局唯一
parentId指向父节点的 id,第一个节点的 parentId 为 null
timestampISO 时间戳

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.tsSessionEntry 联合类型定义,v0.83.0 / 845d6ff1

leaf 是当前位置的游标

树有很多节点,但 pi 只在一个”当前位置”工作——这个位置叫 leaf(叶子指针)。新增的 entry,parentId 自动设为当前 leaf 的 id,新 entry 再变成新的 leaf。

把 leaf 理解成”光标”:你在哪个节点,下一次输入就从那里往下长。leaf 移到谁,谁就是新的根。

Tip

leaf 不是 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光标移动记录

customcustom_message 容易混淆。前者给扩展存自己的恢复数据(比如快捷键计数器),明确不进 LLM 上下文;后者是扩展注入给模型看到的消息(比如”当前天气是 28°C”)。两者都在树上,都推进 leaf。

注意 model_change 属于树的分支配置——从更早的节点开新分支,不会继承原分支后面才发生的模型切换。每条分支有自己独立的模型历史。

源码依据:packages/coding-agent/src/core/session-manager.tsSessionEntry 联合类型,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.tsbranch()createBranchedSession(),v0.83.0。


回退与切换:leaf 是一棵树的游标

你可能会想:那些旧分支还在文件里,我重新打开会话时,pi 怎么知道我在哪条分支上?

答案在 type: "leaf" entry。

leaf entry 是怎么工作的

Session.moveTo() 调用 storage 的 setLeafId(entryId) 时,JSONL backend 做了两件事:

  1. 构造一条 type: "leaf" 的 entry,parentId 指向移动前的 leaf,targetId 指向目标节点
  2. 把这条 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.tssetLeafId()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.tsswitchSession()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.tsgetBranch()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 整个项目的内部架构:四个包各自干什么、一个请求从输入到模型返回要经过哪些模块。