开源自托管 Agent 项目 Hermes Agent 的状态层拆法

2026-07-30

本文基于 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_SQLDEFERRED_INDEX_SQLFTS_SQLFTS_TRIGRAM_SQLSCHEMA_VERSIONFTS_STORAGE_VERSION、会话预览用的 SQL 片段hermes_state_common.py想知道到底存了哪些字段、要读表定义时
SessionSchemaMixin建表、列对齐、延后建索引、老库的主键修补、决定用哪套 FTS DDLhermes_state_schema.py每次进程启动、每次升级后第一次打开库
SessionPortabilityMixin导出单会话/压缩谱系/全量,导入并校验,以及若干富行查询hermes_state_portability.py备份、换机器、把历史挪到另一个 profile 时
SessionSearchMixin检索路径与索引布局的迁移入口hermes_state_search.py搜历史、发现 state.db 体积异常时

数据落在 Hermes home 下的 state.dbhermes_constants.py 里的 get_hermes_home() 决定这个目录,Windows 默认在 LOCALAPPDATA 下的 hermes,其它平台是 ~/.hermesHERMES_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、没有 pinnedmessage_counttool_call_count 也不采信载荷里的数字,而是按实际插进去的行数重算。它恢复的是对话历史,不是某个活着的频道归属或某个正在跑的进程。搬完之后 Agent 不会突然以为自己还占着旧机器上的那个群。

导入的入口也做了区分:/api/sessions/import 只管会话行与消息,/api/ops/import 才是整包备份还原。这两件事混在一个入口里,是很多自托管工具后来出事的地方。

四、两条独立的版本线:搜索索引为什么不跟着 schema 走

这是四个文件里最容易被忽略、但对「换版本」影响最大的设计。hermes_state_common.py 同时定义了 SCHEMA_VERSIONFTS_STORAGE_VERSION,后者记在 state_metafts_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_waterfts_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.pyimport_sessions(),看清哪些字段明确不会被还原;然后 hermes_state_schema.py_init_schema(),理解新版本打开旧库时会自动做什么、不会做什么;最后回到 hermes_state.py 看开库路径上的重试与自我修复,那是你排查「开不起来」时的地图。

自检三问:搬走的东西里有没有你不想外传的内容;落地后 detached 是不是零;目标机器上中文搜索还走不走索引。这三个都过了,再谈继续跑。想顺手把日常巡检也一起理一遍,可以接 Agent 日常运维

本篇属于一个把开源常驻自托管 Agent 项目 Hermes Agent逐层拆开讲的系列,整体地图见 开源自托管 Agent 项目 Hermes Agent 是什么;沿着这条线往下,还可以看 开源自托管 Agent 项目 Hermes Agent 的技能溯源与同步开源自托管 Agent 项目 Hermes Agent

想系统学会用 AI?报名体系课或加入会员,照着学、照着用。