第 06 章:会话持久化 —— JSONL 树
重启就失忆的 agent 没法干活。这一章实现会话持久化。pi 的做法优雅得值得一字不差地学:append-only 的 JSONL 文件,每条记录带 id/parentId,会话历史因此是一棵树——分支不需要新文件,换个 parentId 追加就行。pi 的 /tree、/fork、/clone 全部建立在这个结构上。
为什么是树,而不是线性日志
想回到三天前的某个回答、换个方向继续——线性日志只能开新会话或者截断历史,树则让你就地分叉:
旧分支完好无损地留在同一个文件里。pi 的会话格式(docs/session-format.md)正是如此:每行一个带 type 的 JSON 对象,id/parentId 串成树;文件头部是 session 类型的记录(带版本号,cwd 等)。恢复会话 = 从根沿选定分支走到叶子,把沿途的消息收集起来。
记录类型
mini-pi 只需要三种记录(第 07 章会用到第三种):
注意一个简化:我们的 message 直接就是 OpenAI wire 格式,恢复后可以直接喂回给模型。pi 存的是自己的 AgentMessage(含 usage、stopReason、自定义消息类型等),调 LLM 前再经 convertToLlm 转换——抽象层级不同,但"落盘格式 ≠ 调用格式"的思想一样:落盘要留住一切,调用时按需转换。
Session:追加与重建
rebuildContext 是恢复的核心:建 children 表 → 从 header 出发,每个分叉选时间戳最新的孩子(这就是"最新分支")→ 沿途收集消息;遇到 compaction 记录则插入摘要消息并跳过被压缩的旧消息(下一章填坑):
接线:每条消息落盘
runAgent 和 REPL 里每产生一条消息(user / assistant / tool)就 session.append(...);main() 处理 --continue:
REPL 里用户发消息时也顺手落盘:
验收
- 正常对话两轮,退出。打开
~/.mini-pi/sessions/里最新的.jsonl,看到 header + 一串message记录,parentId首尾相接。 npx tsx src/main.ts --continue——输出[resumed …: N messages],接着上次的话题继续聊,它记得。- 思考(不用做):如果要在 mini-pi 上实现 pi 的
/tree,需要改什么?——只需要一个"从任意节点开始继续"的选择器,append的parentId指过去,分支自然产生。树结构把分叉从"特殊功能"变成了"免费属性"。
本章要点
- append-only JSONL +
id/parentId= 就地分支、崩溃安全(每行写完即持久)、可直接阅读的会话格式。 - 恢复 = 从根沿选定分支走一遍;
--continue选"最新分支"即可覆盖 90% 场景。 - 落盘格式与 LLM 调用格式可以不同;pi 存
AgentMessage并在调用前convertToLlm,我们存 wire 格式本身。 - pi 的会话文件按工作目录归档(
~/.pi/agent/sessions/--<path>--/),支持/tree可视化跳转、HTML 导出等,见设计思想:harness 层。
下一章:第 07 章:上下文压缩。