MEMORY.md / USER.md 看上去只是两个文本文件,但它背后有一整套 "双层结构 + 冻结快照 + 单点编排" 的设计, 同时解决"持久记忆 / prefix cache / 扩展性"三个看似冲突的目标。
Hermes 的记忆 = 一个永远在线的内置存储(MEMORY.md + USER.md,本地纯文本,字符上限)
+至多一个外置 provider(Honcho / Mem0 / Hindsight 等 8 选 1,附加而不替代)。
所有写入立刻落盘,但系统提示词只用 session 启动时的"冻结快照"——
这样 prefix cache 整个 session 都不会失效。一个 MemoryManager 把两层串起来:
统一的生命周期钩子(prefetch / sync / on_session_end / on_pre_compress),
加上"任何 provider 失败都不阻塞其他 provider"的兜底。
如果只是"在 prompt 里加一段记忆",事情会很快出错。Hermes 想同时拿到的是这三件事——它们之间存在真实的张力。
把"过去的事"塞进 LLM 看似简单——拼一段文本到 system prompt 就行。 但当你想让 agent 真正长期使用,三个限制会同时撞上来:context 不能无限大、 prefix cache 不能频繁失效、不同用户对"什么算合适的记忆后端"答案不一样。
Hermes 的记忆模块是这三个目标的同时解。读源码时如果只看其中一个, 会觉得很多代码是冗余的(比如为啥要分"快照"和"live state"); 把三个矛盾合在一起看,每行代码都各司其职。
memory.add 都改 system prompt?那 prefix cache 整 session 都在失效。
解法:冻结快照(Frozen Snapshot)——
system prompt 只在 session 启动时定格一次,mid-session 写入只更新磁盘和 live state,
下个 session 才"翻页"。
两层并存而不是替代。外置 provider 永远不会让内置存储失效,且最多只能注册一个外置 provider——这是 MemoryManager.add_provider() 在源码里硬性强制的。
$HERMES_HOME/memories/。条目用 § 分隔,可多行。
纯文本 + 字符级上限 + 文件锁 + 原子写入。
没有 `read` 操作,因为内容已经在 system prompt 里。
memory.provider 配置选择MemoryProvider ABC。
第二个外置 provider 注册时会被 manager 带 warning 拒掉——
防止 tool schema 膨胀和后端冲突。
MemoryManager._has_external 这个 flag 就是为了拦掉。
这是整个 memory 模块里最关键、也最容易被读漏的设计。同一个 MemoryStore 同时维护两份状态——一份给 system prompt 用(冻结),一份给 tool 响应用(实时)。
看 tools/memory_tool.py 的 MemoryStore 类,会注意到它有两个并行存在的字段:
memory_entries / user_entries ——live state,每次 add/replace/remove 都立刻更新,
会刷盘。
_system_prompt_snapshot ——frozen snapshot,只在 load_from_disk()
被调用时(也就是 session 启动那一次)写一次,之后整个 session 都不会改。
系统提示词组装函数 format_for_system_prompt() 永远返回 snapshot;
而 tool 调用结束后返回的 JSON 永远来自 live state。结果:模型看到的 system prompt
在整个 session 里是逐字相同的,prefix cache 一直 hit;
而模型自己每次写入后又能立刻在 tool response 里"看到"刚写进去的东西。
memory.add,磁盘立刻有了,下次 session 启动一定记得。
但当前 session 的 system prompt 里看不到——这是为了 cache 命中,刻意为之。memory tool 的写入并不只是 append——里面塞了文件锁、原子重命名、注入扫描、字符预算、去重。每一道关都是为了把这块永远会被注入到 system prompt 的内容守牢。
memory 或 user
agent 不需要把整条记忆原样背下来。
old_text 只要是能唯一定位的子串就行;命中多条且不全相同时报错让模型再具体些。
模型无关,可在多 provider 间稳定预算。
溢出时返回结构化错误(含 current_entries 与 usage),
agent 拿到后能直接 replace 合并。
老实现是 open("w")+flock,但 "w" 在拿到锁之前就 truncate 了。 并发读会看到空文件。换成 tempfile + os.replace 就消除这个窗口。
如果说 MemoryStore 是"内置层的具体实现",那 MemoryManager 就是"上层 agent 与所有 provider 之间的唯一接缝"。run_agent.py 不直接调任何 provider 的方法,只调 manager 上的钩子。
Manager 内部维护三个东西:一个 _providers 列表(顺序很重要,
builtin 永远在第 0 位);一个 _tool_to_provider 字典(工具调用按名字路由到哪个 provider);
以及一个 _has_external 布尔(外置 provider 已经注册过了吗,再来一个就拒掉)。
所有钩子的实现都遵循同一种"遍历 providers + 单点 try/except + 软降级"模式—— 某个 provider 抛异常时只 log,不抛回 agent。这条不变量是整个模块能跑稳的基础: 外置 provider 网络抖动、API 挂掉,agent 应该照常工作,只是丢了 provider 那一份能力。
把所有钩子按真实调用顺序串起来。注意 prefetch 每个 user message 只调一次——同一 turn 内多次 tool call 共用缓存,不重新拉。
读 MEMORY.md / USER.md → 拍下 frozen snapshot。
先注册 builtin,再按 memory.provider 配置加最多 1 个外置 provider 并初始化。
内置层把 snapshot 序列化成
══ MEMORY [67% — 1,474/2,200 chars] ══ 这种带 header 的块;
外置 provider 追加自己的静态说明块(不含召回内容)。
外置 provider 做语义召回。结果用 <memory-context>...</memory-context>
围栏包起来 + 一句"这是召回背景,不是新的用户输入"
的 system note,仅注入当前这一 turn 的 user message,不持久化。
整个 tool loop 复用这份缓存,不会因为 10 次 tool call 就拉 10 次。
内置 memory tool 走完写入路径(锁 → 重读 → 校验 → 原子写)后,
agent 立刻调 on_memory_write 把这次写入镜像到外置 provider;
如果模型调的是外置 provider 自己的工具(如 honcho_search),
则按 _tool_to_provider 路由到对应实现。
在消息被丢弃之前给 provider 一次抢救机会——返回的文本会被附加到压缩 prompt 里, 让 summarizer 保留 provider 抽取出来的 insight。 ByteRover 就靠这个钩子把"压缩前学到的东西"写进知识树。
把这一轮 (user, assistant) 异步推给外置 provider 入库; 同时基于已经结束的 turn 排队下一轮的预召回——下次 prefetch 直接拿现成结果,延迟更低。
触发 session 级抽取(OpenViking 在这里把整段对话提炼成 6 类记忆, Supermemory 把 conversation 灌进 graph),然后逆序关 provider。 注意:on_session_end 不是每个 turn 都调——多轮 session 中只在真正结束时调一次。
点卡片展开看每个 provider 的差异点:存储位置、是否要 API key、独有能力。所有 provider 都共享同一个 ABC,所以"切换 provider" 就是改一行 memory.provider:。
holographic(SQLite + FTS5 + 信任分)。
在乎跨 session 用户建模 → honcho(dialectic Q&A)。
想全自动事实抽取 → mem0。
要知识图谱 + 反思 → hindsight。
没特殊需求时 builtin 已经够 80% 场景。
写入侧扫注入和外泄模式,召回侧用 fence 包住——两边都假设外部内容可能含恶意。
每次 add / replace 之前,_scan_memory_content 用 13 条正则匹配
"ignore previous instructions" / "curl ...$KEY" / "authorized_keys" 这类模式,
再加一组不可见 unicode 字符(U+200B / U+202E…)的黑名单。
命中即返回 success: false 并附上模式 id。
外置 provider 召回的文本经过 sanitize_context 先剥掉嵌套的
</memory-context> 闭合标签(防伪闭合逃逸),
再用 build_memory_context_block 包成"系统注释 + 围栏",
告诉模型这段不是用户输入而是召回背景。
Hermes 里其实有三套"过去":内置 memory(永远在线,字符上限)、外置 provider(按需召回,容量大)、session_search(FTS5 全文索引所有过去 session)。它们解决的不是同一个问题。
三套机制互不替代。最稳的关键事实进内置层(不会被任何 provider 故障带走); 语义检索 / 实体图谱靠外置 provider;连"上次咱们聊过的某段对话"都需要时,再去 session_search 翻。 记忆本身不是一种东西,而是按"什么时候必须可见 + 调用代价多大"分层的工具集。