阶段二 · 状态与边界
会话管理 —— 对话如何存储、恢复与分叉
会话让 Agent 的记忆跨越进程存活。Pi 会话的核心设计是 append-only 的树 + JSONL v3:新记录永远追加在文件末尾,节点只认父不认子。这两个决定看似简单,却带来一连串推论:O(1) 追加、可流式读取、以及最重要的——分支不是独立功能,而是 append-only 的自然结果。
学习目标
- 理解会话为什么是 JSONL v3(一行一个记录)而不是 JSON 数组。
- 理解"认父不认子"为什么是 append-only 的必要条件。
- 理解回退只是移动 leafId、分支是回退后追加的自然结果。
一、问题:进程退出后,对话去哪了
对话结束、进程退出,模型当然什么都不记得。要让 Agent 的"记忆"跨越进程,必须把消息历史持久化到磁盘。两个基本问题:存在哪、长什么样。
Pi 的答案是:~/.pi/agent/sessions/ 下按工作目录组织的 JSONL 文件,版本 v3。
二、为什么是 JSONL 而不是数组
三个理由,都指向"追加友好":
- 可追加:新记录永远写在文件末尾,追加是 O(1)——不需要读入整个文件、改完再写回。
- 可流式读:恢复会话时逐行读取即可,不需要一次解析整个 JSON 数组。
- 容忍损坏:某一行坏了,其余行仍可读;数组格式一行坏则整体不可解析。
三、认父不认子:append-only 的必要条件
会话树的每个节点只知道自己的 parentId,不知道孩子。这个"认父不认子"是 append-only 的必要条件:
- 追加新节点时,旧节点一律不动——O(1)。
- 如果"认子",回退后追加新分支时,旧父节点的子列表就要被改写,就违反了 append-only。
一个典型的会话文件长这样(一行一个记录):
{"type":"session","version":3,"id":"uuid","timestamp":"...","cwd":"/path/to/project"}
{"type":"message","id":"a1b2c3d4","parentId":"prev1234","timestamp":"...","message":{"role":"user","content":"Hello"}}
{"type":"message","id":"b2c3d4e5","parentId":"a1b2c3d4","timestamp":"...","message":{"role":"assistant","content":[{"type":"text","text":"Hi!"}],"stopReason":"stop"}}
每个节点都带 id 和 parentId,形成树。
四、回退与分支:同一个操作
- 回退(
/tree跳到历史点):只是移动当前 leafId,不删任何节点。 - 分支:从回退点继续对话,新节点自然长出新分支。
推论:分支不是独立的功能,而是 append-only 的自然结果。因为旧节点从不修改,回退后再追加就天然形成分叉。
五、记录类型按职责分组
9 种记录分三组,关键是操作记录也上树:
| 分组 | 类型 | 作用 |
|---|---|---|
| 会话元数据 | session(头)、session_info、label | 会话本身的信息 |
| 对话内容 | message、compaction、branch_summary | 模型的上下文 |
| 操作记录 | model_change、thinking_level_change、custom | 用户操作与扩展数据 |
"切换了模型"也是一个节点——这样回退到任何历史点,都能还原当时的会话状态(当时用的哪个模型、什么思考级别)。
当前 Pi 行为
- 会话自动保存到
~/.pi/agent/sessions/,按工作目录组织;格式版本 v3。 /tree导航会话树与分支;/export才生成 HTML——两者职责不同。- 旧版本会话加载时自动迁移到 v3。
- 0.85.0 起 SDK 支持"可恢复的内存会话":外部存储的会话条目可以经 SDK 恢复进内存会话,存储后端因此可替换(
session-backends仍是实验性包)。
源码证据表
| 教学结论 | Pi 路径 / 符号 | 说明 |
|---|---|---|
| 格式与迁移约定 | packages/coding-agent/docs/session-format.md、sessions.md |
v3 与旧版迁移 |
| 会话树导航 | TUI /tree 命令 |
分支是"从某节点重来" |
| 树形结构的教学模型 | learn_pi_lab/labs/session_tree.py |
TranscriptTree 只存父指针 |
| 可恢复内存会话 | v0.85.0 release notes(SDK Session Management) | 外部存储条目恢复进内存 |
失败与边界实验
append-only 的 JSONL 会遇到哪些失败?lab session-tree 与 tests/test_04_session_tree 覆盖了四类:
| 场景 | 表现 | 为什么安全 |
|---|---|---|
| 最后一行损坏(写一半崩溃) | 读取时丢弃不完整行,之前的历史完好 | append-only:坏只坏最后一条 |
| 重复追加同一条目 | 树重建时按 ID 去重/报诊断 | ID 是幂等键 |
| 引用不存在的父节点 | 报 UnknownTranscriptEntry 而不是静默丢分支 |
孤儿节点可见,不可悄悄消失 |
| 从中间节点分叉后继续写 | 新记录父指针指向该节点,形成分支 | 认父不认子:重建由子向父 |
对比一个反例:如果会话用"整文件重写"存储(读取-修改-写回),第 1 种失败会损坏全部历史,第 4 种需要显式的分支数据结构。append-only + 父指针把这两个问题都消解在格式层。
python3 -m learn_pi_lab lab session-tree
python3 -m unittest tests.test_04_session_tree -v
在 Pi 里怎么操作
/new:开始新会话;/resume(或pi -r):浏览并选择历史会话。/tree:在会话树中导航,从任意历史点继续。/fork:从某条历史用户消息创建新会话,等价于pi --fork <path|id>。/export [file]:把会话导出为 HTML。
Python 实验
session_tree.py 实现课程专用的不可变 transcript tree,validate() 拒绝四种结构错误(重复 id、悬空父节点、自指、循环),path_to 沿 parent 链回溯得到 root → leaf 分支:
def path_to(self, leaf_id: str) -> tuple[TranscriptEntry, ...]:
try:
current = self._entries_by_id[leaf_id]
except KeyError as error:
raise UnknownTranscriptEntry(leaf_id) from error
branch: list[TranscriptEntry] = []
while True:
branch.append(current)
if current.parent_id is None:
break
current = self._entries_by_id[current.parent_id] # 认父不认子:沿 parent 链走
return tuple(reversed(branch))
session_inspector.py 逐行读取 JSONL,统计消息角色与压缩记录,不执行内容、不修改会话——"解析工具应保留未知记录"的只读示例。Tau 的 tau_agent/session/tree.py 用 seen 集合检测环,逻辑一致。
python3 -m learn_pi_lab lab session-tree # 打印一条 root → leaf 分支
这一模块的核心代码在核心代码导览 · 会话树有逐段解读。
验证方式
python3 -m unittest tests.test_04_session_tree tests.test_11_projects -v
边界与安全
- 不要由第三方工具直接改写正在使用的会话文件:保留备份、写入前验证版本、恢复后审计工具调用记录。
- 解析器应保留未知记录(版本演进后旧记录仍可能被读取),而不是静默重写。
回顾
- JSONL v3:可追加、可流式读、容忍损坏。
- 认父不认子:append-only 的必要条件。
- 回退 = 移动 leafId;分支 = 回退后追加的自然结果。
- 操作记录也上树:回退能还原完整会话状态。