Hermes Agent 源码专题【左扬精讲】—— 会话存储层:SQLite + FTS5 + 血缘追踪

Hermes Agent 源码专题【左扬精讲】—— 会话存储层:SQLite WAL + FTS5 + 血缘追踪

Hermes Agent 的会话存储层负责持久化所有对话历史、消息内容、模型配置等信息。

本篇将深入剖析这一层的实现细节,理解 SQLite WAL 模式如何支撑高并发、FTS5 全文搜索如何实现跨会话检索、parent_session_id 血缘链如何支撑上下文压缩。

 hermes_state.py                     ← SessionDB 核心类(SQLite 存储 + FTS5 搜索)
 agent/conversation_compression.py   ← 上下文压缩(会话分裂 + 血缘链维护)

SQLite WAL FTS5 全文搜索 血缘追踪 会话持久化

本文学习路径

本篇将带你从全局到细节,循序渐进地理解会话存储层。学习路径如下:

  • Overview ─ "这玩意儿是干嘛的":30 秒认知锚点
  • Pinpoint ─ "入口在 哪一行":精确定位源码
  • Structure ─ "模块怎么组织的":静态拓扑关系
  • Behavior ─ "数据怎么流的":动态执行流程
  • Contract ─ "接口怎么定义的":API 契约边界
  • Rationale ─ "为什么这样设计":设计意图解析
  • Details ─ "这行代码啥意思":源码细节解读

建议:按顺序阅读,每一节都是下一节的基础。

学习重点提示

必须掌握

  • 理解 SessionDB 的 WAL 模式并发机制
  • 理解 FTS5 全文搜索与 trigram 分词器
  • 理解 parent_session_id 血缘链设计
  • 理解压缩触发的会话分裂机制

需要了解

  • Schema 版本迁移策略
  • 写入争用优化(随机退避重试)

目录

一、概览:SessionDB 是什么

OOverview — 概览

SessionDB 是 Hermes 的 SQLite-backed 会话存储层,替代了早期的 per-session JSONL 文件方案。它负责:

  • 持久化所有会话的元数据(模型配置、Token 统计、标题)
  • 存储完整消息历史(支持软删除)
  • 提供 FTS5 全文搜索能力
  • 维护会话血缘链(parent_session_id)

一句话定位:SessionDB 是 Hermes 的"记忆仓库",所有对话历史都存在这里。

二、定位:入口文件与核心类

NPinpoint — 定位

入口文件:hermes_state.py

核心类:SessionDB(第 657 行开始)

关键常量:

  • SCHEMA_VERSION = 16(第 110 行)
  • SCHEMA_SQL(第 509-590 行,定义表结构)
  • FTS_SQL(第 601-624 行,FTS5 虚拟表)
  • FTS_TRIGRAM_SQL(第 630-654 行,中文搜索)

血缘链 SQL:

  • _BRANCH_CHILD_SQL(第 38-44 行)
  • _COMPRESSION_CHILD_SQL(第 46-51 行)
  • _LISTABLE_CHILD_SQL(第 55 行)

三、结构:数据库表设计

SStructure — 结构

SessionDB 使用 5 个核心表:

            ┌─────────────────────┐
            │ schema_version      │ ← 记录当前 Schema 版本
            ├─────────────────────┤
            │ sessions            │ ← 会话元数据(模型、Token、标题)
            ├─────────────────────┤
            │ messages            │ ← 消息历史(完整内容)
            ├─────────────────────┤
            │ state_meta          │ ← 键值对存储
            ├─────────────────────┤
            │ compression_locks   │ ← 压缩锁(防止并发压缩)
            └─────────────────────┘
            
            FTS5 虚拟表(全文索引):
            ┌─────────────────────┐
            │ messages_fts        │ ← BM25 排序的全文搜索
            ├─────────────────────┤
            │ messages_fts_trigram│ ← CJK 子串搜索
            └─────────────────────┘

DDetails — 细节

hermes_state.py 第 514-549 行的 sessions 表:

