state.db 里一半以上的行,是你早就「压缩掉」的旧消息副本


你的笔记本风扇开始经常转,备份脚本越来越慢,hermes sessions stats 一看:数据库好几个 GB。可你翻会话列表,并没有那么多内容——大部分对话都只有十几轮,历史也早被压缩过了。空间到底被谁吃了?

答案有点反直觉:被那些你已经压缩掉的旧消息。它们的完整原文被一遍又一遍地复制成“归档副本”,堆在 state.db 里。在一个开了可选 prune 的安装上,某个 profile 积了 31,930 份这样的副本,另一个 profile 是 48,737 份——占它全部行的 55%。

先解释清楚 prune 在做什么

Hermes 有一个可选的功能叫主动 prune(compression.proactive_prune_tokens,默认关闭)。它做的事情很克制:当重发的历史超过你设的 token 阈值时,它把旧的、体积大的工具结果(终端输出、文件读取、网页抓取)替换成简短的摘要,不做任何模型调用。

关键点是:prune 只改几条已有消息的正文。它不新增消息、不删除消息、也不调整顺序。从数据角度看,这是一次“就地改写”。

但旧的提交路径不是这么做的。每次提交,它都走完整的归档+压缩写入器:把所有活跃行归档一份,再把整个消息列表重新插一遍。于是:

你改 3 条消息,数据库里多出一整份 transcript 的副本。

一次两次无所谓。但当 prune 在一个长会话里反复触发,副本就开始指数式堆积:每一份都是“当时的完整历史”,而其中绝大多数内容在下一次 prune 之后又变成了新的副本。这就是为什么你的数据库膨胀得比对话量快得多。

数字长什么样

这些不是估算,是那个 issue 里实测的:

观察项 数值
第一个 profile 的过期副本 31,930 行
第二个 profile 的过期副本 48,737 行
占该 profile 全部行的比例 55%
触发条件 主动 prune 开启(示例配置 proactive_prune_tokens: 12000)

也就是说,一个“只有一半是真正有用数据”的数据库,读写、备份、FTS 索引全都在为另一半空转。

现在改成就地重写

新增的提交原语是 SessionDB.rewrite_pruned_rows(session_id, changes, model_config_patch=...),整个过程在一个事务里完成:

  1. 定位要改的行。先看有没有 _row_id;没有就用 tool_call_id(工具结果)或它携带的 tool-call id 列表(assistant 轮次)——这些在一次会话里是唯一的;再不行,就只在“角色相同、存储内容相同”的唯一活跃行里匹配。
  2. 校验原值还在。这一行必须仍然持有原来那份内容:角色一致、tool call id 一致、call id 列表一致,工具行还要正文一致。任何一处对不上就放弃就地改写。
  3. 给旧自己留一个归档双胞胎。被替换前的那一份写为 active=0, compacted=1,仍然可被搜索——和今天的行为一致,压缩不等于丢数据。
  4. 就地接管变化的列,同时把 rearm 补丁合并进 model_config。

没被改的行、模型顺序、行 id、以及并发追加的行都不会被碰。现有的 messages_fts_update 和 messages_display_identity_update 触发器保证全文索引和展示索引一致;message_count 保持不变,因为没有任何行进出活跃集合。

一个值得注意的细节:如果定位不出唯一的那一行(比如 _absorbed_row_ids 合并过的行、匹配到零个或多个、行已不活跃、是用户消息、或正文已变),它不会猜。它会在任何写入发生之前抛出 PruneRowUnresolvedError,记一条 INFO 日志,然后退回旧的归档+压缩路径——慢,但结果正确。

怎么用、怎么查

一、确认自己有没有开 prune。

hermes config get compression.proactive_prune_tokens

返回 0 或 “Config key not set” 就表示没开,数据库不会因为这个机制膨胀。想开的话,三个相关键都在 compression: 下面:

compression:
  proactive_prune_tokens: 48000        # 0 = 关闭;开启建议从 48000 起
  proactive_prune_min_result_chars: 8000   # 小于这个体积的工具结果不动
  proactive_prune_min_reclaim_tokens: 4096 # 回收不到这个量就不提交,避免无谓改写

二、看数据库到底多大。

hermes sessions stats

它会输出会话存储的统计信息。配合系统层面看一下实际文件大小:

du -sh ~/.hermes/state.db

(非默认 profile 的数据目录在各自的 profile 目录下。)

三、回收空间。 这是两个不同的事情,别混:

hermes sessions optimize          # 合并 FTS5 段 + VACUUM,不改动任何会话数据
hermes sessions optimize-storage  # 把全文索引迁移到更紧凑的 v23 布局,大库上能省一大块

optimize 回收的是空闲页,optimize-storage 换的是索引布局。两者都不会替你删掉历史副本——那些副本要不要清、清哪些,仍然由你的归档和删除策略决定。这个修复做的是“不再制造新的副本”,不是“自动清理旧的副本”。 已经堆积的行会留在那里,直到你按自己的策略归档或删除会话。

还有一点需要如实说明:这个改动 2026 年 10 月 18 日合入主分支,晚于 v0.22.2(10 月 15 日发布),当前 release 里还没有它。你下一次 hermes update 到包含该提交的版本后,新产生的 prune 才会走就地重写。

为什么这类膨胀很难被发现

三个原因叠在一起:

  • 它是“正确”的副产品。归档+重插保证了每一次提交都是完整一致的快照,逻辑上没有任何 bug,只是代价被记在了磁盘上。
  • 指标看不出来。会话数没变、消息数没变、界面一切正常,只有文件体积和 I/O 在慢慢变差。
  • 它和用量强相关。你越常开长会话、越常跑大输出的工具,膨胀越快;而恰恰是重度用户最不容易注意到——他们的机器本来就在忙。

这和“数据库损坏”是两件事,别混为一谈:损坏(schema 错乱、会话消失)有独立的修复路径,比如 state.db 损坏后的自愈。这次的问题数据完全健康,只是存了太多份。

从这件事能学到什么

“就地更新”和“整体重写”在功能上等价,在代价上完全不同。很多系统选择整体重写,是因为它简单、不会出现部分写入;代价是每次小改动都要复制全量数据。当这个操作出现在热路径上(每几轮对话就触发一次),代价会以你意想不到的速度累积。

判断标准很朴素:改一行数据,是否应该写 N 行? 如果答案是否定的,那这条路径迟早会变成一个容量问题。修复不一定需要新的存储引擎,往往只是“认出这是原地更新”——本例中就是按 tool call id 精确指名那一行,再校验它没被别人改过。

想更系统地控制上下文与历史体积,可以接着看这两篇:lean tail 压缩策略 讲压缩时保留什么,压缩提示词重建 讲压缩后提示词怎么重建。习惯用命令行管理会话的话,hermes sessions 命令参考 里有完整的子命令与过滤参数。

小结

  • 症状:state.db 体积远大于会话量,备份慢、I/O 重;hermes sessions stats 能看到行数异常。
  • 原因:主动 prune 每次提交都归档全部活跃行再整表重插,一个 profile 积了 48,737 行过期副本(占 55%)。
  • 修复:rewrite_pruned_rows() 就地改变化的列,留一个可搜索的归档双胞胎,定位不出唯一行时安全回退。
  • 现在做:hermes config get compression.proactive_prune_tokens 确认开关、hermes sessions stats 看规模、hermes sessions optimize 回收空间;等下一次更新拿到新提交路径。