返回课程首页

11

保存和恢复会话

理解 Session、Entry、Repository 与持久化。

源码基线:Pi v0.82.0 · commit 518855d

上一章解释了多轮对话为什么依赖结构化 Transcript。现在用户关闭桌面 Agent,再次启动:

昨天我让 Agent 读取了 src/config.ts,今天打开应用后,它应该继续理解“把那个端口改成 8080”。

内存里的 agent.state.messages 已经消失。我们需要把可重建会话的事实写入持久层,并在启动时恢复。

Pi 源码中有两套相关实现:

  1. pi-agent-core Harness 的通用 Session、Storage、Repository 抽象;
  2. pi-coding-agent 自己的 JSONL SessionManager

两者共享“Header、Entry、树、当前 Leaf、从 Entry 重建 Context”等理念,但不是同一个可互换的类。本章先用通用 Harness 构建桌面 Agent,再解释 coding-agent 的文件格式。

1. 不要只保存 messages.json

最小的错误实现是:

await writeFile(
  "messages.json",
  JSON.stringify(agent.state.messages),
);

它至少遗漏:

  • Session ID 和创建时间;
  • 工作目录;
  • 当前模型;
  • Thinking Level;
  • 当前启用的工具;
  • 分支之间的父子关系;
  • Context Compaction;
  • 扩展的持久状态;
  • 当前位于哪一个分支 Leaf。

Pi 把会话存成一系列 Entry:

flowchart LR
    H["Session Metadata / Header"] --> E1["Message Entry"]
    E1 --> E2["Message Entry"]
    E2 --> M["Model Change Entry"]
    M --> E3["Message Entry"]
    E3 --> C["Compaction Entry"]

消息只是 Entry 的一种。

2. Session Entry 的共同骨架

packages/agent/src/harness/types.ts 定义:

interface SessionTreeEntryBase {
  type: string;
  id: string;
  parentId: string | null;
  timestamp: string;
}

每个 Entry 都有 ID、父 Entry、ISO 时间和具体类型。

消息 Entry:

interface MessageEntry
  extends SessionTreeEntryBase {
  type: "message";
  message: AgentMessage;
}

模型变化不是修改旧 Header,而是追加新 Entry:

interface ModelChangeEntry
  extends SessionTreeEntryBase {
  type: "model_change";
  provider: string;
  modelId: string;
}

这种 append-only 设计保留了发生顺序,也为下一章的会话树和分支打下基础。

3. Entry 有哪些类型

通用 Harness 的 SessionTreeEntry 包括:

Entry 保存什么 是否直接变成 Context Message
message User、Assistant、Tool Result 等 Agent Message
thinking_level_change Thinking Level 变化 否,重建运行设置
model_change Provider 和 Model ID 否,重建运行设置
active_tools_change 当前启用的工具名 否,重建运行设置
compaction 压缩摘要和保留尾部 转成 Compaction Summary
branch_summary 离开分支时的摘要 转成 Branch Summary
custom 应用或扩展状态 默认不进 Context
custom_message 应用注入的上下文消息
label 对目标 Entry 的标签
session_info 会话名称等信息
leaf 显式记录当前活动 Leaf

因此,Session 是“可重放的会话事件”,而不仅是聊天气泡数组。

4. 三层抽象:Repo、Storage、Session

flowchart TB
    APP["桌面应用"] --> R["SessionRepo"]
    R -->|create / open / list / delete / fork| S["Session"]
    S -->|appendMessage / buildContext / moveTo| ST["SessionStorage"]
    ST --> J["JSONL"]
    ST --> M["Memory"]
    ST --> Q["SQLite"]

SessionRepo

管理一组会话:

interface SessionRepo {
  create(options): Promise<Session>;
  open(metadata): Promise<Session>;
  list(options?): Promise<SessionMetadata[]>;
  delete(metadata): Promise<void>;
  fork(source, options): Promise<Session>;
}

桌面应用的“新建、最近会话、删除、复制会话”对应 Repo。

SessionStorage

负责一个会话的底层 Entry:

