全文约 1550 字,读完大约 3 分钟。
Hermes 源码解读七:Session 与 SQLite,长期 Agent 的状态账本
很多聊天机器人项目,第一版都会这样存历史:
一个 session 一个 JSON。
或者一个用户一个 JSONL。
简单、直观、够用。
但 Agent 跑久以后,历史不再只是“聊天记录”。
它还包括模型配置、tool calls、reasoning 信息、token 统计、成本估算、压缩后的 lineage、平台来源、恢复状态、搜索索引、重试痕迹。
这时候继续把它当聊天记录,就会很痛。
我看 Hermes 的 session 相关源码时,一个判断很明确:
Hermes 把会话状态当成账本,而不是聊天 transcript。
这篇主要看 hermes_state.py、gateway/session.py、docs/session-lifecycle.md 和 session-storage.md。
为什么不是简单 JSONL

Hermes 现在把核心状态放在 ~/.hermes/state.db。
里面至少有这些表和索引:
sessions
messages
messages_fts
messages_fts_trigram
state_meta
schema_version这说明它已经从“保存聊天历史”升级成“保存运行时状态”。
sessions 不是只存 session_id。
它会记录:
- • source;
- • user_id;
- • model;
- • model_config;
- • system_prompt;
- • parent_session_id;
- • started_at / ended_at;
- • message_count;
- • tool_call_count;
- • input/output/cache/reasoning tokens;
- • billing 信息;
- • title;
- • api_call_count。
messages 也不是只存 role 和 content。
它还会存 tool_call_id、tool_calls、tool_name、finish_reason、reasoning、Codex Responses message items 等。
这就不是聊天记录了。
这是 Agent 运行账本。
WAL、FTS5、trigram:SQLite 不是随便选的
Hermes 用 SQLite,并不是因为“轻量”两个字。
文档里列了几个明确设计:
| 设计 | 解决的问题 |
|---|---|
| WAL mode | Gateway、CLI、worktree agent 多进程共享时,读写更稳 |
| FTS5 | 在所有 session message 里做全文搜索 |
| trigram tokenizer | 支持 CJK 和 substring 搜索 |
| parent_session_id | 压缩、分支、恢复时保留 lineage |
| source tagging | 按 CLI、Telegram、Discord 等来源筛选 |
| schema_version | 后续迁移可控 |
这些都是长期系统会遇到的问题。
尤其是 FTS。
Agent 的记忆不只是 memory 文件。很多时候你要找的是:
“上周哪个 session 里提过这个错误?”
“之前哪个工具调用失败过?”
“某个 Slack 群里的讨论是否已经处理过?”
如果历史只是散落的 JSONL,搜索和治理都会变得很差。
SessionStore 管的是会话入口,SessionDB 管的是账本
Gateway 里还有一层 SessionStore。
它维护 sessions.json,主要保存 session_key 到 session_id 的映射,以及一些运行状态 flag。
这层和 SQLite 不是重复,而是职责不同。
| 层 | 主要职责 |
|---|---|
SessionStore | 当前 Gateway 会话映射、恢复状态、过期策略、reset/suspend/resume |
SessionDB | 会话元数据、消息明细、搜索、成本、lineage、持久化账本 |
可以把 SessionStore 理解成“当前在线路由表”,把 SessionDB 理解成“长期账本”。
这两个职责如果混在一起,也会出问题。
Gateway 需要快速判断某个平台来的消息该进哪个 session。
而历史检索、成本统计、session lineage、transcript rewrite 又是另一类操作。
拆开以后,运行中路由和长期存储都更清楚。
session 生命周期不是只有 create 和 delete

docs/session-lifecycle.md 里有一个很值得看的状态机。
一个 session entry 有很多 flag:
| flag | 语义 |
|---|---|
was_auto_reset | 因 idle/daily 策略自动 reset |
is_fresh_reset | 用户显式 /new 或 /reset |
suspended | 硬重置,下次强制新 session |
resume_pending | 软恢复,重启后保留同一个 session_id |
expiry_finalized | 过期清理已经执行 |
reset_had_activity | reset 前是否真的有对话 |
这说明 Hermes 把 session 当成运行中对象,而不是一条数据库记录。
尤其是 suspended 和 resume_pending 的区别很重要:
suspended
→ 硬切断,下一次新 session
resume_pending
→ 软恢复,下一次继续原 session这就是长运行系统里的恢复语义。
进程重启、Gateway drain、用户 /stop,这些动作看起来都像“中断”,但它们对下一次会话的含义完全不同。
写入竞争也被当成生产问题处理
SQLite 很容易被误解为“单机小玩具”。
Hermes 的 SessionDB 里专门处理了写入竞争:
- • SQLite timeout 设置较短;
- • 应用层 retry;
- • retry 加随机 jitter;
- •
BEGIN IMMEDIATE; - • 每 50 次写入做 WAL checkpoint。
这解决的是 gateway + CLI + 多 worktree agent 共享一个 state.db 时的竞争问题。
很多人遇到 SQLite lock,会直接把 timeout 调大。
但 timeout 太大可能让所有写入排队卡住。
Hermes 选择短 timeout + 应用层随机退避,更像服务端系统里的竞争控制。
这也是源码里很后端的一面。
这套状态设计给我的启发
如果你自己做 Agent,不一定一开始就上完整 SQLite schema。
但建议早一点把“聊天记录”和“运行账本”分开想。
最少要问:
| 问题 | 为什么重要 |
|---|---|
| 这条消息来自哪个平台和用户? | 影响权限、恢复和上下文隔离 |
| 这轮用了哪个 model/provider? | 影响复现、成本和问题排查 |
| 哪些 tool call 被执行? | 影响审计和回放 |
| token 和成本是多少? | 长期运行必须算账 |
| session 是否压缩或分支? | 影响历史连续性 |
| 历史能不能搜索? | 影响记忆和问题定位 |
| 中断后是恢复还是新会话? | 影响用户体验和安全 |
我的判断是:
Agent 的 session 不是“聊天窗口”,而是一条可恢复、可审计、可搜索、可计费的运行轨迹。
Hermes 用 SQLite 承接这件事,是一个务实选择。
它没有一上来引入复杂数据库,也没有停留在散文件。
对个人和小团队 Agent 平台来说,这个取舍很有参考价值。
有了核心运行时、工具、Provider、Gateway 和 Session 之后,下一个问题就很自然了:
怎么让外部扩展改变系统行为,同时不把核心写成分支地狱。
如果你对 Agent 工程化感兴趣,可以继续收藏这个系列。越往后,越接近真实系统里最难维护的部分。

夜雨聆风