☰
Hermes Agent 自进化架构深度解析(六):Session Search 经验检索与 FTS5 双索引实战
2026/10/1 7:02:08 网站建设 项目流程

1. 为什么 Agent 需要 Session Search:从日志文件到可检索经验库

大多数 Agent 项目的对话记录,本质上就是一堆按时间顺序追加的 JSON 行。你让它处理一个跨平台打包问题,它跑了 50 轮工具调用,路径转换、命令包装、参数拼装全在里面。三个月后你再次遇到类似场景,想让它"回忆一下上次怎么解决的"——它只能回答"我没有相关记忆"。因为那些记录从来没有被当成可检索的数据源,只是日志。

Hermes Agent 的 Session Search 模块要解决的就是这件事:把每一次对话变成可检索的经验库。它不依赖向量数据库,也不调用额外的 embedding 模型,而是直接在 SQLite 上做全文索引。核心是两张表加两个 FTS5 虚拟表,配合三种查询模式,让 Agent 在对话中主动回溯历史。

这套设计适合谁?如果你正在为 Agent 构建长期记忆、想让历史会话可被主动检索、或者单纯想理解 FTS5 在 Agent 场景下怎么落地,这篇可以跟着做。我会给出可复制的建表语句、双索引配置骨架、检索验证命令,以及通过 TaoToken 统一 Key/API 通道接入调试的方式。

先说清楚一个前提:Session Search 的检索结果不经过 LLM 二次加工,直接从数据库取原始消息。这意味着检索延迟低、结果可预期,但也意味着索引质量直接决定召回质量。所以建表和索引配置是整个模块的地基。

Hermes 的 state.db 里,messages 表存消息,sessions 表存会话元信息。messages 表的关键字段包括 session_id、role、content、tool_call_id、tool_calls、tool_name、timestamp、token_count、finish_reason、reasoning、active、compacted。sessions 表则有 id、source、model、started_at、ended_at、title、input_tokens、output_tokens、estimated_cost_usd、parent_session_id 等。

这里有个设计细节值得注意:messages 表用 active 和 compacted 两个标记位来管理消息生命周期。当会话被压缩时,旧消息标记为 compacted=0 或 active=0,而不是直接删除。这样既保留了历史可检索性,又不会让压缩后的会话在检索时返回冗余内容。你在自己的实现里也可以借鉴这个思路——软删除比硬删除更适合需要回溯的场景。

FTS5 双索引是这套方案的核心。第一个是分词索引,适合完整词语搜索;第二个是 trigram 索引,适合子串匹配。为什么要两个?因为代码片段、文件路径、ID 这类内容,分词器往往切不出有意义的词元。比如pyinstaller --onefile这种命令,分词索引可能只匹配到 pyinstaller,而 trigram 索引能匹配到--onefile这样的子串。两者互补,覆盖自然语言和代码两种检索需求。

2. TaoToken 前置:统一 Key/API 通道接入调试

在动手建索引之前,先把调试通道搭好。Session Search 本身是本地 SQLite 操作,不依赖外部 API,但你在验证检索结果、调试 Agent 行为时,需要一个稳定的模型调用通道。TaoToken 在这里的作用是统一 Key 和 API 入口,让你不用在多个供应商之间切换配置。

TaoToken 是一个 API 聚合网关,提供统一的 Base URL 和 Key 管理。它的官网是 https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content=,API 端点是 https://taotoken.net/api。你可以在控制台创建 Key,然后在模型对话页面测试连通性。

具体操作路径:先访问官网注册,进入控制台创建 API Key。控制台地址是 https://taotoken.net/console?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content=,API Keys 管理页是 https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content=。创建好 Key 之后,你可以在模型对话页面 https://taotoken.net/chat?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= 直接测试模型是否可用。

如果你用的是 Claude Code 这类编码工具,TaoToken 也提供了对应的接入文档:https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content=。Claude Code 的接入配置可以参考 https://taotoken.net/ClaudeCodeAnthropic?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content=。对于长期编码和 Agent 场景,Coding Plan 页面 https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= 有更详细的套餐说明。

