Pi Agent · Book
M10

第10章:会话管理 —— 对话的存储、恢复与分叉

7275字 · 含 190 行代码 · 约 37 分钟

第 9 章讲压缩算法时,反复提到一个概念——Session Tree。压缩结果(CompactionEntry)存在 Session Tree 上,buildSessionContext() 从 Session Tree 构建 LLM 需要的上下文。

这一章就来回答:Session Tree 到底是什么?

但在解释 Session Tree 之前,得先回答一个更基础的问题——会话数据到底怎么存? 这是理解后续结构的起点。

校对口径:本章主要描述 Pi v0.80.2 coding-agent 的 session-manager.ts。同版本 agent-core 另有可插拔的 SessionStorage;两套实现概念相近但接口与持久化细节不同,不能混写。


一、问题:会话数据怎么存?

前 8 章我们一直在跟 context.messages 打交道——它是一个数组,存着当前活动路径的线性对话上下文。但每次 Agent 启动时,这个数组从哪来?关闭后到哪去?

这就引出一个最基础的工程问题:会话数据怎么存?

这个问题其实包含两个独立的子问题,需要拆开看:

  • 子问题 A:存在哪里?(存储介质)
  • 子问题 B:长什么样?(数据结构)

两个维度是正交的——你可以“用 MySQL 存线性数组”,也可以“用 JSONL 文件存一棵树”。混淆它们会让后续讨论糊成一团。下面分别展开。

子问题 A:存在哪里?(介质)

如果你做过后端开发,第一反应可能是 MySQL / PostgreSQL 这类关系型数据库——一张 messages 表,user_id + role + content + timestamp,按会话 id 分组查询。

Pi 的 coding-agent 没有走这条路。它选了本地 JSONL 文件——每个会话一个 .jsonl 文件,一行一个 entry。默认目录不是项目内的 .pi/sessions/,而是:

~/.pi/agent/sessions/<encoded-cwd>/

<encoded-cwd> 由当前工作目录编码而来,因此会话仍按项目分组,但文件集中放在用户级 agent 目录。调用方也可以显式传入其他 session 目录。

源码没有留下“为什么不用数据库”的设计宣言,但实现呈现出的取舍很清楚:

  • 适合本地 CLI:会话直接保存在用户机器上,无需额外启动数据库服务
  • 按项目分组:不同 cwd 映射到 ~/.pi/agent/sessions/ 下不同的编码目录
  • 容易检查:JSONL 是纯文本,可以用 catgrep 或编辑器查看
  • 能力边界明确:本地文件实现没有自动提供数据库擅长的跨机器共享、复杂查询或多写者事务;若产品需要这些能力,存储层需要另行实现

但 Pi 没有把“会话必须落到这一种 JSONL 文件”写进 agent-core。agent-core 层提供了 SessionStorage 接口,让其他宿主可以实现自己的存储;仓库内提供 JsonlSessionStorage(文件)和 InMemorySessionStorage(内存)。注意:coding-agent 的 SessionManager 并未实现 agent-core 的 SessionStorage 接口——两者是独立的两套实现,coding-agent 直接读写自己的 JSONL 文件,没有走 agent-core 的抽象层。

实现:agent-core 的 SessionStoragepackages/agent/src/harness/types.ts;coding-agent 的默认路径由 session-manager.tsgetDefaultSessionDirPath() 计算,独立的 SessionManager 不实现前者。

所以”存在哪里”这一层的选择是:coding-agent 选了本地 JSONL 文件,但接口允许其他应用换数据库。这层搞清楚了,下一层问题才好讨论。

子问题 B:长什么样?(结构)

存哪里解决了,但还有更深一层的问题:对话数据的逻辑形态是什么?

最直接的答案是:线性数组messages = [msg1, msg2, msg3, ...],一条接一条。这种结构最简单,也接近常见 messages 表按时间排序后的形态。

但真实使用场景里,对话不总是线性的

  • 重试:Agent 的回复不好,你想”退回上一轮”重新生成
  • 分支:你想在某个节点尝试两种不同的方案,比较结果
  • 回退:走了一条路发现不对,想回到之前的岔路口

如果只维护一个可变的线性数组,最直接的回退做法是删掉后面的消息再重写;若还想保留方案 A,就得额外引入版本、快照或分支索引。

Pi 直接把这层版本关系表达成 Session Tree。在正常的追加与分支流程中,旧 entry 不被修改或删除;回退只是移动内存中的 leafId,下一次追加便从新的位置长出分支。迁移、修复和显式复制会重写或新建文件,因此这里的 append-only 描述的是会话演进模型,不是所有文件 I/O 的绝对规则。

维度一般选择Pi 的选择
存哪里MySQL 等数据库本地 JSONL 文件(agent-core 接口允许其他宿主替换)
长什么样线性数组树(Session Tree)

