:持久记忆——用 SQLite FTS5 让 AI 跨会话记住你是谁)
1. 为什么会话记忆一关就忘持久记忆要单独做一层会话记忆的生命周期只有一次对话。你关掉终端、执行/clear、或者上下文窗口被裁剪前面聊过的内容就没了。它像你和同事“刚才半小时的对话”聊完就散。而持久记忆要解决的是另一件事你的名字、职业、偏好 FastAPI 还是 Go、要求所有数据库操作加 try/except、纠正过 5 次 for 循环要改成 map——这些信息跨会话存在关机重启还在。Hermes 把持久记忆按类型分成五类user你是谁、project项目技术栈与规则、feedback被纠正的模式、reference外部资源、decision决策记录。不同类型有不同的提取触发和检索优先级。比如 user 和 feedback 优先级最高因为“你是谁”和“你纠正过的错”直接影响后续所有回答reference 优先级最低只在明确提到“去看那个文档”时才召回。这一篇不讲概念讲落地。核心是用 SQLite FTS5 搭一个可检索的跨会话记忆库配合 CLAUDE.md 约定写入与召回规则。你会拿到建表与 FTS5 索引的可复制配置、记忆写入/检索的验证命令以及跨会话召回效果的对照测试步骤。适合已经在用 Claude Code 或类似编码 Agent、想让 AI 记住自己习惯的开发者。实测下来FTS5 关键词检索是毫秒级向量语义检索是百毫秒级两者合并取 Top 10 注入上下文效果比单纯把内容塞进 System Prompt 稳定得多。先明确一个边界持久记忆不是“记原文”是“提取后结构化”。Hermes 不会把你说过的每个字都存下来而是先判断类型、提取事实、关联已有记忆、更新置信度最后写入 SQLite 和 MEMORY.md。这个流程决定了表结构不能只有一张大表而要有条目表、关联表、全文索引表三件套。2. TaoToken 前置把模型接入和记忆库解耦在动手建表之前先把模型接入这一层理清楚。持久记忆库本身是本地 SQLite 文件和用哪家模型无关但记忆的“提取”和“召回后的回答”需要模型参与。我的做法是把模型接入统一走 TaoToken这样记忆库可以独立演进换模型不用动数据库。TaoToken 的 API 地址是 https://taotoken.net/api兼容 OpenAI 风格的接口。你需要在控制台创建一个 API Key然后把它写进环境变量。这一步不要硬编码到代码里后面 CLAUDE.md 里也会约定“密钥只从环境变量读”。export TAOTOKEN_API_KEYsk-你的key export TAOTOKEN_BASE_URLhttps://taotoken.net/api如果你用的是 Claude Code 这类工具它读取的是 Anthropic 风格的配置。TaoToken 提供了对应的接入文档路径在 https://taotoken.net/doc 里面有 Claude Code 的完整配置示例。核心是三件套Base URL、API Key、Model ID。Base URL 填 https://taotoken.net/api Key 填你创建的那串Model ID 按文档里列出的可用模型填。这里有个容易踩的坑很多人把 Base URL 写成带/v1的地址结果请求 404。TaoToken 的 API 根路径就是 https://taotoken.net/api 具体版本路径由客户端自己拼。如果你在 Cline 或 CC Switch 里配置Base URL 一栏同样填这个根地址不要自己加后缀。创建 Key 的入口在 https://taotoken.net/api-keys 登录后点新建即可。建议给记忆库单独建一个 Key方便后面按用量排查问题。如果你打算长期跑编码 Agent可以看下 Coding Plan它更适合高频调用场景只是验证模型连通性的话用模型对话页面手动发一条消息就行。配置完成后先用一条 curl 验证连通性确认 Key 和 Base URL 都对curl https://taotoken.net/api/v1/chat/completions \ -H Authorization: Bearer $TAOTOKEN_API_KEY \ -H Content-Type: application/json \ -d { model: claude-sonnet-4-20250514, messages: [{role: user, content: 回复 ok}] }返回里能看到choices数组就说明通了。这一步过了再往下建记忆库否则后面报错你分不清是模型接入问题还是数据库问题。3. 可复制配置SQLite 建表 FTS5 索引 CLAUDE.md 约定记忆库落在~/.hermes/data/memory.db。先建目录再用 sqlite3 执行下面的建表语句。注意 FTS5 是 SQLite 的扩展模块大多数发行版自带的 sqlite3 已经编译进去了如果没有需要装libsqlite3-mod-fts5或重新编译。mkdir -p ~/.hermes/data sqlite3 ~/.hermes/data/memory.db进入交互后粘贴以下 SQL。三张核心表memories存结构化条目memory_links存条目之间的关联memories_fts是 FTS5 虚拟表做全文索引。-- 结构化记忆条目 CREATE TABLE IF NOT EXISTS memories ( id TEXT PRIMARY KEY, type TEXT NOT NULL CHECK(type IN (user,project,feedback,decision,reference)), content TEXT NOT NULL, tags TEXT DEFAULT , confidence REAL DEFAULT 0.5, created_at TEXT DEFAULT (datetime(now)), updated_at TEXT DEFAULT (datetime(now)) ); -- 条目之间的关联关系 CREATE TABLE IF NOT EXISTS memory_links ( from_id TEXT NOT NULL, to_id TEXT NOT NULL, relation TEXT NOT NULL, PRIMARY KEY (from_id, to_id, relation) ); -- FTS5 全文索引content 与 memories 同步 CREATE VIRTUAL TABLE IF NOT EXISTS memories_fts USING fts5( content, tags, contentmemories, content_rowidrowid, tokenizeunicode61 ); -- 触发器插入/更新/删除时同步 FTS5 CREATE TRIGGER IF NOT EXISTS memories_ai AFTER INSERT ON memories BEGIN INSERT INTO memories_fts(rowid, content, tags) VALUES (new.rowid, new.content, new.tags); END; CREATE TRIGGER IF NOT EXISTS memories_ad AFTER DELETE ON memories BEGIN INSERT INTO memories_fts(memories_fts, rowid, content, tags) VALUES (delete, old.rowid, old.content, old.tags); END; CREATE TRIGGER IF NOT EXISTS memories_au AFTER UPDATE ON memories BEGIN INSERT INTO memories_fts(memories_fts, rowid, content, tags) VALUES (delete, old.rowid, old.content, old.tags); INSERT INTO memories_fts(rowid, content, tags) VALUES (new.rowid, new.content, new.tags); END;tokenizeunicode61对中文支持一般它按空格和标点切词。如果你的记忆内容以中文短句为主可以在写入时把关键词用空格隔开或者改用tokenizetrigramSQLite 3.34 支持trigram 对中文子串匹配更友好代价是索引体积变大。我实测下来中文记忆条目用 trigram 召回率明显更高。-- 如果中文召回不理想重建为 trigram 分词 DROP TABLE IF EXISTS memories_fts; CREATE VIRTUAL TABLE memories_fts USING fts5( content, tags, contentmemories, content_rowidrowid, tokenizetrigram ); INSERT INTO memories_fts(memories_fts) VALUES(rebuild);接下来是 CLAUDE.md 的约定。CLAUDE.md 写硬规则持久记忆学软模式两者互补。在项目根目录的 CLAUDE.md 里加一段记忆写入与召回规则## 持久记忆约定 ### 写入规则 - 用户明确说“记住”或“/remember”时提取为结构化条目写入 memory.db - 连续 3 次以上同类纠正自动生成 feedback 类型条目 - 涉及技术选型讨论时生成 decision 类型条目 - 密钥、密码、信用卡号等敏感信息禁止写入 ### 召回规则 - 新会话开始时用当前任务关键词检索 memories_fts取 Top 10 - user 和 feedback 类型优先级最高reference 最低 - 召回结果以 [Memory Context] 块注入不直接拼接在 System Prompt 末尾 ### 存储位置 - SQLite: ~/.hermes/data/memory.db - 人类可读: ~/.hermes/data/MEMORY.md这段约定让 Agent 知道什么时候写、什么时候读、写到哪里。没有它模型可能把记忆写进对话历史一关就没了。4. 验证请求写入、检索、跨会话召回对照测试建完表先插一条测试数据验证 FTS5 索引是否生效。sqlite3 ~/.hermes/data/memory.db SQL INSERT INTO memories (id, type, content, tags, confidence) VALUES (mem_test_001, user, Barry 是全栈工程师偏好 FastAPI 和函数式风格, python,fastapi, 0.9); INSERT INTO memories (id, type, content, tags, confidence) VALUES (mem_test_002, feedback, 所有数据库操作必须加 try/except错误信息用中文, database,error, 0.88); INSERT INTO memories (id, type, content, tags, confidence) VALUES (mem_test_003, decision, 积分金额用 Decimal 而非 Float精度要求, decimal,precision, 0.75); SQL然后做 FTS5 关键词检索。注意 FTS5 的查询语法多个词默认是 AND 关系用OR显式指定或关系。# 单关键词 sqlite3 ~/.hermes/data/memory.db \ SELECT m.id, m.type, m.content FROM memories_fts f JOIN memories m ON m.rowid f.rowid WHERE memories_fts MATCH 数据库; # 多关键词 OR sqlite3 ~/.hermes/data/memory.db \ SELECT m.id, m.type, m.content FROM memories_fts f JOIN memories m ON m.rowid f.rowid WHERE memories_fts MATCH 积分 OR Decimal; # 带类型过滤和排序 sqlite3 ~/.hermes/data/memory.db \ SELECT m.type, m.content, m.confidence FROM memories_fts f JOIN memories m ON m.rowid f.rowid WHERE memories_fts MATCH FastAPI AND m.type IN (user,feedback) ORDER BY m.confidence DESC LIMIT 10;如果第二条能同时召回 mem_test_003说明 FTS5 索引和触发器都工作正常。如果查不到先确认触发器是否创建成功SELECT name FROM sqlite_master WHERE typetrigger;。接下来做跨会话召回对照测试。这个测试的目的是证明“持久记忆确实跨会话生效”而不是只在当前对话里有效。第一步开一个新会话让 Agent 写一个用户积分查询接口但不给任何背景。记录它的输出比如它可能用 Float 存金额、用 for 循环、错误信息用英文。第二步在同一个会话里纠正它三次“金额用 Decimal”“循环改成 map”“错误信息用中文”。然后执行/remember或让它写入记忆。第三步完全退出会话重新开一个。再让它写一个类似的接口这次不给任何提示。观察它是否自动用了 Decimal、map、中文错误信息。第四步用 SQL 确认记忆确实落库了sqlite3 ~/.hermes/data/memory.db \ SELECT type, content, confidence, updated_at FROM memories WHERE type IN (feedback,decision) ORDER BY updated_at DESC;如果第三步的输出和第一步明显不同且第四步能看到新条目说明跨会话召回链路通了。我试过这个对照第一次输出用 Float纠正并重启后第二次直接用了 Decimal召回块里能看到[decision] 积分金额用 Decimal。召回注入的格式建议结构化不要直接拼在 System Prompt 后面[Memory Context - 自动注入] 用户画像: - Barry, 全栈工程师, FastAPI React TypeScript - 偏好函数式风格函数不超过 30 行 项目信息: - API 返回格式: { code: 0, data: ..., message: } - 数据库: PostgreSQL, 所有 DB 操作必须 try/except 相关决策: - 积分金额用 Decimal 而非 Float精度要求 [Memory Context End]这个块由检索脚本生成插入到当前对话的 System Prompt 中。结构化注入的好处是模型能区分“这是记忆”和“这是用户当前说的话”减少混淆。5. 本篇常见错排查401、local proxy failed、reading choices、OAuth排障部分按真实报错来。第一个高频错误是 401 Unauthorized。如果你在调用模型时看到这个先检查环境变量是否真的导出到了当前 shellecho $TAOTOKEN_API_KEY如果为空说明 export 只在另一个终端生效。把它写进~/.bashrc或~/.zshrc然后source一下。另一个可能是 Key 被复制时带了空格或换行用printf %s $TAOTOKEN_API_KEY | wc -c看长度是否和预期一致。第二个错误是local proxy failed。这个通常出现在客户端配置了本地代理端口但代理进程没起来。检查你的客户端配置里有没有http_proxy或https_proxy指向127.0.0.1:某端口。如果有先确认那个端口有服务在监听lsof -i :端口号。没有的话把代理配置清掉直接用 TaoToken 的 Base URL https://taotoken.net/api 。第三个错误是reading choices相关比如cannot read property choices of undefined。这几乎都是响应体不是预期的 JSON 结构。原因可能是 Base URL 写错导致返回了 HTML 错误页或者 Model ID 填了一个不存在的模型。先用 curl 手动发一条看返回的原始 bodycurl -s https://taotoken.net/api/v1/chat/completions \ -H Authorization: Bearer $TAOTOKEN_API_KEY \ -H Content-Type: application/json \ -d {model:你的模型ID,messages:[{role:user,content:hi}]} | head -c 500如果返回的是 HTML说明路径不对如果返回 JSON 里有error字段按错误信息改 Model ID。第四个是 OAuth 相关报错。Claude Code 这类工具默认走 OAuth 登录如果你在配置里同时填了 OAuth token 和 API Key可能冲突。解决办法是明确用 API Key 模式把 OAuth 相关的缓存清掉。Claude Code 的配置在~/.claude/settings.jsonCodex 的在~/.codex/auth.json。以 Codex 的 auth.json 为例三件套要写全{ base_url: https://taotoken.net/api, api_key: sk-你的key, model: claude-sonnet-4-20250514 }如果你用 CC Switch 或 Cline MCP同样确认 Base URL、Key、Model ID 三项都填了缺一项就可能走到默认的 OAuth 流程然后报错。Cline 的 MCP 配置里Base URL 填 https://taotoken.net/api 不要带/v1。还有一个记忆库本身的坑FTS5 查询语法错误会报fts5: syntax error near ...。常见原因是查询词里带了特殊字符比如try/except里的斜杠。FTS5 把斜杠当语法符号需要把查询词用双引号包起来MATCH try/except。中文查询如果召回为空先确认分词器是 trigram 还是 unicode61unicode61 对连续中文不切词整句会被当成一个 token。最后记忆条目写入后如果检索不到检查触发器是否在更新时正确同步。可以手动 rebuild 一次INSERT INTO memories_fts(memories_fts) VALUES(rebuild);。这个命令会重建整个索引数据量大时慢一点但能解决大部分索引不一致问题。6. 把记忆库接进日常编码流到这里建表、索引、写入、检索、排障都跑通了。接下来是把它接进日常流程。我的做法是在项目根目录放一个memory_sync.py每次会话结束前跑一次把本次对话里提取出的条目批量写入 SQLite。提取逻辑可以调 TaoToken 的模型来做提示词里明确要求输出 JSON 数组每个元素包含 type、content、tags、confidence 四个字段。import os, json, sqlite3, requests API https://taotoken.net/api/v1/chat/completions KEY os.environ[TAOTOKEN_API_KEY] def extract_memories(dialog_text): resp requests.post(API, headers{ Authorization: fBearer {KEY}, Content-Type: application/json }, json{ model: claude-sonnet-4-20250514, messages: [{ role: user, content: f从以下对话提取持久记忆输出 JSON 数组字段 type/content/tags/confidence\n{dialog_text} }] }) return json.loads(resp.json()[choices][0][message][content]) def save(entries): conn sqlite3.connect(os.path.expanduser(~/.hermes/data/memory.db)) for e in entries: conn.execute( INSERT OR REPLACE INTO memories (id,type,content,tags,confidence) VALUES (?,?,?,?,?), (e.get(id, fmem_{hash(e[content]) 0xffffffff:08x}), e[type], e[content], e.get(tags,), e.get(confidence,0.5)) ) conn.commit() conn.close()召回侧同理新会话开始时用当前任务描述去查 FTS5取 Top 10 拼成[Memory Context]块。这套流程跑顺之后你会发现 Agent 越来越“懂你”——不是因为它变聪明了而是因为该记的都记下来了该召回的都召回了。如果你还没配 TaoToken 的 Key先去 https://taotoken.net/api-keys 建一个接入细节看 https://taotoken.net/doc 想先手动验证模型效果用模型对话页面发一条消息即可。长期跑编码 Agent 的话Coding Plan 比按次调用更划算。记忆库是你自己的数据导出、备份、迁移都完全可控hermes memory export出来的 JSON 可以直接 scp 到新机器再 import。