跳转到内容

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 组织为 thread
threads {
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

原因

  1. 消息结构变化:压缩可能将多条消息合并为一条摘要,消息总数发生变化。逐条 UPDATE 无法处理消息数量的变化
  2. ID 变化:压缩后部分消息被删除(不再存在于新消息列表中),部分消息是新创建的(摘要),UPDATE 语义不适用
  3. 实现简洁DELETE + 批量 INSERT 在一个事务中完成,逻辑简单,不易出错
  4. 性能:批量 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 宏)。

原因

维度原生 SQLORM
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_jsonmetadata_jsonextension_datamodel_config 都使用 JSON 存储。这是因为 Agent 的消息格式和配置在快速迭代中,JSON 列提供了最大的灵活性。


参考来源