下面用一个具体会话当例子,把这棵树一步一步”长”出来给你看。


二、跟着一次真实会话看树怎么长出来

只抽象地讲”Session Tree 是一棵 append-only 的树”不够直观。我们用一个具体场景:调试一个认证 bug

设想你打开 Pi Agent,进行 8 步操作:

步骤 1: 切换到示例模型 model-a
步骤 2: 你问 "auth.ts 里 salt 验证为什么失败?"
步骤 3: Agent 决定调 read 工具读 auth.ts
步骤 4: read 工具返回 auth.ts 的内容
步骤 5: Agent 分析后回复 "问题在 23 行,salt 没编码"
步骤 6: 你不太满意这个回答,回退到步骤 2 重来
步骤 7: 你换思路问 "先看 hash 函数的实现"
步骤 8: Agent 调 grep 工具并给出新的分析

下面看这 8 步怎么让会话树从”空”长成一棵有分支的树。

第 1 步:切换模型,一个状态节点上树

会话启动时,文件第一行是 Session Header(不是树节点,而是文件元信息)。切换模型会追加一个 ModelChangeEntry。为便于讲解,下面把它记作示例树的 e1;真实入口也可能在新会话初始化时先写入模型与思考级别 entry。

e1: ModelChangeEntry
    parentId: null(根节点)
    provider: "provider-a"
    modelId: "model-a"

这棵树现在只有一个节点:

e1 (model_change)

leafId 在这

第 2 步:你提问,UserMessage 节点上树

你输入”auth.ts 里 salt 验证为什么失败?“,产生第二个节点 SessionMessageEntry

e2: SessionMessageEntry
    parentId: e1
    message:
      role: "user"
      content: [{ type: "text", text: "auth.ts 里 salt 验证为什么失败?" }]

注意 parentId: e1——它指向”上一个节点”,不指向 session header。树形:

e1 (model_change)
 └── e2 (user message)

     leafId

第 3 步:Agent 调 read 工具,AssistantMessage 节点上树

Agent 决定先读文件,产生一个含 ToolCall 的 AssistantMessage:

e3: SessionMessageEntry
    parentId: e2
    message:
      role: "assistant"
      content: [
        { type: "text", text: "让我读一下 auth.ts" },
        { type: "toolCall", id: "call_001", name: "read",
          arguments: { path: "src/auth.ts" } }
      ]
      stopReason: "toolUse"

这一条 AssistantMessage 同时包含文本和工具调用——它们放在同一个 content 数组里,是同一个节点。不是”文本一个节点 + 工具调用一个节点”。这点第 6 章讲过,但读者容易忘,再强调一次。

e1 (model_change)
 └── e2 (user)
      └── e3 (assistant + ToolCall)

          leafId

第 4 步:read 工具返回结果,ToolResult 节点上树

工具执行完,产生一个 ToolResult 消息节点:

e4: SessionMessageEntry
    parentId: e3
    message:
      role: "toolResult"
      toolCallId: "call_001"     ← 关联到 e3 里的 ToolCall
      content: [{ type: "text", text: "export function verifySalt(s) { ... }" }]
      isError: false

注意 toolCallId 字段——它把这条 ToolResult 和触发它的 ToolCall 关联起来。这是第 5 章讲过的”工具结果必须精准关联回调用请求”在数据层的体现。

e1 (model_change)
 └── e2 (user)
      └── e3 (assistant + ToolCall)
           └── e4 (toolResult)

               leafId

第 5 步:Agent 给出分析,又一个 AssistantMessage

Agent 看到文件内容后回复分析:

e5: SessionMessageEntry
    parentId: e4
    message:
      role: "assistant"
      content: [{ type: "text", text: "问题在 23 行,salt 没编码" }]
      stopReason: "stop"

到现在为止,5 步操作产生了 5 个节点,全部挂在一条直线上——这就是”主分支”:

e1 (model_change)
 └── e2 (user: "salt 验证为什么失败?")
      └── e3 (assistant: read auth.ts)
           └── e4 (toolResult: auth.ts 内容)
                └── e5 (assistant: "问题在 23 行")

                    leafId

到这里你看到了”追加操作”——每一步只做两件事:创建新节点(带 parentId)+ 移动 leafId。没有任何旧节点被修改。

第 6 步:回退——这是关键转折

你对”问题在 23 行”这个分析不太满意,想换个思路。这时你执行回退——但没有删除任何节点

sessionManager.branch("e2");
// branch() 校验节点存在后,把内存中的 leafId 移到 e2

操作只有一行:leafId = "e2"。回退后树长这样:

e1 (model_change)
 └── e2 (user: "salt 验证为什么失败?")
      ├── e3 (assistant: read auth.ts)        ← 旧分支还在
      │    └── e4 (toolResult)                     数据完整保留
      │         └── e5 (assistant: "问题在 23 行")

      ↑ leafId 现在指回 e2

