在 v0.4.2 使该插件可通过 pip 安装六周后,hermes-memory-pgvector v0.5.0 发布了。它是 0.x 系列中的第一个真正具有破坏性变更的版本,而原因是一个我本不该一开始就采用的名字。
冲突
导入包原本叫 pgvector。这个名称属于 pgvector-python。把两者都安装到同一个虚拟环境里,最后安装的那个会抢占顶层名称——到那时,这个插件的发现 shim 可能会完全导入错误的模块。
这种失败模式正是它值得进行一次接近重大版本升级的原因:加载器会把错误的导入当作“插件不存在”,并在一行日志后回退到内置内存。不会崩溃。整个集群只会悄悄停止共享内存,而你会在几周后、回忆结果变成空白时才发现。
所以现在导入包改名为 hermes_pgvector。以下三项没有变化,因为它们属于彼此独立的命名空间,而且只有其中一个被移动了:
- 发行包——仍然是
hermes-memory-pgvector - CLI——仍然是
hermes-pgvector - hermes 提供者——仍然是
pgvector(memory.provider: pgvector)
升级
v0.5.0 声明了 hermes_agent.memory_providers 入口点,因此在足够新、能够以这种方式解析插件的主机上,直接 pip install 就足够了,不再需要 shim。shim 目录 仍然优先于入口点,这正是旧 shim 必须清理掉的原因:
pip install -U hermes-memory-pgvector
# preferred: drop the shim, let the entry point resolve it
hermes-pgvector install --remove
# older host that only scans plugin directories:
hermes-pgvector install --force
hermes memory status # expect: Provider: pgvector; Status: available
在离开前先检查你的计划任务。 任何 python -m pgvector ... 调用现在都应改为 hermes-pgvector ...,而且它会静默失败——夜间回填会直接停止,而在嵌入故障期间写入的行会一直不可搜索:
sudo grep -rl 'python -m pgvector' /etc/systemd/system/ /etc/cron.d/
我没想到的部分
对全部 24 个受跟踪文件进行完整代码库审查后,发现了 28 个已确认问题。最令人不舒服的不是边角案例——而是这个仓库内部自相矛盾的配置契约,每一个都在看似正确配置的同时,悄无声息地把控制逻辑反转了:
allowed_themes被声明为字符串,却按列表使用。 一个字符串白名单会被逐字符迭代,所以每个主题都会在成员测试中失败,整个集群被路由到default。身份治理看起来已配置,实际做的却恰好相反。- 布尔开关会忽略
false。embed_on_write、sync_turns、hybrid_search和bulk_sync_on_init在 schema 中被声明为字符串"true"/"false",随后却用普通真值判断读取。bool("false")是True。 - 嵌入超时时间从未从配置中传入。 每个调用方都使用硬编码的 10s。面对一个在 6–17s 内响应的端点,大量写入会超时并以 NULL embeddings 落盘——重试也无济于事,因为每次尝试的上限都低于它所需的延迟。现在按路径拆分为:
embed_timeout(10s,agent 线程)和embed_write_timeout(30s,后台写入器)。 replace()更新了零行。 批量 UPDATE 与UNIQUE(agent_identity, target, content)冲突并抛出UniqueViolation,因此一次匹配两条或更多条记录的替换什么都不会做,而内置工具的状态却继续向前。- 每个会话轮次都写了两次。
sync_turn和on_session_end都捕获了同一批轮次,而conversations没有唯一约束。 - 数据库失效时是无声的。 工作线程失败只记录到
debug,而_healthy从未重新探测,所以一次 Postgres 重启会让该会话的所有持久化写入直接丢失,完全没有任何信号。
这才是本次发布的真实故事:重命名是头条,而这些配置 bug 才是让重命名值得现在做、而不是等到 1.0 的原因。
现在两侧都强制执行身份分桶
whatsapp-dm、新的 external-group,以及 _bench 过去都只是写入侧的接收端。身份归一化会从 identity 中去除 PII,但消息正文仍然保留在 content 中,而且读取时没有任何过滤——因此任何主题都可以通过 scope='all' 将 DM 内容拉入其上下文。现在 scope='all' 会排除这些接收端,而显式命名其中一个也会被拒绝。一个确实是桶的 agent 仍然可以完整访问自己的行,普通的跨主题回忆则保持不变。
有一个值得仔细阅读的注意事项:门控是按桶的 名称 排除,而分桶发生在写入时。行不会被回溯性重写——这是有意为之,因为否则历史行将变得无法回忆。桶化前写入的行会保留原始身份。hermes-pgvector remap --old <raw> --new whatsapp-dm 会先预览迁移;加上 --execute 才会真正执行。
验证
在 skip-mode 下进行了 100 个测试,在启用了 pgvector 0.8.6 且已应用全部四个迁移的 live Postgres 16 上进行了 145 个测试。本次发布没有 schema 变更,也没有新的迁移。
更新 — v0.5.1
v0.5.1 随后一小时发布,而这一小版本正是你不该拖着不升级的那个:一次 memory remove 会删除某个主题的每个镜像条目,而不是一条。
内置工具的 remove 操作会把目标放在 old_text 里,并让 content 为空,而主机通过 metadata 而不是 content 传递它。_worker 传入的是 old_text=item.content,因此它始终是 ""——于是 store.remove 构造出 content LIKE '%%',而这会匹配每一行。一次删除会清空该 (agent_identity, target) 对的整个镜像。内置存储从未受影响;只有 pgvector 镜像受影响。我检查了参考部署是否受损,结果没有——每个主题的历史都是连续的,这在很大程度上说明实际中 remove 操作非常少见。
这个问题在两个层面上都修复了,因为对于一个静默的全范围删除来说,仅靠一层并不够:_worker 现在读取 extra["old_text"],并拒绝没有可用目标的 remove;而 store.remove() 会直接拒绝空模式,因此任何调用方都无法通过省略而触发破坏性删除。remove() 现在也最多只删除一行,与内置行为一致。
同一版本还修复了其背后那个空内容问题。此前没有任何东西会拒绝空的 add/replace,因此会出现一行 embed() 永远无法处理的数据——对于空文本,EmbeddingError("empty input") 是无条件触发的。随后夜间回填会在每次运行时反复重试它,使 failed 始终高于零,并让 remaining == 0 变得不可能,这破坏了运营者真正关注的唯一信号:一条永久卡住的行会变得和新的真实失败没有区别。现在空写入会被跳过(remove 例外),而无法嵌入的行会被排除在扫描之外,并单独报告为 unembeddable。
还有两件更小但值得指出的事,因为它们都很隐蔽。Postgres 的 trim() 只会去掉空格,所以一行只包含换行或制表符的记录仍然会漏网,而且仍然会永久失败——现在所有站点都改用 content ~ '\S'。而这些谓词原本是普通的 f-string,其中 \S 是无效转义:今天会产生四个 DeprecationWarning,在 3.12+ 上会变成 SyntaxWarning,并且在 -W error 下模块会直接导入失败——这会让 hermes-agent 的加载器悄悄回退到内置内存。
验证:在 skip-mode 下进行了 118 个测试,在启用了 pgvector 0.8.6 且已应用全部四个迁移的 live Postgres 16 上进行了 164 个测试。进行了两轮审查——第一轮发现了数据丢失 bug,第二轮发现了该修复中的错误转义序列。
获取它
pip install hermes-memory-pgvector
源代码、升级说明以及完整配置参考在 GitHub:
