Skip to content
BaiRuic
Go back

Pi Agent 的 Session Tree(上):会话历史如何保存

目录

在构建基于大语言模型(LLM)的应用时,消息(Message)的设计往往决定了系统的扩展上限。此前在探讨 LLM App 的 Message 设计 时,我们已经建立了清晰的三层消息分层模型:

+---------------------------------------------------------------+
| App Message (如 AgentMessage)                                 |
| 表达应用运行时丰富状态 (工具轨迹、UI标记、自定义扩展数据)       |
+---------------------------------------------------------------+
                                |
                                | transformContext() & convertToLlm()
                                v
+---------------------------------------------------------------+
| Model Message (如 Message)                                    |
| 干净的跨厂商通用输入 (user / assistant / toolResult)           |
+---------------------------------------------------------------+
                                |
                                | Provider Adapter
                                v
+---------------------------------------------------------------+
| Provider Message (OpenAI / Claude Wire Format)                |
| 厂商网络请求协议 (tool_calls / content blocks)                 |
+---------------------------------------------------------------+
  • Provider Message:厂商原生 API 协议(如 OpenAI 的 tool_calls 或 Claude 的 content blocks)。
  • Model Message:应用在调用模型前清洗出的跨厂商通用上下文(userassistanttoolResult)。
  • App Message:应用运行时的完整业务状态(如 AgentMessage),包含了工具执行轨迹、UI 通知、外部系统事件与自定义扩展数据。

然而,App Message[] 数组只能记录一次执行轮次(Turn)在内存中的完整状态。当面对 Coding Agent、工作流助手这类需要长期交互的系统时,还需要解决一个问题:会话历史(Session History)应该如何保存?

如果简单地把会话历史看作一个随时间增长的线性消息数组(messages: AgentMessage[]),那么当用户想从某个历史节点重新尝试,或者程序在执行过程中发生崩溃时,系统就必须删除或重建后续消息。这样不仅难以保留不同的尝试过程,也容易丢失与当前分支对应的运行状态。

本文基于开源 AI Agent 框架 pi(包括其底层 agent-core 运行时与上层 coding-agent)的架构设计,从存储介质与数据结构两个维度展开,通过跟踪一次真实的开发调试会话,介绍 Session Tree 如何组织会话历史、为什么采用只追加的方式保存数据,以及不同 Entry 类型在其中承担的作用。


一、 会话存储的两个独立维度:介质与形态

当我们试图把会话从内存保存下来时,需要分别回答两个问题:数据存在哪里,以及数据在逻辑上如何组织。

+--------------------------------------+       +------------------------------------+
| 维度一:存到哪里?(Storage Medium)    |       | 维度二:长什么样?(Data Structure)  |
+--------------------------------------+       +------------------------------------+
| - 关系型/文档数据库 (MySQL/Postgres) | <===> | - 线性消息数组 (Linear Messages)   |
| - 本地文件系统 (JSONL 纯文本日志)    |       | - 会话树 (Session Tree)            |
+--------------------------------------+       +------------------------------------+

如果混淆这两个问题,就容易误以为“用了数据库就必须存线性数组”,或者“用了文件就必须存纯文本”。

1. 维度一:存到哪里?(存储介质与边界隔离)

  • 数据库存储:适合多租户、分布式 Web 服务。agent-core 提供了通用的异步 SessionStorage 接口抽象(支持 JsonlSessionStorageInMemorySessionStorage)。
  • 本地 JSONL 文件:针对本地运行的 CLI 工具(如 coding-agent),JSONL 文件是极佳选择:
    • 项目隔离:会话文件自然跟随工作目录(cwd)存放于 .pi/sessions/ 下,切换项目即切换会话历史。
    • 零运维依赖:无需启动任何常驻数据库守护进程,开箱即用。
    • 透明可调试:纯文本格式,开发者可以直接使用 catgrepjq 或编辑器排查会话数据。

pi 项目中,agent-core 提供通用的 SessionStorage 接口;coding-agentSessionManager 则直接操作本地 JSONL 文件。两者分别服务于通用存储和本地 CLI 场景。

2. 维度二:长什么样?(线性数组 vs. Append-Only 会话树)

