【hermes】会话持久化和使用

$

上篇我们拆开了工具系统的黑盒,agent 现在能思考、能干活了。但它有一个致命弱点——失忆。

程序一关,刚才聊了什么全忘了。就像你每次跟朋友打电话,挂了电话就把记忆清空。这显然不行。CLI 单人用也不能忍——退出后对话全丢,想继续上次对话都做不到。

Gateway 模式下问题更大。多个平台的消息同时到达,这些并发的对话需要解决三个问题:读到之前的对话历史、并发写入同一个数据库时不产生写锁冲突、事后能搜索历史会话。

这篇拆开会话持久化。不聊代码,聊决策——面对同样的问题,你会怎么设计?Hermes Agent 又是怎么选的?

你要解决的第一个问题:给对话一个身份

agent 现在能处理消息了。但有一个前提问题:你怎么区分”这是跟 A 用户的对话”和”这是跟 B 用户的对话”?

你的选择有哪些?

选择一:不区分。所有消息混在一起。——A 用户说”帮我查天气”,B 用户接着说”好的”,agent 以为 B 用户要查天气。

选择二:给每次对话一个唯一身份。

Hermes 选了选择二。这个身份叫 session

session 就是一次完整的对话。它有开始时间、结束时间、唯一 ID。对话里的所有消息都属于这个 session。

CLI 模式下,从你启动到退出是一个 session。Gateway 模式下,同一个聊天窗口里的连续对话是一个 session。

这就像通话记录。 每次打电话产生一条记录,记录里存了这次通话的所有内容。不同通话之间互不干扰。

一个 session 记录包含三个核心字段:唯一 ID、消息来源(cli、telegram、discord 等)、开始时间。

source 字段标记消息来自哪个入口。这让你能按平台过滤会话:”只看 Telegram 的对话”。

完整系统还会存模型名称、system prompt、父 session ID、token 统计、费用估算。但最小版本只需要上面三个字段就能工作。

到这里,”给对话一个身份”的问题解决了。但身份只是标签,对话内容还在内存里。进程一退出,标签和内容一起没了。

第二个问题:把对话搬到磁盘

session 有了身份,但消息还在内存里。怎么让对话跨重启存活?

你的选择有哪些?

选择一:文件系统。每个 session 存一个 JSON 文件。简单直接,文件天然隔离,不同 session 的写入不会冲突。但有两个问题——搜索历史要遍历所有文件,而且丢掉了事务保证(写到一半进程崩了,文件可能损坏)。

选择二:数据库。所有 session 的数据存在同一个数据库里。搜索方便,事务安全。但并发写入同一个文件可能打架。

Hermes 选了选择二——SQLite。

不是因为”SQLite 比文件高级”,而是因为它同时解决了三个问题。 历史读取、并发安全、全文搜索,一个方案全搞定。

最小实现只需要五步:建两张表(sessions 表和 messages 表)、开启 WAL 模式(第 4 节展开讲)、创建 session 时生成唯一 ID、每轮对话后只写入新增的消息(不是全量重写)、下次启动时从数据库读出历史。

这就是最小版本。两张表,能用。agent 退出后重启,对话还在。

⚠️ 初学者最容易犯的错:

一,存整个消息列表而不是增量。每轮对话后把完整的消息列表全量写入,而不是只写新增的。对话越长,写入越慢。正确的做法是只存本轮新增的部分。

二,把 session ID 写死。每次启动都用同一个 session ID,所有对话混在一起。session ID 应该每次新对话生成一个。

三,不处理工具调用的序列化。工具调用是一个嵌套结构,不能直接存到文本字段。需要 JSON 序列化。

会话持久化生命周期:从启动到退出的完整流程
会话持久化生命周期:从启动到退出的完整流程

到这里,”对话跨重启存活”的问题解决了。但 Gateway 模式下还有一个更棘手的问题。

第三个问题:多人同时说话不卡住

假设你的 agent 同时接了 Telegram 和 Discord。两个平台的消息同时到达,都要往同一个数据库写消息。

SQLite 默认模式下会发生什么?

有人在写数据库时,其他人连读都不行。Telegram 适配器正在写消息,整个数据库被锁住,Discord 适配器想读历史消息,只能等着,读都不行。

CLI 单人用无所谓。但 Gateway 模式下多个平台同时收发消息,这就卡住了。

你的选择有哪些?

选择一:每个平台用一个独立的数据库文件。读写不冲突了,但搜索历史要跨多个文件,事务也不统一。

选择二:换 PostgreSQL 这种真正的多用户数据库。能解决并发,但部署复杂度直接上一个台阶。一个 agent 项目值得吗?

选择三:SQLite 有一种模式叫 WAL(Write-Ahead Logging),能让读写不互相阻塞。

Hermes 选了选择三。

WAL 模式怎么工作的?

开了 WAL 之后,写操作先写到一个临时的日志文件里,不动主数据库。读操作继续读主数据库文件,不受影响。

Telegram 适配器正在写消息,写到 WAL 日志文件里,不动主数据库。Discord 适配器想读历史消息,照读,读的是主数据库。

日志会在合适的时机自动合并回主数据库。

SQLite 默认模式 vs WAL 模式:读写是否互相阻塞
SQLite 默认模式 vs WAL 模式:读写是否互相阻塞

但有一个重要的限制:写和写之间还是要排队的。 两个适配器同时想写,一个要等另一个写完。

默认模式下,写阻塞读,写也阻塞写。WAL 模式下,写不阻塞读(这是关键改进),但写仍然阻塞写。

开启方法就一行配置。

Hermes 的独特设计:随机退避重试