interface SessionStorage {
  getMetadata(): Promise<SessionMetadata>;
  getLeafId(): Promise<string | null>;
  setLeafId(id: string | null): Promise<void>;
  createEntryId(): Promise<string>;
  appendEntry(entry): Promise<void>;
  getEntry(id):
    Promise<SessionTreeEntry | undefined>;
  getEntries(options?):
    Promise<SessionTreeEntry[]>;
  getPathToRootOrCompaction(id):
    Promise<SessionTreeEntry[]>;
}

Session

在 Storage 上提供领域 API:

await session.appendMessage(message);
await session.appendModelChange(
  provider,
  modelId,
);

const context =
  await session.buildContext();

业务代码主要使用 Repo 和 Session,不需要知道 Entry 实际存成一行 JSON 还是一张数据库表。

5. 先用 Memory Repository 跑通

开发阶段可以用:

import {
  InMemorySessionRepo,
} from "@earendil-works/pi-agent-core";

const repo = new InMemorySessionRepo();
const session = await repo.create();

它实现和持久后端相同的 Repo 接口:

await session.appendMessage({
  role: "user",
  content: "记住默认端口是 3000",
  timestamp: Date.now(),
});

const restored =
  await session.buildContext();

但它只存在于当前 JavaScript 进程。应用退出后,Map 和 Entry 数组一起消失。

Memory Repository 适合单元测试、界面预览、临时 Session 和不希望污染磁盘的场景。它不满足“重启恢复”。

6. JSONL Session 是什么

JSONL 是“一行一个 JSON 对象”:

{"type":"session","version":3,"id":"session-1","timestamp":"2026-07-27T08:00:00.000Z","cwd":"/work/app"}
{"type":"message","id":"e1","parentId":null,"timestamp":"2026-07-27T08:00:01.000Z","message":{"role":"user","content":"读取 config.ts","timestamp":1785139201000}}
{"type":"model_change","id":"e2","parentId":"e1","timestamp":"2026-07-27T08:00:02.000Z","provider":"openai","modelId":"gpt-example"}

第一行是 Header,后面每行是 Entry。

优点:

  • 新 Entry 可以追加到文件末尾;
  • 人可以直接查看;
  • 单个 Entry 容易导出和调试;
  • 文件损坏可定位到具体行;
  • 不需要数据库服务。

代价:

  • 打开时需要解析文件;
  • 大量会话的搜索和分页不如数据库自然;
  • 多进程并发写需要额外设计;
  • 更新通过追加新事实表达,而不是原地改行。

7. Session Header 保存什么

通用 Harness 的 JSONL Header 当前是:

interface SessionHeader {
  type: "session";
  version: 3;
  id: string;
  timestamp: string;
  cwd: string;
  parentSession?: string;
  metadata?: Record<string, unknown>;
}

Header 不是树中的 Entry,没有 parentId。它描述格式版本、Session ID、创建时间、工作目录、父 Session 文件和应用 Metadata。

JsonlSessionStorage.open() 会检查:

  • 第一行必须是有效 JSON;
  • type 必须是 "session"
  • 当前通用 Harness JSONL Storage 接受 version: 3
  • ID、时间和 cwd 必须存在;
  • Metadata 必须是对象。

这比直接 JSON.parse() 后盲目信任可靠,但 Entry 读取只做基础形状检查。桌面应用仍应把 Session 文件视为需要谨慎处理的输入。

8. 创建一个 JSONL Repository

Node 宿主可以提供 NodeExecutionEnv

import {
  JsonlSessionRepo,
} from "@earendil-works/pi-agent-core";
import {
  NodeExecutionEnv,
} from "@earendil-works/pi-agent-core/node";

const cwd = "/work/my-desktop-agent";
const env = new NodeExecutionEnv({ cwd });

const repo = new JsonlSessionRepo({
  fs: env,
  sessionsRoot:
    "/work/app-data/sessions",
});

const session = await repo.create({
  cwd,
  metadata: {
    appVersion: "1.0.0",
  },
});