e3、e4、e5 一个都没删——它们还在树上,只是不在”当前路径”上。这就是 append-only 的核心:回退不是删数据,是移动指针

为什么要保留?因为你不知道以后会不会想回到旧分支。也许新思路走半天没结果,你想退回去看原来的”问题在 23 行”分析。如果回退时删了,就再也找不回来了。

第 7 步:换思路重新问——新分支自然长出

你从 e2 这条岔路口换了一个问题,产生新节点:

e6: SessionMessageEntry
    parentId: e2   ← 跟 e3 共享同一个父!
    message:
      role: "user"
      content: [{ type: "text", text: "先看 hash 函数的实现" }]

注意——e6 的 parentId 也是 e2,跟 e3 一样。这是分支的本质:两个节点共享同一个 parent,就是树上的两个分支

e1 (model_change)
 └── e2 (user: "salt 验证为什么失败?")
      ├── e3 (assistant: read auth.ts)
      │    └── e4 (toolResult)
      │         └── e5 (assistant: "问题在 23 行")

      └── e6 (user: "先看 hash 函数")    ← 新分支起点

          leafId

第 8 步:新分支继续长

Agent 在新分支上调 grep 工具,产生 3 个新节点(assistant + toolResult + assistant):

e1 (model_change)
 └── e2 (user: "salt 验证为什么失败?")
      ├── e3 (assistant: read auth.ts)
      │    └── e4 (toolResult)
      │         └── e5 (assistant: "问题在 23 行")

      └── e6 (user: "先看 hash 函数")
           └── e7 (assistant: grep hash)
                └── e8 (toolResult: grep 结果)
                     └── e9 (assistant: 新分析)

                         leafId

到这里示例树有 9 个节点,分成两个分支。正常分支操作保留了两条路径上的 entry——你可以回到 e5 所在分支继续工作,也可以沿 e9 所在分支推进。

这就是 Session Tree 的完整叙事:对话是一棵树,每条消息是一个节点,回退/分支不删数据,只是移动指针

Session Tree:append-only 的状态变化
Session Tree:append-only 的状态变化

配图说明:三幅快照展示树的逻辑演化——① 当前在 A2 → ② 用户回退到 A1(移动 leafId,A2 节点仍在树上变虚线)→ ③ 从 A1 长出新分支 B1→B2。正常分支操作会保留旧节点;迁移、修复和显式文件重写不受“只追加”这个逻辑模型约束。底部图例:红色 = leafId 当前位置,绿色 = 当前分支,虚线灰 = 当前路径之外但仍保留的分支。


三、树上节点的解剖

看完树怎么长出来,我们仔细看节点本身。

一个简化的 SessionMessageEntry 长这样

第 3 步产生的 AssistantMessage,在 .jsonl 文件中的结构大致如下。为了突出树字段,示例使用便于阅读的短 id,并省略了 apiprovider、message 内层的毫秒时间戳和完整 usage 明细;真实 v0.80.2 AssistantMessage 会携带这些字段,entry id 通常是 8 位十六进制字符串:

{
  "type": "message",
  "id": "e3",
  "parentId": "e2",
  "timestamp": "2026-07-03T10:23:45.000Z",
  "message": {
    "role": "assistant",
    "content": [
      { "type": "text", "text": "让我读一下 auth.ts" },
      { "type": "toolCall", "id": "call_001", "name": "read",
        "arguments": { "path": "src/auth.ts" } }
    ],
    "model": "model-a",
    "stopReason": "toolUse",
    "usage": { "input": 1250, "output": 80 }
  }
}

读这串 JSON 你就明白了 Session Tree 的全部精髓:

字段干什么设计动机
type: "message"区分节点类型树上不止消息,还有 model_change、compaction 等多种类型
id: "e3"节点唯一标识别的节点通过 parentId 指向它
parentId: "e2"指向父节点认父不认子——节点不知道自己有哪些子节点
timestamp创建时间排序、调试用
message真正的消息载荷role + content + model 等字段(第 6 章讲过)

关键点:entry 只持有 parentId。节点知道自己从哪来,但父节点本身不保存 children 列表。这样追加新子节点时无需改写父节点,很适合 append-only 的会话演进方式;要重建 children,getTree() 会扫描所有 entry,再把节点挂到各自父节点下。其他 append-only 表示法也可以额外存边,因此这是一种直接的实现选择,而不是唯一可能的数据结构。

9 种 Entry 类型,按职责分三组

第 2 节的例子出现了一个 model_change entry,以及 SessionMessageEntry 中的 user、assistant、toolResult 三种标准消息角色。coding-agent 一共定义了 9 种 Entry 类型;按“对重建结果的影响”分三组更容易理解:

第一组:进 LLM 上下文(4 种)

