回到旧对话为什么没有恢复代码:Pi Session Tree 与上下文投影
你让 Coding Agent 修复登录问题。它先改了 Token 校验,测试失败;你退回到"读取认证模块"那条消息,换成检查数据库时区。对话看起来回到了过去,但工作区里第一次尝试留下的文件改动、已安装依赖,甚至数据库写入,可能仍然存在。
这不是一个界面小问题,而是 Agent 状态模型的核心边界:回到旧对话节点,只能改变模型接下来读取哪条历史,不能自动回滚已经发生的环境副作用。
Pi 的 Session Tree 很适合解释这个边界。它没有把会话当作不断追加的消息数组,而是把完整事件保存在树中,再选择一条活动路径投影成模型上下文。本文基于 Pi v0.82.1 的官方文档与源码,回答三个问题:Session 为什么是一棵树;Branch、Fork、Clone 到底改了什么;自研 Harness 还必须补上哪一层,才能让"会话回退"不再冒充"系统回滚"。

一、先看真正会出错的地方
假设一次任务产生了下面的轨迹:
text
A 用户:修复登录问题
└─ B Agent:读取 auth.ts
├─ C 路线一:修改 Token 校验
│ └─ D 测试失败,但已经改过文件
└─ E 路线二:检查数据库时区
└─ F 测试通过
用户从 D 回到 B,再选择路线二。对于对话系统,这意味着当前路径从 A→B→C→D 切换为 A→B→E→F。但环境状态并不会因此自动回到 B:C 写过的文件、启动的进程、发出的请求仍可能存在。
如果系统把"当前对话路径"与"当前工作区状态"混成一个概念,至少会出现三类事故:
- 模型以为某段代码还没改,实际文件已经被旧分支修改;
- 新分支重复执行旧分支已经产生的外部副作用;
- 复盘时只看到成功路径,无法解释污染从哪条失败路线进入。
因此本文只有一条主线:Session Tree 管理执行记录的分支;可靠回滚必须由独立的环境版本机制完成。
二、四种"历史"必须拆开

Pi 的模型可以拆成四层:
text
JSONL Session File
↓ 通过 id / parentId 重建
Session Tree
↓ 选择当前 Leaf
Active Path
↓ 过滤、转换、处理压缩边界
LLM Context
第一层是磁盘上的 JSONL 文件。它保存 Header 和全部 Entry,包括已经放弃的路线。
第二层是 Session Tree。除 Header 外,Entry 通过自己的 id 和父节点 parentId 建立关系。物理文件按行追加,逻辑历史仍然可以分叉。
第三层是 Active Path,也就是从根节点到当前 Leaf 的唯一路径。上例当前 Leaf 为 F 时,活动路径是 A→B→E→F,C 和 D 仍在文件里,但不属于当前路线。
第四层才是 LLM Context。Pi 还会从活动路径中选择能进入模型的 Entry,并把 Compaction Summary、Branch Summary 或扩展消息转换成模型可读消息。
所以,三个常见等号都不成立:
text
完整 Session ≠ 当前活动路径
当前活动路径 ≠ 模型最终上下文
模型上下文 ≠ 当前工作区状态
这比"Pi 用 JSONL 保存聊天记录"更接近真实设计。
三、树是怎样写进线性 JSONL 的


Pi Session 第一行是 Header,后续每行是一个 Entry。Header 保存格式版本、Session ID、创建时间、工作目录,以及可选的 parentSession;它没有 Entry 的 id 和 parentId,因此不属于树。
Entry 的最小关系可以表示为:
typescript
interface SessionEntryBase {
type: string;
id: string;
parentId: string | null;
timestamp: string;
}
正常追加时,新 Entry 的父节点是当前 Leaf。回到旧节点继续时,新 Entry 会成为那个旧节点的另一个 Child。
text
A.parentId = null
B.parentId = A
C.parentId = B
D.parentId = C
E.parentId = B
F.parentId = E
JSONL 的优势是追加简单、局部损坏容易定位、可以逐行审计,也容易扩展 Entry 类型。Pi 的 Session 不只保存聊天消息,还可记录模型切换、Thinking Level、Compaction、Branch Summary、扩展状态、扩展消息、Label 和 Session 信息。
其中一个容易忽略的边界是:custom 用于持久化扩展状态,但不会进入 LLM Context;custom_message 才会被转换成模型能看到的消息。持久化在 Session 里,并不等于模型自动可见。
JSONL 也不是万能数据库。它不天然提供多进程事务、复杂查询、分布式一致性或多用户并发写。对于本地默认工作流,它很实用;对于多人共享和高并发服务,仍需要独立存储层。
四、Branch、Fork、Clone 分别改变什么


