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')做一次优化,合并索引碎片。数据量不大的话,一周一次就够了。