时间旅行 —— 会话、树,和后悔药
30 秒版本: 每段 Pi 对话都自动保存成纯 JSONL 文件里的一棵树。回退从不删东西——只是移动一个指针。所以你可以先试方案 A、倒回去再试方案 B,两段历史都永远留着。
对话不该只有一条直线
大多数聊天工具把历史当列表:最新的在底下,删除就是撤销。闲聊没问题,但工程工作不行,因为工程里全是这种事:
- "让我重来一次"——Agent 答偏了,你想倒回一轮重新问
- "如果当初走左边呢?"——方案 A 做到一半,方案 B 一直在心里挠你
- "把那天那个找回来"——三天后你需要周二那次会话的上下文
线性历史只能靠销毁数据来回答第一个问题。Pi 的答案:别把对话存成列表,存成树。
会话树是怎么长出来的
每个会话都住在 ~/.pi/agent/sessions/ 下的一个 JSONL 文件里(按项目目录组织)。一行一个条目(entry),每个条目对整棵树只了解一件事:它的父节点是谁。
e1 模型切换(换到了 Claude)
└─ e2 你:"auth.ts 里 salt 验证为什么失败?"
├─ e3 助手:读 auth.ts
│ └─ e4 工具结果:文件内容
│ └─ e5 助手:"第 23 行——salt 没编码"
│
└─ e6 你:"先给我看 hash 函数" ← 第二个分支!
└─ e7 助手:grep hash……
注意 e6:它的父节点是 e2——和 e3 同一个爹。分支就这么简单:两个条目认领同一个父节点。没有别的。没有特殊的分支对象,没有额外的记账结构。
而让整个设计成立的关键决策是:条目只往后指。节点知道自己的父节点,但父节点不维护孩子列表。为什么?因为这样新增节点就永远不需要修改旧节点。文件只会生长——append-only,直到永远。(如果父节点要记录孩子,每长一个分支都得重写某行旧数据,整个保证就塌了。)
回退,就是挪一下指针
在 Pi 里回退——比如你对"第 23 行"的分析不满意,想从 e2 重新问——什么都不会被删。内部操作本质上就一行:
当前位置 ← e2
旧分支(e3 → e4 → e5)原封不动留在文件里,永远。你明天还能回去。这就是让 git 强大的同款戏法:历史不可变,"撤销"只是选择站到另一个位置。
日常命令:
| 你想…… | 用 |
|---|---|
| 续上最近一次会话 | pi -c |
| 浏览历史会话 | pi -r(会话内 /resume) |
| 看当前会话的树 | /tree |
| 从某条早期消息开叉新会话 | /fork(或 pi --fork) |
| 把当前分支复制成新文件 | /clone |
| 导出会话为 HTML | /export |
最好玩的是 /tree:当前会话完整分支结构的交互式视图,你可以跳到任意节点——然后从那里继续走下去。
等等——模型的记忆怎么办?
问得好。文件留着所有分支,但模型永远只看到一条路径:从当前位置一路回溯到根。跳到另一个分支,从模型视角看,旧时间线就蒸发了。
Pi 用分支摘要(branch summarization)来缓和这件事:当你在树上跳转时,它可以把你正要离开的时间线总结一份,注入新分支。于是模型知道"之前试过 PostgreSQL 触发器,因为性能问题放弃了",而不用背着那个死胡同的完整记录。
用的是上下文压缩同一套结构化摘要机器——同模板、同文件跟踪——只是对准了不同的问题。压缩对抗太长的历史,分支摘要对抗被抛弃的历史。
纯文本是特性,不是妥协
这套设计还有个安静的美德:会话就是 JSONL 文件。你可以 grep 它、diff 它、用任何工具备份它,或者凌晨两点觉得哪里不对时用编辑器直接读。没有数据库要维护,没有导出仪式——档案本身就是数据。
(删会话时,如果你装了 trash CLI,Pi 还会把它扔进系统回收站而不是永久删除。小小的善意,很 Pi。)
带走的话
Pi 的会话模型就是一个思想的彻底贯彻:历史只增不改,导航只是指针,每一次后悔都留档。 在一个"回退"免费且永久的 Agent 里工作过之后,再回到线性聊天历史,就像开一辆没有倒挡的车。
资料来源: Sessions · Session format