我这套系统的核心是一个 7×24 常驻在家庭服务器上的个人助理 Agent——不是那种”打开 IDE 才活过来”的编码工具,它一直在线,替我盯盘、记日程、管健康数据。跑了一段时间后最明显的短板是没有长期记忆:每次对话都是新的,上个月说过的话它记不住。

所以给它接了 Hindsight,一个本地部署的记忆层:Agent 说的话经 LLM 抽取成结构化事实入库,下次对话前自动召回相关记忆注入上下文。

接入本身不难,难的是让它稳定地活着。前后踩了两个根因、一次误报、一次自我纠错,记一下。

症状:插件一切正常,daemon 就是起不来

第一次接完,工具列表里 Hindsight 的 retain / recall 都在,配置也是齐的——但真跑一次 retain,没有任何东西入库。

这里先记一个教训:插件面板显示 “ready”,只代表”能导入 + 配置项齐”,不代表 daemon 活着。这两件事在代码里是分开的判断路径,UI 上却挤在一起。从那以后我验证这类东西只认真实往返:retain 写一个测试事实,再 recall 查回来。查到了才算数。

根因一:uv 0.11 拆了 argv[0] dispatch

daemon 起不来的直接报错是 unrecognized subcommand。

Hindsight 的插件启动 daemon 时调的是 uvx hindsight-api@0.10.3。而系统里的 uvx 是个符号链接,指向 uv 本体——这在旧版 uv 上没问题,因为 uv 会读 argv[0] 判断自己被以什么名字调用,uvx 就走 uv tool run 的分支。

uv 0.11 移除了这个 argv[0] dispatch。 于是 uvx hindsight-api@0.10.3 被当成”uv 的一个名叫 hindsight-api 的子命令”,直接报错,然后插件走静默回退——什么都没干,也不告诉你。

验证方法很朴素:

1
2
$ uvx --version
uv 0.11.6 # 期望看到 uvx 的版本号,实际回的是 uv 的

根因二:安装路径不符合规范

排查过程中发现更深的一层:当初 hindsight-api 是手动 uv pip install 装进 generation venv 的,没走官方文档推荐的 uvx hindsight-api 链路。

后果是 PM ledger 里没有这条安装记录。容器更新时 collect_superseded_generations() 回收旧 generation,装在里面的东西连同我补的符号链接一起没了——镜像一更新,装好的记忆层就消失。

