会话不是聊天记录的简单数组。对 coding agent 来说,会话承担了三个职责:
Pi 使用 JSONL 文件保存会话,并用 id / parentId 把条目组织成树。
JSONL 的每一行都是一个独立 JSON 对象。
{"type":"session","version":3,"id":"s1","cwd":"/project"}
{"type":"message","id":"a1","parentId":null,"message":{"role":"user","content":"修 README"}}
{"type":"message","id":"a2","parentId":"a1","message":{"role":"assistant","content":[]}}
::: tip 真实 Pi 与教学版的版本号
Pi 官方 Session Format 文档说明当前 session header 会迁移到 version: 3:v1 是早期线性 entry,v2 引入 id / parentId 树结构,v3 统一了扩展消息命名。本教程的教学版协议故意使用 version: 1,只是表示“教学版文件格式第 1 版”,不是在复刻 Pi 的真实版本号。
两者的学习主线是一致的:稳定 id、parentId、当前 leafId,以及通过 JSONL append 保存历史。
:::
它的好处是:
| 优点 | 解释 |
|---|---|
| 追加写简单 | 每产生一个条目就 append 一行 |
| 崩溃恢复友好 | 已写入的行仍可解析 |
| 适合长会话 | 不必每次重写整个大 JSON |
| 便于外部工具处理 | rg、脚本、日志工具都能扫 |
flowchart TD A["u1: 初始问题"] --> B["a1: 方案 A"] B --> C["u2: 继续方案 A"] C --> D["a2: A 的结果"] B --> E["u3: 改走方案 B"] E --> F["a3: B 的结果"]
如果当前 leaf 是 D,上下文就是 A -> B -> C -> D。当你跳回 B 并提交新用户消息,就产生 E -> F 这条新分支。旧分支不会丢。
leafId 是当前会话视角的末端。构建上下文时,不是把文件里所有条目都发给模型,而是从 leaf 一路沿 parentId 回溯到根,再反转成顺序。
function buildContext(leaf: Entry, byId: Map<string, Entry>) {
const path: Entry[] = [];
let current: Entry | undefined = leaf;
while (current) {
path.unshift(current);
current = current.parentId ? byId.get(current.parentId) : undefined;
}
return path.flatMap(entryToMessage);
}
Pi 的 /tree 有一个很妙的交互:如果你选中一条用户消息,它不是把 leaf 移到这条消息,而是移到它的父节点,并把这条用户消息放回编辑器。
这样你可以改写原问题,然后重新提交,形成一条新分支。
flowchart LR A["选中 user message"] --> B["leaf = parentId"] B --> C["用户消息文本回填到编辑器"] C --> D["编辑后重新提交"] D --> E["产生新分支"]
当你从一条长分支切到另一条分支时,有时不想完全丢掉离开分支上的重要发现。Pi 支持 branch summary:把离开的分支总结成一个条目,附着到新位置。
这不是普通压缩,而是“跨分支携带经验”。
Pi 的 session file 里不只有 message。它还会记录模型切换、thinking level、compaction、branch summary、自定义扩展条目和 session 名称等。这样恢复会话时,系统不只是恢复聊天文本,而是恢复“当时的运行上下文”。
| entry 类型 | 为什么要保存 |
|---|---|
message |
构造后续 LLM 上下文 |
model_change |
恢复时知道上一轮用的 provider/model |
thinking_level_change |
恢复 reasoning 设置 |
compaction |
长会话用摘要替代旧消息 |
branch_summary |
分支跳转后携带离开分支的经验 |
custom / custom_message |
扩展持久化自己的状态或注入上下文 |
教学版只实现 message 和最小 compaction,但类型上保留了继续扩展的空间。
| 误区 | 后果 | 修正 |
|---|---|---|
| 用数组下标表示历史位置 | 删除、压缩、分支后引用会错 | 用稳定 id 和 parentId |
| 切分支时删除旧分支 | 失去探索记录,无法回滚 | 保留旧 entry,只移动 leaf |
| 压缩后把旧消息全删掉 | 以后无法审计和重新构建分支 | 追加 compaction entry,不破坏原始历史 |
| 只保存 assistant 最终文本 | 工具结果和错误丢失 | 用户、助手、工具结果都作为消息保存 |
我们的教学版会保留:
| 能力 | 保留程度 |
|---|---|
| JSONL append | 保留 |
id / parentId |
保留 |
leafId |
保留 |
| 从 leaf 构建上下文 | 保留 |
| branch summary | 作为扩展练习 |
| 完整 TUI tree selector | 不实现,只用 API/日志展示 |
运行:
npm run demo:03
观察输出里的两条分支。然后把 Demo 改成三条分支,试着预测每个 leaf 对应的上下文路径。