这三个操作经常被统称为"开分支",但语义不同。
| 操作 | 是否创建新 Session 文件 | 历史边界 | 接下来怎样继续 |
|---|---|---|---|
/tree 中 Branch |
否 | 仍保留同一 JSONL 中的完整树 | 把当前 Leaf 移到旧 Entry 后继续追加 |
/fork |
是 | 复制活动路径到所选旧 User Message 之前 | 把旧 Prompt 恢复到编辑器,可修改后重提 |
/clone |
是 | 复制活动路径到目标 Entry,并包含它 | 以空编辑器开始新的后续任务 |
Branch 适合在同一份实验记录里比较两条路线。失败路线与成功路线都留在一棵树中,便于复盘。
Fork 适合"回到提出问题之前,再换一种问法"。官方 README 描述的交互流程是选择活动路径中的旧 User Message,复制到该消息之前,并把原 Prompt 恢复到编辑器。
Clone 适合"保留当前有效状态,另开一个后续任务"。SDK 用 position: "at" 表示包含目标 Entry。
CLI 的 --fork <path|id> 又是另一个入口,固定版本文档描述为从指定 Session 复制。工程上不能只看到同一个单词,就假设所有入口的复制范围和编辑器行为完全相同。
Fork 或 Clone 还会替换活动 Session Runtime。SDK 文档明确提醒订阅者需要重新连接新 Runtime;这说明 Session 切换不仅是复制文件名,也会影响扩展与监听生命周期。
五、模型为什么不会看到所有分支
保存整棵树是为了审计和导航,不是为了每轮把全部历史塞进 Context Window。
固定版本源码中的 buildContextEntries() 从指定 Leaf 沿 parentId 回溯到根,再反转为 Active Path,并处理最近的 Compaction 边界。buildSessionContext() 再把路径中的 Entry 转成 Agent Message:普通 message 进入上下文,compaction 和 branch_summary 以摘要消息进入,custom_message 可以进入,纯 custom 状态则不会进入。
这个过程最好叫"上下文投影",而不是"加载聊天记录":
typescript
function projectContext(
events: Event[],
leafId: string,
policy: ContextPolicy
): AgentMessage[]
投影有两个价值。
第一,旧失败路线可以保留,而不会永久占用当前模型上下文。
第二,模型看到的内容变成可解释的结果:哪条 Leaf、哪条路径、哪些 Entry 类型、哪次 Compaction,共同决定了本轮输入。
Pi 还支持 Branch Summary,把离开路线中仍有价值的信息带到新路径。但摘要是有损表示,不能替代原始分支;而且"摘要如何生成"与"摘要 Entry 如何进入上下文"是两层问题。本文只确认固定版本的 Entry 与投影边界,不把未运行的摘要质量写成 Runtime 结论。
六、最危险的误解:Session Tree 不是 Git

Entry 的父引用、从旧节点分叉和 Label 的确容易让人联想到 Git。这个类比只能帮助理解树,不能推导出 Git 的工作区语义。
Pi Session Tree 没有自动提供:
- 文件树快照;
- Merge Commit;
- 基于内容寻址的对象数据库;
- 分支间独立工作目录;
- 数据库与外部服务的事务回滚。
回到旧 Entry 后,文件系统不会自动恢复。此前已经安装的依赖、修改的数据库、启动的子进程、发送的网络请求或创建的 Git Commit,也不会被撤销。
因此需要把两件事写成不同合同:
text
Conversation State Branching
≠
Environment State Branching
这是本文基于官方 Session 语义得出的工程判断,不是声称 Pi 官方已经实现工作区回滚。
如果任务有真实副作用,Branch 前后至少要记录:
yaml
session_leaf_id: "B"
workspace_version: "git:4b7c2e1"
database_checkpoint: "sandbox-db-17"
process_scope: "container:task-204"
external_effects:
- "ticket:none"
- "email:none"
只有 Session Leaf 与环境版本一起变化,系统才能回答"我回到了哪里"以及"环境是否真的与那一刻一致"。
七、给自研 Harness 的四层接口

