
1. 为什么 Agent 一重启就失忆从 Hermes 会话存储说起你花四十分钟跟一个 AI Agent 聊项目背景、代码风格、接口约定它记得清清楚楚。然后你按了 CtrlC重新跑起来它开口第一句是「你好有什么可以帮你的吗」。这种体验就像跟一个失忆症患者共事每次见面都要重新自我介绍。Hermes 这个自主 Agent 框架在持久化记忆层上给出的答案很直接用 SQLite 做会话存储开 WAL 模式解决并发写入再用 FTS5 建全文索引让历史对话可检索。这一篇就围绕s03_session_store.py这个模块把建表 SQL、WAL 参数、FTS5 触发器配置全部拆开最后用 TaoToken 统一 Key 接入模型跑一次真实的记忆写入与召回验证。先说清楚这套东西适合谁。如果你正在手搓 Agent或者用现成框架但被「多平台并发写库冲突」「历史对话搜不到」「重启丢上下文」这几个问题卡住那这篇的配置可以直接抄。SQLite 是嵌入式数据库不需要独立服务进程Python 标准库自带sqlite3零依赖一个库文件全搞定。为什么不用 JSON 文件存对话因为你要自己解决三件事按 session 读全部消息、两个平台同时写、按关键词搜某段对话。文件系统做这些你得手写索引、手写锁、手写分词。SQLite 一个库文件全包了而且 WAL 模式下写不阻塞读这对 Agent 这种高频读写场景是刚需。Hermes 的存储层设计有个关键取舍不是会话结束时批量写而是每产生一条消息就落盘。这样即使进程中途崩溃已经产生的对话也不丢。代价是写入频率高所以 WAL 模式不是可选项是必选项。下面从原问题场景开始一步步把整套记忆库搭起来中间穿插 TaoToken 的接入配置最后做一次完整的写入召回验证。2. TaoToken 前置准备统一 Key 接入 Hermes 模型调用在动手建表之前先把模型接入这条链路打通。Hermes 的存储层本身不依赖模型但你要验证「记忆写入与召回」就得让 Agent 真的调一次模型把对话内容写进库、再搜出来。这里用 TaoToken 做统一 Key 接入好处是一个 Key 管多个模型切换模型不用改代码里的 base_url 和鉴权逻辑。TaoToken 的定位是模型 API 聚合接入层官网在 https://taotoken.net/?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content API 端点固定为 https://taotoken.net/api 。注意 API 地址不带 UTM 参数直接写进配置就行。你需要准备的东西不多一个 TaoToken 账号、一个 API Key、以及 Hermes 项目里读取环境变量的地方。Key 的获取入口在控制台的 API Keys 页面地址是 https://taotoken.net/console/api-keys?utm_sourcetaotoken_aicg_blog_endutm_contentapi_keysutm_campaignrewrite 。拿到 Key 之后不要硬编码进源码用环境变量注入。Hermes 的模型调用层通常读三个环境变量OPENAI_API_KEY、OPENAI_BASE_URL、HERMES_MODEL。TaoToken 兼容 OpenAI 风格的接口所以 base_url 填https://taotoken.net/apiKey 填你申请到的那串模型 ID 按你实际要用的填。如果你不确定有哪些模型可用可以先去模型对话页面试一下地址是 https://taotoken.net/models?utm_sourcetaotoken_aicg_blog_endutm_contentmodelsutm_campaignrewrite 在网页里发一条消息确认通路再写进代码。这里有个容易踩的坑base_url 结尾不要多加/v1。TaoToken 的 API 端点已经包含了版本路径你写https://taotoken.net/api就行写成https://taotoken.net/api/v1反而会 404。这个我在配置 Cline 和 Claude Code 的时候都遇到过报错信息是404 page not found排查半天才发现是路径多了一段。环境变量配置好之后Hermes 里读取模型名的逻辑大概是这样的import os MODEL os.environ.get(HERMES_MODEL, claude-sonnet-4-20250514) API_KEY os.environ.get(OPENAI_API_KEY) BASE_URL os.environ.get(OPENAI_BASE_URL, https://taotoken.net/api)MODEL这个全局变量后面会写进 sessions 表的 model 字段方便你回溯某次会话用的是哪个模型。这一点在多模型对比测试时特别有用你搜历史对话的时候能直接看到当时用的模型 ID。如果你打算长期跑编码类 Agent 任务可以考虑 Coding Plan入口在 https://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_contentcoding_planutm_campaignrewrite 适合高频调用场景。不过这一篇的重点是存储层模型接入只是验证手段配置通了就行。环境变量设好之后先别急着建表确认一下 Python 能读到python -c import os; print(os.environ.get(OPENAI_BASE_URL))输出应该是https://taotoken.net/api。如果输出 None说明环境变量没生效检查一下你是写在.bashrc、.zshrc还是.env文件里以及有没有 source 过。接入文档在 https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_contentdocutm_campaignrewrite 里面有完整的参数说明和错误码对照遇到 401 或者模型不存在的时候可以对照排查。前置准备到这里就够了接下来进入建表环节。3. 可复制配置建表 SQL、WAL 参数与 FTS5 触发器这一节是全文的核心所有配置都可以直接复制进你的项目。Hermes 的init_db函数是整个存储的根基三张表加一个全文索引虚表结构清晰。先看完整的建表脚本import sqlite3 import uuid import json import time def init_db(db_path: str) - sqlite3.Connection: conn sqlite3.connect(db_path) conn.execute(PRAGMA journal_modeWAL) conn.executescript( CREATE TABLE IF NOT EXISTS sessions ( id TEXT PRIMARY KEY, source TEXT NOT NULL, model TEXT, started_at REAL NOT NULL ); 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_calls TEXT, tool_call_id TEXT, timestamp REAL NOT NULL ); CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5(content, contentmessages, content_rowidid); CREATE TRIGGER IF NOT EXISTS messages_ai AFTER INSERT ON messages BEGIN INSERT INTO messages_fts(rowid, content) VALUES (new.id, new.content); END; ) return conn逐段拆解。PRAGMA journal_modeWAL这一行是并发写入的关键。SQLite 默认的 journal 模式下写操作会锁住整个库文件一旦有写入所有读操作都得排队等。对 Agent 这种高频读写场景这是灾难。WAL 模式改变规则写操作先追加到独立的 WAL 日志文件读操作继续读主库文件两边互不干扰只有写和写之间才需要互斥。一行 PRAGMA 解决。sessions表是会话主表。id是 UUID 主键source标记平台来源cli/telegram/discordmodel存模型名started_at是时间戳。这张表的作用是给每条消息一个归属你搜历史的时候能知道这段对话是从哪个平台来的、用的什么模型。messages表是消息明细。session_id外键关联会话role是 user/assistant/tooltool_calls存结构化数据timestamp排序用。注意tool_calls字段的类型是 TEXT因为工具调用是结构化数据函数名、参数、调用 ID不能直接塞进 TEXT 列需要序列化。messages_fts是 FTS5 全文索引虚表。contentmessages表示它不存数据本身只存索引通过content_rowid指向 messages 表的行。这种设计叫「外部内容表」索引和原始数据分离省空间。触发器messages_ai在每次向 messages 插入数据时自动把 content 同步进全文索引。写入路径完全透明业务代码无感知。你只管往 messages 表插索引自动更新。这里有个细节要注意FTS5 的触发器只处理了 INSERT没有处理 UPDATE 和 DELETE。如果你的 Agent 会修改或删除消息需要补上messages_au和messages_ad两个触发器。Hermes 的场景里消息是只追加不修改的所以只写了 INSERT 触发器。你如果要做消息编辑功能记得补全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;WAL 模式还有几个配套参数值得调。PRAGMA synchronousNORMAL可以在 WAL 模式下降低 fsync 频率提升写入性能代价是极端断电情况下可能丢最后几条事务。对 Agent 场景来说丢最后一条消息比每次写入都等磁盘 IO 更可接受conn.execute(PRAGMA synchronousNORMAL) conn.execute(PRAGMA busy_timeout1000)busy_timeout1000设置写锁等待超时 1 秒。多平台并发写入时如果拿不到写锁SQLite 会等待而不是立刻报错。Hermes 在应用层还加了随机退避重试这个后面排障章节会讲。如果你用配置文件管理参数可以写成一个 TOML[hermes.storage] db_path ./data/hermes.db journal_mode WAL synchronous NORMAL busy_timeout 1000 fts_enabled true对应的读取逻辑用tomllibPython 3.11或者tomli库。这样换环境的时候不用改代码改配置就行。建表脚本跑完之后可以用.schema命令确认一下sqlite3 ./data/hermes.db .schema应该能看到三张表和触发器的定义。如果messages_fts没出现检查一下 FTS5 扩展是否编译进了你的 SQLite。Python 标准库自带的 sqlite3 通常都带 FTS5但某些精简版系统可能需要单独装。4. 验证请求一次完整的记忆写入与召回配置搭好了现在跑一次真实验证。目标很明确让 Agent 通过 TaoToken 调一次模型把对话写进 SQLite然后用 FTS5 搜出来。先写四个核心函数。create_session开新会话def create_session(conn, sourcecli): session_id str(uuid.uuid4()) conn.execute( INSERT INTO sessions (id, source, model, started_at) VALUES (?, ?, ?, ?), (session_id, source, MODEL, time.time()), ) conn.commit() return session_idadd_message写消息关键在 tool_calls 字段的序列化def add_message(conn, session_id, msg): tool_calls_json None if msg.get(tool_calls): tool_calls_json json.dumps(msg[tool_calls]) conn.execute( INSERT INTO messages (session_id, role, content, tool_calls, tool_call_id, timestamp) VALUES (?, ?, ?, ?, ?, ?), (session_id, msg[role], msg.get(content, ), tool_calls_json, msg.get(tool_call_id), time.time()), ) conn.commit()get_session_messages恢复历史按 id 升序保证对话顺序正确def get_session_messages(conn, session_id): rows conn.execute( SELECT role, content, tool_calls, tool_call_id FROM messages WHERE session_id ? ORDER BY id, (session_id,), ).fetchall() messages [] for role, content, tool_calls_json, tool_call_id in rows: msg {role: role, content: content or } if tool_calls_json: msg[tool_calls] json.loads(tool_calls_json) if tool_call_id: msg[tool_call_id] tool_call_id messages.append(msg) return messagessearch_sessions全文检索用 FTS5 的 MATCH 语法def search_sessions(conn, query): rows conn.execute( SELECT m.session_id, m.content FROM messages_fts f JOIN messages m ON f.rowid m.id WHERE f.content MATCH ? LIMIT 10, (query,), ).fetchall() return [{session_id: r[0], snippet: r[1][:200]} for r in rows]现在跑一次端到端验证。先建会话写入两条消息再搜conn init_db(./data/hermes.db) sid create_session(conn, sourcecli) add_message(conn, sid, { role: user, content: 我们的项目用 FastAPI 做后端数据库是 PostgreSQL代码风格遵循 PEP8。 }) add_message(conn, sid, { role: assistant, content: 好的我记住了FastAPI 后端、PostgreSQL 数据库、PEP8 代码风格。 }) results search_sessions(conn, FastAPI) print(results)预期输出是搜到那条 user 消息snippet 里包含「FastAPI 做后端」。如果搜不到先确认触发器有没有生效sqlite3 ./data/hermes.db SELECT count(*) FROM messages_fts;这个数字应该等于 messages 表的行数。如果 messages_fts 是空的说明触发器没建成功或者插入的时候没触发。接下来接入模型做真实对话。用 TaoToken 的 API 发一次请求把返回的 assistant 消息写进库import openai client openai.OpenAI( api_keyos.environ[OPENAI_API_KEY], base_urlhttps://taotoken.net/api, ) resp client.chat.completions.create( modelMODEL, messagesget_session_messages(conn, sid) [ {role: user, content: 帮我确认一下刚才说的技术栈。} ], ) assistant_msg resp.choices[0].message add_message(conn, sid, { role: assistant, content: assistant_msg.content, })注意这里get_session_messages把历史拉回来拼上新消息一起发给模型。这就是「记忆召回」的实际用法Agent 一启动就知道之前聊过什么。模型返回的内容再写回库形成闭环。验证召回的时候搜一个只有历史里出现过的关键词比如「PostgreSQL」print(search_sessions(conn, PostgreSQL))应该能搜到两条一条 user 的原始描述一条 assistant 的确认回复。到这一步写入和召回都通了。如果你想在网页端先确认模型通路可以去模型对话页面 https://taotoken.net/models?utm_sourcetaotoken_aicg_blog_endutm_contentmodelsutm_campaignrewrite 发一条测试消息确认 Key 和模型 ID 没问题再回到代码里跑。这样能把「模型接入问题」和「存储层问题」分开排查省时间。5. 本篇常见错排查401、local proxy failed 与 reading choices配置跑不通的时候报错信息往往指向好几个方向。这一节把最常见的几类错误对照着讲每个都给排查路径。401 Unauthorized。这个最直接Key 不对或者没传。先确认环境变量读到了echo $OPENAI_API_KEY如果输出为空说明环境变量没设。如果输出了但请求还是 401检查 Key 有没有多余空格或者是不是复制的时候漏了字符。TaoToken 的 Key 在控制台可以重新生成地址是 https://taotoken.net/console/api-keys?utm_sourcetaotoken_aicg_blog_endutm_contentapi_keysutm_campaignrewrite 。还有一种情况是 base_url 写错了请求打到了别的端点鉴权自然过不了。确认 base_url 是https://taotoken.net/api结尾没有多余的斜杠或/v1。local proxy failed。这个报错通常出现在你本地配了 HTTP 代理但代理进程没起来或者端口不对。排查方法是先看环境变量env | grep -i proxy如果有HTTP_PROXY或HTTPS_PROXY临时清掉再试unset HTTP_PROXY HTTPS_PROXYAgent 调模型走的是标准 HTTPS不需要额外代理层。如果你在公司内网确认网络策略允许访问taotoken.net。reading choices 报错。典型信息是TypeError: NoneType object is not subscriptable或者KeyError: choices发生在resp.choices[0]这一行。根因是 API 返回的结构和预期不符。先打印完整响应print(resp.model_dump_json(indent2))常见原因有三个模型 ID 写错了返回的是错误对象而不是 completion请求超时返回了空或者流式和非流式模式搞混了。模型 ID 一定要和 TaoToken 文档里列的一致别自己拼。如果你不确定当前可用的模型 ID去模型对话页面发一条消息看返回里用的什么模型名。OAuth 相关报错。如果你用的是 Claude Code 或者某些需要 OAuth 的客户端可能会遇到 token 过期。这类客户端通常有自己的凭证存储和 API Key 是两套机制。排查的时候先确认你用的是 API Key 模式还是 OAuth 模式。Hermes 这套存储层用的是标准 API Key不涉及 OAuth。如果你在 Claude Code 里配置参考接入文档 https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_contentdocutm_campaignrewrite 里的客户端配置章节。FTS5 搜不到结果。先确认索引表有数据sqlite3 ./data/hermes.db SELECT count(*) FROM messages_fts;如果为 0检查触发器。触发器只在 INSERT 时同步如果你先插了数据再建触发器历史数据不会自动进索引。补索引的方法是手动重建INSERT INTO messages_fts(messages_fts) VALUES(rebuild);这条命令会从 messages 表重新构建整个全文索引。database is locked。多平台并发写入时的经典报错。WAL 模式下写写仍然互斥两个进程同时写会有一个拿不到锁。解决办法是设busy_timeout让拿不到锁的进程等待而不是立刻失败conn.execute(PRAGMA busy_timeout1000)Hermes 在应用层还加了随机退避重试捕获sqlite3.OperationalError后 sleep 一个随机短时间再重试。这样能打散多个写入者的节奏避免同频重试继续撞车。tool_calls 读出来是字符串。如果你看到{_json: ...}这种结构说明序列化的时候多包了一层。检查add_message里是不是对已经是字符串的内容又做了一次json.dumps。正确做法是只对 dict 或 list 做序列化写入前判断类型。session_id 写死。所有会话共用一个 ID历史全串在一起。检查create_session有没有真的生成新 UUID以及调用方有没有把返回值传下去。这个错误在多平台场景下特别隐蔽因为单平台测试的时候看不出来。system prompt 放错位置。如果你把 system prompt 塞进 messages 列表里当普通消息发多轮对话会污染上下文。正确做法是走独立的system参数。这个和存储层关系不大但经常和记忆召回一起出现顺手提一下。排查顺序建议从外到内先确认 Key 和 base_url 能通用模型对话页面测再确认数据库能写手动插一条最后确认 FTS5 能搜手动 rebuild 一次。分层排查比盯着一个报错猜要快得多。6. 语义一致 CTA把记忆库接进你的 Agent 主循环存储层写好了怎么接进 Agent 主循环改动点很明确就四处。开头恢复messages get_session_messages(conn, session_id)把历史拉回来Agent 一启动就知道之前聊过什么。用户消息落盘收到 user 输入后add_message写入。每轮 assistant 回复落盘生成一条写一条。每个 tool 结果落盘工具调用返回也写。关键设计是每产生一条消息就落盘不是结束时批量写。这样即使进程中途崩溃已产生的对话也不丢。代价是写入频繁所以 WAL 和 busy_timeout 是配套的。Hermes 在这套基础上还有几个独特设计。写锁冲突的随机退避前面讲过了。system prompt 缓存进 session 表避免每次新会话重新拼装同时保证 Anthropic prompt cache 不失效——prompt caching 要求 system prompt 多轮间完全一致变了缓存就失效成本飙升。还有parent_session_id会话链上下文压缩时新 session 指向旧 session旧历史归档不删新会话接着聊形成完整可回溯的链路。如果你要把这套接进自己的项目建议先跑通最小闭环建表、写一条、搜一条。确认通路之后再接模型。模型接入用 TaoToken 统一 Keybase_url 填https://taotoken.net/apiKey 从控制台拿。遇到问题先分层排查别一上来就怀疑存储层。长期跑编码类 Agent 任务的话Coding Plan 入口在 https://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_contentcoding_planutm_campaignrewrite 适合高频调用。接入文档在 https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_contentdocutm_campaignrewrite 参数和错误码都有对照。下一篇讲上下文压缩与错误恢复当对话历史超过上下文窗口怎么压缩不丢关键信息当工具调用失败怎么自动重试和降级。parent_session_id的会话链会在那里派上大用场。