为什么要在 Session Search 的教程里提 TaoToken?因为你在调试检索结果时,往往需要让 Agent 基于检索到的历史上下文继续推理。这时候模型调用的稳定性直接影响调试效率。统一通道意味着你只需要维护一份 Key 和 Base URL,不用在多个配置文件之间来回改。

配置方式很简单,以环境变量为例:

export TAOTOKEN_API_KEY="你的Key" export TAOTOKEN_BASE_URL="https://taotoken.net/api"

然后在你的 Agent 配置里引用这两个变量。如果你用的是 OpenAI 兼容的 SDK,直接把 base_url 指向 https://taotoken.net/api 即可。注意 API 端点不要加 UTM 参数,保持干净。

这里有个实际经验:调试 Session Search 时,建议先用模型对话页面手动验证一次检索到的上下文是否合理,再接入 Agent 自动流程。因为检索结果的质量问题(比如返回了不相关的会话)在自动流程里很难定位,手动看一遍 snippet 和 bookend 能快速判断索引配置是否正确。

3. 可复制配置:FTS5 建表语句与双索引骨架

现在进入核心部分。下面这套建表语句可以直接复制到你的 SQLite 项目里。我按 Hermes 的结构做了简化,保留了关键字段和索引配置。

先建基础表:

-- 会话表 CREATE TABLE IF NOT EXISTS sessions ( id TEXT PRIMARY KEY, source TEXT NOT NULL, model TEXT, started_at REAL NOT NULL, ended_at REAL, title TEXT, input_tokens INTEGER DEFAULT 0, output_tokens INTEGER DEFAULT 0, estimated_cost_usd REAL, parent_session_id TEXT ); -- 消息表 CREATE TABLE IF NOT EXISTS messages ( id INTEGER PRIMARY KEY AUTOINCREMENT, session_id TEXT NOT NULL REFERENCES sessions(id), role TEXT NOT NULL, content TEXT, tool_call_id TEXT, tool_calls TEXT, tool_name TEXT, timestamp REAL NOT NULL, token_count INTEGER, finish_reason TEXT, reasoning TEXT, active INTEGER NOT NULL DEFAULT 1, compacted INTEGER NOT NULL DEFAULT 0 ); CREATE INDEX IF NOT EXISTS idx_messages_session ON messages(session_id); CREATE INDEX IF NOT EXISTS idx_messages_timestamp ON messages(timestamp);

然后是双 FTS5 索引:

-- 分词索引:适合自然语言完整词语搜索 CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5( content, content='messages', content_rowid='id' ); -- trigram 索引:适合代码片段、路径、ID 等子串匹配 CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts_trigram USING fts5( content, content='messages', content_rowid='id', tokenize='trigram' );

注意这里用了 external content 模式(content='messages'),这样 FTS5 表不重复存储内容,只存索引。content_rowid 指向 messages 表的 id 列。这种模式下,你需要手动维护索引同步,通过触发器实现:

-- 分词索引同步触发器 CREATE TRIGGER IF NOT EXISTS messages_ai AFTER INSERT ON messages BEGIN INSERT INTO messages_fts(rowid, content) VALUES (new.id, new.content); END; CREATE TRIGGER IF NOT EXISTS messages_ad AFTER DELETE ON messages BEGIN INSERT INTO messages_fts(messages_fts, rowid, content) VALUES('delete', old.id, old.content); END; CREATE TRIGGER IF NOT EXISTS messages_au AFTER UPDATE ON messages BEGIN INSERT INTO messages_fts(messages_fts, rowid, content) VALUES('delete', old.id, old.content); INSERT INTO messages_fts(rowid, content) VALUES (new.id, new.content); END; -- trigram 索引同步触发器 CREATE TRIGGER IF NOT EXISTS messages_tri_ai AFTER INSERT ON messages BEGIN INSERT INTO messages_fts_trigram(rowid, content) VALUES (new.id, new.content); END; CREATE TRIGGER IF NOT EXISTS messages_tri_ad AFTER DELETE ON messages BEGIN INSERT INTO messages_fts_trigram(messages_fts_trigram, rowid, content) VALUES('delete', old.id, old.content); END; CREATE TRIGGER IF NOT EXISTS messages_tri_au AFTER UPDATE ON messages BEGIN INSERT INTO messages_fts_trigram(messages_fts_trigram, rowid, content) VALUES('delete', old.id, old.content); INSERT INTO messages_fts_trigram(rowid, content) VALUES (new.id, new.content); END;

