第 10 章:会话管理 —— 对话的存储、恢复与分叉
第 9 章反复提到 Session Tree:压缩结果存在树上,buildSessionContext() 从树构建上下文。这章回答 Session Tree 到底是什么。但先要回答一个更基础的问题:会话数据到底怎么存?
问题:两个独立的子问题
"会话数据怎么存"其实包含两个正交的子问题,混在一起讨论会糊成一团:
子问题 A:存在哪里(介质)。 做过后端的第一反应是 mysql——一张 messages 表按会话 id 查询。Pi 的 coding-agent 没走这条路,选了本地 JSONL 文件:每个会话一个 .jsonl,一行一个 entry,纯文本。为什么?coding-agent 是单用户本地 CLI,数据库的并发/索引/事务全是过度设计;会话跟项目走(cd 到哪个项目就打开哪个项目的档案);零依赖零运维;JSONL 可读可调试。但这条路没焊死——agent-core 层提供了 SessionStorage 接口(harness/types.ts:440),自带 JsonlSessionStorage 和 InMemorySessionStorage 两个实现。注意:coding-agent 的 SessionManager 并未实现这个接口——它是完全独立的实现,直接读写自己的 JSONL 文件。这种"接口存在但不强制复用"是 Pi 包间松耦合的真实案例。
子问题 B:长什么样(结构)。 最直觉的答案是线性数组。但真实使用里对话不总是线性的:回退重试、分支对比、走错路回到岔路口。线性数组做这些操作意味着"删掉后面的消息再重写"——删了就没了。Pi 的答案是 Session Tree:一棵只追加、不修改、不删除的树。回退/分支不是删数据,而是移动指针。
| 维度 | 一般选择 | Pi 的选择 |
|---|---|---|
| 存哪里 | mysql 等数据库 | 本地 JSONL 文件(接口允许换) |
| 长什么样 | 线性数组 | 树(Session Tree) |
跟着一次真实会话看树怎么长出来
场景:调试一个认证 bug(示例 id e1~e9 是教学简化写法,实际是 8 位短 UUID,如 a1b2c3d4)。
-
切换模型 → 第一个节点
e1: ModelChangeEntry(parentId: null,根节点)。 -
你提问 →
e2: MessageEntry(parentId: e1,user 消息)。注意 parentId 指向上一个节点,不是 session header。 -
Agent 调 read →
e3(parentId: e2,assistant 消息,content 数组里同时有文本和 ToolCall——它们是同一个节点)。 -
read 返回 →
e4(parentId: e3,toolResult,靠toolCallId关联回 e3 的 ToolCall)。 -
Agent 给出分析 →
e5(parentId: e4)。5 个节点挂在一条直线上,这就是"主分支"。每步只做两件事:创建带 parentId 的新节点 + 移动 leafId。 -
回退——关键转折。 你不满意分析,执行回退,但没有删除任何节点:
branch(branchFromId: "e2"): void {
this.leafId = "e2"; // 核心就这一行
}
e3、e4、e5 还在树上,只是不在"当前路径"上。回退不是删数据,是移动指针。 为什么保留?你不知道以后会不会想回到旧分支——删了就再也找不回来。
- 换思路重新问 →
e6: MessageEntry,parentId 也是 e2,跟 e3 共享同一个父。分支的本质:两个节点共享同一个 parent,就是两个分支。 - 新分支继续长 → e7(assistant)、e8(toolResult)、e9(assistant)。
整棵树 9 个节点、两个分支,所有数据完整保留——随时可以回到 e5 分支继续,也可以在 e9 分支推进。
树上节点的解剖
一条 MessageEntry 在 .jsonl 里是这样一行:
{
"type": "message",
"id": "e3",
"parentId": "e2",
"timestamp": "2026-07-03T10:23:45.000Z",
"message": { "role": "assistant", "content": [...], "stopReason": "toolUse" }
}
关键点:parentId 是单向的——节点知道自己从哪来,父节点不知道自己有哪些孩子。这不是疏忽是设计:如果父节点要维护 children 列表,追加新子节点时就得修改父节点,违反 append-only。所以"认父不认子"是 append-only 的必要条件。 结构上单向,但通过全局 byId 映射表可以反查所有指向某节点的子节点。
Entry 类型:按"对 LLM 调用的影响"分三组
coding-agent 层定义了 9 种 Entry(agent-core 层是 11 种,两套独立实现类型数不同),按对 LLM 调用的影响分三组:
第一组:进 LLM 上下文(4 种)——MessageEntry(user/assistant/toolResult 消息)、CustomMessageEntry(扩展注入的自定义消息)、CompactionEntry(压缩摘要,替换旧消息)、BranchSummaryEntry(被抛弃分支的摘要)。
第二组:影响后续 LLM 调用参数(2 种)——ModelChangeEntry(后续用哪个模型)、ThinkingLevelChangeEntry(思考级别)。不产生消息,只改状态变量。
第三组:纯元数据(3 种)——LabelEntry(节点书签)、SessionInfoEntry(会话元信息)、CustomEntry(扩展自存数据)。buildSessionContext 直接跳过。
为什么分这么细?因为 buildSessionContext 要按类型分派:是消息就进 messages 数组,是状态变更就改变量,是元数据就跳过。
三个核心操作 + 分支摘要
追加——O(1),三步:创建新 Entry(parentId 指向当前 leafId)→ 存入 byId → leafId = 新 id。不修改任何旧节点。
回退——只移动 leafId(外加存在性检查)。旧节点还在 byId 里、还在 .jsonl 文件里。
分支——不是独立操作,是"回退 + 追加"的自然结果:回退到 e2 后追加的 e6,parentId 自动就是 e2。
分支摘要(可选)——回退后旧分支数据完整保留,但当前路径的 LLM 看不到它(buildSessionContext 只走当前路径)。想让新分支的 Agent 大致知道之前试过什么,用 branchWithSummary():把被抛弃分支喂给 LLM 生成结构化摘要(复用第 9 章的摘要 prompt),创建 BranchSummaryEntry,parentId 指向分叉点。它和普通消息的区别在于——它是被抛弃分支的"遗言",不是真实发生的对话;buildSessionContext 会把它转成 BranchSummaryMessage(注意区别于压缩产生的 CompactionSummaryMessage,是不同消息类型),新分支的 Agent 看到"之前尝试过 X,结论是 Y",知道历史但不被细节淹没。觉得旧分支不重要就直接 branch(),不生成摘要。
从树到 LLM 上下文:buildSessionContext
LLM 不认识树——API 只接受线性 messages 数组。所以每次调用前要把树"压扁"。
第一步:路径遍历。 从 leafId 沿 parentId 往回走到 root,收集路径上所有 entry 再反转:
const path: SessionEntry[] = [];
let current = byId.get(leafId);
while (current) {
path.push(current);
current = current.parentId ? byId.get(current.parentId) : undefined;
}
path.reverse(); // root → leaf
当前 leafId 是 e9 时,path 是 [e1, e2, e6, e7, e8, e9]——e3、e4、e5 不在 path 里,它们不在当前分支上,不会发给 LLM。
第二步:按类型分派。 e1(model_change)更新状态变量不进 messages;e2/e6(user)、e7(assistant)、e8(toolResult)、e9(assistant)推入 messages。
一个微妙问题:e2 和 e6 是连续两条 user 消息,LLM API 要求 user/assistant 交替,有些 Provider 会拒绝连续同角色消息。Pi 在 convertToLlm 层做相邻合并:遍历时如果当前消息和上一条都是 user,就把 content 数组合并进同一条 UserMessage,而不是发两条——既保留了全部内容,又满足了交替约束。
状态变量:覆盖式提取。 沿路径从 root 走到 leaf,遇到 model_change 就覆盖 model 变量,最后一次生效。如果路径上没有任何 model_change,model 初始值是 null,由调用方兜底用会话启动时配置的模型。这就是为什么"切换模型"要存成节点而不是状态变量:节点完整记录"什么时候、在哪个位置切的";回退到切换之前的节点,路径不包含那条 model_change,model 自动回到切换前的值——节点化的状态让回退天然正确。
CompactionEntry 的选择性收集。 遍历到压缩节点时不是简单"停止收集之前的消息",而是按它记录的 firstKeptEntryId 选择性收集:先生成 CompactionSummaryMessage 推到 messages 开头;压缩节点之前的 entry 只保留 firstKeptEntryId 及之后的,其余跳过;之后的正常收集。这就是第 9 章"压缩替换旧消息"的具体实现——不是真删(append-only 不允许删),而是遍历时跳过。压缩不是破坏性的,只是"当前路径上"的视图:回退到压缩节点之前的位置,被压缩的消息又会作为正常消息出现。
JSONL 持久化的细节
磁盘上的文件长这样(每行一个 entry):
{"type":"session","version":3,"id":"UUIDv7","cwd":"/project","timestamp":"..."}
{"type":"model_change","id":"e1","parentId":null,"modelId":"claude-sonnet-4-6",...}
{"type":"message","id":"e2","parentId":"e1","message":{"role":"user",...},...}
{"type":"message","id":"e6","parentId":"e2","message":{"role":"user",...},...}
第一行是 Session Header(元信息,不是树节点)。e6 的 parentId 是 e2——grep '"parentId":"e2"' 就能找出所有从 e2 长出的子节点。为什么用 JSONL 而不是单个 JSON? 行级追加——新 Entry 直接 appendFileSync 到文件末尾,不需要读入-修改-重写整个文件,和 append-only 树完美契合。
延迟写入:首次 assistant 消息到达前,user 消息先不写盘(标记未 flushed);首个 assistant 到达时一次性重写整个文件(header + 所有积压 entry,用 openSync("wx") + writeFileSync 保证原子性),之后所有 entry 立即 append。为什么?避免"有问无答"的半截对话残留——用户问了但 Agent 没回(网络断了),立即写盘的话下次打开会看到一条孤零零的用户消息。延迟写入保证落盘的对话至少有一对完整的 user-assistant 往返。
偶尔的全文件重写(克隆分支副本、修复损坏文件)不破坏 append-only——重写产生的是新文件或新格式,原历史数据完整保留。
总结
会话存储要拆成两个独立维度想:存哪里(介质)和长什么样(结构)可以独立做选择——不要误以为"用了数据库就必须线性数组"。
Session Tree 的一连串设计选择是连贯的:为什么树?对话不是线性的。为什么 append-only?删了的数据找不回来。为什么认父不认子?append-only 要求节点不可变。为什么路径遍历?LLM 只认线性数组。每个选择都回应上一个选择带来的约束。
三个可迁移的思路:拆开"存哪里"和"长什么样";append-only + 指针定位用于撤销/回退/分支场景(代价是存储空间,但磁盘便宜、数据无价);节点化状态变量让回退天然正确(路径遍历自动忽略被回退掉的变更)。
至此,本指南覆盖的 Pi 核心机制全部讲完:从 Agent Loop 的引擎,到模型调用的翻译层、工具系统的管道、消息系统的双层设计、事件驱动的神经系统、上下文工程的四层防线、压缩算法的切割与摘要,再到本章的会话树。你已经有了一张完整的地图——接下来最好的学习方式,就是打开源码,沿着这张地图亲自走一遍。
本章关键源码索引:
agent/src/harness/types.ts(SessionEntry、SessionStorage 接口)、agent/src/harness/session/jsonl-storage.ts(JsonlSessionStorage)、coding-agent/src/core/session-manager.ts(独立的 SessionManager:buildSessionContext / appendEntry / branchWithSummary)。本文改编自 CC-BY-SA-4.0 许可的开源教程。