CREATE TABLE IF NOT EXISTS sessions (
                id TEXT PRIMARY KEY,
                source TEXT NOT NULL,                          -- 'cli', 'TG', 'discord'
                user_id TEXT,
                model TEXT,
                model_config TEXT,                             -- JSON 序列化的模型配置
                system_prompt TEXT,
                parent_session_id TEXT,                        -- 血缘追踪:父会话 ID
                started_at REAL NOT NULL,
                ended_at REAL,
                end_reason TEXT,                              -- 'normal', 'compression', 'branch'
                message_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,
                reasoning_tokens INTEGER DEFAULT 0,
                title TEXT,
                archived INTEGER NOT NULL DEFAULT 0,
                FOREIGN KEY (parent_session_id) REFERENCES sessions(id)
            );

DDetails — 细节

hermes_state.py 第 551-570 行的 messages 表:

CREATE TABLE IF NOT EXISTS messages (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                session_id TEXT NOT NULL REFERENCES sessions(id),
                role TEXT NOT NULL,                             -- 'system', '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,
                reasoning TEXT,                               -- 轨迹存储用
                reasoning_content TEXT,                       -- API 传输用
                active INTEGER NOT NULL DEFAULT 1            -- 软删除标记
            );

四、行为:FTS5 搜索与血缘追踪

BBehavior — 行为

会话存储层支持两类核心行为:

1. FTS5 全文搜索流程

用户搜索 "Python 异步"
                     ↓
            ┌─────────────────────────┐
            │ search_sessions()      │ ← 入口方法
            └─────────────────────────┘
                     ↓
            ┌─────────────────────────┐
            │ 检测查询是否为 CJK      │
            │ (_is_cjk_query)        │
            └─────────────────────────┘
                     ↓
                ┌───────────────────┐
                │ CJK 查询?         │
                └───────────────────┘
                     ↓ Yes              ↓ No
            ┌─────────────────┐  ┌─────────────────┐
            │ trigram 路径    │  │ BM25 路径       │
            │ LIKE 模糊匹配    │  │ messages_fts    │
            └─────────────────┘  └─────────────────┘

2. 血缘追踪流程

会话 A(根会话)
                │
                ├── /branch → 会话 B(Branch 子会话,用户可见)
                │
                ├── 上下文压缩 → 会话 C(Compression 子会话,用户可见)
                │                    │
                │                    └── 继续对话 → 更多消息
                │
                └── delegate_task → 会话 D(Ephemeral 子会话,内部,不可见)
                                        │
                                        └── 子 Agent 完成后级联删除

DDetails — 细节

FTS5 触发器:见 hermes_state.py 第 606-623 行

CREATE TRIGGER IF NOT EXISTS messages_fts_insert AFTER INSERT ON messages BEGIN
                INSERT INTO messages_fts(rowid, content) VALUES (
                    new.id,
                    COALESCE(new.content, '') || ' ' ||
                    COALESCE(new.tool_name, '') || ' ' ||
                    COALESCE(new.tool_calls, '')
                );
            END;

FTS5 索引同时包含 contenttool_nametool_calls 三个字段。

DDetails — 细节

trigram 分词器(中文优化):见 hermes_state.py 第 630-634 行

-- Trigram FTS5 table for CJK substring search.
            -- The default unicode61 tokenizer splits CJK characters into individual
            -- tokens, breaking phrase matching. The trigram tokenizer creates
            -- overlapping 3-byte sequences so substring queries work natively.
            CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts_trigram USING fts5(
                content,
                tokenize='trigram'
            );

默认的 unicode61 分词器将每个 CJK 字符独立切分,无法做短语匹配。trigram 分词器按 3 字节滑动窗口建立重叠序列,实现任意子串匹配。

五、契约:核心 API 接口

CContract — 契约

SessionDB 的核心 API 契约:

核心方法签名

# 会话管理
            def create_session(session_id: str, source: str, model: str, ...) -> None
            def end_session(session_id: str, end_reason: str) -> None
            def list_sessions(limit: int = 20, source: str = None, ...) -> List[Dict]
            
            # 消息管理
            def append_message(session_id: str, role: str, content: str, ...) -> int
            def get_messages(session_id: str, limit: int = None, ...) -> List[Dict]
            
            # 全文搜索
            def search_sessions(query: str, limit: int = 10, rank_mode: str = None) -> List[Dict]
            
            # 血缘追踪
            def resolve_resume_session_id(session_id: str) -> str
            def get_compression_tip(session_id: str) -> Optional[str]