JsonlSessionRepo 会规范化 Session 根目录,把 cwd 编码为子目录,创建带时间和 Session ID 的 .jsonl 文件,写入 Header,再返回 Session

不要把 API Key 放进 metadata。JSONL 是普通本地文件,不会自动加密。

9. 把 Session 交给 AgentHarness

创建新 Session 时,先显式记录初始运行设置:

await session.appendModelChange(
  model.provider,
  model.id,
);
await session.appendThinkingLevelChange("off");
await session.appendActiveToolsChange(
  tools.map((tool) => tool.name),
);

AgentHarness 构造器只把 modelthinkingLevelactiveToolNames 放进当前进程的内存字段,不会因为收到这些构造参数就自动追加 Session Entry。只有显式调用 Session 的 append 方法,或后续调用 Harness 的 setModel()setThinkingLevel()setTools()setActiveTools() 等变更 API,相关设置才会写入 Session。

通用 Harness 接收 Session:

const harness = new AgentHarness({
  session,
  models,
  model,
  tools,
  systemPrompt:
    "你是桌面开发助手。",
});

await harness.prompt(
  "读取 src/config.ts,告诉我默认端口。",
);

Harness 在每个 message_end 执行:

await session.appendMessage(event.message);

所以会写入 User Message、最终 Assistant Message、Tool Result Message,以及进入 Agent 生命周期的 Custom Agent Message。

turn_end,Harness 还会刷新本轮通过变更 API 排队的 Model、Thinking、Active Tools 和 Custom Entry 等写入,然后发出 save_point。这不包含从未显式写入的初始构造参数。

sequenceDiagram
    participant A as Agent Loop
    participant H as AgentHarness
    participant S as Session
    participant D as Storage

    A-->>H: message_end(User)
    H->>S: appendMessage
    S->>D: appendEntry
    A-->>H: message_end(Assistant)
    H->>S: appendMessage
    S->>D: appendEntry
    A-->>H: turn_end
    H->>S: flush pending writes
    H-->>H: emit save_point

Partial message_update 不会逐块写入 Session;最终 message_end 才保存消息。这避免每个 Token 都形成一条历史 Entry。

10. 直接使用 Agent 时怎样保存

如果没有使用 AgentHarness,可以订阅基础 Agent 事件:

agent.subscribe(async (event) => {
  if (event.type !== "message_end") {
    return;
  }

  await session.appendMessage(
    event.message,
  );
});

还要自己保存 Model、Thinking、Active Tools、自定义状态和写入失败。只订阅 agent_end 再保存全部 State 也能工作,但需要自行处理重复、崩溃窗口和增量写入。

11. 应用重启后怎样找到会话

Repository 的 list() 返回 Metadata,而不是把所有完整 Session 一次载入:

const recent = await repo.list({ cwd });
const selected = recent[0];

if (!selected) {
  throw new Error("没有可恢复会话");
}

const session =
  await repo.open(selected);

JSONL Repo 按创建时间从新到旧排序。它读取每个文件第一行的 Header 生成 Metadata;无效 Session Header 会在列表发现中被跳过。

用户真正选择后再 open(),会话列表则可以先显示 ID、创建时间、cwd、Metadata 和文件路径。

12. 从 Entry 重建 Context

打开 Session 后:

const restored =
  await session.buildContext();

返回:

interface SessionContext {
  messages: AgentMessage[];
  thinkingLevel: string;
  model: {
    provider: string;
    modelId: string;
  } | null;
  activeToolNames: string[] | null;
}

buildContext() 不是返回所有物理 Entry。它会:

  1. 从当前 Leaf 沿 parentId 找到活动路径;
  2. 处理最新 Compaction;
  3. 把 Message Entry 转回 Agent Message;
  4. 把 Custom Message、Branch Summary 和 Compaction Summary 投影成 Agent Message;
  5. 默认忽略普通 Custom Entry;
  6. 从路径恢复最近的 Model、Thinking Level 和 Active Tools。