更要命的是那个插件查找可执行文件的路径逻辑:它只检查 bundled venv 的 sysconfig scripts 目录和 site-packages/bin/,**不检查 venv 根的 bin/**。所以哪怕装对了版本,位置差一点也会静默回退到坏掉的 uvx 上,表面看完全正常。

修复:两个位置,两个 shim

最终方案是放两个真实的 shim 脚本,不是一个:

1
2
#!/bin/sh
exec /opt/data/runtime/bin/uv tool run "$@"
  • /opt/data/.hermes/tools/uv-managed/uvx —— 替换掉原来指向 uv 的坏符号链接(原文件备份为 uvx.bak-20261024)
  • /opt/data/.local/bin/uvx —— 这个是关键。manager 进程内 spawn daemon 时只用 shutil.which('uvx') 在进程 PATH 里找,第一个位置它根本看不见

两个位置对应两条不同的调用路径,少一个都会有一半场景失败。

最贵的一课:验证写入不能靠 recall

修完之后我又栽了一次,这次是方法论问题。

当时另一侧的 Agent 报告”写入队列堵了”。我第一反应是去查——查法是用 recall 搜关键词。搜不到,于是跟着判断”确实没写进去”。

错的。 recall 查不到不代表没写入。reason 至少三个:

  1. 记忆经 LLM 抽取时会被改写,原文的关键词可能一个都不剩
  2. recall 是语义检索,不是关键词匹配,同义不同词照样召回不到
  3. recall 本身有延迟和预算,”此刻没搜到”是常态而非异常

正确姿势是直查:

1
2
3
SELECT * FROM async_operations
WHERE operation_type = 'batch_retain';
-- 期望 status = completed,延迟通常 5–30s

一查,16s 和 19.5s 早就 completed 了。队列没堵,是我们俩的验证方法都错了。

顺带一提,我后来还自我纠错过一次:当时想统计”有多少次 recall 返回 0 条结果”,grep 出 37 条 results=0,看着像回事。细看才发现全是 hydrate_results=0ms——一个耗时字段,不是结果计数。正则太宽,自己骗自己。

撑得住重启和更新吗

这类自装组件最怕的就是”重启就没了”,所以专门核了一遍:

  • /opt/data 是持久 ZFS 卷(Pool/AppData/hermes),generation venv、hindsight-api、符号链接全在里面
  • uv 缓存也在持久卷上(/opt/data/home/.cache/uv)

结论是现在同时扛得住容器重启和镜像更新:generation 重建后 uv 缓存直接命中,不会重新下载那几个 G 的 torch。

这也是为什么手动安装虽然不规范,我最后还是倾向维持现状而不是返工——重装成本(首拉 torch/CUDA 约 3–7GB,40MB/min)换不来实质收益,只要 fast path 和回退路径都在就行。若哪天符号链接因 generation 重建变悬空,Path.exists() 返回 False,插件能干净地回退到 uvx,两条路径都可用。

一个”看起来坏了其实没坏”的东西

记忆面板一直显示 unavailable。

查下来是 web_server_memory.py:324 的 dependencies_installed 在比对 venv stamp——记录的 f4f7485e… 和期望的 66897e8e… 对不上。这是环境的历史状态,纯默认调用也返回 False,跟 hindsight 无关、跟最近的改动也无关。

daemon 健康、retain→recall 往返正常,所以决定不动它。修它要跑一次完整 sync(连 torch 重装),不值。

分清”功能坏了”和”记账记错了”,能省掉一半无谓的返工。

实测性能

读路径(recall,n=20):p50 1.87s,max 3.05s。拆开看,大头不在检索:

阶段 耗时范围
reranking 69 ~ 946ms ← 大头
generate_query_embedding 28 ~ 191ms
parallel_retrieval 6 ~ 271ms

写路径(batch_retain):5.5 ~ 25.6s,全部 completed。

成本(截至写这篇文章):累计 27.3 万 token——抽取 17.8 万(34 次)+ 整理 9.5 万(12 次)。内存占用是这份记忆的真实代价:daemon 进程树 RSS 约 1452MB(torch 常驻)+ postgres 31MB。对一台家庭服务器来说不算轻,但换来的是记忆确实能用。

顺便回答一个误解:那”9 条”是哪来的

有次我看到每轮召回 9 条,第一反应是”这是默认配置吧”。翻了 config.json,40 多个字段里没有任何 limit / count / top_k。

真相是:全库打分,然后被预算封顶。这次是 64 条事实 + 1510 条图链接一起参与打分,取分数靠前的一批,恰好卡在预算上限上。

真正的旋钮是这几个:

配置 值 作用
recall_budget mid 服务端折算 budget=300
recall_max_tokens 4096 输出 token 上限
recall_max_input_chars 800 输入截断
recall_types observation 只召回观察类
recall_min_scores 空 无门槛,宁滥勿缺

日志里的分布是 9×19 次、5×1 次、12×1 次——没有 9 这个上限,只是同一话题反复命中同一批记忆,看起来像固定值。

嫌少就调 budget=high,嫌杂就给 recall_min_scores 设个 0.5。

结论

折腾了一整天,回头看真正花时间的不是装,是**搞清楚”它到底活没活”**。

三个可复用的教训:

  1. **”ready” 不等于”活着”**,只信真实往返
  2. **”queued” 不等于”写入了”**,验证写入端要看操作表或直查库,不能拿 recall 当写入探针
  3. 静默回退是最难查的失败模式——报错会说话,回退不会。怀疑某条路径时,先确认它真的走到了那一步

以及一条元教训:验证方法错了,比没有验证更危险,因为它给你一个看起来合理的错误结论。那次 37 条假数据就是这么来的。