这 4 种会为重建后的消息上下文贡献内容;其中压缩摘要单独放到开头,自定义消息还会在后续 convertToLlm() 阶段投影成标准 user 消息:

类型产生什么消息例子
SessionMessageEntry标准消息,以及允许直接持久化的 coding-agent 消息User / Assistant / ToolResult;直接执行的 BashExecution
CustomMessageEntry重建为 CustomMessage,之后投影成 user 消息扩展注入、可选择是否在 TUI 显示的内容
CompactionEntryCompactionSummaryMessage(替换旧消息)第 9 章讲的压缩结果
BranchSummaryEntryBranchSummaryMessage(被抛弃分支的摘要)后面 §四 讲

第二组:影响后续 LLM 调用(2 种)

这 2 种不产生消息,但改变后续 LLM 调用的参数:

类型改变什么例子
ModelChangeEntry后续用哪个模型第 1 步切到 model-a
ThinkingLevelChangeEntry后续的思考级别用户调整思考强度

第三组:纯元数据,不影响 LLM(3 种)

这 3 种既不产生消息、也不改 LLM 参数,纯粹是给 UI 或扩展用的:

类型干什么
LabelEntry给节点贴书签(“这里是关键点”)
SessionInfoEntry会话元信息(当前实现可记录名称)
CustomEntry扩展自己存的元数据

为什么要把类型分这么细? 因为 buildSessionContext()(下一节讲)需要按类型分派处理——是消息就进 messages 数组,是状态变更就改状态变量,是元数据就跳过。如果只有一种类型,处理逻辑就得塞满 if-else,可读性和扩展性都差。

SessionEntry 联合类型的 9 个 type 分支
SessionEntry 联合类型的 9 个 type 分支

配图说明:所有 Entry 共享基础字段(type / id / parentId / timestamp),按“对重建结果的影响”分三组——① 贡献上下文内容(4 种,红色);② 影响状态(2 种,黑色,更新 model / thinkingLevel);③ 纯元数据(3 种,虚线灰色,buildSessionContext() 跳过)。图内使用精确的 type 判别值,正文表格列出对应接口名;这个分类对应下一节的派发逻辑。

本节的 9 种类型定义在 packages/coding-agent/src/core/session-manager.tsSessionEntry 联合类型。agent-core 另有一套 11 种类型的 SessionTreeEntry,见 §七。

为什么这里选择“认父不认子”

回到 parentId 这个设计。如果改成”认子不认父”——子节点没 parentId,但父节点有 children 列表——会发生什么?

回到第 7 步,你要从 e2 长出新分支 e6。如果是”认子”,e2 的 children 列表从 [e3] 改成 [e3, e6]——这需要修改 e2。但 append-only 规定节点不可变,这就矛盾了。

所以 Pi 选择让新节点记录 parentId:当前实现追加节点时不必修改旧节点。也可以设计独立的边记录来达到类似效果;这里重要的是把“新增关系”也表示为追加数据,而不是断言树只能用一种方式表达。


四、三个核心操作 + 分支摘要

有了第 2 节的具体例子,现在可以把三个核心操作 + 分支摘要一起讲清楚。

操作 1:追加——创建新节点,不改旧节点

追加操作只有三步:

1. 创建新 Entry(含自己的 id、parentId 指向当前 leafId、payload)
2. 存入 byId 映射表(id → entry)
3. leafId = 新 entry 的 id

回到第 3 步(产生 e3 节点):

const e3Id = sessionManager.appendMessage(assistantMessage);
// appendMessage() 先生成 id,并把 parentId 设为当前 leafId;
// 随后私有的 _appendEntry() 才执行:
//   fileEntries.push(entry);
//   byId.set(entry.id, entry);
//   leafId = entry.id;
//   _persist(entry);

没有修改 e2——只是创建新 entry 时让它的 parentId 指向当时的 leafId(本例为 e2)。appendMessage() 是公开入口;私有 _appendEntry(entry) 再把 entry 加入 fileEntriesbyId、移动 leaf,并交给 _persist() 处理持久化。byId 用于按 id 找节点;需要完整树时,getTree() 扫描 entry,并借助节点映射把每个 child 挂到其 parent 下。

操作 2:回退——只移动 leafId

branch(branchFromId: string): void {
    if (!this.byId.has(branchFromId)) {
        throw new Error(`Entry ${branchFromId} not found`);
    }
    this.leafId = branchFromId;   // 核心就是这一行
}

核心就是 leafId = branchFromId 这一行(外加一个存在性检查防止指向不存在的节点)。没有删除 e3、e4、e5——它们还在 byId 里,还在 .jsonl 文件里。回退只是说”以后追加新节点时,parentId 指向 e2 而不是当前最后那个节点”。

操作 3:分支——回退后追加的自然结果

回退到 e2 后再追加 e6,e6 的 parentId 自动就是 e2——这就是分支。分支不是独立的操作,是”回退 + 追加”的组合效果

