Skip to content

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_rowidmessages 表回查,所以索引更新要用这种特殊语法。

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

关键决策:

  1. BEGIN IMMEDIATE:在事务开始时就获取写锁,不等到 commit 时才发现冲突,让锁竞争在最早的时机暴露
  2. 应用层随机 jitter:绕过 SQLite 内置的确定性 backoff,自然打散多进程的竞争时机
  3. 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_version

estimated_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 索引,清理后空间才真正释放。操作前建议先备份。