WAL 模式下写和写仍然需要串行。SQLite 默认的 busy handler 用确定性等待,高并发时会导致队列效应——所有人等同样长的时间,然后同时重试,然后又冲突。

Hermes 的做法:把 SQLite 超时设短(1 秒),然后在应用层做随机退避重试。每个写入者等一个随机间隔再重试,自然打散了竞争的写入者。

这个思路在分布式系统里很常见——加一个随机因子,打破确定性冲突。就像十字路口没有红绿灯时,大家不会同时起步,而是各自犹豫一下再走。

到这里,”多人同时说话不卡住”的问题解决了。但还有一个现实问题。

第四个问题:对话太长怎么办

一个 session 里的消息会越来越多。100 条、500 条、1000 条……全塞进 context window,模型处理不过来,API 费用也扛不住。

S05 会讲上下文压缩的具体机制。这里先讲压缩后的存储问题。

压缩之后,旧消息怎么办?

删掉?那用户想回顾之前的对话就找不到了。留着?那 context window 还是会被撑爆。

Hermes 的做法:旧历史不删除,只是归档。 压缩时创建一个新的 session,新 session 通过父 session ID 指向旧 session。

这形成一条链:session_001(完整历史,500 条消息)→ session_002(压缩摘要 + 新消息)→ session_003(又压缩了一次)。

新 session 只存压缩后的摘要和新消息。旧 session 完整保留在数据库里,需要时可以沿着链回溯。

Hermes 的独特设计:system prompt 缓存

Gateway 每条消息创建新的 AIAgent 实例。如果每次都重新组装 system prompt,中间可能因为记忆文件被改了导致 prompt 变化,Anthropic 的 prompt cache 就失效了。

所以 Hermes 把第一次组装好的 system prompt 存到 session 表里。后续实例直接读缓存,保证 prompt 不变。

Hermes 的独特设计:schema 版本管理

数据库 schema 会随版本演进。比如 V1 只有三个字段,V2 要加父 session ID。

Hermes 维护一个 schema 版本表,启动时检查版本号。如果需要迁移,自动执行表结构变更。用户升级版本后第一次启动,数据库自动升级,不需要手动操作。

第五个问题:事后找东西

对话存下来了。但用户想搜索历史——”上周我跟 agent 聊过的那个方案,关键词是 ROI”——怎么找?

你的选择有哪些?

选择一:遍历所有消息,逐行做模糊匹配。简单,但慢。消息多了之后,每次搜索都要扫全表。

选择二:建一个全文搜索索引。

Hermes 选了选择二——FTS5(Full-Text Search 5),SQLite 的全文搜索扩展。

FTS5 让你能在大量历史消息中快速搜索关键词。不需要遍历所有行做模糊匹配。每次插入消息时,SQLite 自动更新 FTS 索引。

这就像给图书馆建了一个索引卡片系统。找书不用一排排书架翻,直接查索引卡片。

教学边界:这一章不讲 FTS5 的高级搜索语法,那是进阶内容。如果读者能做到”agent 退出后重启,对话还在”,这一章就达标了。

复盘:三层各管各的事

回头看,S01+S02+S03 拼在一起,完整流程是这样的:

程序启动时,判断是新对话还是继续旧对话。新对话就创建 session,继续旧对话就从数据库读出历史。然后进入对话循环:用户输入消息,agent 调用 API 生成回复,如果有工具调用就执行工具,循环直到模型说完了。每轮对话结束后,只把新增的消息写入数据库。等待用户下一条输入。

三层各管各的事:

职责 不管什么
S01 循环 消息 → API → 工具调用 → 下一轮 不管工具怎么找到的,不管消息存哪
S02 工具 按名字查表、分发执行、同步/异步桥接 不管循环逻辑,不管持久化
S03 存储 写入数据库、读出历史、WAL 并发安全 不管循环怎么跑,不管工具怎么执行

和 S01+S02 对比,变化集中在存储层,循环和工具纹丝不动:

能力 S01+S02 +S03
对话存储 内存 SQLite
跨重启 不支持 读回来继续
并发安全 WAL + 随机退避
历史搜索 FTS5
对话压缩 父 session ID 链
核心循环 不变 不变
工具系统 不变 不变

⚠️ Gateway 场景下初学者最容易犯的错:

四,在 Gateway 里不分 chat ID。不同平台的不同聊天窗口应该是不同的 session。如果所有消息都混到一个 session 里,agent 会把 A 用户的对话当成 B 用户的上下文。

五,不开 WAL 模式。默认模式下,一个写操作会阻塞所有读操作。Gateway 场景下 agent 在写消息时,另一个平台的读取请求会挂住。

最后,三个能带走的判断。

一,SQLite 优于文件系统,是因为场景需求。 不是因为”SQLite 比文件高级”,而是它同时解决了历史读取、并发安全、全文搜索三个问题。选型要看场景需求,不是看技术高低。做产品也一样——选型不是选”最好的”,是选”最适合当前阶段的”。

二,增量写入优于全量写入。 只存新增的消息,不重复存旧的。这个原则在任何持久化场景都适用——日志、数据库同步、备份。每次全量写入就像每次拍照都把之前的照片重新拍一遍,浪费且慢。

三,WAL 是并发场景的基本需求,不是优化。 多平台同时收发消息时,WAL 让读写不互相阻塞。这不是锦上添花,是 Gateway 模式能工作的前提。做产品也一样——有些”基础设施”看起来是技术细节,但它是上层功能能跑起来的前提条件。


这是「Hermes Agent 源码精读」系列的第三篇。上一篇讲了工具系统,下一篇讲提示词组装——system prompt 不是写死的,是动态拼装出来的。