分支摘要:BranchSummaryEntry——回退时的可选项

回到第 6 步。你从 e5 回退到 e2,旧分支(e3-e5)就成了”被抛弃的分支”。这些数据完整保留在树上,但当前路径的 LLM 看不到它们(因为 buildSessionContext 只走当前路径)。

有时候你希望当前路径的 Agent 大致知道之前试过什么——不用看完整对话,知道个大概就行。比如“之前的尝试发现 salt 编码有问题,但没解决根因”。低层的 SessionManager.branchWithSummary() 接收已经生成好的摘要,把它挂到指定节点;下面先用它展示“摘要挂在 e2 下”的树形效果:

// generatedSummary 由 AgentSession 的树导航流程提前生成
const summaryId = sessionManager.branchWithSummary("e2", generatedSummary);
// 新 entry 的 parentId 是 e2,leafId 随后移到 summaryId

挂上去后树变成:

e2 (user)
 ├── e3 (assistant: read auth.ts)
 │    └── e4 (toolResult)
 │         └── e5 (assistant: "问题在 23 行")

 └── e_BranchSummary
      BranchSummaryEntry:
      "试过 read auth.ts,发现 salt 编码问题,未解决根因"
      └── e6 (user: "先看 hash 函数")
           ...

产品层的高层入口是 AgentSession.navigateTree(targetId, { summarize: true }),但它对 user 节点有特殊语义:导航到 e2 这样的 user entry 时,会把 newLeafId 设为 e2 的父节点 e1,并把 e2 的文本放回编辑器;如果生成摘要,摘要也挂在 e1 下。custom_message 目标同样退到父节点并恢复编辑文本;只有其他类型的目标才直接把所选节点作为 newLeafId。因此上图准确展示的是显式调用 branchWithSummary("e2", ...) 的低层结果,不能直接当作 navigateTree("e2", { summarize: true }) 的结果。

BranchSummaryEntry 跟普通消息的区别:它表示离开路径的摘要,不是真实发生的一轮对话。buildSessionContext() 会把它重建为 BranchSummaryMessage;它与压缩产生的 CompactionSummaryMessage 是不同的 AgentMessage 类型。后续 convertToLlm() 再把两者分别包装成带说明与 <summary> 标签的 UserMessage。因此新分支能获得旧路径摘要,而不必携带旧路径的全部消息。

这是可选的——不需要摘要时,高层树导航会根据目标类型调用 branch()resetLeaf(),或对 user / custom_message 目标退到其父节点并恢复编辑文本。需要摘要时,分支专用 prompt 负责生成内容,再由 branchWithSummary() 在计算出的 newLeafId 下追加 BranchSummaryEntrybranchWithSummary() 自身不会调用模型。

实现:packages/coding-agent/src/core/agent-session.tsnavigateTree() 负责摘要工作流,packages/coding-agent/src/core/compaction/branch-summarization.ts 负责生成摘要,session-manager.tsbranchWithSummary() 负责挂载。


五、从树到 LLM 上下文:buildSessionContext

树长好了,但 LLM 调用链不认识树。coding-agent 在恢复会话,或在树导航、压缩等操作后重建 Agent 状态时,用 buildSessionContext() 先把当前路径“压扁”为线性的 AgentMessage[];调用模型前,convertToLlm() 再把它归一化为 pi-ai 内部的三角色 Message[](user / assistant / toolResult)。具体 Provider 的 HTTP 消息格式并不统一,随后还要由各 Provider adapter 继续转换。普通的后续模型请求直接使用已经维护在 agent.state.messages 中的线性状态,并不会每次都重扫整棵树。

为什么这一步必须存在

沿着调用边界看,形态依次是:Session Tree → 当前路径的 AgentMessage[]convertToLlm()pi-aiMessage[] → Provider adapter 所需的 HTTP 请求。远端模型不会接收到 Session Tree,也不需要知道本地历史是树形还是数组。

所以无论你的内部数据结构多复杂,pi-ai 内部消息边界必须先变成线性。这是 Session Tree 的”出口”——树的存储形态是树形,重建结果先是线性的 AgentMessage[],再投影成统一的 Message[];Provider adapter 之后仍可按各家协议改造成别的载荷结构。

第一步:路径遍历——从 leaf 往回走到 root

回到我们的例子,当前 leafId 是 e9。buildSessionContext 先从 e9 往回走到根,收集路径上的所有 entry:

const path: SessionEntry[] = [];
let current = byId.get(leafId);   // e9
while (current) {
    path.push(current);   // 先按 leaf → root 顺序收集
    current = current.parentId ? byId.get(current.parentId) : undefined;
}
path.reverse();           // 反转为 root → leaf 顺序

走完之后,path 数组(从 root 到 leaf 的顺序)是:

[e1, e2, e6, e7, e8, e9]