flowchart LR
    ALL["Storage 中所有 Entry"] --> PATH["当前 Leaf 的活动路径"]
    PATH --> COMPACT["应用 Compaction 规则"]
    COMPACT --> PROJECT["Entry → AgentMessage"]
    PROJECT --> CTX["SessionContext"]

所以 getEntries() 用于历史、审计和树,buildContext() 用于继续 Agent。

13. 恢复 AgentHarness

Harness 每次 Turn 开始时都会:

const context =
  await session.buildContext();

把打开后的 Session 传给新 Harness,就能恢复消息:

const restored =
  await session.buildContext();

const restoredModel =
  resolveModelFromAppRegistry(
    restored.model,
    fallbackModel,
  );

const restoredTools =
  resolveKnownToolNames(
    restored.activeToolNames,
    tools,
  );

const harness = new AgentHarness({
  session,
  models,
  model: restoredModel,
  thinkingLevel:
    normalizeThinkingLevel(
      restored.thinkingLevel,
    ),
  tools,
  activeToolNames: restoredTools,
});

三个 resolve... 是桌面应用 Helper,不是 Pi API。

AgentHarness 构造函数要求宿主传入当前 Model、Thinking Level 和 Active Tools。它会从 Session 重建消息,但不会在构造时自动用 SessionContext 覆盖这些参数。宿主应从 Model Registry 解析保存的 ID、为不存在的模型提供明确回退、过滤失效工具并验证 Thinking Level。

恢复后:

await harness.prompt(
  "把那个端口改成 8080。",
);

本轮开始时会再次从 Session 构建消息历史。

14. 为什么用 Entry 重建,而不是保存 Agent 实例

JavaScript 对象不能可靠跨版本、跨进程恢复:

  • 函数和闭包不能 JSON 化;
  • Provider Client 不能安全持久化;
  • AbortController 已失效;
  • Event Listener 属于当前窗口;
  • 工具实现可能已经升级;
  • 模型目录可能变化;
  • Partial Message 可能处于不一致状态。

Entry 保存的是稳定事实:用户说了什么、模型回答了什么、工具返回了什么、何时切换设置,以及当前分支在哪里。新进程用新代码和新依赖重建运行对象。

15. JSONL 怎样追加和恢复 Leaf

JsonlSessionStorage.appendEntry()

  1. 把 Entry 序列化成一行;
  2. 追加换行;
  3. 更新内存 Entry 数组;
  4. 更新 ID 索引和 Label Cache;
  5. 把当前 Leaf 设为新 Entry ID。

切换到旧分支时,setLeafId() 不修改旧行,而是追加:

interface LeafEntry {
  type: "leaf";
  targetId: string | null;
  // 以及共同 Entry 字段
}

重新打开文件时,普通 Entry 让 Leaf 指向自己的 ID,leaf Entry 让 Leaf 指向 targetId。因此“当前在哪里”也能从 append-only 日志恢复。

16. SQLite Repository 什么时候有价值

桌面应用会话数量增多后,SQLite 为事务、索引、物化状态以及后续扩展复杂查询提供了基础。当前 Pi 的 SqliteSessionRepo.list() 公开能力只有列出全部 Session,或按 cwd 过滤;它没有通用多条件或分页参数。

Pi 的 Node SQLite 后端在独立包:

@earendil-works/pi-storage-sqlite-node

创建:

import {
  NodeExecutionEnv,
} from "@earendil-works/pi-agent-core/node";
import {
  createNodeSqliteFactory,
  SqliteSessionRepo,
} from "@earendil-works/pi-storage-sqlite-node";

const env = new NodeExecutionEnv({ cwd });

const repo = new SqliteSessionRepo({
  env,
  sqlite: createNodeSqliteFactory(),
  databasePath:
    "/work/app-data/sessions.sqlite",
});

const session = await repo.create({
  cwd,
  metadata: {
    appVersion: "1.0.0",
  },
});

源码打开数据库时配置:

PRAGMA journal_mode=WAL;
PRAGMA synchronous=FULL;
PRAGMA busy_timeout=5000;

