开源自托管 Agent 项目 Hermes Agent 的状态层拆法
本文基于 hermes-agent 仓库 commit 2d40494(2026-07-29)梳理,该项目仍在高频迭代,具体行为以仓库 https://github.com/NousResearch/hermes-agent 最新代码与文档为准。
判断先放这里:Agent 的状态能不能搬走,不取决于你用了什么数据库,而取决于你有没有把「表结构长什么样」和「一条会话被搬出去时该带什么」这两件事分开写。 NousResearch 的 hermes-agent(MIT 许可证,LICENSE 署名 Nous Research;注意它和 Nous Research 的开源模型系列 Hermes 同名,也和若干同名商标无关,本文说的是那个常驻在你自己机器上、开终端执行命令、连你聊天账号的 Agent 项目)把这件事拆成了四个 Python 文件,拆法本身比它用 SQLite 这个事实有信息量得多。
站内已经有几篇写方法论和别的项目的:会话该存成什么形状看 pi 的会话存储,长任务怎么做检查点看 Agent 检查点与长任务,输出结构不稳定怎么治看 结构化输出不稳。这一篇不重复那些通用结论,只做一件事:把 hermes-agent 这个具体仓库的状态层落到实处,逐个文件说清它挡住了哪些换机器、换版本时才会暴露的问题。
一、四个文件分别在管什么
状态主体在仓库根目录的 hermes_state.py,核心类是 SessionDB。它的类声明一眼就能看出结构:
class SessionDB(SessionSearchMixin, SessionSchemaMixin, SessionPortabilityMixin):
三个 mixin 分别来自三个独立模块,加上一个存放共享常量的模块,构成下面这张分工表。
| 组成部分 | 它负责什么 | 对应仓库位置 | 你什么时候会碰到它 |
|---|---|---|---|
SessionDB 主体 | 打开连接、WAL、写重试与抖动、读连接分离、token 计数队列、异常时的自我修复 | hermes_state.py | 报「database is locked」、开库失败、state.db 被写坏时 |
| 共享常量层 | SCHEMA_SQL、DEFERRED_INDEX_SQL、FTS_SQL、FTS_TRIGRAM_SQL、SCHEMA_VERSION、FTS_STORAGE_VERSION、会话预览用的 SQL 片段 | hermes_state_common.py | 想知道到底存了哪些字段、要读表定义时 |
SessionSchemaMixin | 建表、列对齐、延后建索引、老库的主键修补、决定用哪套 FTS DDL | hermes_state_schema.py | 每次进程启动、每次升级后第一次打开库 |
SessionPortabilityMixin | 导出单会话/压缩谱系/全量,导入并校验,以及若干富行查询 | hermes_state_portability.py | 备份、换机器、把历史挪到另一个 profile 时 |
SessionSearchMixin | 检索路径与索引布局的迁移入口 | hermes_state_search.py | 搜历史、发现 state.db 体积异常时 |
数据落在 Hermes home 下的 state.db:hermes_constants.py 里的 get_hermes_home() 决定这个目录,Windows 默认在 LOCALAPPDATA 下的 hermes,其它平台是 ~/.hermes,HERMES_HOME 环境变量可以整体改指。这个细节在迁移时很关键——你要搬的不是「某个隐藏目录」,而是一个由环境变量决定的、可以显式换掉的位置。
二、schema 这一层:让旧库自己长出新列
传统做法是写一串版本号闸门:某一版加这列、下一版加那列,漏一个就永远缺列。hermes_state_schema.py 里的 _init_schema() 换了个思路:SCHEMA_SQL 是唯一事实来源,每次启动都拿它跟活库对一遍。
对比的方式很讨巧。_parse_schema_columns() 不写正则,而是开一个内存 SQLite,把 SCHEMA_SQL 执行一遍,再用 PRAGMA table_info 把每张表的列名、类型、NOT NULL、DEFAULT 读回来——SQL 语法由 SQLite 自己解析,带逗号的 DEFAULT 表达式、内联 REFERENCES、CHECK 约束全都不需要你操心。拿到期望列之后,_reconcile_columns() 逐表做差集:
for col_name, col_type in declared_cols.items():
if col_name not in live_cols:
safe_name = col_name.replace('"', '""')
try:
cursor.execute(
f'ALTER TABLE "{table_name}" ADD COLUMN "{safe_name}" {col_type}'
)
except sqlite3.OperationalError as exc:
logger.debug("reconcile %s.%s: %s", table_name, col_name, exc)
这就是「换版本」这一半麻烦的解法:你从旧版本升到新版本,中间跳过了几个 tag,甚至迁移脚本的编号被人重排过,缺的列在下一次启动时照样补上。加字段这件事退化成「改 SCHEMA_SQL」,不再需要写迁移。
代价它自己也写清楚了,而且写得比很多项目诚实:
- 索引必须延后建。 引用了「后来才加的列」的索引,不能放进
SCHEMA_SQL,否则老库跑executescript时会直接报「no such column」。它们被单独放进DEFERRED_INDEX_SQL,在列对齐之后执行。 - ADD COLUMN 治不了主键。
gateway_routing早期版本的主键只有session_key,后来要加scope做隔离。补列能补,主键 SQLite 改不了,于是有了_heal_gateway_routing_pk():重命名旧表、按新定义建表、按updated_at顺序搬行、丢掉旧表。整个仓库里这是列对齐唯一表达不出来的表形修复。 - 补出来的列可能少了默认值。 早期的对齐逻辑重建类型表达式时漏过 NOT NULL DEFAULT,结果
messages.active写进了 NULL,而加载历史的查询是WHERE active = 1,整段历史就被藏起来了。现在启动时无条件跑一次幂等修复,把 NULL 改回 1。这类 bug 的特征值得记住:不报错,只是内容凭空消失。
schema_version 表被保留下来,但只留给真正改数据的迁移——按会话回填 usage 行、给委派子会话打标记这类没法声明式表达的活。
三、可移植性这一层:定义什么叫「搬得走」
hermes_state_portability.py 的导出侧有三个梯度:export_session() 导一条会话连带消息;export_session_lineage() 把一条压缩谱系拼成一个逻辑会话,段落放在 segments 里、消息拼平、message_count 重算;export_all() 把全部会话导成一个列表,落成 JSONL 用于备份分析。命令行侧的 hermes sessions export 支持 jsonl、md、qmd、html、trace 几种格式,并且带 --redact。
真正定分寸的是导入侧的 import_sessions()。它做了两轮:先在事务外把整个载荷规范化一遍——每个文本字段必须是字符串,model_config 必须是 JSON 对象,token_count 必须能转整数,会话条数、单会话消息数、单会话字节数、总字节数都有 _IMPORT_MAX_SESSIONS 一族的类常量兜着;任何一条不合格就整批返回 ok: False 和逐条 errors,一行都不写。这个「先全量校验、再一次性写」的顺序,避免了半截导入留下一个说不清状态的库。
写入侧有三处判断值得抄:
已存在的 id 跳过,不覆盖。 导入的语义是补历史,不是同步。
父子关系单独一轮回填,并且检环。 会话之间靠 parent_session_id 连成压缩链和分支树。导入时先把所有会话以无父状态插进去,最后再挂边:
if parent_exists and not _would_create_cycle(session_id, parent_id):
conn.execute(
"UPDATE sessions SET parent_session_id = ? WHERE id = ?",
(parent_id, session_id),
)
else:
parent_by_child.pop(session_id, None)
detached += 1
父不在库里、也不在同一批载荷里,就只掉这一条边,把这个会话当根节点留下,返回值里 detached 计数加一。你从一堆会话里只挑了几条搬走,不会因为外键校验整批失败——这是「换机器」时最常见的那种半截数据。
运行时状态明确不还原。 插入语句的列清单里没有网关路由、没有 handoff_state、没有 rewind_count、没有 pinned,message_count 与 tool_call_count 也不采信载荷里的数字,而是按实际插进去的行数重算。它恢复的是对话历史,不是某个活着的频道归属或某个正在跑的进程。搬完之后 Agent 不会突然以为自己还占着旧机器上的那个群。
导入的入口也做了区分:/api/sessions/import 只管会话行与消息,/api/ops/import 才是整包备份还原。这两件事混在一个入口里,是很多自托管工具后来出事的地方。
四、两条独立的版本线:搜索索引为什么不跟着 schema 走
这是四个文件里最容易被忽略、但对「换版本」影响最大的设计。hermes_state_common.py 同时定义了 SCHEMA_VERSION 和 FTS_STORAGE_VERSION,后者记在 state_meta 的 fts_storage_version 键上,两者独立推进。
原因是全文索引的布局换代太贵。旧的内联布局里,两张 FTS5 虚表各自存一份消息副本,trigram 索引还把 role='tool' 的行也收进去——工具输出是 base64、文件转储、委派记录,占了消息字节的绝大头,而 trigram 本身有明显的体积放大。换成新的 external-content 布局能省掉这部分,但整个过程要先降级旧表、建新表、分块回填、拆旧表,最后 VACUUM 才能把页真正还给操作系统。它的磁盘预检把这笔账算得很直白:新索引在旧索引拆掉之前就已经建起来,收尾的 VACUUM 又要整份文件的副本,所以要求的空闲空间大致等于当前库本身的大小;加上 --no-vacuum 只重建索引、暂不回收空间,空闲要求随之降一档。
它的选择是:不在打开库时偷偷做。 老库继续用它原本能工作的内联索引,启动时只往 state_meta 写一个 fts_optimize_available 标记,真正的迁移交给显式的 hermes sessions optimize-storage——前台跑、报进度、检查磁盘、可中断可续跑、--no-vacuum 可选。而 SCHEMA_VERSION 照常推进,这样将来的新迁移对还没迁索引的用户一样生效。这条「主 schema 不被最贵的那步拖住」的分离,是四个文件里最值得抄的一条。
配套的细节也都指向同一个取向:_db_has_legacy_inline_fts() 判断库属于哪一代,老库只补自己那一代的触发器,绝不喂新 DDL,避免落成新旧混合的坏状态;回填未完成时用 fts_rebuild_high_water 与 fts_rebuild_progress 两个键界定哪些行已在索引里,每个触发器都按同一个谓词判断要不要发 delete,因为对不在索引里的行发 delete 会直接把 FTS 索引搞坏;_sqlite_supports_fts5() 探到当前 SQLite 没有 FTS5 时,只删触发器保住消息写入,等以后跑在有 FTS5 的运行时上再重建;CJK 索引在缺少可加载分词器的机器上被迫掉队时,会留下 fts_cjk_stale 面包屑,在重建之前不许它服务查询。
换机器最阴的一类问题就在这里:同一个库,两台机器上的 SQLite 编译选项不一样。这套设计的态度是宁可降级到能用,也不写出一个自称完好的坏索引。
五、边界与代价
这个拆法不是没有放弃东西。
- 它是单机 SQLite,不是多机共享存储。 写竞争靠应用层带随机抖动的重试压,
WAL下读连接按线程分开,压缩锁存在本地表里、靠本地 PID 存活判断回收。这些手段在一台机器上多进程(网关、CLI、多个工作区)之间成立,跨机器共享同一个文件不在它的射程里。 - 导出是逻辑导出,不是二进制克隆。 你搬走的是会话与消息,运行时归属明确不带走。想要「原样起来继续跑」,得连整个 Hermes home 一起搬,而不是靠会话导入。
- 列对齐只管加列。 改类型、改约束、删列、改主键,都得手写修复,
gateway_routing就是现成的例子。 - 搜索索引的账要自己算。 老库不主动迁移,意味着体积会一直挂着,直到你自己想起来跑那条命令。它只负责告诉你「可以迁了」。
- 能力边界之外的风险要自己盯。 这个项目常驻在你机器上、开终端执行命令、往磁盘写文件、连外部服务,状态库里因此存着 cwd、git 仓库根、系统提示词、完整消息内容。导出前不看一眼就往外发,等于把这些一起发出去;
hermes sessions export有--redact,但脱敏永远是尽力而为,不是保证。
六、上手与避坑清单
- 别只拷 state.db。 为什么会踩:会话历史确实在
state.db里,看起来拷一个文件就完事。怎么避:WAL 模式下还有 sidecar 文件,且 Hermes home 里除了 state.db 还有别的状态。要么整目录搬,要么走导出导入。 - 迁移前先确认 Hermes home 到底在哪。 为什么会踩:路径由
get_hermes_home()决定,HERMES_HOME会改指,Windows 和其它平台默认位置不同,多 profile 场景下更容易搬错目录。怎么避:以进程实际使用的路径为准,别照文章里的默认路径抄。 - 导入前先想清楚你要的是会话还是整包。 为什么会踩:
/api/sessions/import和/api/ops/import名字很像,语义完全不同。怎么避:只补历史用前者,整机还原用后者。 - 导入返回值要读完,别只看 ok。 为什么会踩:
detached不为零意味着有会话的父边被丢掉了,谱系被截断,但整批是成功的。怎么避:把imported/skipped/detached三个数一起记进日志。 - 升级后第一次打开库要看日志。 为什么会踩:列对齐、主键修补、
active修复、重复标题清理这些都发生在启动路径上,正常情况下静默完成,出问题也多半只留一条日志。怎么避:升级后第一次启动别丢掉输出。 - 别指望索引布局自己变新。 为什么会踩:
SCHEMA_VERSION会推进,容易误以为存储布局也一起跟上了。怎么避:把hermes sessions optimize-storage当成一次有计划的运维动作安排——它要磁盘、要时间,但可中断可续跑。 - 换机器时留意 SQLite 编译差异。 为什么会踩:目标机器缺 FTS5 或缺 trigram 分词器,库能开、能写,但搜索会安静降级。怎么避:迁完先搜一次历史(尤其中文),确认走的是索引而不是回退路径。
收束:迁移前按这个顺序读
如果你打算把 hermes-agent 的状态搬到另一台机器,读文件的顺序建议是:先 hermes_state_common.py 里的 SCHEMA_SQL,搞清你到底要搬哪些字段;再 hermes_state_portability.py 的 import_sessions(),看清哪些字段明确不会被还原;然后 hermes_state_schema.py 的 _init_schema(),理解新版本打开旧库时会自动做什么、不会做什么;最后回到 hermes_state.py 看开库路径上的重试与自我修复,那是你排查「开不起来」时的地图。
自检三问:搬走的东西里有没有你不想外传的内容;落地后 detached 是不是零;目标机器上中文搜索还走不走索引。这三个都过了,再谈继续跑。想顺手把日常巡检也一起理一遍,可以接 Agent 日常运维。
本篇属于一个把开源常驻自托管 Agent 项目 Hermes Agent逐层拆开讲的系列,整体地图见 开源自托管 Agent 项目 Hermes Agent 是什么;沿着这条线往下,还可以看 开源自托管 Agent 项目 Hermes Agent 的技能溯源与同步 和 开源自托管 Agent 项目 Hermes Agent。