错误处理约定

  • 初始化失败时设置 _last_init_error,供 /resume 等命令展示错误原因
  • NFS/SMB 文件系统返回 "locking protocol" 错误,自动回退到 DELETE 模式
  • FTS5 不可用时降级为 LIKE 模糊匹配
  • 压缩锁超时后自动释放

六、理由:为什么这样设计

RRationale — 理由

为什么用 SQLite 而不是 PostgreSQL/MySQL?

答案:零部署依赖,文件级复制即可备份。SQLite 是嵌入式数据库,不需要独立进程,非常适合 Agent 这种桌面/服务器场景。单文件 state.db 可以直接 cp 备份。

为什么用 WAL 模式?

答案:支撑多进程并发读写。Hermes 支持 Gateway + CLI + Worktree agents 同时运行,所有进程共享同一个 state.db。WAL 模式允许并发读,只有写者之间需要锁,避免了 DELETE 模式的写锁阻塞读问题。

为什么应用层随机退避而不是 SQLite 内置 busy handler?

答案:避免 convoy 效应。SQLite 内置 busy handler 使用确定性的 sleep 递增策略,多个竞争写入线程会在同一时间醒来重试,形成 convoy 效应。随机退避(20ms~150ms)自然分散竞争者。

为什么需要血缘链?

答案:支撑上下文压缩和分支会话。压缩后的摘要消息与原始消息内容完全不同,放在同一会话会导致历史混乱。血缘链使得 Agent 可以回溯完整对话历史,同时又不暴露已压缩的原始消息。

What-if ─ 删除血缘链会发生什么?

① 压缩后无法追溯之前的对话上下文

② /branch 分支会话无法找到原始会话

③ /resume 无法正确找到压缩后的最新会话

④ 委托子 Agent 的会话无法级联清理

七、细节:关键源码解析

DDetails — WAL 写入争用优化

hermes_state.py 第 665-678 行

# ── Write-contention tuning ──
            # With multiple hermes processes (gateway + CLI sessions + worktree agents)
            # all sharing one state.db, WAL write-lock contention causes visible TUI
            # freezes. SQLite's built-in busy handler uses a deterministic sleep
            # schedule that causes convoy effects under high concurrency.
            #
            # Instead, we keep the SQLite timeout short (1s) and handle retries at the
            # application level with random jitter, which naturally staggers competing
            # writers and avoids the convoy.
            _WRITE_MAX_RETRIES = 15
            _WRITE_RETRY_MIN_S = 0.020   # 20ms
            _WRITE_RETRY_MAX_S = 0.150   # 150ms
            # Attempt a PASSIVE WAL checkpoint every N successful writes.
            _CHECKPOINT_EVERY_N_WRITES = 50

DDetails — 连接初始化

hermes_state.py 第 712-728 行

def _connect_and_init():
                self._conn = sqlite3.connect(
                    str(self.db_path),
                    check_same_thread=False,
                    # Short timeout — application-level retry with random
                    # jitter handles contention instead of sitting in
                    # SQLite's internal busy handler for up to 30s.
                    timeout=1.0,
                    # auto-starts transactions on DML, which conflicts with
                    # our explicit BEGIN IMMEDIATE. None = we manage
                    # transactions ourselves.
                    isolation_level=None,
                )
                self._conn.row_factory = sqlite3.Row
                apply_wal_with_fallback(self._conn, db_label="state.db")
                self._conn.execute("PRAGMA foreign_keys=ON")
                self._init_schema()

DDetails — WAL 回退机制

hermes_state.py 第 128-131 行