并执行版本迁移。SQLite Repo 仍返回相同的 Session 领域对象,所以 Harness 上层不需要因为后端变化而改写 Agent 流程。

17. JSONL、Memory、SQLite 怎样选择

后端 重启恢复 适合
InMemory 测试、临时会话、预览
JSONL 本地优先、易调试、会话文件导入导出
SQLite 桌面产品、大量会话、事务与查询

第一版桌面 Agent 可以从 JSONL 开始。若产品已有 SQLite 数据层,SQLite Repo 更自然。业务层应依赖 Repo,不要为每个后端复制一套 Agent 逻辑。

18. coding-agent 的 SessionManager

packages/coding-agent/src/core/session-manager.ts 是 coding-agent 使用的专用实现:

  • 当前格式版本也是 3;
  • Header + append-only Entry;
  • Session 内部也是树;
  • 支持 createopencontinueRecentinMemory
  • 默认文件位于 ~/.pi/agent/sessions/ 下按 cwd 编码的目录;
  • buildSessionContext() 恢复 Message、Model 和 Thinking Level;
  • 旧格式可在加载时迁移。

SessionManager 负责解析和构建 Session Context,真正把恢复结果应用到运行 Agent 的是 packages/coding-agent/src/core/sdk.ts 中的 createAgentSession()

flowchart TD
    O["createAgentSession(options)"] --> C["SessionManager.buildSessionContext"]
    C --> H{"存在历史消息?"}
    H -->|是| M["尝试恢复 Model"]
    M --> A{"模型存在且 Provider 已配置认证?"}
    A -->|是| RM["使用恢复模型"]
    A -->|否| F["选择可用回退模型并返回提示"]
    H -->|是| T["恢复 Thinking 或使用默认值"]
    T --> CL["按当前模型能力 clamp"]
    C --> MSG["恢复 Agent Messages"]
    O --> TOOLS["按本次 tools / noTools / excludeTools 计算 Active Tools"]

更精确地说:

  • 调用方没有显式传 model 时,才尝试使用 Session 保存的 Model;
  • 保存的模型必须仍存在,并且 Provider 已配置认证;
  • 无法恢复时会选择初始可用模型,并通过 modelFallbackMessage 提示;
  • 调用方没有显式传 thinkingLevel 时,才从 Session 恢复;
  • 恢复值会按当前模型能力 clampThinkingLevel()
  • 历史消息会写入新 Agent 的 state.messages
  • 新 Session 会主动追加初始 Model 和 Thinking Entry;
  • 已有旧 Session 若没有 Thinking Entry,会补写当前有效 Thinking;
  • Active Tools 不在 coding-agent 的 Session Context 中,而是根据本次启动的 toolsnoToolsexcludeTools 和默认工具集合重新计算。

它与 Harness Session 的关键区别:

通用 Harness Session coding-agent SessionManager
SessionRepo / SessionStorage 抽象 一个专用 Manager 管理 JSONL
Memory、JSONL、SQLite 后端 coding-agent 自己的 JSONL/内存模式
Context 还包含 Active Tools 专用 Context 返回 Message、Thinking、Model
异步 Storage 接口 主要使用 coding-agent 本地文件实现

不要把 SessionManager 直接当成 AgentHarness 构造函数需要的 Session。直接嵌入完整 coding-agent SDK 时可使用它;围绕通用 Agent Harness 构建时,优先使用 Repo/Session 抽象。

19. 从 Agent Event 到持久 Entry

sequenceDiagram
    participant UI as 桌面 UI
    participant H as AgentHarness
    participant L as Agent Loop
    participant S as Session
    participant R as Repository Storage

    UI->>H: prompt
    H->>S: buildContext
    S->>R: 读取活动路径
    R-->>S: Session Entries
    S-->>H: SessionContext
    H->>L: runAgentLoop
    L-->>H: message_end
    H->>S: appendMessage
    S->>R: append Entry
    L-->>H: turn_end
    H->>S: flush pending writes
    H-->>UI: save_point

