:Session Search 经验检索与 FTS5 双索引实战)
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 两个标记位来管理消息生命周期。当会话被压缩时旧消息标记为 compacted0 或 active0而不是直接删除。这样既保留了历史可检索性又不会让压缩后的会话在检索时返回冗余内容。你在自己的实现里也可以借鉴这个思路——软删除比硬删除更适合需要回溯的场景。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_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_contentAPI 端点是 https://taotoken.net/api。你可以在控制台创建 Key然后在模型对话页面测试连通性。具体操作路径先访问官网注册进入控制台创建 API Key。控制台地址是 https://taotoken.net/console?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_contentAPI Keys 管理页是 https://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content。创建好 Key 之后你可以在模型对话页面 https://taotoken.net/chat?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content 直接测试模型是否可用。如果你用的是 Claude Code 这类编码工具TaoToken 也提供了对应的接入文档https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content。Claude Code 的接入配置可以参考 https://taotoken.net/ClaudeCodeAnthropic?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content。对于长期编码和 Agent 场景Coding Plan 页面 https://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content 有更详细的套餐说明。为什么要在 Session Search 的教程里提 TaoToken因为你在调试检索结果时往往需要让 Agent 基于检索到的历史上下文继续推理。这时候模型调用的稳定性直接影响调试效率。统一通道意味着你只需要维护一份 Key 和 Base URL不用在多个配置文件之间来回改。配置方式很简单以环境变量为例export TAOTOKEN_API_KEY你的Key export TAOTOKEN_BASE_URLhttps://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, contentmessages, content_rowidid ); -- trigram 索引适合代码片段、路径、ID 等子串匹配 CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts_trigram USING fts5( content, contentmessages, content_rowidid, tokenizetrigram );注意这里用了 external content 模式contentmessages这样 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, limit5): 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_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_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 的做法把 sourcecron 的会话降权而不是排除。降权可以通过在 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_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content接入文档在 https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content。长期编码和 Agent 场景可以看 Coding Planhttps://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content。最后说一个实际踩过的坑FTS5 的 rank 排序在数据量大了之后可能不够精准。Hermes 的做法是结合时间衰减和 source 权重做二次排序。你可以在查询结果返回后在应用层按 timestamp 和 source 重新排序。这样既能保证相关性又能让近期的手工会话优先展示。索引建好之后建议定期用INSERT INTO messages_fts(messages_fts) VALUES(optimize)做一次优化合并索引碎片。数据量不大的话一周一次就够了。