# WAL mode requires shared-memory coordination that doesn't work on NFS/SMB.
            # On those filesystems PRAGMA journal_mode=WAL raises SQLITE_PROTOCOL.
            # Fall back to journal_mode=DELETE which works on NFS.
            _WAL_INCOMPAT_MARKERS = (
                "locking protocol",       # SQLITE_PROTOCOL on NFS/SMB
                "not authorized",         # Some FUSE mounts block WAL pragma outright
            )

DDetails — 血缘类型区分

hermes_state.py 第 36-66 行

# A child session counts as a /branch (kept visible) if it carries
            # the stable marker OR the legacy end_reason heuristic holds.
            _BRANCH_CHILD_SQL = (
                "json_extract(COALESCE({a}.model_config, '{}'), '$._branched_from') IS NOT NULL"
                " OR EXISTS (SELECT 1 FROM sessions p"
                "            WHERE p.id = {a}.parent_session_id"
                "            AND p.end_reason = 'branched'"
                "            AND {a}.started_at >= p.ended_at)"
            )
            
            _COMPRESSION_CHILD_SQL = (
                "EXISTS (SELECT 1 FROM sessions p"
                "        WHERE p.id = {a}.parent_session_id"
                "        AND p.end_reason = 'compression'"
                "        AND {a}.started_at >= p.ended_at)"
            )
            
            def _ephemeral_child_sql(alias: str = "s") -> str:
                """Subagent runs (cascade-delete targets), not branches or compression tips."""
                branch = _BRANCH_CHILD_SQL.format(a=alias)
                compression = _COMPRESSION_CHILD_SQL.format(a=alias)
                return (
                    f"({alias}.parent_session_id IS NOT NULL"
                    f" AND NOT ({branch})"
                    f" AND NOT ({compression}))"
                )

Lesson血缘链设计的三层可见性 ─ Branch 子会话(用户可见)、Compression 子会话(用户可见,通过 /resume 透明重定向)、Ephemeral 子会话(内部,不可见,随父会话级联删除)。理解这三层的区别是理解会话分裂机制的关键。

本节小结

  • SessionDB 使用 SQLite WAL 模式支撑多进程并发
  • 应用层随机退避(20ms~150ms)避免 convoy 效应
  • FTS5 虚拟表提供 BM25 全文搜索,trigram 优化中文子串匹配
  • 血缘链(parent_session_id)支撑压缩分裂和 /resume 重定向
  • NFS/SMB 文件系统自动回退到 DELETE 模式

八、FAQ 20 问

FAQ 分组说明

以下 20 组 FAQ 涵盖会话存储层的常见问题,按主题分为四组:WAL 并发(1-5)、表结构(6-10)、FTS5 搜索(11-15)、血缘追踪(16-20)。

Q1. Hermes 为什么选择 SQLite 而不是 PostgreSQL/MySQL?

零部署依赖,文件级复制即可备份。SQLite 是嵌入式数据库,不需要独立进程,非常适合 Agent 这种桌面/服务器场景。单文件 state.db 可以直接 cp 备份。

Q2. WAL 模式和 DELETE 模式有什么区别?

WAL 支持并发读,DELETE 不支持。DELETE 模式下写操作会阻塞所有读操作。WAL 将写操作写入独立的 WAL 文件,读者无需等待写锁。

Q3. 为什么应用层随机退避而不是用 SQLite 内置的 busy handler?

避免 convoy 效应。SQLite 内置 busy handler 使用确定性的 sleep 递增策略,多个竞争写入线程会在同一时间醒来重试,形成 convoy 效应。随机退避(20ms~150ms)自然分散竞争者。

Q4. 每 50 次写入 checkpoint 一次会不会 WAL 文件无限膨胀?

会,但有 TRUNCATE checkpoint 清理。TRUNCATE checkpoint 会将 WAL 文件截断为 0,释放磁盘空间。

Q5. WAL 模式在 Windows 上有没有问题?

本地 Windows 无问题,WSL1 有问题。WSL1 不支持 SQLite WAL 所需的共享内存机制,会回退到 DELETE 模式。WSL2 和原生 Windows 没问题。

Q6. sessions 表的 model_config 字段存的是什么?

完整的模型配置 JSON。包括 provider、base_url、api_key(加密后)、max_tokens、temperature 等配置。存储为 JSON 字符串以便灵活扩展。