存储方式确定后,还需要决定:会话数据应该如何组织?

  • Linear Messages(messages[]:普通聊天应用的默认形态。但在编码 Agent 的高频试错场景下,线性数组面临根本性缺陷——一旦用户回退或重试,就必须物理删除后续的历史数据,导致已探索出的成果彻底丢失。
  • Session Tree 与 Append-Only Log 模型
[传统可变更新 (In-Place Mutation)]
Msg 1 ───> Msg 2 ───> Msg 3 (若回退重试,直接覆写或物理截断)

[仅追加模型 (Append-Only Log)]
Entry 1 ───> Entry 2 ───> Entry 3 ───> Entry 4 (新追加,永不修改/删除历史)
(只读不可变)  (只读不可变) (只读不可变)  (指针单向指向前驱)

Session Tree 建立在 Append-Only Log 存储哲学之上:

  1. 不修改或删除已有记录:新增对话、切换模型、重命名会话和添加书签,都会在日志末尾追加一条新的 Entry
  2. 回到历史节点:回退时只需移动指向当前叶子的 leafId,之前的消息、错误日志和修改记录都会保留。
  3. 按顺序写入文件appendFileSync 不需要重写整个 JSONL 文件;如果流式生成中断,已经写入的记录仍然保留。

这种基于树结构的设计主要解决两个问题

  • Branching(无损分支):允许从任意历史节点派生新思路,多条探索链路在同一个 Session 中完整共存。
  • Branch Summarization(分支摘要):在放弃某条深度探索的长分支时,自动提取该分支的教训与结论挂载到分叉点,使新分支汲取经验而不承受冗余的 Token 负担。

二、 跟踪一次真实 Debug Session:看 Session Tree 如何步步生长

我们模拟一个排查认证 Bug 的 8 步交互场景,观察树拓扑与磁盘 Entry 的演化:

步骤 1: 切换模型至 Claude 3.7
步骤 2: 用户提问 "auth.ts 里的 salt 校验为什么失败?"
步骤 3: Agent 决定调用 read 工具读取 auth.ts
步骤 4: read 工具执行完毕,返回文件内容
步骤 5: Agent 分析并回复 "问题在第 23 行,salt 缺少 Base64 编码"
步骤 6: 用户对该推断存疑,决定回退到步骤 2
步骤 7: 用户换方向提问 "先帮我看 hash 函数的实现"
步骤 8: Agent 调用 grep 工具给出全新分析

第 1 步:切换模型(根节点建立)

会话文件首先写入 Session Header(记录会话级元信息,含 UUIDv7 格式的会话 ID)。用户切换模型产生第一个 Entry:

{
  "type": "model_change",
  "id": "a1b2c3d4",
  "parentId": null,
  "provider": "anthropic",
  "modelId": "claude-3-7-sonnet",
  "timestamp": "2026-08-19T10:00:00Z"
}
e1 (model_change)

leafId (指向 e1)

两种 ID 的设计考量

  • 会话 ID(Session ID):采用 UUIDv7。UUIDv7 具备时间单调递增特性,确保基于 Session 的请求在 Provider 缓存与请求路由上具备极高的时间局部性。
  • 条目 ID(Entry ID):采用 8 位短 UUID(如 a1b2c3d4),在单会话内保证唯一性的同时,在 CLI/TUI 终端交互中具备极高的人类可读性。

第 2 步:用户提问(单向挂载)

用户提问,系统生成 MessageEntry,其 parentId 指向当前 leafIde1):

{
  "type": "message",
  "id": "e2",
  "parentId": "e1",
  "timestamp": "2026-08-19T10:00:10Z",
  "message": {
    "role": "user",
    "content": [{ "type": "text", "text": "auth.ts 里的 salt 校验为什么失败?" }]
  }
}
e1 (model_change)
 └── e2 (user message)

     leafId

第 3 步:Agent 触发工具调用(文本与工具调用同节点)

Agent 输出回复并要求读取文件。在消息设计中,文本与 toolCall 处于同一个 Content 数组,作为同一个 AssistantMessage 挂载:

{
  "type": "message",
  "id": "e3",
  "parentId": "e2",
  "timestamp": "2026-08-19T10:00:15Z",
  "message": {
    "role": "assistant",
    "content": [
      { "type": "text", "text": "我先查看一下 auth.ts 的内容。" },
      { "type": "toolCall", "id": "call_001", "name": "read", "arguments": { "path": "src/auth.ts" } }
    ],
    "stopReason": "toolUse"
  }
}
e1 (model_change)
 └── e2 (user)
      └── e3 (assistant + toolCall)

          leafId

