同一个组件坏三次,我把它搬出去了
上个月写过一篇给常驻 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 | |
注意 _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 | |
读 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 | |
HERMES_DASHBOARD=true 和端口都设了,没用——那两个只是让 s6 愿意拉起服务,过不了认证门。必须配 BASIC_AUTH_USERNAME + PASSWORD。
判断它在崩溃循环的方法:s6-svstat /run/service/dashboard,看 PID 是不是一直在变(我看到的是 537 → 556)。
坑五,也是最丢人的一个:三颗星。
PostgreSQL 起不来,报:
1 | |
我让助手核对配置,它发回来一行:
1 | |
那三颗星是显示层的脱敏。但我照着抄进了 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 独立了”,是一条判断标准:
同一个组件反复出故障,且故障根因都指向它的运行形态时,修它就是在交利息。
判断信号有三个:
- **故障集中在”环境”而非”逻辑”**——版本对不上、key 被覆盖、进程被别的进程的重启带走
- 每次别的组件更新,它都要被检查一遍——这个税无法通过监控消除
- 故障形态是静默降级——不报错,只是慢慢不好用,发现时已经坏了一周
满足两条以上,该做的不是继续查日志,是画一张新架构图。
顺便记一笔效果:内嵌 daemon 停掉之后释放了 2.3GB 内存,Hermes 容器的常驻占用降下来了。而它现在是个纯 HTTP 客户端,下次 Hermes 更新镜像,这个组件连”被影响”的机会都没有。
至于那两个记忆库,搬家的时候顺手改了名——雪乃的库叫 yukino,管股票那位叫 iroha。反正原来的库在误操作里已经被清空过一次,正好从零开始。
有些东西坏了是修,有些东西坏了是提醒你该换地方放了。分清楚这两种,比修得快更重要。