Q7. messages 表的 token_count 字段有什么用?

精确统计每条消息的 Token 消耗。每次 API 调用后累加到 sessions 表的 input_tokens / output_tokens 等统计字段,用于计费和预算控制。

Q8. Schema 版本升级如何保证不丢数据?

增量迁移 + 事后列对齐。_reconcile_columns 方法在每次初始化时检查 schema_version 表,按顺序执行每个版本的迁移 SQL。

Q9. messages 表支持软删除吗?

支持,通过 active 列。v16 引入了 active INTEGER NOT NULL DEFAULT 1 列。删除时设置 active = 0 而非物理删除。

Q10. 为什么需要 compression_locks 表?

防止并发压缩冲突。多个进程可能同时检测到上下文超限并尝试压缩。compression_locks 表使用 session_id 作为主键,确保同一会话只有一次压缩运行。

Q11. FTS5 和 LIKE 查询哪个更快?

FTS5 显著更快(大规模数据)。LIKE '%keyword%' 只能全表扫描。FTS5 使用倒排索引,搜索复杂度接近 O(log N)。

Q12. trigram 分词器和 unicode61 分词器哪个用于中文搜索?

trigram 用于中文,unicode61 用于英文。unicode61 将每个中文字符独立切分,无法做"你好世界"这样的短语匹配。trigram 按 3 字节滑动窗口建立索引,支持任意子串搜索。

Q13. FTS5 查询语法支持哪些特性?

短语搜索、布尔搜索、通配符。例如 "hello world"(短语)、hello AND world(同时包含)、hello*(前缀匹配)。

Q14. FTS5 索引会自动更新吗?

是的,通过触发器自动同步。messages_fts_insert/delete/update 三个触发器保证 FTS5 索引与 messages 表实时同步。

Q15. 如果 Python SQLite 模块没有 FTS5 支持会怎样?

FTS5 搜索降级为 LIKE 模糊匹配。检测 FTS5 可用性,如果不支持则回退到 LIKE 查询(性能下降但功能可用)。

Q16. Branch 子会话和 Compression 子会话有什么区别?

来源不同,行为相同(都可见、不级联删)。Branch 由用户通过 /branch 命令创建,Compression 由压缩自动分裂产生。两者都在会话列表中可见,且父会话删除时不会被级联删除。

Q17. Ephemeral 子会话什么时候会被删除?

随父会话级联删除。委托子 Agent 的会话(Ephemeral 类型)在父会话结束时通过 _delete_delegate_children 一起删除。

Q18. 压缩会话分裂后,原始会话的消息还在吗?

还在,但新会话不包含它们。压缩分裂结束的是当前会话(标记 end_reason = 'compression'),但不会物理删除 messages 表中的历史消息。

Q19. /resume 时如何找到压缩后的最新会话?

通过 resolve_resume_session_id 自动重定向。该方法先检查是否有压缩接续子会话(有消息的),如果有则返回子会话 ID,否则沿血缘链递归查找。

Q20. 为什么压缩需要辅助模型(auxiliary client)?

主模型上下文已满,无法再处理压缩。如果用主模型做压缩,消息太长放不进去。用一个更小、更便宜的辅助模型专门做摘要。

FAQ 总纲

  • WAL 模式支撑多进程并发,DELETE 模式保证 NFS 兼容
  • sessions 存元数据,messages 存消息,FTS5 提供全文搜索
  • 血缘链(parent_session_id)支撑压缩分裂和 /resume 重定向
  • 辅助模型负责压缩,避免主模型上下文已满的问题

九、后续 Roadmap

敬请期待

本篇介绍了会话存储层的架构设计。后续文章将深入剖析:

  • 上下文压缩的完整实现细节(agent/conversation_compression.py)
  • Gateway 会话路由与会话隔离
  • Token 计费与成本追踪系统
  • 记忆系统的插件化架构
  • Profile 多实例隔离机制

欢迎持续关注!


posted @ 2026-07-17 15:57  左扬  阅读(33)  评论(0)    收藏  举报