第 4 步:工具执行返回(ToolResult 挂载)

工具执行完成,产生 toolResult 节点,通过 toolCallId: "call_001" 关联回调用请求:

{
  "type": "message",
  "id": "e4",
  "parentId": "e3",
  "timestamp": "2026-08-19T10:00:16Z",
  "message": {
    "role": "toolResult",
    "toolCallId": "call_001",
    "content": [{ "type": "text", "text": "export function verifySalt(s) { ... }" }],
    "isError": false
  }
}
e1 (model_change)
 └── e2 (user)
      └── e3 (assistant + toolCall)
           └── e4 (toolResult)

               leafId

第 5 步:Agent 输出分析结论

Agent 输出推断结论:

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

                    leafId

第 6 步:关键转折 —— 用户回退(Backtracking)

用户对推断存疑,决定回退到提问前的节点 e2

在 Append-Only Session Tree 中,回退操作的代码只有一行:

function branch(branchFromId: string): void {
  if (!byId.has(branchFromId)) {
    throw new Error(`Entry ${branchFromId} not found`);
  }
  this.leafId = branchFromId; // 仅修改内存中的游标指针
}

此时的树拓扑:

e1 (model_change)
 └── e2 (user: "salt 校验为什么失败?")
      ├── e3 (assistant: read auth.ts)        ← 旧分支数据完整保留
      │    └── e4 (toolResult)                     磁盘与内存均无删除
      │         └── e5 (assistant: "第 23 行...")

      ↑ leafId 重置为 "e2"

O(1)O(1) 的指针移动即完成回退。e3 -> e4 -> e5 没有任何一条数据被从磁盘或内存中抹除。

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

用户从 e2 重新提问,系统生成 e6。因为当前 leafIde2,新 Entry 的 parentId 自然设为 e2

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

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

          leafId

e3e6 共享同一个父节点 e2,分支在数据层面自然形成。

第 8 步:新分支继续生长

Agent 在新分支下执行 grep 并给出回答,依次追加 e7e8e9

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: "hash 实现正常...")

                         leafId

三、 节点结构与单向 parentId 指针设计

1. 为什么必须采用单向 parentId 指针?

在传统内存树中,父节点通常维护子节点列表(children: Node[])。但在不可变持久化体系中,这种“正向持有”是无法成立的:

[正向指针模型 (违背 Append-Only)]
Parent e2 ───> [ Child e3 ]

   └── 追加 e6 必须原地修改 e2 的 children 列表 ──> [ Child e3, Child e6 ] (产生覆写)

[单向 parentId 指针模型 (符合 Append-Only)]
Parent e2 (永久只读不可变)
   ▲             ▲
   │ parentId    │ parentId
Child e3      Child e6 (新节点追加挂载,e2 零改动)
  • 如果采用“父持有子”:当我们在第 7 步从 e2 分叉出 e6 时,e2 的子节点列表就必须从 [e3] 改写为 [e3, e6]。为了记录这个变化,我们不得不去磁盘和内存中重新修改已写入的 e2。这就直接打破了 Append-Only 原则。
  • 采用“子引用父”(单向 parentId 指针):新节点 e6 产生时,只需在自身的 payload 中声明 parentId: "e2"。父节点 e2 本身无需发生任何改动。
  • 从磁盘记录还原树形结构:磁盘中的每个节点只保存自己的 parentId。加载时建立 byId: Map<string, SessionEntry> 映射表,就可以找到每个节点的子节点并向下遍历整棵树。

