上个月写过一篇给常驻 Agent 装长期记忆,那次是修 Hindsight——我那个常驻 Agent 的记忆系统。折腾了一整天,最后跑起来了,还总结了一堆”验证要信真实往返”的教训。

这个月它又坏了两次。这次我没修。

一、它坏过的四次

把故障排一遍,规律很清楚:

第一次,插件版本和数据库 schema 对不上。容器里的 hindsight_embed 是 0.10.1,数据库已经被 0.10.3 迁移过,启动日志里一行 newer revision ... Skipping,然后就不动了。

第二次,LLM API key 被人改错了。401 攒了 1391 条,而且改不动——我改过的那个配置文件,每次容器启动都会被插件按另一处源头重写一遍,改了等于没改。

第三次,召回全部超时。reranker 默认最多让 300 个候选进 cross-encoder 重排,这颗 N100 每候选 1.17 秒,300 个就是 351 秒,而客户端 120 秒就断开。写路径一直是好的,读路径彻底死掉,表现出来是”记忆还在,但什么都想不起来”。

第四次,Hermes 容器更新了两次镜像,Hindsight 跟着挂。

四次里有三次,根因都在同一个地方:Hindsight 是作为 Hermes 容器的内嵌进程跑的。插件帮你在容器里拉起一个 daemon,用本地文件当数据库,key 由插件注入,版本由插件管。

这套设计本身没问题,它省掉了”自己搭一套”的所有麻烦。但它有个代价我之前没算过——这两个东西的更新周期是绑死的,而它们本来不该绑在一起。

二、每次更新都要检查,这才是真成本

上面那四次故障,单次修复的时间其实都不长。真正消耗人的是另一件事:

每次 Hermes 更新,我都得去看一眼 Hindsight 还好不好。

这件事看起来很小,但它是个税。一个月里 Hermes 更新了五六次,我每次都要问一遍”它还活着吗”。而且这种检查是无法通过加监控消除的——你可以在它挂掉之后收到告警,但你没法在更新之前确认它不会被搞坏。

更麻烦的是故障的形态。它不是干脆利落地崩掉,而是静默降级:Agent 照常说话、照常用工具,只是不记得以前的事了。你不主动去 recall 一次,根本发现不了。

这就引出一个判断:当一个组件开始反复出故障,该停下来问的问题不是”这次怎么修”,而是”是不是形态选错了”。

我拖了三次故障才想到问这句话。

三、代码里写着:这两条路根本不一样

拆之前先读代码,确认拆分真的能解决问题,而不是把问题挪个位置。

Hindsight 插件支持三种模式:cloud、local_external、local_embedded。关键在这两行:

1
2
3
4
5
# __init__.py:944
self._new_embedded_client() if self._mode == "local_embedded" else self._new_cloud_client()

# __init__.py:81
_LOCAL_MODES = {"local", "local_embedded"}

注意 _LOCAL_MODES 里没有 local_external。

再看 _build_embedded_profile_env()——那个每次启动重写 6 个环境变量、把我的 key 覆盖掉、导致 401 攒了 1391 条的函数,它只在 _new_embedded_client() 里被调用。

而 _new_cloud_client() 是个纯 HTTP 客户端:base_url、timeout、user_agent、api_key,四个参数,一行 env 都不碰。

结论很干净:切成 local_external 之后——

  • 版本漂移:消失。服务端自己管自己的版本,客户端只是个 HTTP 消费者
  • key 被覆盖:消失。没有任何代码路径会去写那个文件
  • 容器更新拖垮 daemon:消失。daemon 根本不在容器里了

这三条正好对应我坏掉的一、二、四次。

四、搬的过程中踩的坑

拆本身不复杂:一个 docker-compose.yml,两个服务(应用 + PostgreSQL with pgvector),一个 bank 概念做逻辑隔离。麻烦全在细节上。

坑一:不能给容器指定 user。

我想让容器以固定 UID 跑,好让文件属主对齐,第一反应是写 user: "568:568"。这个写法会让容器直接退出:

1
2
[hermes] ERROR: container started with --user 568 — not supported.
NAS users (Synology / unRAID / UGOS) can use the PUID/PGID aliases

读 stage2-hook.sh 才明白:正确做法是传 PUID/PGID,让 s6 的启动 hook 去做 usermod 和目录 chown。这不是风格问题,main-wrapper.sh 里有硬性检测,uid 对不上就 exit 1。

坑二:HERMES_HOME 不写,数据全丢。

