Skip to content

第 17 章 · 会话持久化与 SQLite 后端

本章目标:用 SQLite 会话后端把 Agent 的对话历史落盘,实现进程重启后会话恢复与多会话管理。

17.1 为什么需要会话持久化

到目前为止,Agent 的消息都活在内存里——进程一退出就全部丢失。真实产品需要:

  • 重启恢复:服务升级重启后用户接着聊;
  • 多会话:同一时间服务多个用户/任务,各自独立上下文;
  • 审计回放:事后查看 Agent 到底做了什么决策。

pi 生态的答案是独立的 SQLite 会话后端包。

17.2 安装独立的会话后端包

出于依赖最小化考虑,SQLite 后端不在核心包里。官方说明:

The SQLite session backend and the node:sqlite adapter live in a separate package, @earendil-works/pi-session-backend-sqlite-node, so the core package does not pull in runtime builtins or native SQLite dependencies by default.

bash
npm install @earendil-works/pi-session-backend-sqlite-node

这样设计的收益:核心包不携带 Node 内建模块(node:sqlite)和原生依赖,浏览器打包、边缘运行时都不受影响。

17.3 后端的适配器设计

这个包的架构很讲究:后端接受一个运行时相关的 SQLite 工厂,而不是绑死某个具体实现:

typescript
// 后端接受 runtime-specific SQLite factory,
// 这为未来其他形式的会话后端留出了空间
import { createSqliteSessionBackend } from '@earendil-works/pi-session-backend-sqlite-node';

const backend = createSqliteSessionBackend({
  // 注入 node:sqlite 的工厂实现(Node 22+ 内置)
  sqliteFactory: /* node:sqlite 适配器 */,
  path: './sessions.db', // 会话数据库文件
});

官方扩展点

文档明确提到 allowing other session backends to ship as their own packages in the future——你可以实现自己的后端(如 PostgreSQL),只要满足同一接口契约。

17.4 结合 sessionId 管理多个会话

Agent 与 pi-ai 的请求选项都有 sessionId 概念——它是 Provider 缓存亲和与会话持久化的共同钥匙:

typescript
import { Agent } from '@earendil-works/pi-agent-core';

const agent = new Agent({
  initialState: {
    systemPrompt: '你是一个项目助手。',
    model,
    tools,
    messages: [], // 启动时可从后端加载历史消息
  },
  streamFn: models.streamSimple.bind(models),
  sessionId: 'session-123', // 用于 provider 缓存与会话关联
});

// 运行中也可以切换会话
agent.sessionId = 'session-456';

一个多用户的 HTTP 服务中,典型模式是"每个请求携带 userId → 查找或创建该用户的 sessionId → 从后端恢复 messages → 处理 → 写回":

typescript
// 多用户会话管理伪代码
async function handleChat(userId: string, text: string) {
  const session = await backend.load(userId);       // 加载历史
  const agent = spawnAgent(session?.messages ?? []); // 冷启动或热恢复
  agent.sessionId = userId;

  await agent.prompt(text);
  await backend.save(userId, agent.state.messages);  // 落盘
  return lastAssistantText(agent.state.messages);
}

恢复历史时直接把消息数组填回 initialState.messages,Agent 就从上次中断的地方继续:

typescript
// 热恢复:把持久化的历史消息注入初始状态
const saved = await backend.load('session-123');
const agent = new Agent({
  initialState: {
    systemPrompt: '你是一个项目助手。',
    model,
    tools,
    messages: saved?.messages ?? [],   // 有历史则热启动,无则空数组冷启动
  },
  streamFn: models.streamSimple.bind(models),
});

## 17.5 轻量替代:JSON 序列化

不需要多会话数据库的小场景,直接用 JSON 序列化上下文也够用(下一章展开细节)。两种方案的取舍:

| 方案 | 适用场景 | 能力 |
|---|---|---|
| JSON 文件 | 单用户、低频写入 | 最简单,无依赖 |
| SQLite 后端 | 多用户、高频写、需查询 | 事务安全、可索引 |

## 本章小结

- 会话持久化解决重启恢复、多用户隔离、审计回放三类需求;
- SQLite 后端在独立包 `@earendil-works/pi-session-backend-sqlite-node` 中,核心包保持零原生依赖;
- 后端通过注入 SQLite 工厂的适配器设计解耦,官方预留了自定义后端的扩展空间;
- `sessionId` 是缓存亲和与会话管理的统一标识,Agent 上可直接赋值;
- 小场景可用 JSON 序列化做轻量持久化。

<Quiz :items="quiz" />

## 🛠️ 动手实践

<script setup>
const quiz = [
  {
    question: '为什么 SQLite 会话后端被拆成独立包而不是放进 pi-agent-core?',
    options: [
      '因为它还不稳定',
      '让核心包不引入 node:sqlite 内建模块与原生依赖',
      '因为要收费',
      '为了兼容 Python'
    ],
    answer: 1,
    explain: '官方说明:so the core package does not pull in runtime builtins or native SQLite dependencies by default——保持核心轻量可移植。'
  },
  {
    question: 'pi 的 SQLite 后端采用了什么架构设计?',
    options: [
      '硬编码绑定 better-sqlite3',
      '接受运行时相关的 SQLite 工厂注入',
      '纯 WASM 实现',
      '必须连接远程数据库'
    ],
    answer: 1,
    explain: 'The backend accepts a runtime-specific SQLite factory——通过工厂注入解耦具体实现,也为未来更多后端类型留出空间。'
  },
  {
    question: 'Agent 实例上的 sessionId 属性有什么作用?',
    options: [
      '仅用于日志追踪',
      'Provider 缓存亲和 + 会话关联的统一标识',
      '加密密钥',
      '没有实际作用'
    ],
    answer: 1,
    explain: '注释标注 Session ID for provider caching;它同时是持久化层定位会话的钥匙,一处设置多处受益。'
  },
  {
    question: '单用户小工具想保存对话历史,最轻量的方案是?',
    options: ['必须部署 MySQL', 'JSON 序列化 Context 到文件', '只能用 SQLite', '存进浏览器 Cookie'],
    answer: 1,
    explain: 'Context 是普通 JSON 可序列化对象(下一章详解),单用户低频场景写文件即可,无需引入数据库。'
  }
]
</script>

1. 用 SQLite 后端改造第 19 章(预告)的 CLI:退出程序重新进入后,对话历史自动恢复。
2. 实现一个 `listSessions()` 函数:列出数据库中所有会话及各自的消息数、最后活跃时间。
3. 压测对比:1000 条消息的会话在 JSON 文件 vs SQLite 两种方案下的读写耗时。

> 下一章深入上下文的序列化细节与浏览器端运行。