会话状态:为什么 Pi 把历史存成一棵树
线性日志只能后悔,树可以回到任意一步重新走。这个选择直接改变了使用方式。
学完这节你能做到
- 解释树状会话与线性日志的差别,以及 fork 解决了什么实际问题
- 用 `/tree` 回到指定节点重开一条分支
- 设计自己应用里的会话持久化格式
大多数 agent 把会话存成一条线性日志:一问一答一路往下追加。Pi 存成一棵树。 这个选择不是炫技 —— 它改变了你使用 agent 的方式。
线性日志的问题
你和 agent 折腾了四十分钟,走到第 18 轮发现:第 6 轮那个决定错了, 后面全都建立在错的前提上。线性日志给你两个选择:
- 继续往下说「刚才第 6 步做错了,重来」—— 错误的上下文还在,模型会被它拖着走, 而且这些 token 你要一直付到会话结束。
- 全部重开 —— 前 5 轮辛苦建立的上下文一起没了。
两个都不好。真正想要的是「回到第 6 轮之前,换个方向重走」。
树是怎么存的
Pi 的会话文件是 JSONL,每行一个 entry,靠 id / parentId 串成树:
entry-1 (user: 帮我重构这个模块)
└ entry-2 (assistant + tool calls)
└ entry-3 (user: 用策略模式吧)
│ └ entry-4 ... entry-9 ← 走岔的那条
└ entry-10 (user: 算了,用组合) ← fork 出来的新分支
└ entry-11 ...
会话文件在 ~/.pi/agent/sessions/,按工作目录分组。一整棵树在一个文件里 ——
分支不会散成一堆文件,这是它比「复制一份重开」高明的地方。
SDK 里的接口把这个结构摆得很清楚:
const entries = sm.getEntries() // 全部 entry
const tree = sm.getTree() // 完整树结构
const path = sm.getPath() // 从根到当前叶子的路径
sm.branch(entryId) // 把叶子移到更早的 entry
sm.createBranchedSession(leafId) // 把某条路径抽成新文件
sm.appendLabelChange(id, "checkpoint") // 打标签
注意 buildContextEntries() 和 getBranch() 的区别:后者是当前分支的全部 entry,
前者是应用了 compaction 之后真正会发给模型的那些。写扩展时经常搞混这两个。
几个命令的实际用法
| 命令 | 做什么 | 什么时候用 |
|---|---|---|
/tree | 浏览整棵树,跳到任意节点 | 「回到那个决定之前」 |
/fork | 从更早的用户消息开一条新分支 | 换个方向重走 |
/clone | 把当前分支复制成新会话文件 | 想留一份干净的基线 |
/compact | 压缩早期历史 | 窗口紧了 |
/export、/share | 导出 HTML / 推到 gist | 给人看、留证据 |
/name、标签 | 给会话和节点命名 | 长会话里找路 |
/fork 有两种落点:默认 position: "before" 会把那条用户消息恢复到编辑器里
让你改了再发;"at" 则是原样复制那条路径。前者是「改问法重问」,后者是「从这里岔开」。
Pi 有个很实用的设计:从一条分支跳走时,可以把「被放弃的那条分支干了什么」
总结一段,注入到目标分支里。这样试错的收获不会白丢 ——
你在 A 路撞的墙,B 路能知道。这叫 branch summarization,
存成 BranchSummaryEntry。
什么该进会话,什么不该
Pi 分了两个口子,这个划分值得抄:
pi.sendMessage(msg) // 进 LLM 上下文(也持久化)
pi.appendEntry(type, data) // 只持久化,不进上下文
appendEntry 用来记「审计信息」:某次权限确认的结果、某个扩展的内部状态、
一次外部 API 调用的元数据。它们对复盘有价值,但没必要占模型的窗口。
Pi 文档给的做法是:把状态写进工具结果的 details,然后在 session_start 时
遍历分支重建。原因就是树 —— 用户 fork 到另一条分支后,你的内存状态是那条路上的,
必须能按当前分支重算,否则状态和会话就分叉了。
自己应用里的会话该怎么存
如果你在做嵌入式集成(L3 的 SDK 那节),照抄这几条就够:
- 一棵树一个文件,追加写。JSONL 天生适合追加,崩了也只丢最后一行。
- entry 要自包含:
id、parentId、timestamp、类型、载荷。 别把「第几轮」这种位置信息编码进去 —— fork 之后就错了。 - 区分「进上下文」和「只存档」,两类 entry 分开。
- 压缩记录也是 entry(Pi 的
CompactionEntry带summary+firstKeptEntryId), 这样重建上下文时只按 entry 走,不需要额外状态。
会话到第 18 轮,你发现第 6 轮的技术选型选错了。用树状会话最合适的动作是?
这一节的结论
- 线性日志只能后悔,树可以回到任意一步重走 —— 一整棵树存在一个文件里。
/tree+/fork是长会话的主要工具;走开时的分支总结让试错不白费。- 区分「进上下文」与「只存档」两类 entry。
- 有状态的扩展必须能从当前分支重建状态,因为用户随时可能 fork。