如果你不想用触发器,也可以在应用层手动同步。触发器的好处是数据库层面保证一致性,坏处是批量插入时性能开销略高。对于 Agent 场景,消息写入频率不高,触发器方案更省心。

双索引的查询策略是这样的:自然语言查询走 messages_fts,代码或路径查询走 messages_fts_trigram。你可以在应用层根据查询内容自动选择,也可以两个都查然后合并结果。Hermes 的做法是自动推断——如果查询里包含特殊字符或看起来像代码片段,就走 trigram。

这里有个配置细节:trigram 索引对大小写敏感。如果你的场景需要大小写不敏感的子串匹配,可以在查询时用 lower() 函数处理,或者在应用层统一转小写后再写入。Hermes 默认保留原始大小写,因为代码片段的大小写往往有意义。

4. 验证请求:检索命令与成功结果

建好表之后,先插入几条测试数据,然后验证检索是否正常工作。

-- 插入测试会话 INSERT INTO sessions (id, source, model, started_at, title) VALUES ('20260705_183522_a33c38', 'cli', 'claude-sonnet', 1751711722.0, '跨平台打包问题排查'); -- 插入测试消息 INSERT INTO messages (session_id, role, content, timestamp) VALUES ('20260705_183522_a33c38', 'user', 'pyinstaller 打包后 MSYS 路径转换失败', 1751711722.0), ('20260705_183522_a33c38', 'assistant', '需要加 --onefile 参数并处理 cmd /c 包装', 1751711730.0), ('20260705_183522_a33c38', 'tool', 'pyinstaller --onefile --paths=/mingw64/lib', 1751711740.0);

分词索引查询:

SELECT m.id, m.session_id, snippet(messages_fts, 0, '>>>', '<<<', '...', 20) AS snippet FROM messages_fts JOIN messages m ON m.id = messages_fts.rowid WHERE messages_fts MATCH '打包' ORDER BY rank LIMIT 5;

trigram 索引查询:

SELECT m.id, m.session_id, snippet(messages_fts_trigram, 0, '>>>', '<<<', '...', 20) AS snippet FROM messages_fts_trigram JOIN messages m ON m.id = messages_fts_trigram.rowid WHERE messages_fts_trigram MATCH 'onefile' ORDER BY rank LIMIT 5;

成功的结果应该返回匹配的消息 id、session_id 和高亮片段。snippet 函数会把匹配词用 >>> <<< 包裹,方便你在 UI 里展示。

如果你在 Python 里操作,可以用 sqlite3 模块:

import sqlite3 conn = sqlite3.connect('state.db') conn.row_factory = sqlite3.Row def search_messages(query, limit=5): cursor = conn.execute(""" SELECT m.id, m.session_id, m.role, m.content, snippet(messages_fts, 0, '>>>', '<<<', '...', 20) AS snippet FROM messages_fts JOIN messages m ON m.id = messages_fts.rowid WHERE messages_fts MATCH ? ORDER BY rank LIMIT ? """, (query, limit)) return [dict(row) for row in cursor.fetchall()] results = search_messages('打包') for r in results: print(f"[{r['session_id']}] {r['snippet']}")

验证通过后,你可以进一步实现三种查询模式。Discovery 模式做全文搜索加 session 去重,Scroll 模式按 session_id 和 around_message_id 前后翻页,Browse 模式返回最近会话列表。这三种模式都不调用 LLM,直接从数据库取数据。

在调试检索结果时,我习惯用 TaoToken 的模型对话页面手动验证一次上下文是否合理。把检索到的 snippet 和 bookend 贴进去,让模型判断这些历史信息是否足以支撑当前任务。如果模型说"信息不足",那说明你的索引配置或查询策略需要调整。