2. 内存树构建算法(getTree

当需要向 UI/TUI 终端组件展示完整的树形分支选择器时,系统通过 byId 映射表在内存中重建完整的树形结构:

function getTree(entries: SessionEntry[]): SessionTreeNode[] {
  const nodeMap = new Map<string, SessionTreeNode>();
  const roots: SessionTreeNode[] = [];

  // 第一步:构建全量节点映射表
  for (const entry of entries) {
    nodeMap.set(entry.id, { entry, children: [] });
  }

  // 第二步:通过 parentId 单向指针自底向上连接父子关系
  for (const entry of entries) {
    const node = nodeMap.get(entry.id)!;
    if (entry.parentId === null) {
      roots.push(node); // 根节点 (通常是会话第一个 Entry)
    } else {
      const parentNode = nodeMap.get(entry.parentId);
      if (parentNode) {
        parentNode.children.push(node); // 挂载到父节点的 children 列表中
      } else {
        roots.push(node); // 容错处理:若遇到断链的孤儿节点,作为独立根节点返回
      }
    }
  }

  // 第三步:按时间戳对子节点排序,确保视觉分支自上而下按时间顺序展开
  const stack: SessionTreeNode[] = [...roots];
  while (stack.length > 0) {
    const node = stack.pop()!;
    node.children.sort((a, b) => new Date(a.entry.timestamp).getTime() - new Date(b.entry.timestamp).getTime());
    stack.push(...node.children);
  }

  return roots;
}

3. 9 种 Entry 类型及其作用(全部遵循 Append-Only)

很多开发者初次接触会话树时,会误以为“树上只存对话消息”。实际上,为了在回退时恢复正确的分支和上下文,pi 一共定义了 9 种 Entry 类型。

这 9 种类型可以按照对后续 LLM 调用的影响清晰划分为三组:

第一组:参与 LLM 上下文构建(4 种)

这 4 种节点最终会被转换为发送给 LLM 的具体消息项:

  1. MessageEntry:最基础的对话消息。其 payload 包裹了 AgentMessage(包含 userassistanttoolResult)。值得注意的是,MessageEntry 节点本身在树中处于平等地位——无论是一条用户提问还是一条助手回复,都是树上的一个节点,都拥有 parentId,都可以作为未来分叉的起点。
  2. CustomMessageEntry:扩展插件注入的模型上下文消息(例如代码审查规则、动态注入的文件树)。与纯扩展数据不同,它会被转换成一条特殊的 UserMessage 发给模型,并可控制在 TUI 中是否以特定高亮样式渲染。
  3. CompactionEntry:上下文压缩摘要节点。记录了当对话超长时由 LLM 生成的高密度阶段性摘要,以及从哪个节点开始保留原文(firstKeptEntryId)。
  4. BranchSummaryEntry:废弃分支经验摘要节点。记录当用户离开一条长探索分支时,系统为该分支生成的经验总结。

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

这 2 种节点不产生任何发给模型的文本消息,但会改变后续调用模型时的请求配置: 5. ModelChangeEntry:记录模型切换操作(如从 Sonnet 切换至 Opus)。 6. ThinkingLevelChangeEntry:记录思考强度或 Reasoning 预算的调整操作。

为什么必须把配置变更存成树节点? 如果把当前模型配置作为一个全局变量保存,当用户从第 20 步回退到第 3 步时,全局变量仍然会保留第 20 步的配置。将配置变更保存为 Entry 后,系统只需扫描当前分支上的这些 Entry,就能恢复该分支最后一次生效的配置。

第三组:纯元数据与扩展状态(3 种)

这 3 种节点既不进模型上下文,也不改变模型参数,专门服务于界面交互与插件扩展: 7. LabelEntry:用户打上的书签标记(例如在关键排查节点打上 "root_cause_found" 标签,方便在 TUI 树状分支图中快速跳转)。 8. SessionInfoEntry:会话级别的展示元数据(例如用户通过 /rename 命令给会话设置的可读名称)。 9. CustomEntry:供扩展插件保存私有状态(如测试运行器的内部计数器、缓存索引等)。插件在会话重新加载时可以扫描自己的 customType 恢复内存状态。

特别注意:所有 Entry 均为 Append-Only,包括元数据 很多开发者容易陷入一个误区:以为修改会话名称或更新书签是对数据库记录的原地 UPDATE。 实际上,在 pi 中重命名会话也是向 JSONL 追加一条新的 SessionInfoEntry 节点!在读取时,以回溯路径或全文件中最后出现的 SessionInfoEntry 为准。打书签也是追加一条包含 targetIdlabelLabelEntry。所有状态变更全部被转化为不可变的时间序列事件。


四、 磁盘持久化引擎:JSONL 实现细节

1. 存储格式与单行原子性

会话文件在磁盘上以 .jsonl 格式保存,首行为 Header,随后每行一个 Entry:

{"type":"session","version":3,"id":"01916382-7b2a-7c39-8588-4227f51b6e4d","cwd":"/workspace/app","timestamp":"2026-08-19T10:00:00Z"}
{"type":"model_change","id":"a1b2c3d4","parentId":null,"provider":"anthropic","modelId":"claude-3-7-sonnet","timestamp":"2026-08-19T10:00:00Z"}
{"type":"message","id":"e2","parentId":"a1b2c3d4","timestamp":"2026-08-19T10:00:10Z","message":{"role":"user","content":[{"type":"text","text":"auth.ts 里的 salt 校验为什么失败?"}]}}
{"type":"message","id":"e3","parentId":"e2","timestamp":"2026-08-19T10:00:15Z","message":{"role":"assistant","content":[{"type":"text","text":"我先查看一下 auth.ts 的内容。"},{"type":"toolCall","id":"call_001","name":"read","arguments":{"path":"src/auth.ts"}}],"stopReason":"toolUse"}}
{"type":"message","id":"e4","parentId":"e3","timestamp":"2026-08-19T10:00:16Z","message":{"role":"toolResult","toolCallId":"call_001","content":[{"type":"text","text":"export function verifySalt(s) { ... }"}]}}
{"type":"message","id":"e5","parentId":"e4","timestamp":"2026-08-19T10:00:25Z","message":{"role":"assistant","content":[{"type":"text","text":"问题在第 23 行,salt 缺少 Base64 编码。"}]}}
{"type":"message","id":"e6","parentId":"e2","timestamp":"2026-08-19T10:01:00Z","message":{"role":"user","content":[{"type":"text","text":"先帮我看 hash 函数的实现"}]}}

2. 延迟写入机制(Delayed Flush):避免“有问无答”的半截对话

在实际工程中,有一个关键的写入边界:首次 assistant 消息到达前,写入会延迟

如果用户刚发了一条消息,Agent 还没来得及回复(遇到断网、进程崩溃或 API 报错),若每收到一条用户消息就立即写盘,磁盘上就会残留一条孤零零的“有问无答”半截对话。

为了解决这个问题,SessionManager 引入了 4 种状态组合的延迟写入决策:

决策状态表:

会话是否已有 assistant?当前是否已 flushed?磁盘写入行为业务意图
没有未 flushed暂不写盘(保留在内存)处于首轮提问等待状态,防网络异常残留孤儿提问
没有已 flushed立即追加(appendFileSync已经上轨道的正常追加
有(首次到达)未 flushed全文件写入(Header + 积压 Entry),标记为已 flushed首个完整问答闭环达成,一次性原子落盘
已 flushed立即追加(appendFileSync常规状态下的单行高速追加

核心价值:首次 flush 成功后,后续所有 Entry 都会进入常态的 appendFileSync 单行极速追加。这种设计兼顾了极端异常下的数据整洁性与正常运行下的极高 I/O 效率。

function appendMessage(message: AgentMessage): void {
  const entry: SessionMessageEntry = {
    type: "message",
    id: generateShortId(),
    parentId: this.leafId,
    timestamp: new Date().toISOString(),
    message
  };

  // 1. 内存索引与 leafId 始终立即更新
  this.entries.push(entry);
  this.byId.set(entry.id, entry);
  this.leafId = entry.id;

  // 2. 磁盘写入控制:首条 assistant 消息返回前暂不写盘
  if (!this.hasAssistantMessage(this.entries)) {
    if (this.isFlushed) {
      this.appendLineToDisk(entry);
    } else {
      this.isFlushed = false; // 暂存内存
    }
    return;
  }

  // 3. 首次见到 assistant 消息,一次性将 header + 积压 entry 写入磁盘
  if (!this.isFlushed) {
    this.rewriteEntireFile(); // 包含 Header 与全部积压条目
    this.isFlushed = true;
    return;
  }

  // 4. 之后进入稳定通道,直接单行顺序追加写盘
  this.appendLineToDisk(entry);
}

五、 本篇小结

在本篇中,我们总结了会话树的存储方式和数据结构:

  1. Append-Only 记录:不修改或删除已有记录,所有变化都追加到日志末尾,因此无需删除历史记录就能进行多分支探索。
  2. 单向 parentId 指针模型:以“子引用父”的单向指针保障历史节点的绝对不可变,通过内存映射表实现灵活遍历。
  3. 9 种 Entry 全追加:即便重命名与打书签也是追加新节点,状态随时间单向演进。

存储层的问题解决后,接下来的问题是:如何从当前分支生成 LLM 接口需要的线性消息数组,以及如何把被放弃分支的经验摘要加入新分支? 该部分内容将在 Pi Agent 的 Session Tree(下):如何生成模型上下文 中展开介绍。