Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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

三个关键设计:

  1. BEGIN IMMEDIATE:在事务开始时就获取写锁(而非默认的 deferred 模式在 commit 时才获取)——让锁竞争在最早时刻暴露
  2. 随机 jitter:20-150ms 的随机延迟打破 convoy effect
  3. 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 展示了"为个人代理选择存储方案"的关键考量:

  1. SQLite > PostgreSQL:零配置、单文件部署、无守护进程——适合 $5 VPS 场景
  2. WAL > 默认 journal:支持并发读写——Gateway 多平台并发的必需
  3. 应用级 retry > SQLite busy handler:随机 jitter 打破 convoy effect
  4. 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 中持续扩展。