开源自托管 Agent 项目 Hermes Agent 的会话检索链路

2026-07-30

本文基于 hermes-agent 仓库 commit 2d40494(2026-07-29)梳理,该项目仍在高频迭代,具体行为以仓库 https://github.com/NousResearch/hermes-agent 最新代码与文档为准。

这条链路真正难的地方不是”把对话存下来”,而是”从几个月的历史里挑出该塞给模型的那几十条消息”。 Hermes Agent 是 Nous Research 放出的开源自托管 Agent 项目(MIT 许可,LICENSE 署名 Nous Research,与同名的 Hermes 开源模型系列不是一回事),它常驻在你自己的机器上,会话直接落进本地 SQLite 库。我几个月前那次翻旧对话,就是想搞清楚:它到底怎么从库里把当年那段找回来的,以及为什么有时候明明记得聊过、却什么也搜不到。

站内已有几篇相邻的内容,分工先说清楚:多轮对话为什么会遗忘 讲的是这个现象本身和通用对策,Agent 记忆的分层设计 讲的是记忆该怎么切层,Agent 复现与回放 讲的是方法论,pi 的会话存储 讲的是另一个项目的存储做法。本篇不重复这些,只做一件事:把这个具体项目的检索链路逐层拆开,落到你能打开核对的文件和函数上。

一、它先解决的是”翻旧账”这件事

常驻久了,历史一定比一次请求能装下的内容多得多。这个项目的处理方式是:会话本身持久化到本地库(默认在 ~/.hermes/state.db,WAL 模式;配置目录可以用 HERMES_HOME 改),检索则做成一个模型可以主动调用的工具,工具名就叫 session_search

这个工具有个不常见的地方:它没有 mode 参数,调用形态是从你传了哪些参数推断出来的。传 query 是检索(源码里叫 discovery);传 session_idaround_message_id 是在某个会话内滚动(scroll);只传 session_id 是整段读出来(read);什么都不传是列最近的会话(browse)。滚动优先于读取——同时给了锚点就说明调用方要的是那一片,而不是整段。

读源码时这里有个小坑值得先记住:文件开头的模块说明只列了三种形态,把 read 漏了;给模型看的那份工具描述才写的是四种。以后者为准,代码里的分支也是四条。这两处对不上,是 read 形态后加进来、模块注释没跟上留下的痕迹。四种形态的共同点两处都写得很直白——全程没有任何模型调用,每一种返回的都是库里的真实消息。

这个取向决定了后面所有的设计:既然不靠模型做二次总结,返回什么、截断多少、按什么排序,就必须在 SQL 和 Python 这一层做对。召回不准的时候,你要查的也正是这一层,而不是去调提示词。

二、一次检索调用在库里走过的几道关

query 调用时,链路大致是这样几步,每一步都能在 hermes_state_search.pytools/session_search_tool.py 里找到对应代码。

第一关是查询清洗。 _sanitize_fts5_query 把用户输入改造成 FTS5 敢接的样子:先按上限截断(源码常量 MAX_FTS5_QUERY_CHARS),用一次线性扫描保住成对的双引号短语、把落单的引号换成空格,再剥掉 + { } ( ) : " ^ 这些有特殊含义的字符,把连续的 * 折成一个并去掉开头的 *,删掉首尾悬空的 AND / OR / NOT,最后把带点、连字符、下划线的整词包上引号。最后这步是为了 chat-sendP2.2my-app.config.ts 这类词——不包引号,FTS5 的分词器会把它们拆开,语义就变了。注释里特别点了 : 的坑:它是 FTS5 的列过滤操作符,未加引号的 TODO: fix 会被 FTS5 当成”列名 : 词”来解析,TODO 不是列名,于是报”没有这一列”——而这个异常在执行处被吞掉,最终呈现给你的是零结果,不是报错。所以它和其它特殊字符一样被直接剥掉。这类”错误被静默降级成空结果”的处理在这条链路上反复出现,也是后面排查召回问题时最需要提防的一层。

