返回课程首页

第 11 章

保存和恢复会话

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

源码基线:Pi v0.83.0 · commit 845d6ff

上一章解释了多轮对话为什么依赖结构化 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 构造器只把 model、thinkingLevel 和 activeToolNames 放进当前进程的内存字段,不会因为收到这些构造参数就自动追加 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 内部也是树;
  • 支持 create、open、continueRecent、inMemory;
  • 默认文件位于 ~/.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 中,而是根据本次启动的 tools、noTools、excludeTools 和默认工具集合重新计算。

它与 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 混用。

下一章会利用 id、parentId 和 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?