注意 e3、e4、e5 不在 path 里——它们不在当前分支上。这就是”当前路径”的含义——只看 leaf 到 root 这一条线,其他分支的数据不会发给 LLM。

第二步:按类型分派处理

路径上的每个 entry,按类型决定怎么处理:

e1 (model_change)   → 更新状态变量 model = "model-a",不进 messages
e2 (user message)   → 推入 messages 数组
e6 (user message)   → 推入 messages 数组
e7 (assistant + ToolCall) → 推入 messages 数组
e8 (toolResult)     → 推入 messages 数组
e9 (assistant)      → 推入 messages 数组

最后构建出来的 messages 数组长这样:

[
  { role: "user", content: "auth.ts 里 salt 验证为什么失败?" },        // e2
  { role: "user", content: "先看 hash 函数的实现" },                    // e6
  { role: "assistant", content: [{ text: ... }, { toolCall: grep ...}] }, // e7
  { role: "toolResult", toolCallId: "call_002", content: ... },        // e8
  { role: "assistant", content: [{ text: "新分析..." }] }              // e9
]

注意一个微妙的事情:e2 和 e6 都是 user 消息,连续两条 userbuildSessionContext() 只负责解析树,coding-agent 的 convertToLlm() 也会原样透传标准 user 消息,并不在这里合并。若目标 API 要求角色规范化,应由对应 Provider 适配器在请求转换阶段处理。

状态变量:覆盖式提取

model_changethinking_level_change 不进 messages 数组,但它们影响”用什么参数调用 LLM”。提取方式是覆盖式——沿路径从 root 走到 leaf,遇到变更就覆盖:

e1 (model_change: "model-a")  → model 变量 = "model-a"
e2-e9(没有 model_change)    → model 变量保持不变

如果路径上有多条 model_change(比如先切到 model-a、再切到 model-b、最后切回 model-a),最后一次覆盖生效——这跟“最后生效”的语义一致。

模型恢复有两个来源model 初始值是 null。沿路径遇到 ModelChangeEntry 时,记录“之后希望使用的模型”;遇到 AssistantMessage 时,又用该消息自己的 providermodel 覆盖,记录“这次响应实际由哪个模型生成”。因此 AssistantMessage 确实携带模型信息buildSessionContext() 会读取它。若两类信息都不存在,调用方才使用启动时配置的模型。

这就是为什么 Pi 仍把“切换模型”存成节点:它能在下一条 assistant 消息产生之前立即表达新的活动模型,也保留“何时切换”的历史。回退后,当前路径之外的变更不会参与重建;路径上的 assistant 消息又能提供实际生成模型作为恢复依据。

CompactionEntry 的特殊处理:选择性收集

第 9 章讲压缩时说”压缩结果替换掉被压缩的旧消息”——具体怎么替换,就在这一步。假设路径上有 CompactionEntry:

e1 (user)              ← 这之前是早期对话(已被压缩)
e2 (assistant)         ← 被压缩
e3 (assistant)         ← 保留区第一条
e4 (compaction)        ← 压缩节点,记录了 firstKeptEntryId = "e3"
e5 (user)              ← 压缩后保留的近期消息
e6 (assistant)

buildSessionContext 遍历到 e4 时,不是简单地”停止收集之前的消息”,而是按 firstKeptEntryId 选择性收集

  1. 先生成一条 CompactionSummaryMessage(来自 e4 的 summary 字段),推入 messages 数组开头
  2. 在 e4 之前的 entry 中,只收集 firstKeptEntryId(e3)及之后的——e1、e2 被丢弃,e3 保留
  3. e4 之后的所有 entry 正常收集

最终的 messages 数组:

[
  CompactionSummaryMessage ( e4 生成),   // 替换了 e1、e2
  { role: "assistant", ... },              // e3(保留区第一个)
  { role: "user", ... },                    // e5
  { role: "assistant", ... },              // e6
]

这就是第 9 章说的“压缩结果替换旧消息”的具体实现——正常流程没有删除 e1、e2,而是 buildSessionContext() 在包含该压缩节点的路径上按 firstKeptEntryId 跳过它们。如果把 leaf 移到 e4 之前,重建路径不再包含 e4,e1-e3 又会作为普通消息出现;压缩改变的是当前路径的上下文视图。

注意 firstKeptEntryId 是 CompactionEntry 自己记录的字段——它在压缩发生时就计算好了”保留哪些近期消息”。第 9 章讲的”找切割点”就是为了确定这个 firstKeptEntryId。

实现:packages/coding-agent/src/core/session-manager.tsbuildSessionContext,路径遍历和分派处理的核心逻辑


六、JSONL 持久化的具体细节

§一 已经讲了”为什么选 JSONL 文件”,这一节展开”具体怎么写”。

格式:一行一个 Entry

