返回首页
10
SessionTreeappend-onlyJSONLbuildSessionContext分支摘要

会话管理:对话的存储、恢复与分叉

一棵 append-only 的树,让回退与分支只是移动指针而非删除数据

带着问题读

读完本章,你应该能回答这三个问题:

  1. 1Pi 的 Session Tree 节点只有 parentId、没有 children 列表——为什么说'认父不认子'是 append-only 的必要条件?答案见正文中对应的「面试题 1」气泡
  2. 2会话切换分支后,旧分支的探索成果怎么让新分支的 LLM 知道?BranchSummaryEntry 和普通消息节点有什么区别?答案见正文中对应的「面试题 2」气泡
  3. 3Pi 把'切换模型'也存成树上的节点而不是全局状态变量,这在回退场景下带来什么好处?答案见正文中对应的「面试题 3」气泡

第 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)。

  1. 切换模型 → 第一个节点 e1: ModelChangeEntry(parentId: null,根节点)。

  2. 你提问 → e2: MessageEntry(parentId: e1,user 消息)。注意 parentId 指向上一个节点,不是 session header。

  3. Agent 调 read → e3(parentId: e2,assistant 消息,content 数组里同时有文本和 ToolCall——它们是同一个节点)。

  4. read 返回 → e4(parentId: e3,toolResult,靠 toolCallId 关联回 e3 的 ToolCall)。

  5. Agent 给出分析 → e5(parentId: e4)。5 个节点挂在一条直线上,这就是"主分支"。每步只做两件事:创建带 parentId 的新节点 + 移动 leafId。

  6. 回退——关键转折。 你不满意分析,执行回退,但没有删除任何节点:

branch(branchFromId: "e2"): void {
    this.leafId = "e2";   // 核心就这一行
}

e3、e4、e5 还在树上,只是不在"当前路径"上。回退不是删数据,是移动指针。 为什么保留?你不知道以后会不会想回到旧分支——删了就再也找不回来。

  1. 换思路重新问 → e6: MessageEntry,parentId 也是 e2,跟 e3 共享同一个父。分支的本质:两个节点共享同一个 parent,就是两个分支。
  2. 新分支继续长 → e7(assistant)、e8(toolResult)、e9(assistant)。

整棵树 9 个节点、两个分支,所有数据完整保留——随时可以回到 e5 分支继续,也可以在 e9 分支推进。

Session Tree:append-only 的状态变化

树上节点的解剖

一条 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 数组,是状态变更就改变量,是元数据就跳过。

Session Tree 上的 9 种 Entry 类型

三个核心操作 + 分支摘要

追加——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 许可的开源教程。