Session 与 SessionDB:会话状态的持久化
本章核心源码:
hermes_state.py(1304 行)
定位:本章拆解 SessionDB 的 SQLite schema、WAL 并发安全、FTS5 全文检索和 session 血缘链。 前置依赖:第 4 章(AIAgent 内核)。适用场景:想理解对话如何被持久化,或需要查询历史会话数据。
为什么需要一个专用的会话存储
Hermes 作为长期运行的个人代理,每一次对话都需要持久化,不仅为了历史回顾,更为了跨会话回忆(session_search 工具)、上下文压缩后的 session splitting,以及 Gateway 多平台并发写入。
早期版本使用 per-session JSONL 文件。这在单用户 CLI 场景下勉强可用,但 Gateway 场景(多平台并发、需要跨会话搜索)暴露了文件方案的局限。SessionDB 引入 SQLite 作为结构化主存储,提供查询、全文检索和并发安全;不过当前代码仍处于迁移期,Gateway 还保留着 legacy JSONL 的兼容双写与回退路径。
Schema 设计
hermes_state.py:36 定义了三张核心表:
-- hermes_state.py:41-68
CREATE TABLE sessions (
id TEXT PRIMARY KEY,
source TEXT NOT NULL, -- "cli", "telegram", "discord", ...
user_id TEXT, -- Gateway 场景的用户标识
model TEXT,
system_prompt TEXT, -- 冻结的 system prompt 快照
parent_session_id TEXT, -- 压缩后的父 session 链接
started_at REAL NOT NULL,
ended_at REAL,
message_count INTEGER DEFAULT 0,
tool_call_count INTEGER DEFAULT 0,
input_tokens INTEGER DEFAULT 0,
output_tokens INTEGER DEFAULT 0,
cache_read_tokens INTEGER DEFAULT 0,
cache_write_tokens INTEGER DEFAULT 0,
estimated_cost_usd REAL,
title TEXT,
FOREIGN KEY (parent_session_id) REFERENCES sessions(id)
);
CREATE TABLE messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL,
role TEXT NOT NULL, -- "user", "assistant", "tool"
content TEXT,
tool_call_id TEXT,
tool_calls TEXT, -- JSON 序列化的工具调用
tool_name TEXT,
timestamp REAL NOT NULL,
token_count INTEGER,
finish_reason TEXT,
FOREIGN KEY (session_id) REFERENCES sessions(id)
);
-- FTS5 虚拟表:跨会话全文搜索
CREATE VIRTUAL TABLE messages_fts USING fts5(
content,
content=messages,
content_rowid=id
);
为什么存 system_prompt?
sessions.system_prompt 存储了每个 session 的完整 system prompt 快照。这不是为了审计,而是为了在会话恢复、缓存失效或进程重建时复原同一段冻结前缀。Gateway 在正常路径下会优先复用 _agent_cache 中的 AIAgent 实例(详见第 14 章),但当缓存被驱逐、进程重启、或需要根据历史记录继续会话时,SessionDB 中保存的 system prompt 快照就成为精确恢复 prompt cache 前缀的重要依据(详见第 5 章“System Prompt 构建”节)。
parent_session_id:压缩后的血缘链
当上下文压缩触发 session splitting 时(详见第 12 章),Hermes 创建一个新 session,将压缩后的消息写入新 session,并通过 parent_session_id 链接到旧 session。这形成了一条血缘链——用户可以追溯一次长对话的完整历史。
WAL 并发安全
SessionDB 需要在多进程/多线程环境下安全工作:CLI + Gateway + Cron 可能同时写入同一个 state.db。
WAL 模式
# hermes_state.py:157
self._conn.execute("PRAGMA journal_mode=WAL")
WAL(Write-Ahead Logging)模式允许多个读者和一个写者并发工作。读者不会被写者阻塞,写者也不会被读者阻塞。
应用级 Jitter Retry
SQLite 的内置 busy handler 使用确定性的退避时间表——这在多个进程同时争抢写锁时会产生 convoy effect(所有进程在相同的时间点重试,再次冲突)。
Hermes 的解法(hermes_state.py:164-214):
# hermes_state.py:164-214
def _execute_write(self, fn):
for attempt in range(15): # 最多 15 次重试
try:
with self._lock:
self._conn.execute("BEGIN IMMEDIATE") # 立即获取写锁
try:
result = fn(self._conn)
self._conn.commit()
except BaseException:
self._conn.rollback()
raise
# 每 50 次写入做一次 PASSIVE checkpoint
self._write_count += 1
if self._write_count % 50 == 0:
self._try_wal_checkpoint()
return result
except sqlite3.OperationalError as exc:
if "locked" in str(exc).lower():
jitter = random.uniform(0.020, 0.150) # 20-150ms 随机延迟
time.sleep(jitter)
continue
raise
三个关键设计:
- BEGIN IMMEDIATE:在事务开始时就获取写锁(而非默认的 deferred 模式在 commit 时才获取)——让锁竞争在最早时刻暴露
- 随机 jitter:20-150ms 的随机延迟打破 convoy effect
- PASSIVE checkpoint:每 50 次写入触发一次 WAL checkpoint,将 WAL 帧回写到主数据库文件——防止 WAL 文件无限增长
为什么不用 ORM
SessionDB 直接使用 sqlite3 模块,没有 ORM。原因:
- Hermes 需要精确控制事务边界(BEGIN IMMEDIATE)
- ORM 的连接池和自动事务管理会与自定义的 jitter retry 冲突
- 查询简单到不需要 ORM 的抽象
FTS5 全文检索
messages_fts 虚拟表让 agent 可以搜索所有历史会话的消息内容。session_search 工具(tools/session_search_tool.py)使用它实现跨会话回忆:
# 用户:"上次我们讨论的那个 deploy 脚本在哪?"
# Agent 调用 session_search(query="deploy script")
# → FTS5 搜索 messages_fts → 返回匹配的历史消息
FTS5 的优势是零配置全文搜索——不需要外部搜索引擎,不需要向量嵌入,不需要网络调用。对于个人代理的历史搜索场景,这比复杂的 RAG 方案更实用。
设计启示
SessionDB 展示了"为个人代理选择存储方案"的关键考量:
- SQLite > PostgreSQL:零配置、单文件部署、无守护进程——适合 $5 VPS 场景
- WAL > 默认 journal:支持并发读写——Gateway 多平台并发的必需
- 应用级 retry > SQLite busy handler:随机 jitter 打破 convoy effect
- FTS5 > 向量搜索:对于精确的文本匹配,FTS5 更简单可靠
第 11 章将分析 Memory Provider——在 SessionDB 之上的可插拔记忆层。
设计赌注回扣:SessionDB 直接服务于 Personal Long-Term(FTS5 让 agent 可以搜索所有历史对话)和 Run Anywhere(WAL + jitter retry 让 SQLite 在多进程/多平台并发下稳定工作,不需要外部数据库服务)。
版本演化说明
本章核心分析基于 Hermes Agent v0.8.0(2026 年 4 月),Schema Version 6。 SessionDB 和 SQLite 会话存储早在 v0.3.0 之前就已经进入代码库;之后一直演化到今天的 Schema Version 6。到本书写作时,它仍处在“SQLite 主存储 + legacy transcript 双写兼容”的迁移阶段,成本字段和会话元数据也在最近几个 release 中持续扩展。