Appearance
Hermes 的会话存储系统设计比大多数 AI Agent 项目都要扎实:WAL 模式支持多进程并发读写,FTS5 虚拟表实现跨会话全文检索,写竞争用随机 jitter 重试而不是 SQLite 内置的确定性 backoff。理解这套存储系统,能帮你更好地调试、扩展 Hermes,也能了解 AI Agent 持久化的工程考量。
Hermes Agent 会话存储深度解析:SQLite WAL + FTS5 全文检索的工程实现
大多数 AI Agent 工具的对话记录存在 JSONL 文件里,重启后靠文件名来找历史记录,没有索引,没有检索,数据多了之后就是个黑洞。
Hermes 从一开始就选择了 SQLite,现在(v6 schema)已经相当完整:WAL 模式并发写、FTS5 全文检索、多平台会话来源标记、成本追踪字段。这篇文章从源码层面解析这套存储系统的设计。
数据库结构
sessions 表
sql
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
source TEXT NOT NULL, -- 'cli', 'telegram', 'discord' 等
user_id TEXT, -- 消息平台用户 ID
model TEXT,
model_config TEXT,
system_prompt TEXT,
parent_session_id TEXT, -- 上下文压缩后的父会话链
started_at REAL NOT NULL,
ended_at REAL,
end_reason TEXT,
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, -- Anthropic caching 统计
cache_write_tokens INTEGER DEFAULT 0,
reasoning_tokens INTEGER DEFAULT 0,
estimated_cost_usd REAL,
actual_cost_usd REAL,
cost_status TEXT,
title TEXT,
FOREIGN KEY (parent_session_id) REFERENCES sessions(id)
);parent_session_id 是理解 Hermes 上下文压缩机制的关键字段:当对话太长触发压缩时,旧会话不会被删除,而是通过 parent_session_id 链连接起来,形成压缩历史链。这样可以回溯压缩前的完整对话。
messages 表 + FTS5 虚拟表
sql
CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL,
role TEXT NOT NULL,
content TEXT,
tool_call_id TEXT,
tool_calls TEXT,
tool_name TEXT,
timestamp REAL NOT NULL,
token_count INTEGER,
reasoning TEXT, -- Extended thinking 的推理过程
reasoning_details TEXT,
codex_reasoning_items TEXT
);
-- FTS5 虚拟表,自动同步
CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5(
content,
content=messages, -- 内容表指向 messages
content_rowid=id -- 主键映射
);FTS5 虚拟表通过三个触发器和 messages 保持同步:
sql
-- INSERT 时自动写入 FTS 索引
CREATE TRIGGER IF NOT EXISTS messages_fts_insert AFTER INSERT ON messages BEGIN
INSERT INTO messages_fts(rowid, content) VALUES (new.id, new.content);
END;
-- DELETE 时从 FTS 删除
CREATE TRIGGER IF NOT EXISTS messages_fts_delete AFTER DELETE ON messages BEGIN
INSERT INTO messages_fts(messages_fts, rowid, content) VALUES('delete', old.id, old.content);
END;
-- UPDATE 时更新 FTS(先删后插)
CREATE TRIGGER IF NOT EXISTS messages_fts_update AFTER UPDATE ON messages BEGIN
INSERT INTO messages_fts(messages_fts, rowid, content) VALUES('delete', old.id, old.content);
INSERT INTO messages_fts(rowid, content) VALUES (new.id, new.content);
END;FTS5 的触发器写法有一个反直觉的地方:DELETE 操作不是用普通 DELETE FROM 语句,而是用特殊的 INSERT INTO messages_fts(messages_fts, rowid, content) VALUES('delete', ...) 语法——这是 FTS5 的 content= 模式要求的写法,content=messages 意味着 FTS5 不存储原始内容,而是通过 content_rowid 从 messages 表回查,所以索引更新要用这种特殊语法。
WAL 模式下的多进程写竞争
这是 Hermes 存储系统里最有工程价值的部分。
问题背景
Hermes 可能同时有多个进程共享一个 state.db:
- Hermes Gateway(持续运行,高频写)
- CLI 会话(偶尔启动,有写操作)
- 子代理(
delegate_task启动的进程) - ACP Server(另一个进程)
SQLite WAL 模式允许多个读者并发,但写入仍然需要互斥锁。当多个进程同时触发写操作,SQLite 内置的 busy handler 会让等待的进程进入一个确定性 sleep 循环。
确定性 backoff 的问题:假设 5 个进程都在等待锁,它们的 sleep 时间是确定的(比如都是 100ms),结果就是同时醒来、同时竞争,形成 convoy 效应——周期性的"全员冲撞"。
Hermes 的解法:随机 jitter 重试
python
_WRITE_MAX_RETRIES = 15
_WRITE_RETRY_MIN_S = 0.020 # 20ms
_WRITE_RETRY_MAX_S = 0.150 # 150ms
def _execute_write(self, fn):
for attempt in range(self._WRITE_MAX_RETRIES):
try:
with self._lock:
self._conn.execute("BEGIN IMMEDIATE") # 立即锁定,不等到 commit
try:
result = fn(self._conn)
self._conn.commit()
except:
self._conn.rollback()
raise
# ... 成功,定期触发 WAL checkpoint
return result
except sqlite3.OperationalError as exc:
if "locked" in str(exc).lower():
# 随机 jitter:20~150ms,打散冲撞
jitter = random.uniform(0.020, 0.150)
time.sleep(jitter)
continue
raise关键决策:
BEGIN IMMEDIATE:在事务开始时就获取写锁,不等到 commit 时才发现冲突,让锁竞争在最早的时机暴露- 应用层随机 jitter:绕过 SQLite 内置的确定性 backoff,自然打散多进程的竞争时机
- SQLite timeout=1.0s:配合应用层重试,避免在 SQLite 内部等太久
WAL Checkpoint 策略
python
_CHECKPOINT_EVERY_N_WRITES = 50
# 成功写入后,每 50 次做一次 PASSIVE checkpoint
if self._write_count % self._CHECKPOINT_EVERY_N_WRITES == 0:
self._try_wal_checkpoint()WAL 模式的问题:WAL 文件会持续增长,直到被 checkpoint 合并回主 DB 文件。PASSIVE checkpoint 只合并没有活跃读者在访问的帧,不阻塞读操作,适合后台定期执行。
session_search 工具的实现
FTS5 索引最直接的用处是 session_search 工具,AI 可以主动调用它检索历史对话:
python
session_search(
query="how to configure Telegram bot token",
limit=10,
source="telegram" # 只在 Telegram 会话里搜
)内部实现:
sql
SELECT m.content, m.role, m.timestamp, s.source, s.started_at
FROM messages m
JOIN messages_fts ON messages_fts.rowid = m.id
JOIN sessions s ON m.session_id = s.id
WHERE messages_fts MATCH ?
AND (s.source = ? OR ? IS NULL)
ORDER BY rank
LIMIT ?FTS5 的 MATCH 操作符支持:
- 短语搜索:
"configure telegram" - 前缀搜索:
telegram* - 布尔组合:
telegram AND bot - 排除:
telegram NOT discord
rank 列是 FTS5 自动计算的 BM25 相关度分数,越相关排越前。
成本追踪字段的设计意图
sessions 表里的成本字段值得单独说:
input_tokens, output_tokens
cache_read_tokens, cache_write_tokens ← Anthropic caching 专用
reasoning_tokens ← Extended thinking 专用
estimated_cost_usd, actual_cost_usd
cost_status, cost_source
billing_provider, billing_base_url, billing_mode
pricing_versionestimated_cost_usd vs actual_cost_usd 的区别:
estimated:基于本地 pricing 表计算(实时可得)actual:从 API 响应里拿到的实际账单金额(不是所有 Provider 都提供)
pricing_version 记录计算时用的哪套价格表,方便在价格变动后重新计算历史数据。
这套字段设计让 Hermes 能回答「我这个月在各 Provider 上各花了多少」——不是靠 Provider 自己的仪表盘,而是本地自计算。
实际查询示例
bash
# 查看今日 CLI 会话
sqlite3 ~/.hermes/state.db "
SELECT title, model, message_count, estimated_cost_usd
FROM sessions
WHERE source = 'cli'
AND started_at > (strftime('%s', 'now', '-1 day'))
ORDER BY started_at DESC;"
# 查找提到某个错误的历史消息
sqlite3 ~/.hermes/state.db "
SELECT s.source, m.content, datetime(m.timestamp, 'unixepoch') as ts
FROM messages m
JOIN messages_fts ON messages_fts.rowid = m.id
JOIN sessions s ON m.session_id = s.id
WHERE messages_fts MATCH 'database locked'
LIMIT 20;"
# 统计各 Provider 的 token 消耗
sqlite3 ~/.hermes/state.db "
SELECT billing_provider,
SUM(input_tokens) as in_tok,
SUM(output_tokens) as out_tok,
SUM(estimated_cost_usd) as cost
FROM sessions
WHERE started_at > strftime('%s', date('now', 'start of month'))
GROUP BY billing_provider;"FAQ
Q: WAL 模式下数据库文件变多了正常吗?
正常。WAL 模式会生成 state.db-wal(写日志)和 state.db-shm(共享内存索引)。Checkpoint 后 WAL 文件会被清空,但不会删除。只要 Hermes 正常退出,WAL 会自动合并。异常崩溃后下次启动会自动回滚未完成事务。
Q: session_search 工具能搜图片/附件内容吗?
不能,FTS5 只索引文本 content 字段。图片会存成 base64 或文件引用,文字描述(如 AI 生成的图片分析结果)会被索引,但图片内容本身不在 FTS5 范围内。
Q: 数据库文件太大了,能清理吗?
bash
# 删除 30 天前的会话(谨慎)
sqlite3 ~/.hermes/state.db "
DELETE FROM messages WHERE session_id IN (
SELECT id FROM sessions WHERE ended_at < strftime('%s', 'now', '-30 days')
);
DELETE FROM sessions WHERE ended_at < strftime('%s', 'now', '-30 days');
VACUUM;"VACUUM 会重建 FTS5 索引,清理后空间才真正释放。操作前建议先备份。