PicoBot/PERSISTENCE.md

12 KiB
Raw Blame History

PicoBot 持久化设计说明

本文档介绍 PicoBot 当前的会话持久化实现,目标读者是需要维护或集成该模块的技术人员。

1. 总览

PicoBot 使用 SQLite 持久化会话和消息历史,当前只有一份数据库文件:

  • 默认路径:~/.picobot/storage/sessions.db
  • 初始化入口:SessionStore::new()
  • 核心实现:src/storage/mod.rs

数据库启动时会完成以下初始化:

  • 打开 SQLite 连接
  • 创建父目录
  • 打开 WAL 模式
  • 打开外键约束
  • 自动建表和建索引

当前持久化只覆盖两类核心数据:

  • sessions:会话元数据
  • messages:会话内的消息流水

内存中的 Session 负责运行态处理SQLite 负责跨进程、跨重启保留历史。整体设计是“内存缓存 + SQLite 事实来源”。

2. 持久化在系统中的位置

相关模块职责如下:

  • src/gateway/session.rs
    • 管理运行时 Session
    • 在收到消息时确保持久化会话存在
    • 首次访问某个 chat_id 时从数据库加载历史
    • 在新消息产生后同时写入数据库和内存历史
  • src/storage/mod.rs
    • 封装 SQLite 访问
    • 提供会话和消息的增删改查
  • src/bus/message.rs
    • 定义持久化消息结构 ChatMessage
  • src/providers/*
    • 将历史消息转换为不同 LLM provider 需要的格式

典型关系如下:

  1. 网关收到用户消息。
  2. SessionManager 定位到对应 channel 的运行时 Session
  3. Session::ensure_persistent_session(chat_id) 确保数据库里有对应会话记录。
  4. Session::ensure_chat_loaded(chat_id) 在内存中没有历史时,从 messages 表加载该会话全部历史。
  5. 新的用户消息先写入 messages,再放入内存历史。
  6. Agent 执行后产生的 assistant/tool 消息按实际顺序继续写入 messages
  7. 下次进程重启或 session 过期后,可从数据库完整恢复上下文。

3. 会话标识规则

数据库中的会话主键并不总是随机 UUID而是依据 channel 类型区分:

  • CLI 会话:session_id == chat_id
  • 非 CLI 会话:session_id = "{channel_name}:{chat_id}"

这套规则由 persistent_session_id(channel_name, chat_id) 统一生成,目的是:

  • 对 CLI 支持显式创建、切换和管理多个会话
  • 对外部渠道(例如飞书)让同一个 chat 稳定映射到同一条持久化会话

4. 表结构

4.1 sessions

保存会话级元数据,每条记录代表一个可被恢复的历史会话。

字段说明:

字段 类型 含义 当前用途
id TEXT PRIMARY KEY 会话主键 作为会话唯一标识,被 messages.session_id 引用
title TEXT NOT NULL 会话标题 CLI 展示、重命名
channel_name TEXT NOT NULL 来源渠道名 例如 clifeishu
chat_id TEXT NOT NULL 渠道侧会话标识 用于恢复和路由到同一聊天
summary TEXT 会话摘要 预留字段,当前 schema 中存在,但当前代码未写入实际摘要
created_at INTEGER NOT NULL 创建时间 毫秒级 Unix 时间戳
updated_at INTEGER NOT NULL 最近元数据更新时间 重命名、归档、追加消息时更新
last_active_at INTEGER NOT NULL 最近活跃时间 追加消息、清空历史时更新,用于排序
archived_at INTEGER 归档时间 非空表示会话已归档
deleted_at INTEGER 删除时间 预留字段,当前读取逻辑会过滤该字段,但当前删除实现是物理删除
message_count INTEGER NOT NULL DEFAULT 0 消息数 追加消息时自增,清空历史时重置

索引:

  • idx_sessions_channel_archived(channel_name, archived_at, last_active_at DESC)
    • 用于按渠道列出会话,并支持过滤归档态和按最近活跃排序
  • idx_sessions_updated_at(updated_at DESC)
    • 用于最近更新时间维度的查询优化

4.2 messages

保存会话中的消息流水。这里的“消息”不仅包括用户和助手文本,还包括工具调用结果。

字段说明:

字段 类型 含义 当前用途
id TEXT PRIMARY KEY 消息唯一标识 对应 ChatMessage.id
session_id TEXT NOT NULL 所属会话 外键指向 sessions.id
seq INTEGER NOT NULL 会话内顺序号 保证同一会话消息顺序稳定
role TEXT NOT NULL 消息角色 典型值为 userassistantsystemtool
content TEXT NOT NULL 消息正文 文本内容或工具结果文本
media_refs_json TEXT NOT NULL 媒体引用列表 JSON 存储附件、本地文件路径等上下文引用
tool_call_id TEXT 工具调用 ID role=tool 时通常有值,用来关联某次工具结果对应哪一个 tool call
tool_name TEXT 工具名称 例如 calculatorfile_write
tool_calls_json TEXT assistant 发起的工具调用列表 JSON 仅 assistant 发出工具调用时有值
created_at INTEGER NOT NULL 消息创建时间 毫秒级 Unix 时间戳

约束和索引:

  • 外键:FOREIGN KEY(session_id) REFERENCES sessions(id) ON DELETE CASCADE
  • 唯一约束:UNIQUE(session_id, seq),确保同一会话内顺序号不重复
  • 索引:
    • idx_messages_session_seq(session_id, seq),按顺序读取历史
    • idx_messages_session_created(session_id, created_at),按时间维度检索

5. 字段与运行时结构的映射

持久化层存储的消息对象是 ChatMessage,关键映射关系如下:

ChatMessage 字段 对应数据库字段 说明
id messages.id 消息唯一 ID
role messages.role 消息角色
content messages.content 文本主体
media_refs messages.media_refs_json 序列化为 JSON 数组
timestamp messages.created_at 时间戳
tool_call_id messages.tool_call_id 工具结果与调用的关联 ID
tool_name messages.tool_name 工具名
tool_calls messages.tool_calls_json assistant 发起的工具调用列表

设计上分成 tool_call_idtool_calls_json 两种字段,是因为两者表达的是不同方向的信息:

  • tool_calls_json 表示“assistant 想调用哪些工具”
  • tool_call_id 表示“这一条 tool 结果是在回应哪一次工具调用”

6. 数据写入流程

6.1 创建会话

有两种进入方式:

  • CLI 模式调用 create_cli_session() 显式创建会话
  • 渠道消息进入时调用 ensure_channel_session() 自动创建或复用会话

创建时会写入 sessions 表,初始状态:

  • summary = NULL
  • archived_at = NULL
  • deleted_at = NULL
  • message_count = 0

6.2 追加消息

消息持久化统一走 append_message(),写入过程是一个 SQLite 事务:

  1. 查询当前会话 MAX(seq) + 1 作为下一条消息顺序。
  2. media_refs 序列化为 media_refs_json
  3. tool_calls 序列化为 tool_calls_json
  4. 插入一条 messages 记录。
  5. 更新 sessions.message_countupdated_atlast_active_at
  6. sessions.archived_at 置空。
  7. 提交事务。

其中第 6 步很重要:归档会话一旦收到新消息,会自动恢复为活跃态。

6.3 读取历史

load_messages(session_id) 会按 seq ASC 读取整个消息历史,并把 JSON 字段反序列化回 ChatMessage

因此它恢复的是“逻辑顺序”,而不是简单按创建时间排序。只要 seq 连续,重放顺序就稳定。

7. 典型时序

7.1 普通问答

  1. 用户消息进入网关。
  2. 如果数据库中没有对应会话,先插入一条 sessions
  3. 用户消息写入 messagesrole = user
  4. Agent 基于历史生成回复。
  5. assistant 回复写入 messagesrole = assistant
  6. 会话的 message_count 增加 2last_active_at 更新时间。

7.2 带工具调用的问答

  1. assistant 先生成一条带 tool_calls_json 的消息,role = assistant
  2. 系统执行对应工具。
  3. 每个工具结果作为独立消息写入 messagesrole = tool
  4. 这些 tool 消息会带 tool_call_idtool_name
  5. assistant 最终整理工具结果后再写入一条普通回复。

这样保存后,即使进程重启,后续仍能完整恢复:

  • assistant 当时发起了哪些工具调用
  • 每个工具调用返回了什么
  • 最终 assistant 给了什么结论

8. 会话生命周期操作

8.1 重命名

rename_session(session_id, title)

  • 更新 sessions.title
  • 更新 sessions.updated_at

8.2 归档

archive_session(session_id)

  • sessions.archived_at 设为当前时间
  • 更新 sessions.updated_at
  • 不删除消息数据

列出会话时:

  • include_archived = false 只返回 archived_at IS NULL 的会话
  • include_archived = true 返回全部未删除会话

8.3 清空消息

clear_messages(session_id)

  • 删除该会话在 messages 中的所有记录
  • sessions.message_count 重置为 0
  • 更新 updated_atlast_active_at
  • 保留会话本身

这适合“保留会话入口,但丢弃聊天内容”的场景。

8.4 删除会话

delete_session(session_id)

  • 显式删除 messages
  • 再删除 sessions

虽然表结构中存在 deleted_at 字段,并且查询时也会过滤 deleted_at IS NULL,但当前实现并没有做软删除,而是直接物理删除。换句话说:

  • deleted_at 当前是保留字段
  • 如果后续需要回收站或审计恢复,可以基于它演进成软删除

9. 并发与一致性

当前 SessionStore 的一致性策略比较直接:

  • 进程内使用 Arc<Mutex<Connection>> 保护单连接访问
  • 追加消息时使用 SQLite 事务
  • 单条消息的写入与会话计数更新在同一事务中完成

这意味着:

  • 对单进程场景,消息顺序和 message_count 是一致的
  • seq 通过事务内 MAX(seq) + 1 分配,避免同一连接并发下的顺序错乱
  • WAL 模式提升读取和写入并存时的稳定性

需要注意的是,当前设计主要面向单进程本地运行。如果未来要扩展到多进程或多实例共享同一数据库,需要重新评估:

  • 单连接模型
  • MAX(seq) + 1 的扩展性
  • 会话加载缓存和跨实例同步

10. 当前实现中的保留点

下面这些字段或能力已经在 schema 中出现,但还没有完整业务闭环:

  • sessions.summary
    • 当前代码没有把 ContextCompressor 产出的摘要写回数据库
    • 目前摘要只参与运行时上下文压缩,不参与持久化
  • sessions.deleted_at
    • 当前查询逻辑兼容软删除
    • 当前删除实现仍然是物理删除

这说明当前 schema 已经为“会话摘要”和“软删除”预留了演进空间,但并未完全落地。

11. 给维护者的快速判断指南

如果你要排查持久化问题,可以先按下面的思路判断:

  • 会话查不到:先看 persistent_session_id 是否和实际 channel_name/chat_id 一致
  • 重启后没历史:检查 ensure_chat_loaded() 调用链,以及数据库文件路径是否正确
  • 消息顺序不对:检查 messages.seq
  • 工具调用上下文异常:同时检查 tool_calls_jsontool_call_id
  • 会话列表里看不到记录:检查 archived_atinclude_archived 参数
  • 清空后仍有上下文:确认是内存历史没清掉,还是数据库 messages 没删掉

12. 总结

PicoBot 当前的持久化设计比较克制,核心目标只有两个:

  • 让同一会话在重启后可以恢复上下文
  • 让工具调用链可以被完整回放

从实现上看,它不是通用 ORM也不是复杂事件存储而是一层针对聊天历史的轻量 SQLite 封装。对于当前单机、单进程、聊天驱动的运行方式,这个设计足够直接,也便于继续演进。