5. 本篇常见错排查:401、local proxy failed、reading choices、OAuth

这一节对照真实报错,给出排查路径。这些错误不一定都来自 Session Search 本身,但你在接入调试时会遇到。

401 Unauthorized:如果你在调用模型时遇到 401,先检查 TaoToken 的 Key 是否正确。常见原因是 Key 复制时带了空格,或者环境变量没生效。用echo $TAOTOKEN_API_KEY确认一下。另外注意 Base URL 是否写成了 https://taotoken.net/api,不要多加路径。

local proxy failed:这个报错通常出现在本地代理配置冲突时。如果你同时开了多个代理工具,端口可能被占用。检查你的 HTTP_PROXY 和 HTTPS_PROXY 环境变量,确保没有指向失效的本地端口。Session Search 本身不走网络,但 Agent 调用模型时会受影响。

reading choices 报错:这通常是模型返回格式不符合预期。如果你用的是 OpenAI 兼容接口,检查请求体里的 model 参数是否拼写正确。TaoToken 的模型 ID 可以在模型对话页面确认。另外确认 max_tokens 没有设成 0 或负数。

OAuth 相关错误:如果你用的是 Claude Code 或类似工具,OAuth 流程可能因为回调地址不匹配而失败。检查你的客户端配置里的 redirect_uri 是否和 TaoToken 控制台里设置的一致。Claude Code 的接入文档在 https://taotoken.net/ClaudeCodeAnthropic?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content=,里面有完整的配置步骤。

FTS5 查询返回空结果:先确认触发器是否生效。用SELECT * FROM messages_fts LIMIT 1看索引表里有没有数据。如果没有,说明触发器没建成功,或者插入数据时触发器没触发。另外注意 FTS5 的 MATCH 语法,中文分词需要确认 SQLite 编译时是否带了 ICU 或 simple 分词器。默认的 unicode61 分词器对中文支持有限,你可能需要自定义分词器。

trigram 索引查询报错:trigram 要求查询字符串至少 3 个字符。如果你搜 "ab",会报错。这是 trigram 的固有限制,不是 bug。对于短查询,走分词索引。

session 去重后结果太少:检查你的去重逻辑是否过于激进。Hermes 按 session lineage 去重,父子关系只保留一个。如果你把所有同 source 的会话都合并了,会丢失有效结果。去重应该基于 parent_session_id 链,而不是 source 字段。

cron 会话淹没结果:如果你有定时任务产生的会话,它们会大量出现在检索结果里。参考 Hermes 的做法,把 source='cron' 的会话降权,而不是排除。降权可以通过在 ORDER BY 里加一个权重字段实现。

6. 语义一致 CTA:把 Session Search 接入你的 Agent 工作流

Session Search 的价值不在于索引本身,而在于它让 Agent 能主动回溯历史经验。你可以在系统提示词里加入类似这样的指令:

当用户引用过去对话中的内容,或你怀疑存在相关的跨会话上下文时,使用 session_search 来回忆它,而不是要求用户重复。

这样 Agent 在对话中就会主动调用检索工具。用户说"上次那个打包的问题又出现了",Agent 不会反问"哪个问题",而是直接检索 "打包 MSYS pyinstaller",找到三个月前的会话,加载上下文,继续推进。

如果你想把检索结果接入模型推理,TaoToken 的统一通道可以简化配置。API Keys 管理在 https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content=,接入文档在 https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content=。长期编码和 Agent 场景可以看 Coding Plan:https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content=。

最后说一个实际踩过的坑:FTS5 的 rank 排序在数据量大了之后可能不够精准。Hermes 的做法是结合时间衰减和 source 权重做二次排序。你可以在查询结果返回后,在应用层按 timestamp 和 source 重新排序。这样既能保证相关性,又能让近期的手工会话优先展示。

索引建好之后,建议定期用INSERT INTO messages_fts(messages_fts) VALUES('optimize')做一次优化,合并索引碎片。数据量不大的话,一周一次就够了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询