06 会话管理与 SQLite 持久化
06 会话管理与 SQLite 持久化
学习目标
读完本章后,你将能够:
- 理解 Goose 的 Schema v13 三表模型(sessions / messages / threads)
- 掌握七种 session_type 的语义和使用场景
- 实现游标分页和全文搜索的查询模式
- 掌握会话导出/导入、克隆(Fork)和自动命名的实现方式
前置知识
核心概念
为什么需要会话持久化
Agent 的会话(Session)是用户与 AI 之间一次完整对话的记录。会话持久化需要解决:
- 状态恢复:Agent 重启后能否继续之前的对话?
- 历史查询:能否搜索过去的对话内容?
- 会话管理:能否导出、导入、复制对话记录?
- 多模式支持:CLI、Gateway、Telegram、ACP 等不同入口的会话如何统一管理?
Goose 选择 SQLite 作为会话存储后端,Schema 版本为 v13。
整体架构
项目实践
Schema v13 三表模型
Goose 的数据库包含三张核心表:
// 伪代码:Schema v13 表结构
// sessions 表:会话元数据sessions { id: TEXT PRIMARY KEY, // 会话 ID(日期格式: 20250529_1) name: TEXT, // 会话名称(自动命名或用户自定义) working_dir: TEXT, // 工作目录 session_type: TEXT, // 会话类型(见下表) created_at: DATETIME, // 创建时间 extension_data: TEXT (JSON), // 扩展数据 token_counts: TEXT (JSON), // Token 用量统计 provider_name: TEXT, // 模型提供商 model_config: TEXT (JSON), // 模型配置 goose_mode: TEXT, // Goose 运行模式}
// messages 表:对话消息messages { message_id: TEXT PRIMARY KEY, // 消息唯一 ID session_id: TEXT (FK), // 关联的会话 ID role: TEXT, // 角色(system/user/assistant/tool) content_json: TEXT (JSON), // 消息内容(JSON 序列化) created_timestamp: DATETIME, // 创建时间 metadata_json: TEXT (JSON), // 附加元数据}
// threads / thread_messages 表:线程分组// 迁移 10 引入,用于将多个 session 组织为 threadthreads { thread_id: TEXT PRIMARY KEY, name: TEXT, created_at: DATETIME,}
thread_messages { thread_id: TEXT (FK), session_id: TEXT (FK),}七种 Session Type
Goose 定义了七种会话类型,用于区分不同来源和用途的会话:
| Session Type | 用途 | 可见性 |
|---|---|---|
| User | 用户直接在 CLI 中发起的对话 | 对用户可见 |
| Scheduled | 定时任务触发的对话 | 对用户可见 |
| SubAgent | 子 Agent 派生的对话 | 通常对用户隐藏 |
| Hidden | 内部操作(如压缩前的静默 Turn) | 对用户隐藏 |
| Terminal | 终端模式的一次性对话 | 对用户可见 |
| Gateway | 通过 Gateway(Telegram/Web)接入的对话 | 对用户可见 |
| Acp | 通过 ACP 协议接入的对话 | 对用户可见 |
设计考虑:不同类型的会话在 UI 中的展示策略不同。例如,Hidden 类型的会话不会出现在会话列表中,SubAgent 类型的会话只在父会话的上下文中可见。
游标分页
Goose 使用游标分页而非传统的 OFFSET 分页来列出会话:
// 伪代码:游标分页查询fn list_sessions(cursor, limit): if cursor is None: // 第一页:按创建时间倒序取前 limit 条 query = """ SELECT * FROM sessions ORDER BY created_at DESC LIMIT ? """ return db.execute(query, [limit]) else: // 后续页:从 cursor 位置之后取 limit 条 query = """ SELECT * FROM sessions WHERE created_at < ? ORDER BY created_at DESC LIMIT ? """ return db.execute(query, [cursor.created_at, limit])为什么不用 OFFSET?
| 维度 | OFFSET 分页 | 游标分页(Goose 采用) |
|---|---|---|
| 大数据量性能 | 差(需要扫描并跳过前 N 行) | 优(直接定位到游标位置) |
| 数据一致性 | 差(插入/删除导致页偏移变化) | 优(游标位置不受新增数据影响) |
| 实现复杂度 | 低 | 中(需要管理游标状态) |
| 适用场景 | 小数据量、后台管理 | 大数据量、用户滚动加载 |
对于会话列表这种「按时间倒序滚动加载」的场景,游标分页是更优选择。
全文搜索(FTS)
Goose 利用 SQLite 的 FTS(Full-Text Search)扩展实现跨会话的对话内容搜索:
// 伪代码:全文搜索fn search_chat_history(query): // FTS 匹配消息内容 results = db.execute(""" SELECT message_id, session_id, role, content_json, snippet(fts_messages, -1, '<b>', '</b>', '...', 32) as snippet FROM fts_messages WHERE fts_messages MATCH ? ORDER BY rank """, [query]) return results
fn search_chat_sessions(query, cursor, limit): // 搜索并返回匹配的会话列表(游标分页) matched_sessions = search_chat_history(query) unique_sessions = deduplicate_by_session_id(matched_sessions) return paginate(unique_sessions, cursor, limit)FTS 的优势:
- 无需引入外部搜索引擎,SQLite 内置即可满足需求
- 支持关键词匹配和短语搜索
- 配合
snippet()函数提供带高亮标记的搜索结果片段 - 适合 Agent 场景中的精确引用(文件路径、函数名、错误信息)
会话导出与导入
Goose 支持将会话完整导出为 JSON 文件,以及从 JSON 文件导入:
// 伪代码:会话导出fn export_session(session_id, output_path): session = db.query_one("SELECT * FROM sessions WHERE id = ?", session_id) messages = db.query_all("SELECT * FROM messages WHERE session_id = ?", session_id)
export_data = { "version": "1.0", "session": session, "messages": messages, "exported_at": now(), }
write_json(output_path, export_data)
// 伪代码:会话导入fn import_session(input_path): import_data = read_json(input_path)
// 验证版本和结构 if import_data.version != "1.0": raise UnsupportedVersionError
// 生成新的会话 ID(避免冲突) new_session_id = generate_session_id()
db.transaction(): // 插入会话(使用新 ID) session = import_data.session session.id = new_session_id db.execute("INSERT INTO sessions ...", session)
// 插入所有消息(关联新 ID) for msg in import_data.messages: msg.session_id = new_session_id db.execute("INSERT INTO messages ...", msg)
return new_session_id设计考虑:
- 导出格式包含版本号,便于未来格式升级时的兼容性处理
- 导入时生成新 ID 而非复用原 ID,避免 ID 冲突
- 整个导入过程在事务中执行,保证原子性
会话克隆(Fork)
会话克隆用于从现有会话创建一个副本,常用于分支实验或从某个状态重新开始:
// 伪代码:会话克隆fn copy_session(source_session_id): source = db.query_one("SELECT * FROM sessions WHERE id = ?", source_session_id) messages = db.query_all("SELECT * FROM messages WHERE session_id = ?", source_session_id)
new_id = generate_session_id()
db.transaction(): // 复制会话元数据 db.execute(""" INSERT INTO sessions (id, name, working_dir, session_type, ...) VALUES (?, ?, ?, ?, ...) """, [new_id, source.name + " (副本)", source.working_dir, source.session_type, ...])
// 复制所有消息 for msg in messages: db.execute(""" INSERT INTO messages (message_id, session_id, role, content_json, ...) VALUES (?, ?, ?, ?, ...) """, [generate_message_id(), new_id, msg.role, msg.content_json, ...])
return new_id自动命名
Goose 通过 LLM 自动为新会话生成有意义的名称:
// 伪代码:自动命名fn auto_name_session(session_id): // 获取会话的前几条用户消息 messages = db.query_all(""" SELECT content_json FROM messages WHERE session_id = ? AND role = 'user' ORDER BY created_timestamp ASC LIMIT 3 """, [session_id])
if messages.is_empty(): return "新会话" // 默认名称
// 调用 LLM 生成简短名称 prompt = """ 根据以下用户消息,为会话生成一个简短的名称(不超过 10 个字)。 名称应该反映用户的主要意图或任务。
用户消息: {messages}
只输出名称,不要输出其他内容。 """
name = call_llm(prompt, model=naming_model) name = sanitize_name(name) // 清理非法字符
db.execute("UPDATE sessions SET name = ? WHERE id = ?", [name, session_id])设计考虑:
- 只取前 3 条用户消息,避免将过多上下文发送给命名模型
- 使用较轻量的模型即可完成任务(不需要最强的推理模型)
- 名称限制在 10 个字以内,确保 UI 显示美观
- 用户可随时手动修改会话名称
问题与规避
DELETE + INSERT 而非 UPDATE
问题:压缩后需要替换会话消息,Goose 采用 DELETE + 批量 INSERT 而非逐条 UPDATE。
原因:
- 消息结构变化:压缩可能将多条消息合并为一条摘要,消息总数发生变化。逐条 UPDATE 无法处理消息数量的变化
- ID 变化:压缩后部分消息被删除(不再存在于新消息列表中),部分消息是新创建的(摘要),UPDATE 语义不适用
- 实现简洁:
DELETE+ 批量INSERT在一个事务中完成,逻辑简单,不易出错 - 性能:批量 INSERT 比逐条 UPDATE 更高效
// 伪代码:压缩后的数据库操作fn replace_conversation(session_id, new_messages): db.transaction(): // 删除旧消息 db.execute("DELETE FROM messages WHERE session_id = ?", session_id) // 批量插入新消息 for msg in new_messages: db.execute("INSERT INTO messages ...", msg) // 提交事务注意事项:由于 DELETE 操作不释放 SQLite 磁盘空间(标记为可用页),长期运行的 Goose 可能需要进行 VACUUM 操作回收空间。但通常 VACUUM 由用户手动触发或定期后台执行。
为什么不用 ORM
问题:Goose 直接使用原生 SQL 而非 ORM(如 Diesel、SQLx 宏)。
原因:
| 维度 | 原生 SQL | ORM |
|---|---|---|
| SQL 控制 | 完全控制,精确优化 | 隐藏 SQL 细节,难以优化 |
| FTS 集成 | 直接使用 SQLite FTS 语法 | ORM 通常不支持 FTS |
| 性能 | 无 ORM 开销 | 序列化/反序列化开销 |
| Schema 迁移 | 手动 SQL 迁移,精确可控 | 自动迁移,但可能生成次优 SQL |
| 学习曲线 | 需要 SQL 知识 | 需要学习 ORM API |
Goose 的会话管理需要深度利用 SQLite 特性(FTS、JSON 列、游标分页),原生 SQL 提供了更好的灵活性和性能。
并发写入与 WAL 模式
问题:多个入口(CLI、Gateway、ACP)可能同时写入同一会话的消息。
对策:
- 启用 SQLite WAL(Write-Ahead Logging)模式,允许读写并发
- 设置
synchronous = NORMAL,平衡安全与性能 - 配置 busy timeout(如 5 秒),自动重试锁竞争
- 单条消息 INSERT 是原子操作,不需要额外事务保护
设计取舍
SQLite vs 远程数据库
| 维度 | SQLite(Goose 采用) | PostgreSQL/MySQL |
|---|---|---|
| 部署 | 零配置,单文件 | 需要服务器、网络配置 |
| 并发 | 中等(WAL 模式支持读写并发) | 高(连接池、行级锁) |
| 容量 | 适合 GB 级别(单用户场景足够) | TB 级别 |
| FTS | 内置 FTS5 扩展 | 需要额外配置(如 pg_trgm) |
| 备份 | 文件复制即可 | 需要专业工具(pg_dump 等) |
| 多用户 | 不支持(单文件锁) | 原生支持 |
| 适用场景 | 单机 CLI Agent | 多用户 SaaS Agent |
Goose 的选择:SQLite 完美匹配单机 Agent 的需求——零配置、FTS 内置、文件即数据库。当 Goose 需要支持多用户、分布式部署时,才需要考虑迁移到远程数据库。
日期格式 ID vs UUID
| 维度 | 日期格式 ID(Goose 采用) | UUID |
|---|---|---|
| 可读性 | 高(20250529_1 表示 5 月 29 日第 1 个) | 低(f47ac10b-58cc-…) |
| 排序 | 天然按时间排序 | 需要额外时间戳字段 |
| 冲突处理 | 同一天内序号递增 | 全局唯一,无冲突 |
| 跨设备同步 | 可能冲突(需要额外的冲突解决) | 无冲突 |
Goose 的选择:日期格式 ID 对用户更友好,UI 中可以直接展示时间信息。对于单机场景,冲突概率极低。如果需要跨设备同步,可以在 ID 中加入设备标识前缀。
JSON 列 vs 规范化列
| 维度 | JSON 列(Goose 采用) | 规范化列 |
|---|---|---|
| Schema 灵活性 | 高(随时添加新字段,无需迁移) | 低(添加字段需要 ALTER TABLE) |
| 查询能力 | 中(SQLite 支持 JSON 函数,但有限) | 高(直接索引和 WHERE) |
| 存储空间 | 中(JSON 序列化有冗余) | 低(类型化存储更紧凑) |
| 适用场景 | 快速迭代、字段频繁变化 | 稳定 Schema、需要精确查询 |
Goose 的选择:content_json、metadata_json、extension_data、model_config 都使用 JSON 存储。这是因为 Agent 的消息格式和配置在快速迭代中,JSON 列提供了最大的灵活性。
参考来源
- SQLite WAL Mode
- SQLite FTS5 文档
- Goose 源码:
crates/goose/src/session/session_manager.rs