容器里 HOME=/opt/data/home,而 HERMES_HOME 不设时的默认值是 $HOME/.hermes——也就是 /opt/data/home/.hermes。

我的数据在 /opt/data。不显式写这一行,容器起来之后是个全新的空 Agent:没有人格、没有记忆、没有技能。

坑三:command 不写,跑出来的不是 gateway。

镜像的 main-wrapper.sh 有路由规则:无参数时 exec hermes,也就是交互式 chat。

少写 command: ["gateway", "run"] 这一行,你以为在跑服务,实际在跑一个等着你输入的 REPL。

坑四:面板绑 0.0.0.0 需要认证,这是硬性的。

想开 dashboard,绑了非 loopback 地址,进程一起来就退:

1
2
Refusing to bind dashboard to 0.0.0.0 — the auth gate engages on
non-loopback binds (0.0.0.0), but no auth providers are registered.

HERMES_DASHBOARD=true 和端口都设了,没用——那两个只是让 s6 愿意拉起服务,过不了认证门。必须配 BASIC_AUTH_USERNAME + PASSWORD。

判断它在崩溃循环的方法:s6-svstat /run/service/dashboard,看 PID 是不是一直在变(我看到的是 537 → 556)。

坑五,也是最丢人的一个:三颗星。

PostgreSQL 起不来,报:

1
FATAL: password authentication failed for user "hindsight"

我让助手核对配置,它发回来一行:

1
HINDSIGHT_API_DATABASE_URL=postgresql://hindsight:***@db:5432/hindsight

那三颗星是显示层的脱敏。但我照着抄进了 compose,于是密码真的变成了 ***。

这个坑的恶劣之处在于:配置看起来完全正确,格式对、字段对、位置对,只有密码是三个星号。而报错只说”认证失败”,不会告诉你密码长什么样。唯一能发现它的方式是意识到星号不该出现在文件里。

顺带一个真正的原因:POSTGRES_PASSWORD 只在 data 目录为空时生效。目录里已经有了用旧密码初始化的库,改环境变量是没用的,必须先把 pgdata 清掉。

五、内外置不是”有没有坑”,是坑在哪

搬完之后,有一套说法是”早知道一开始就这么做,能省掉中间所有问题”。这话只对一半。

对的那一半是架构决策:内外置的选择第一天就有答案,不用等三次故障。

不对的那一半是:今天踩的这五个坑,跟内外置没有关系。它们是容器化本身的成本——UID 映射、路径约定、CMD 路由、bind mount 所有权、数据库初始化时机。换个部署方式一样会遇见。

在内嵌形态下,这些成本由插件的 ensure_running 替你付了,你看不见。拆出来,等于把”别人替你处理”变成”你自己看见”。不是消除了成本,是换了个位置显现。

还有一层更实在的:今天写进 compose 的每一行,都是从具体故障里换来的。WORKER_ID 要固定(默认值是 hostname,容器重建就变,僵尸任务回收不回来)、RERANKER_MAX_CANDIDATES=50(默认 300 会超时)、RERANKER_LOCAL_TIMEOUT=90(兜底退回 RRF,不拖到客户端断连)。

上个月 Hindsight 刚跑起来那会儿,这些我一个都不知道。就算当时就拆,也只是把同样的坑在更早的时间踩一遍。

结论:什么时候该质疑形态

这次迁移真正的收获不是”Hindsight 独立了”,是一条判断标准:

同一个组件反复出故障,且故障根因都指向它的运行形态时,修它就是在交利息。

判断信号有三个:

  1. **故障集中在”环境”而非”逻辑”**——版本对不上、key 被覆盖、进程被别的进程的重启带走
  2. 每次别的组件更新,它都要被检查一遍——这个税无法通过监控消除
  3. 故障形态是静默降级——不报错,只是慢慢不好用,发现时已经坏了一周

满足两条以上,该做的不是继续查日志,是画一张新架构图。

顺便记一笔效果:内嵌 daemon 停掉之后释放了 2.3GB 内存,Hermes 容器的常驻占用降下来了。而它现在是个纯 HTTP 客户端,下次 Hermes 更新镜像,这个组件连”被影响”的机会都没有。

至于那两个记忆库,搬家的时候顺手改了名——雪乃的库叫 yukino,管股票那位叫 iroha。反正原来的库在误操作里已经被清空过一次,正好从零开始。

有些东西坏了是修,有些东西坏了是提醒你该换地方放了。分清楚这两种,比修得快更重要。