第 2 节那个会话,在磁盘上的结构可以简写成下面这样。为方便对应示意树,这里继续使用 e1、e2 这样的教学 id;这是一份结构草图,省略了 message 载荷的若干必需字段,例如三种标准消息内层的毫秒时间戳、AssistantMessage 的 api / provider / model / 完整 usage,以及 ToolResultMessage 的 toolName。真实 entry 通常使用 8 位十六进制 id,真实 JSONL 的每一行都是完整 JSON:

{"type":"session","version":3,"id":"UUIDv7","cwd":"/project","timestamp":"2026-07-03T10:00:00Z"}
{"type":"model_change","id":"e1","parentId":null,"provider":"provider-a","modelId":"model-a","timestamp":"2026-07-03T10:00:05Z"}
{"type":"message","id":"e2","parentId":"e1","message":{"role":"user","content":[{"type":"text","text":"auth.ts 里 salt 验证为什么失败?"}]},"timestamp":"2026-07-03T10:23:00Z"}
{"type":"message","id":"e3","parentId":"e2","message":{"role":"assistant","content":[{"type":"text","text":"让我读一下 auth.ts"},{"type":"toolCall","id":"call_001","name":"read","arguments":{"path":"src/auth.ts"}}],"stopReason":"toolUse"},"timestamp":"2026-07-03T10:23:30Z"}
{"type":"message","id":"e4","parentId":"e3","message":{"role":"toolResult","toolCallId":"call_001","content":[{"type":"text","text":"export function verifySalt(s) { ... }"}],"isError":false},"timestamp":"2026-07-03T10:23:31Z"}
{"type":"message","id":"e5","parentId":"e4","message":{"role":"assistant","content":[{"type":"text","text":"问题在 23 行,salt 没编码"}],"stopReason":"stop"},"timestamp":"2026-07-03T10:24:00Z"}
{"type":"message","id":"e6","parentId":"e2","message":{"role":"user","content":[{"type":"text","text":"先看 hash 函数的实现"}]},"timestamp":"2026-07-03T10:30:00Z"}
{"type":"message","id":"e7","parentId":"e6",...}

第一行是 Session Header(type: "session"),记录会话元信息(cwd、版本号等)。后续每行是一个 Entry。

几个值得注意的细节

  1. e6 的 parentId 是 e2——通过这个字段就能在文件里看出”这里有个分支”。用 grep '"parentId":"e2"' 就能找到所有从 e2 长出来的子节点
  2. Session Header 与 Entry 外层的 timestamp 是 ISO 字符串——人类可读,调试时一眼看出顺序;message 载荷自己的 timestamp 则是 Unix 毫秒数
  3. entry id 是 8 位十六进制短 ID(如 a1b2c3d4),由 UUID 截取后在当前会话索引中做碰撞检查;Session Header 的 id 则默认用 UUIDv7

JSONL 在正常写入路径上的优势是行级追加:首次 flush 后,新 entry 通常可直接通过 appendFileSync 写到文件末尾,无需为了每条消息解析并重写一个完整 JSON 文档。它与逻辑上的追加式会话演进相配合,但并不意味着文件永远不会重写——例外见下文。

实现:packages/coding-agent/src/core/session-manager.ts_appendEntry()_persist()

延迟写入:避免”有问无答”的半截对话

有一个写入策略细节:首次 assistant 消息到达前,写入会延迟。具体规则分四种情况:

已有 assistant?已 flushed?行为
没有已 flushed立即 append 当前 entry
没有未 flushed标记为”未 flushed”,不写盘——等 assistant 到来
未 flushedopenSync("wx") 独占创建新文件,再写入 header 与所有积压 entry,标记为已 flushed
已 flushed立即 append 当前 entry

为什么要这套逻辑?新会话在出现第一条 assistant 消息前不创建文件,可避免留下只有 header 或只有用户输入的新文件。第一条 assistant 也可能是错误响应,所以这不是“业务任务完成”的保证,只是持久化起点的约束。

"wx" 保证目标文件不存在时才创建,防止覆盖同名文件;随后多次 writeFileSync 并不构成“整文件原子提交”。进程在写入中途崩溃仍可能留下部分文件,加载逻辑会跳过坏行或在缺少有效 header 时重建。

之后所有 entry 都立即 append——首次 flush 后这个机制就不再起作用,因为已经”上了轨道”。

偶尔的全文件重写

虽然日常追加用 appendFileSync,以下路径会创建或重写完整文件:

  • 新会话首次 flush:一次写入 header 和此前积压的 entries
  • 迁移旧 session version:内存迁移后写回当前格式
  • 打开空文件或没有有效 header 的文件:重建最小有效会话
  • 从当前路径创建分支副本:向新文件写入重链后的路径与标签
  • 跨项目 fork:独占创建新 header,再复制源文件的非 header entries