借鉴 Pi 时,不必照搬全部 TUI 和 JSONL 细节,但应保留四个角色。
Event Store:保存完整轨迹
typescript
interface Event {
id: string;
parentId: string | null;
type: string;
payload: unknown;
timestamp: string;
}
它负责可追溯,不负责决定模型看什么。
Active Cursor:明确当前节点
不要默认"文件最后一行就是当前状态"。树一旦分叉,最后写入和当前选择是两个概念。
Context Projection:生成模型输入
投影函数应记录 Leaf、策略版本、压缩边界和被排除的 Entry 类型。这样模型输入才可复现、可审计。
Workspace Version:约束真实副作用
工作区版本可以是 Git SHA、Worktree、容器快照、临时数据库版本或业务 Checkpoint。它不一定与每条消息一一对应,但必须在发生工具副作用前后可核对。
四层合起来,才是一份可执行状态:
text
完整事件图
+ 当前活动节点
+ 上下文投影策略
+ 工作区与外部状态版本
对于低风险、短会话、没有工具副作用的助手,线性消息数组可能更简单。树形 Session 不是所有产品的默认答案;只有当系统需要回退、比较路线、保留失败轨迹或长期审计时,它的复杂度才有回报。
八、如何验收这套状态模型

不要只测试"树能不能画出来"。最小验收应围绕开头的登录案例:
- 在 B 后执行路线一,产生文件修改并记录工作区版本;
- Branch 回 B,确认活动路径不再包含 C、D;
- 确认 C、D 仍能从完整 Session Tree 查询;
- 构建新 Context,确认模型只收到
A→B与明确允许携带的摘要; - 检查文件系统是否仍含路线一改动;若仍存在,系统必须提示环境漂移,而不是宣称已回滚;
- Fork 与 Clone 分别验证复制边界、编辑器状态和新 Session ID;
- 切换 Runtime 后确认订阅和扩展状态重新绑定;
- 导出 Session 前检查源码、路径、命令输出和 Tool Result 的隐私风险。
Batch 08 的 Fixture 可以帮助检查树投影算法,但它只是 PASS-SPEC-NOT-RUNTIME。只有真实 Pi CLI 生成 Session、完成导航并核对工作区结果,才能升级为 Runtime 证据。本稿没有完成这一步。
九、结论
Pi Session Tree 的价值,不是让聊天界面看起来更高级,而是把"完整发生过什么"和"模型这一次应该看到什么"拆开。
它通过 id、parentId 和当前 Leaf 保留所有路线,再把一条 Active Path 投影为 LLM Context。Branch 在同一文件内移动活动节点,Fork 从旧问题之前创建新 Session,Clone 复制到目标 Entry 后继续。它们管理的是会话轨迹,不是文件、数据库和外部副作用。
所以,自研 Harness 最重要的补充不是再做一个树形 UI,而是把 Session Leaf 与 Workspace Version 绑定:对话回退时,系统必须明确环境已恢复、环境未恢复,或恢复状态未知。
只要这条边界没有建立,"回到旧对话"就可能给用户一种虚假的安全感。建立之后,Session Tree 才真正从聊天记录功能变成可审计的 Agent 状态导航层。
参考资料
- Pi Session Format:github.com/earendil-wo...
- Pi Coding Agent README(Session Branching):github.com/earendil-wo...
- Pi SDK(Session 与 Runtime Fork API):github.com/earendil-wo...
- Pi SessionManager:github.com/earendil-wo...