恢复不是“把文件直接传给模型”,而是:

Storage
→ Entry
→ 活动路径
→ SessionContext
→ AgentContext
→ convertToLlm
→ pi-ai Context
→ Provider

20. 崩溃与写入失败怎样处理

应用需要:

  • 在窗口退出前等待当前运行 settle;
  • 展示持久化错误,不能假装已保存;
  • 不在 message_update 中高频写完整历史;
  • 对数据库或 JSONL 文件做备份;
  • 写入自定义 Entry 时保持数据可迁移;
  • 不把秘密无条件写入 Message、Details 或 Metadata;
  • 打开损坏 Session 失败时保留原文件并提供诊断;
  • 不把“UI 已显示”当成“磁盘已持久化”。

Harness 在 message_end 保存消息;turn_end 刷新排队写入后发出 save_point。桌面 UI 可以把 save_point 作为“这一 Turn 的待写变更已经刷新”的信号。

JSONL 的 append-only 结构降低了重写整个文件的风险,但不代表系统掉电时绝对不会出现尾行问题。SQLite 的事务也不替代应用级错误处理。

21. 最小恢复清单

  1. 创建同一类型的 Repository;
  2. list() 获取 Session Metadata;
  3. 用户选择后 open()
  4. buildContext() 验证活动路径可重建;
  5. 从应用 Model Registry 解析保存的 Model;
  6. 验证 Thinking Level;
  7. 过滤已经不存在的 Active Tool;
  8. 用新 Provider、工具和 Listener 创建新 Harness;
  9. UI 从 Entry 或 Agent Message 重建聊天卡片;
  10. 下一次 Prompt 前再次从 Session 构建 Context;
  11. 写入失败时明确告诉用户。

22. 常见误区

“只保存最后一次 Assistant 文本就能恢复”

错误。模型还需要 User Message、Tool Call、Tool Result 和相关协议字段。

“Session 中所有 Entry 都应该发给模型”

错误。Custom State、Label、Session Name、Leaf 等 Entry 不直接进入 Context。

“open Session 后,Harness 会自动选择已经保存的模型”

错误。Harness 构造时的运行配置由宿主传入;宿主要解析 SessionContext

“Memory Repository 也能在应用重启后继续”

错误。它只适合当前进程。

“JSONL 和 SQLite 需要两套 Agent 业务代码”

错误。它们都通过 Repo、Storage 和 Session 抽象提供相同领域操作。

“coding-agent SessionManager 就是 Harness Session”

错误。它们理念相近,但类、接口和支持的后端不同。

23. 本章小结

  • Session 由 Header/Metadata 和 append-only Entry 组成;
  • Message、Model、Thinking、Active Tools、Compaction 和扩展状态分别记录;
  • parentId 与当前 Leaf 让历史天然成为树;
  • Repo 管理会话集合,Storage 管理单会话 Entry,Session 提供领域 API;
  • InMemory 适合测试,JSONL 适合本地文件,SQLite 适合桌面产品;
  • AgentHarness 在 message_end 追加消息,在 Turn 边界刷新待写 Entry;
  • buildContext() 从活动路径投影出继续运行所需的消息和设置;
  • 宿主仍要解析已保存的 Model、Thinking Level 和 Active Tools;
  • coding-agent 的 SessionManager 是另一套专用 JSONL 实现,不能和 Harness Session 混用。

下一章会利用 idparentId 和 Leaf,解释为什么一次会话不是数组,而是一棵树。

24. 自测

  1. 为什么 Session Entry 不只有 Message Entry?
  2. SessionRepo、SessionStorage 和 Session 各负责什么?
  3. JSONL 文件第一行和后续行分别是什么?
  4. AgentHarness 在什么事件上保存最终消息?
  5. getEntries()buildContext() 的区别是什么?
  6. 为什么恢复 Harness 时仍要由宿主解析 Model 和 Active Tools?
  7. Memory、JSONL 和 SQLite 分别适合什么场景?
  8. coding-agent SessionManager 能否直接传给 AgentHarness?