17 KiB
PicoBot 持久化设计说明
本文档介绍 PicoBot 当前的会话持久化实现,目标读者是需要维护或集成该模块的技术人员。
1. 总览
PicoBot 使用 SQLite 持久化会话和消息历史,当前只有一份数据库文件:
- 默认路径:
~/.picobot/storage/sessions.db - 初始化入口:
SessionStore::new() - 核心实现:
src/storage/mod.rs
数据库启动时会完成以下初始化:
- 打开 SQLite 连接
- 创建父目录
- 打开 WAL 模式
- 打开外键约束
- 自动建表和建索引
当前持久化覆盖以下核心数据:
sessions:会话元数据messages:会话内的消息流水skill_events:技能发现和使用事件memories:长期记忆scheduler_jobs:定时任务定义和运行状态
内存中的 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 需要的格式
典型关系如下:
- 网关收到用户消息。
SessionManager定位到对应 channel 的运行时Session。Session::ensure_persistent_session(chat_id)确保数据库里有对应会话记录。Session::ensure_chat_loaded(chat_id)在内存中没有历史时,从messages表加载该会话全部历史。- 如果当前活动段历史为空,系统会从
~/.picobot/agent/AGENT.md读取 Agent 基本设定,并先写入一条system消息。 - 在新的用户消息进入前,系统会检查当前活动段的
user_turn_count是否刚跨过配置项gateway.agent_prompt_reinject_every指定的下一轮阈值;如果跨过,就再次把AGENT.md写入一条新的system消息。 - 新的用户消息先写入
messages,再放入内存历史。 - Agent 执行后产生的 assistant/tool 消息按实际顺序继续写入
messages。 - 下次进程重启或 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 |
来源渠道名 | 例如 cli、feishu |
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 |
消息数 | 追加消息时自增,清空历史时重置 |
reset_cutoff_seq |
INTEGER NOT NULL DEFAULT 0 |
逻辑重置切点 | /reset 后默认只恢复 seq > reset_cutoff_seq 的活动段 |
user_turn_count |
INTEGER NOT NULL DEFAULT 0 |
当前活动段用户轮次数 | 只在追加 role = user 消息时递增,清空历史和 /reset 时归零 |
agent_prompt_reinjection_count |
INTEGER NOT NULL DEFAULT 0 |
AGENT.md 周期重注入次数 | 每完成一次“达到配置阈值后的下一轮前注入”就递增,清空历史和 /reset 时归零 |
索引:
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 |
消息角色 | 典型值为 user、assistant、system、tool |
content |
TEXT NOT NULL |
消息正文 | 文本内容或工具结果文本 |
media_refs_json |
TEXT NOT NULL |
媒体引用列表 JSON | 存储附件、本地文件路径等上下文引用 |
tool_call_id |
TEXT |
工具调用 ID | 仅 role=tool 时通常有值,用来关联某次工具结果对应哪一个 tool call |
tool_name |
TEXT |
工具名称 | 例如 calculator、file_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_id 和 tool_calls_json 两种字段,是因为两者表达的是不同方向的信息:
tool_calls_json表示“assistant 想调用哪些工具”tool_call_id表示“这一条 tool 结果是在回应哪一次工具调用”
6. scheduler_jobs
scheduler_jobs 是 PicoBot 计划任务的事实来源。它同时保存任务定义和最近一次运行后的状态,使 scheduler 在重启后仍能恢复调度。
字段说明:
| 字段 | 类型 | 含义 |
|---|---|---|
id |
TEXT PRIMARY KEY |
任务唯一标识 |
kind |
TEXT NOT NULL |
任务类型,当前支持 internal_event、outbound_message、agent_task |
schedule_json |
TEXT NOT NULL |
统一 schedule 定义,JSON 形式保存 delay / interval / at / cron |
interval_secs |
INTEGER NOT NULL DEFAULT 0 |
兼容首版 interval 配置的冗余字段 |
startup_delay_secs |
INTEGER NOT NULL DEFAULT 0 |
兼容首版 interval 配置的冗余字段 |
target_json |
TEXT NOT NULL |
任务目标,例如 channel/chat_id |
payload_json |
TEXT NOT NULL |
任务执行载荷 |
enabled |
INTEGER NOT NULL DEFAULT 1 |
是否启用 |
state |
TEXT NOT NULL DEFAULT 'scheduled' |
生命周期状态:scheduled / running / paused / completed |
last_status |
TEXT |
最近一次运行结果:ok / error / skipped |
last_error |
TEXT |
最近一次执行错误信息 |
run_count |
INTEGER NOT NULL DEFAULT 0 |
已执行次数 |
max_runs |
INTEGER |
最大执行次数,达到后转为 completed |
last_fired_at |
INTEGER |
最近一次触发时间 |
next_fire_at |
INTEGER |
下一次计划触发时间 |
paused_at |
INTEGER |
暂停时间 |
completed_at |
INTEGER |
完成时间 |
created_at |
INTEGER NOT NULL |
创建时间 |
updated_at |
INTEGER NOT NULL |
最近更新时间 |
运行语义:
- scheduler 启动后会先把 config 中声明的 jobs 同步进
scheduler_jobs。 - 运行时新建、暂停、恢复、删除任务可以通过
scheduler_manage工具直接操作数据库。 - repeating job 会在每次执行后更新
run_count、last_fired_at、next_fire_at。 - one-shot job(
delay/at)完成后会进入completed状态,不再调度。 - 内置
internal_event当前包含session_cleanup,用于回收超时的内存 session 缓存。 agent_task会把payload.prompt作为一次合成用户输入,交给SessionManager::run_scheduled_agent_task()执行,因此会复用持久化历史、工具调用和渠道下发链路。payload.fresh_session = true时,会先对目标 chat 执行一次逻辑 reset,再开始本次任务运行。payload.system_prompt会作为额外 system 消息写入本次任务上下文。payload.sender_id会覆盖默认的scheduler发送者标识。payload.metadata会映射到 outbound metadata,便于渠道侧做追踪或特殊处理。
7. 数据写入流程
7.1 创建会话
有两种进入方式:
- CLI 模式调用
create_cli_session()显式创建会话 - 渠道消息进入时调用
ensure_channel_session()自动创建或复用会话
创建时会写入 sessions 表,初始状态:
summary = NULLarchived_at = NULLdeleted_at = NULLmessage_count = 0
7.2 追加消息
消息持久化统一走 append_message(),写入过程是一个 SQLite 事务:
- 查询当前会话
MAX(seq) + 1作为下一条消息顺序。 - 将
media_refs序列化为media_refs_json。 - 将
tool_calls序列化为tool_calls_json。 - 插入一条
messages记录。 - 更新
sessions.message_count、updated_at、last_active_at。 - 将
sessions.archived_at置空。 - 提交事务。
其中第 6 步很重要:归档会话一旦收到新消息,会自动恢复为活跃态。
另外,只有 role = user 的消息会递增 user_turn_count;system、assistant、tool 消息不会影响周期注入阈值的判定。
7.3 读取历史
load_messages(session_id) 会按 seq ASC 读取当前活动段历史,并把 JSON 字段反序列化回 ChatMessage。活动段的定义是:
- 只返回
seq > sessions.reset_cutoff_seq的消息 - 因此
/reset之后,旧消息仍然保留在数据库中,但不会默认回灌到运行时上下文
如果需要审计、导出或查看完整历史,应使用全量读取接口 load_all_messages(session_id)。
因此运行态恢复的是“当前活动段的逻辑顺序”,而不是简单按创建时间排序。只要 seq 连续,重放顺序就稳定。
8. 典型时序
8.1 普通问答
- 用户消息进入网关。
- 如果数据库中没有对应会话,先插入一条
sessions。 - 用户消息写入
messages,role = user。 - Agent 基于历史生成回复。
- assistant 回复写入
messages,role = assistant。 - 会话的
message_count增加 2,last_active_at更新时间。
8.2 带工具调用的问答
- assistant 先生成一条带
tool_calls_json的消息,role = assistant。 - 系统执行对应工具。
- 每个工具结果作为独立消息写入
messages,role = tool。 - 这些
tool消息会带tool_call_id和tool_name。 - assistant 最终整理工具结果后再写入一条普通回复。
这样保存后,即使进程重启,后续仍能完整恢复:
- assistant 当时发起了哪些工具调用
- 每个工具调用返回了什么
- 最终 assistant 给了什么结论
9. 会话生命周期操作
9.1 重命名
rename_session(session_id, title):
- 更新
sessions.title - 更新
sessions.updated_at
9.2 归档
archive_session(session_id):
- 将
sessions.archived_at设为当前时间 - 更新
sessions.updated_at - 不删除消息数据
列出会话时:
include_archived = false只返回archived_at IS NULL的会话include_archived = true返回全部未删除会话
9.3 清空消息
clear_messages(session_id):
- 删除该会话在
messages中的所有记录 - 将
sessions.message_count重置为 0 - 将
sessions.reset_cutoff_seq重置为 0 - 将
sessions.user_turn_count重置为 0 - 将
sessions.agent_prompt_reinjection_count重置为 0 - 更新
updated_at和last_active_at - 保留会话本身
这适合“保留会话入口,但丢弃聊天内容”的场景。
8.4 逻辑重置
reset_session(session_id):
- 不删除
messages中的任何记录 - 将当前会话的
MAX(seq)写入sessions.reset_cutoff_seq - 将
sessions.user_turn_count重置为 0 - 将
sessions.agent_prompt_reinjection_count重置为 0 - 更新
updated_at和last_active_at - 后续默认恢复和发给模型的历史,只包含这次重置之后新增的消息
这适合“开始新对话,但保留完整历史以便审计或未来检索”的场景。
由于 AGENT.md 注入消息也会持久化,/reset 前的 Agent 设定消息仍会保留在完整历史中,但不会继续出现在新的活动段。下一次活动段首次加载时,系统会重新读取当前版本的 ~/.picobot/agent/AGENT.md,并把它作为新的首条系统消息写入活动段。
8.5 删除会话
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- 当前查询逻辑兼容软删除
- 当前删除实现仍然是物理删除
sessions.reset_cutoff_seq- 当前已用于实现
/reset的非破坏性逻辑重置 - 只影响默认恢复的活动段,不影响数据库中的全量历史
- 当前已用于实现
这说明当前 schema 已经为“会话摘要”和“软删除”预留了演进空间,但并未完全落地。
11. 给维护者的快速判断指南
如果你要排查持久化问题,可以先按下面的思路判断:
- 会话查不到:先看
persistent_session_id是否和实际channel_name/chat_id一致 - 重启后没历史:检查
ensure_chat_loaded()调用链,以及数据库文件路径是否正确 /reset后重启又带回旧上下文:检查sessions.reset_cutoff_seq是否已写入,以及恢复路径是否走了活动段读取而不是全量读取- 消息顺序不对:检查
messages.seq - 工具调用上下文异常:同时检查
tool_calls_json和tool_call_id - 会话列表里看不到记录:检查
archived_at和include_archived参数 - 清空后仍有上下文:确认是内存历史没清掉,还是数据库
messages没删掉
12. 总结
PicoBot 当前的持久化设计比较克制,核心目标只有两个:
- 让同一会话在重启后可以恢复上下文
- 让工具调用链可以被完整回放
从实现上看,它不是通用 ORM,也不是复杂事件存储,而是一层针对聊天历史的轻量 SQLite 封装。对于当前单机、单进程、聊天驱动的运行方式,这个设计足够直接,也便于继续演进。