0x00 序※
前两篇记录了记忆引擎的进化:《Hermes 记忆引擎实践》选型了 Holographic 做本地记忆,《记忆引擎的自我进化》把它改造成了带 LLM 实体提取的 Seraph。这两步解决的是"记忆怎么进、怎么连成图谱"。
但有个不对称一直膈应着:记忆进图谱靠定时同步,图谱里的笔记却是"死"的——Hermes 这边写一条事实,30 分钟后同步脚本才把它镜像到 Trilium 展示;而我在 Trilium 里亲手写下的笔记,根本不进记忆引擎,没有被提取、没有实体、没有关系边,永远躺在角落里。
这篇文章记录的就是补上这条反向通道的全过程:实时、异步、防抖、防循环。
瓶颈:同步脚本的方向不对称※
已有的桥接方案是 hermes-memory-sync.py:每 30 分钟把 memory_store.db 的变更通过 Trilium 的 ETAPI 镜像成知识图谱节点,双向同步。它管的是"出"——记忆往 Trilium 长。
而"入"的方向几乎不存在:反向同步虽然能把 Trilium 内容写回 facts 表,但不做实体和关系提取。写进去的东西在记忆引擎里是"裸文本",图谱里没有边,检索时也召不回来。
这不是频率问题,是能力缺口。把 30 分钟改成 1 分钟也补不上——缺的是"Trilium 编辑触发 → LLM 提取 → 进记忆引擎"这条完整链路。
为什么不做"两侧直连"※
第一反应是让 Trilium 直接和记忆引擎通信,少一个中间组件。逐一排除后,三条路都是死路:
| 方案 | 死在哪 |
| Trilium 脚本直调 Hermes 进程 | Hermes 是对话循环不是服务,没有暴露记忆 API;每次编辑都唤醒 agent 也不现实 |
| Trilium 脚本直写 SQLite | LLM 实体/关系提取是 Python 独占逻辑(复用 Hermes 模型配置、fallback 链路),Node 裸写库 = 数据是死的;还违背"通过 API 不改库"的原则 |
| Trilium 自己调 LLM | 提取逻辑要写两份(Node 版 + Python 版),永远不同步,维护噩梦 |
跨语言通信的瓶颈从来不是网络,是提取逻辑只活在一侧。结论:单独组件,它本质是"翻译层 + 队列 + 单一写入口"——Node 的 JSON 翻译成 Python 的写入,队列住在一个不会随 Hermes 重启而消失的地方,所有进库的写都走这一个口。
架构:独立服务 + 异步队列※
最终结构:
- Seraph API 服务(
api_server.py):独立进程,监听 127.0.0.1:8787,Bearer token 鉴权。三个端点:POST /v1/memories(入队,202 即回)、GET /v1/queue/status、GET /health - 异步队列:
pending_extractions表,就在 memory_store.db 里(不新增数据库)。worker 线程后台轮询 → LLM 提取 → 写 facts/entities/关系 - Trilium 事件脚本:JS backend 脚本,通过
~runOnNoteContentChange(可继承)挂在图谱根上,子树内笔记内容变化即触发
为什么服务必须独立:Trilium 不依赖 Hermes 活着。Hermes 升级、重启、甚至关掉,记忆通道照常入库,等它醒来直接能用。
三个关键决策※
1. note_id 去重 = 天然防抖※
Trilium 编辑是高频的,脚本如果每次变化都提交,会打爆队列。传统解法是在脚本里写防抖逻辑。这里换了个思路:队列表 note_id UNIQUE,重复编辑冲突就 UPDATE,队列里永远只有最新一条。连续编辑 10 次,最终处理的只有第 10 次的完整内容。防抖逻辑从"前端脚本"移到了"存储约束",代码量归零。
2. 图谱根用属性定位,不硬编码※
Trilium 的树会动:目录会整理、根节点可能迁移。如果脚本里写死图谱根节点的 noteId,一次重构就失联。方案:图谱根带 #hermesKnowledgeGraph 标签,脚本触发时沿祖先链找这个标签——根在哪由属性决定,不由代码决定。
3. 循环防护:镜像节点自带免疫※
最怕的是死循环:记忆 → 同步 → 镜像节点 → 触发事件 → 又进记忆。解法很朴素:同步脚本建的镜像节点都带 #syncKey 标签,Trilium 事件脚本见 #syncKey 就跳过。实测确认:30 分钟同步跑完后,队列零新增、日志零回流。
踩坑实录※
三个坑,每个都花了一轮排查:
- mime 决定脚本可不可执行:ETAPI 建的 code 笔记 mime 写成
application/javascript,Trilium 报must be of type "Code: JS backend"。JS backend 脚本的 mime 必须是application/javascript;env=backend——多一个env=backend,决定了它跑在前端还是后端 - api.currentNote 不是触发者:事件脚本里
api.currentNote是脚本自身(执行上下文),被修改的笔记在api.originEntity。一开始用错了对象,脚本永远在检查自己,被自己的#memoryIgnore标签过滤掉——日志里一切正常,就是什么都不提交 - create-note 不触发内容事件:ETAPI 创建笔记(带内容)不触发
runOnNoteContentChange,PUT /content才触发。误打误撞成了循环防护的双保险——同步脚本建镜像用 create-note,天然不回流
验证:噪声治理见真章※
全链路验证通过后,特意用"脏数据"测了新提示词的排除效果——内容里故意放了 DNS 记录类型(A、CNAME)和端口号(8080):
| 提取结果 | 说明 |
| trilium / cloudflare / R2 / alpha 测试服务器(server)/ example.com | 5 个实体全部有意义,带正确类型(service/resource/server/domain) |
| A / CNAME / 8080 | 被 STRICT EXCLUSIONS 排除,不建实体;端口 8080 保留在关系里(uses_port),语义无损 |
| alpha → runs → Trilium 等 6 条关系 | 实体间直接关系正常建立 |
对比旧版提示词实测时提取出的 unknown 噪声实体(web/resource、server/system、alert 这类泛词),新版的排除效果是量级上的提升。
性能与成本:瓶颈在 LLM※
三层拆开看性能:存储层(SQLite,毫秒级)、传输层(localhost HTTP,可忽略)、LLM 提取层(实测单条从入队到落库约 3 秒,真瓶颈)。不管怎么集成,Seraph 每次写入都要跑实体 + 关系两次 LLM 调用,这一刀躲不掉。
异步队列的价值正在这里:编辑秒级入队(202 即回),LLM 提取后台消化,前端无感。代价是 Trilium 的写作量会变成 LLM 调用量——但原本写在 Trilium 里的内容本来就应该被提取,这笔钱是"本该花的",不是"多花的"。
0xFF 跋※
通道建好了,但"双向"还不完全对称:用户在 Trilium 里编辑镜像节点(记忆引擎同步过去的)目前不回流——刻意留的缺口,防止循环。将来可以加白名单:编辑带 #syncKey 的节点 = 主动改记忆,走同一队列,冲突用时间戳裁决。
另一个方向是把这个服务通用化:现在它叫"Trilium 记忆桥",但接口就是 POST /v1/memories。任何编辑器、脚本、agent 都能用同一个口喂记忆。记忆引擎从"Hermes 的私有大脑"变成了"可接入的外部服务"——这可能才是它真正的形态。
参考资料※
- TriliumNext/Notes — Trilium 笔记系统
- Trilium ETAPI 文档
- Trilium 事件系统文档(runOn 系列事件)
- tsaitang404/seraph-memory — Seraph 记忆插件 + 记忆服务
- NousResearch/hermes-agent — Hermes Agent