第二关是取候选行。 search_messages 是带监控的外壳,真正干活的是 _search_messages_impl:对 messages_fts 做 MATCH,联 messagessessions 两张表,用 snippet() 生成高亮片段。默认可见性规则很关键——活跃行(active = 1)和被压缩归档的行(compacted = 1)都可搜,只有 rewind/undo 撤回的行(active = 0compacted = 0)被藏起来,因为那是用户主动收回的内容。

第三关是排序与去重。 工具层不是拿 3 条就走,而是先扫一批候选(_DISCOVER_SCAN_LIMIT,源码里是 300 行)。原因写在常量旁边:定时任务产生的会话词汇高度重复,在裸 BM25 下会把用户自己的交互会话挤出前几名,出现只搜得到定时任务的”召回失明”(issue #19434)。处理办法是降权而不是排除——_order_for_recall 用稳定排序把 sourcecron 的行压到交互会话后面,类内保持原有相关度顺序;只有它是唯一匹配时才浮上来。另有两类来源直接不进结果:subagenttool,前者是委派子任务的运行记录,后者是第三方集成打的标记。

去重按”血缘”做:_resolve_to_parent 沿 parent_session_id 一路走到根,同一条血缘只留一行,并且留的是真正拥有命中消息的那个会话 id——只有它才能和命中消息 id 配成合法的窗口查询。

第四关是拼上下文。 get_anchored_view 以命中消息为锚点取前后各若干条(默认 ±5),过滤掉工具响应这类噪声,但锚点自己不管什么角色都保留。再加两段”书夹”:会话开头三条和结尾三条真人对话,且只取窗口之外的部分,窗口已经贴到头或尾时就是空。这个组合的用意很实在——开头是目标,命中是过程,结尾是结论,一次调用就够拼出”当初想干什么、中间聊到哪、最后怎么定的”,不用把整段记录读回来。

工具层还做了两件收尾:把生成的上下文压缩摘要挡在书夹之外(_COMPACTION_PREFIXES 匹配的那些前缀),免得把机器生成的大段摘要通过检索又灌回新会话;以及给每条结果附一个 @session: 形式的链接值,Hermes 自己会把它渲染成显示该会话标题的链接。工具说明里对这个值的要求写得相当细:让模型逐字照抄,不要改写成 markdown 链接、也不要用反引号包起来,而且要当名词嵌在句子中间用,不许单独占一行、也不许再把标题和 id 重复写一遍——否则用户会在同一段回复里看到同一个会话两次。

三、这条链路由哪些零件拼成

组成部分它负责什么仓库位置你什么时候会碰到它
session_search 工具入口按传入参数推断四种调用形态,组织结果,全程不调模型tools/session_search_tool.py想调返回条数、想让模型换个翻法时
search_messages / _search_messages_impl执行 MATCH、给中文与拉丁文查询选路、生成片段hermes_state_search.py命中明显不对、想知道走了哪条索引时
get_anchored_view取锚点窗口,再补会话头尾各三条真人消息hermes_state_search.py觉得返回上下文太少或太杂时
FTS 表与触发器 DDL定义 messages_ftsmessages_fts_trigram 及其来源视图和触发器hermes_state_common.py索引缺行、想知道工具行为何搜不到时
开库时的 schema 对齐与索引修复补列、必要时重建索引与触发器、处理旧库形态hermes_state_schema.py换了缺分词器的 SQLite、日志报索引异常时
cjk_unicode61 分词器把连续中日韩字符切成重叠双字,让短词也能走索引native/fts5_cjk/README.mdfts5_cjk.c中文检索慢、两个字的词搜不到时
optimize-storage 子命令前台完成旧库到外部内容索引的迁移、回填与清理hermes_cli/main.pyhermes_cli/sessions_cmd.py老库体积异常、中文索引需要回填时

四、中文查询为什么另走一条路

这是整条链路上最容易让人误判的地方。SQLite 默认的 unicode61 分词器会把中日韩字符切成单字,注释里给的例子是”大别山项目”变成”大 AND 别 AND 山 AND 项 AND 目”——既造假命中,又搜不到完整短语。所以带中日韩字符的查询根本不走主索引,而是另外选路:

一条是三字组索引 messages_fts_trigram,用 trigram 分词器索引重叠的三字符序列,天然支持子串匹配。代价是每个词至少要三个字,两个字的词产生不出三字组;而且由于 FTS5 多词之间默认是 AND,只要有一个短词,整条 MATCH 就会返回空。所以代码里逐词检查,任何一个非操作符的中文词不足三字,就不走这条路(issue #20494 的场景是”广西 OR 桂林 OR 漓江”,总字数够但每个词只有两字)。

一条是双字索引 messages_fts_cjk,靠一个可加载的 FTS5 分词器扩展 cjk_unicode61(源码在 native/fts5_cjk/,README 里写的是 unicode61 加中日韩双字组,Lucene CJKAnalyzer 的语义,由社区贡献)。它把两个字的词也拉回索引路径。但它有两个例外仍走老路:显式过滤 role='tool' 的查询(工具行不在这个索引里),以及查询里出现孤立单字的情况——_has_lone_cjk_run 会识别出来,因为双字索引只为长度不小于二的连续段存双字,单字词在里面只能命中孤立字,语义比 LIKE 的子串匹配窄。

最后一条兜底是 LIKE 子串扫描。它一定能出结果,代价是全表扫。注释里对这条路的判断很直接:短中文词落到 LIKE 全表扫,在多 GB 的库上是中文检索的主要成本来源。多词 OR 查询在这条路上会拆成逐词的 LIKE 条件,保证每个词独立匹配。

还有个反向的坑:纯拉丁文查询走的是主索引,而 unicode61 不会在拉丁字母和紧邻的中日韩字符之间断词,像”修改youer服务端”这种混排会被当成一个词,搜 youer 命中不了。代码的处理是只在主索引零结果时才回退到支持子串的索引,先双字索引再三字组索引——严格是增量的,成功的查询不会被重排。回退的代价也写明了:拉丁文查询一旦落到三字组那条腿,就带上了子串语义,cat 可能命中 concatenate

排查这些的入口是慢查询日志:search_messages 外壳会记一行,里面带 _describe_search_path 给出的路径名(fts5fts_cjktrigramlike_scanempty),阈值来自环境变量 HERMES_SEARCH_SLOW_MS。看到 path=like_scan 反复出现,就知道该去装分词器扩展,而不是继续改查询词。

五、召回不准时按什么顺序查

下面几条都是会真踩到的,写清”为什么会踩”和”怎么避”。

多词默认是 AND,词越多越搜不到。 工具说明里明确写了这一点。你顺手写四五个词,其实是在要求同一条消息里全都出现。避法是先用 OR 把词铺开,或者用引号锁一个确定说过的短语,命中之后再靠滚动形态往前后翻。

当前会话被有意跳过,看起来像”搜不到”。 检索会跳过当前会话所在的整条血缘,理由是那些内容已经在活动上下文里,再返一遍是浪费。但有两个例外放行:命中行是被压缩归档的行(active = 0compacted = 1),以及命中所在会话本身以压缩为结束原因。判断这一点靠 _is_compacted_message_is_compression_ended,代码里特意区分了”压缩产生的续接”和”委派产生的子会话”——后者内容对父级仍然可见,所以继续排除。避法是先确认你要找的内容是不是就在当前会话里,别把这条规则当成故障。

撤回过的内容是真的搜不到。 rewind/undo 行默认不可见,这是设计而不是索引缺失。想连这些一起搜,只有把包含非活跃行的开关打开这条路(工具没有暴露这个入口,得从库层调)。

定时任务的会话被压在后面。 前面说过是降权不是排除。如果你确信某条内容出自定时任务,把返回条数往上调(工具把它夹在 1 到 10 之间),让它有机会进结果集。

索引可能正在后台回填,结果本来就不全。 旧库迁移到外部内容形态时是分块回填的,进度记在库的元数据里。检索层会做两件事:一是给结果附一段回填进度说明(_annotate_rebuild_status),让人知道旧消息暂时可能不全,别把稀薄的结果当成事实;二是对尚未索引的那段 id 区间做有界的 LIKE 补扫(_search_unindexed_gap),宁可临时多召回一点,也不让老消息在回填期间悄悄消失。看到结果里带进度提示,正确反应是等回填完再判断。

跨 profile 的链接别丢掉 profile 段。 会话链接形如 @session:<profile>/<id>,读取时会以只读方式打开对应 profile 的库。模型如果把 profile 段丢了只传裸 id,兜底逻辑会遍历所有 profile 的库去找(_locate_session_db,只读打开,命中即返回)。能找到,但那是扫描;老实把 profile 段带上更省事。

中文检索慢先看有没有装扩展。 双字分词器是可加载扩展,装不上就退回三字组或 LIKE。README 写明:装好之后下一次开库会创建对应索引,已有数据要跑一次 hermes sessions optimize-storage 回填,新消息两种情况下都是实时索引的;关掉它是在配置文件里对应那一项写 false,扩展文件位置可以用 HERMES_FTS5_CJK_SO 覆盖。

六、边界与代价:它明确不管什么

这是词面检索,不是语义检索。 这条链路上没有向量检索那一层,命中靠的是词、短语、子串。你当初说”数据库连接池打满”,现在搜”连接数上不去”,它不会替你联想。想提高命中率,只能靠自己换词、用 OR 铺开,或者靠时间维度定位——工具提供了按最新或最早排序的偏置,用来回答”这事最后停在哪”和”这事最早怎么起的”。

索引是要拿磁盘换的。 注释里写得很清楚:三字组索引大约是它覆盖文本的 2.6 倍,而工具行(大段 base64、文件转储、委派记录)占了消息字节的约九成却几乎全是机器噪声。所以子串索引读的是一个排除工具行的视图,工具行仍然完整存在主表和主索引里,只是不享受子串检索;对应地,显式搜工具行的中文查询只能落到 LIKE。旧库的迁移是明确的可选项而不是自动执行——注释里给的理由是这个过程磁盘开销重、耗时长,静默地在别人下次开库时替他做,是错误的默认值。迁移过程本身还得让路:块与块之间按上一块耗时按比例休眠,否则连续持有写锁会把共享同一个库的其他进程饿死。

它不是外部世界的证据。 工具说明里专门有一段”来源优先”的限制:这个工具只搜 Hermes 自己的对话历史,不能当作外部来源当前状态的证明。用户给了链接、文件路径、账号、联系人这类直接来源,能访问就先看原始来源;历史只作为”当时说过什么”的补充。更狠的一条是:不允许仅凭这个工具的空结果就断言”没找到""没有过往沟通”。

常驻本身有代价,得如实说。 这个项目常驻在你的机器上、能开终端执行命令、能连聊天账号、往磁盘写文件、访问外部服务。会话历史因此是一份长期躺在本地磁盘上的完整对话记录,包含你贴进去的一切;跨 profile 读取虽然是只读打开,但确实读得到别的 profile 的内容。谁能访问这台机器,谁就能访问这份历史——这不是配置能绕开的,是自托管的定义。要不要让它常驻、放在哪台机器上、磁盘是否加密,属于你在装之前就该定的事,可以参照 Agent 的最小权限设计 的思路先把边界画出来。

收束:三个自检问题

下次它没找回你要的东西,按这个顺序问自己:一,内容是不是在当前会话里(那就是被有意跳过的);二,查询是不是被 AND 掐死了,或者中文词短到走了 LIKE(看慢查询日志里的路径名);三,索引是不是还在回填(看结果里有没有进度说明)。三个都排掉,再去怀疑内容真的不在库里。

想自己往下读,顺序建议是:先 tools/session_search_tool.py 看四种调用形态和结果形状,再 hermes_state_search.py 看选路与窗口,接着 hermes_state_common.py 看索引和触发器怎么定义、来源视图排除了什么,最后 native/fts5_cjk/README.md 看中文那条路的前提条件。这四个文件读完,你就能自己判断一次召回失手该归到哪一层,而不是靠猜。

本篇属于一个把开源常驻自托管 Agent 项目 Hermes Agent逐层拆开讲的系列,整体地图见 开源自托管 Agent 项目 Hermes Agent 是什么;沿着这条线往下,还可以看 自托管开源项目 Hermes Agent 的跨会话记忆由谁写入、何时写开源自托管 Agent 项目 Hermes Agent 的并行派活机制

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