这里的 append-only 主要描述正常会话演进的逻辑模型,不是“底层文件永远只调用 append”。迁移和修复会原地重写;分支副本与 fork 会写新文件。

实现:packages/coding-agent/src/core/session-manager.ts_rewriteFile()


七、两层实现:接口允许换数据库

Session Tree 有两层实现,呼应 §一 讲的”接口允许换数据库”:

agent-core 层coding-agent 层
API 风格异步同步
Entry 类型11 种9 种
存储SessionStorage 接口(可插拔)独立实现,直接操作 JSONL
用途通用框架层编码 Agent 产品层

agent-core 提供通用 Session API 和 SessionStorage 接口。coding-agent 的 SessionManager 并未实现这个接口:它自己定义 9 种 SessionEntry 并直接操作 JSONL;agent-core 则定义 11 种 SessionTreeEntry,包括额外的 active_tools_change 与显式 leaf entry。两套实现平行存在,名字相似不等于共享同一个类型。

这种“接口存在但 coding-agent 不复用”的安排有两个含义:(1)其他宿主可以在 agent-core 的接口后实现数据库存储,只要完整满足树、leaf、entry id 与查询语义;迁移、并发和运维仍由该宿主负责;(2)不能直接把 coding-agent 的 SessionManager 当作 agent-core 的 SessionStorage 使用——它们签名不兼容。这是 §一 所说“其他宿主可替换存储”的准确范围。

SessionStorage 接口在 packages/agent/src/harness/types.ts:440;两个参考实现:packages/agent/src/harness/session/jsonl-storage.ts(文件)和 memory-storage.ts(内存);coding-agent 独立实现在 packages/coding-agent/src/core/session-manager.ts:758


八、总结

一条主线:会话存储的两个独立维度

回头看你能在这一章带走的最核心的东西——会话数据怎么存,要拆成两个独立问题来想

维度回答什么一般做法Pi 的选择
存储介质存哪里?MySQL 等数据库本地 JSONL 文件(agent-core 接口允许其他宿主替换)
数据结构长什么样?线性数组树(Session Tree)

这两个维度可以独立做选择。下次你设计任何”对话历史持久化”或类似的状态保存系统,先问自己这两个问题,再决定方案——不要把它们粘成一团。

Session Tree 的本质:用树形 + append-only 实现”不丢数据的回退”

为什么树?因为对话不是线性的——用户会回退、重试、分支。 为什么 append-only?因为删了的数据找不回来,而历史分支可能有价值。 为什么只记录 parentId?因为这样追加子节点时无需改写父节点,getTree() 可在读取时重建 children。 为什么路径遍历?因为 coding-agent 要先重建线性 AgentMessage[],再经 convertToLlm()pi-ai 交付 Message[];树形会话必须在这些消息边界前“压扁”,之后各 Provider adapter 再按远端协议转换。

这一连串设计选择是连贯的——每一个选择都回应了上一个选择带来的约束,最终形成一套自洽的方案。

三个可迁移的思路

1. 拆开”存哪里”和”长什么样”两个维度。 设计持久化系统时,先分别想清楚这两个问题,再组合方案。混在一起讨论会让选择空间被压缩——你会以为”用了数据库就必须线性数组”或”用了文件就必须 append-only”,其实不然。

2. append-only 数据结构适合”撤销/回退/分支”场景。 用 append-only + 指针定位(leafId)保留历史,代价是文件会持续增长,需要另行考虑归档和敏感数据清理。

3. 节点化状态变量,让路径重建包含历史状态。 Pi 把“切换模型”“调整思考级别”也存成节点(ModelChangeEntry 等),而不是只放在一个会丢失历史的全局对象中。重建时只读取当前路径上的变更,并用路径中 AssistantMessage 的实际 provider / model 补充模型恢复信息;路径外的变更不会参与结果。


九、下一站

到本章为止,主线从模型适配一路走到会话持久化:Agent Loop(第 3 章)、模型调用(第 4 章)、工具与消息(第 5–6 章)、事件与上下文(第 7–9 章),最终都落到 Session Tree 的可恢复状态上。

本教程在第 10 章收束。扩展系统值得单独成章,但当前仓库尚未发布对应正文;因此这里不再指向不存在的旧版路径。


本章关键源码索引

  • packages/agent/src/harness/types.ts — agent-core 的 SessionTreeEntry 类型与 SessionStorage 接口
  • packages/agent/src/harness/session/jsonl-storage.ts — JsonlSessionStorage 实现
  • packages/agent/src/harness/session/memory-storage.ts — InMemorySessionStorage(内存实现,常用于测试或临时会话)
  • packages/coding-agent/src/core/session-manager.ts — coding-agent 的 SessionManager(buildSessionContext、appendMessage、私有 _appendEntry、branchWithSummary 等)
  • packages/coding-agent/src/core/compaction/branch-summarization.ts — 分支摘要生成