记忆系统:跨会话持久化的工程实现

Hermes 如何用记忆快照 + 外部 Provider 让 Agent 拥有「长期记忆」

系列:通过 Hermes 探秘 Agent 工程 | 第 5 篇 上一篇:上下文压缩:让 Agent 在有限窗口里「记得住」


为什么 Agent 需要「记忆」

Agent 有一个根本性缺陷:每次会话都是全新开始。如果不做额外处理,你上次告诉 Hermes「我用了 Node.js 22」,这次它又问你「用哪个 Node 版本」——因为它对你一无所知。

系统提示词的 Volatile 层包含了记忆快照(比如「用户偏好:简洁回复」),但那只是「这个会话内」的快照。一旦会话结束、新开会话,这些内容就完全没了。

Hermes 的记忆系统解决的问题就是:把跨会话的信息持久化下来,并在每次对话开始时注入到系统提示词里——让 Agent「记得你」。


内置记忆:两个文件,没有固定 key

Hermes 的内置记忆不是状态数据库里的 key-value 对,而是两个纯文本文件:

~/.hermes/memories/MEMORY.md   ← Agent 的个人笔记
~/.hermes/memories/USER.md     ← 关于用户的信息

每个文件的内容就是一段由 § 分隔符分割的自由文本列表,没有任何硬编码的 key

用户偏好用中文回复,风格简洁直接
§
当前项目使用 Vue 3 + FastAPI,数据库是 PostgreSQL 15
§
部署流程必须经过 DBA 审批,不能直接操作生产库

§ 就是分隔符。没有 schema、没有 key 约束、没有枚举——Agent 写什么就是什么。

记忆工具 memory 只有两个 target:

{"enum": ["memory", "user"]}
  • memory = Agent 自己的笔记(写入 MEMORY.md)
  • user = 关于用户的信息(写入 USER.md)

存储规则是严格的——tool schema 描述里明确告诉模型什么不该存:

  • ❌ 任务进度(「PR 已提交」)
  • ❌ 临时 TODO(「今天要做 X」)
  • ❌ 7 天后会过时的信息

只保存"能让未来少问用户一次"的事实。

每个文件有独立的字符上限:MEMORY.md 2200 字符,USER.md 1375 字符——用字符数限制而非 token,是因为字符数与模型无关。

外部记忆 Provider:不是 LLM,是外部服务

Hermes 支持接入外部记忆 Provider(Honcho、Hindsight、Mem0、SuperMemory 等),但Provider 不是 LLM——它们是独立的外部服务:

  • Honcho:基于嵌入向量的语义匹配
  • Hindsight:基于会话的观察者模式
  • Mem0:向量化记忆存储

Hermes 只允许同时注册一个外部 Provider(通过 MemoryManager.add_provider() 注册),防止工具 schema 膨胀和记忆语义冲突。内置存储始终可用,不依赖外部 Provider。


记忆怎么加载到对话里

这是最关键的问题:不同类型的记忆有不同的加载时机和注入位置。

内置静态记忆 → 会话启动时写入 system prompt

会话开始 → MemoryStore.load_from_disk()
         → 读取 MEMORY.md 和 USER.md
         → 生成冻结快照 _system_prompt_snapshot
         → 注入到 Volatile 层

注入位置是系统提示词的 Volatile 层。因为 load_from_disk() 调用一次后结果缓存在 _system_prompt_snapshot 里,整个会话期间不再变化——这是 Prefix Caching 的要求。

注意:内置记忆的注入位置是 system prompt,不是 user message。 它作为系统提示词的一部分,在每次 API 调用时携带,多次调用之间内容不变。

外部 Provider 动态记忆 → 每轮开始注入 user message

每轮 API 调用前 → MemoryManager.prefetch_all(user_query)
                → 外部 Provider 执行语义匹配
                → 返回匹配到的文本
                → build_memory_context_block() 用 <memory-context> fence 包装
                → 注入到当前轮用户消息的末尾

关键代码路径(conversation_loop.py:770-781):

if idx == current_turn_user_idx and msg.get("role") == "user":
    if _ext_prefetch_cache:
        _fenced = build_memory_context_block(_ext_prefetch_cache)
        _injections.append(_fenced)
    # 注入到用户消息末尾
    api_msg["content"] = _base + "\n\n" + "\n\n".join(_injections)

注入位置是用户消息,不是系统提示词。这样做的关键原因是:用户消息每次都重新发送,不会破坏前缀缓存。如果把外部记忆注入 system prompt,会让系统提示词变化,导致既有缓存全部失效。

所以两套记忆走的是完全不同的加载路径:

来源加载时机注入位置生命周期
内置 MEMORY.md / USER.md会话启动一次Volatile 层(system prompt)整个会话不变
外部 Provider prefetch每轮 API 调用前当前轮 user message每轮重新查询

sync_all 不是"写入记忆"

sync_all() 每一轮后都被触发,但它不改变内置存储。它只是一个通知机制,告诉外部 Provider「这轮对话发生了什么」——如果 Provider 需要异步写入自己的后端(比如向量化到向量数据库),它可以在写入完成后再处理。

唯一改变内置存储的是 memory 工具——Agent 调用 memory(action="add") 直接修改 MEMORY.md 文件。


记忆的生命周期

Hermes 的记忆不是一次性写入就完事了——它有完整的生命周期:

1. 写入:memory 工具调用

memory(action="add", target="user", content="用户偏好用中文回复")
memory(action="replace", target="memory", old_text="旧的偏好", content="新的偏好")
memory(action="remove", target="memory", old_text="需要删除的记忆")

内置存储直接修改 state.db。外部 Provider 收到 on_memory_write 钩子调用,把这次变更异步同步到自己的后端。

2. 读取:system prompt 注入 + prefetch

每次 API 调用前,Hermes 会调用所有已注册的 Provider,把它们的 prefetch() 结果汇聚成一段记忆文本。

注意这是两层记忆读取:

层次来源注入位置时机
静态记忆~/.hermes/MEMORY.md + ~/.hermes/USER.mdVolatile 层(固定文本)会话启动时构建一次
动态记忆memory_manager.prefetch_all(query)<memory-context> fence 块每轮 API 调用前

静态记忆(内置存储)走系统提示词的 Volatile 层,一天内多次调用不变化。动态记忆(外部 Provider)走每轮 prefetch,内容根据当前用户输入实时变化。

3. 更新:sync_turn 钩子

每一轮 API 调用完成后,Hermes 触发:

agent._memory_manager.sync_all(
    user_content=user_message,
    assistant_content=assistant_response,
    session_id=agent.session_id,
    messages=messages  # 完整的 OpenAI 格式消息
)

但注意:这不是 Agent 主动写的记忆。sync_all 是给外部 Provider 用的同步钩子——内置存储并不会因此被修改。

内置记忆写入只有一个触发路径:Agent 通过 memory 工具显式调用 memory(action="add", ...)——这是 Agent 的主动决策,不是自动同步。

4. 跨会话持久化:session_switch

每次 /new/resume、上下文压缩等 session 切换时:

agent._memory_manager.on_session_switch(
    new_session_id,
    parent_session_id=old_session_id,
    reason="compression"
)

外部 Provider 使用这个钩子来刷新缓存的 session 状态——它们需要知道"现在切换到哪个 session 了",以便把新的记忆写入正确的 session 记录。


内置存储 vs 外部 Provider:分工清晰

内置存储(~/.hermes/MEMORY.md + ~/.hermes/USER.md):

  • 存 key-value 对
  • 读写直接由 memory 工具触发
  • 不需要网络、不需要 LLM
  • 会话启动时读取一次 → Volatile 层 → 多次调用复用

外部 Provider(Honcho/Mem0/Hindsight 等):

  • 存向量/嵌入/观察日志
  • 通过 prefetch() 每轮实时查询
  • <memory-context> fence 注入
  • 可以跨用户、跨 session 积累语义记忆

记忆系统不是"非此即彼"——两者同时工作,且互相独立。外部 Provider 挂了,内置的 Volatile 层记忆照常运行;没有外部 Provider,内置的也够用。


与上下文压缩的关系

内存系统和记忆系统的交叉点上下文压缩上。

每次触发压缩时,Hermes 会在压缩流程开始之前调用:

compression_insights = agent._memory_manager.on_pre_compress(messages)
if compression_insights:
    # 把 Provider 提供的洞察注入摘要模型的 prompt

外部 Provider 用这个钩子告诉摘要模型"这部分信息要保留"——比如用户之前特别强调的技术选型、某个长期追踪的问题等。

压缩完成后,生成的新摘要通过 _invalidate_system_prompt() 标记为需要重建。下次 API 调用时,Volatile 层的记忆会被重新注入到新 summary 之后——包括:

  1. 内置 MEMORY.md 的内容(原样)
  2. Provider prefetch() 的结果(根据压缩后的对话重新查询)

工程启示

1. 记忆不是"采集一切"

记忆越多≠越好。Hermes 在系统提示词里明确告诉模型"不要保存任务进度、不要保存 temp TODO、7天内会过时的信息不要保存"——因为这会导致系统提示词膨胀,反而让模型更难定位真正有用的信息。

2. 内置存储负责高频写入,外部 Provider 负责语义检索

内置存储走 memory 工具→数据库→Volatile 层,是"我知道有什么记忆"。外部 Provider 走语义匹配→prefetch→fence injection,是"我不知道但根据当前输入应该查什么"。两条路径互不干扰。

3. 记忆注入要做「安全围栏」

Hermes 对外部 Provider 返回的内容用 <memory-context> 标签包裹,明确标注"这是回忆的新记忆,不是用户输入"。这个防护措施防止了记忆内容被误认为新的用户指令。如果没有这个围栏,恶意构造的记忆内容可能覆盖用户的真实意图——这就是记忆注入的 prompt 注入风险。

4. 记忆是分层注入的

当前 Hermes 的记忆分四层协同工作:

  1. 内置静态记忆(MEMORY.md)→ 会话级固定
  2. 内置动态记忆(state.db KV)→ 会话级固定
  3. 外部 Provider prefetch → 每轮动态变化
  4. 压缩摘要(上一篇文章的产物)→ 若干轮后逐渐失效

最终组成了一段"历史 + 现在 + 跨会话事实"混合而成的 Volatile 层——每一层都有不同的生命周期、不同的更新时机。


总结

Hermes 的记忆系统不是简单的 key-value 存储,而是一套多层协同的工程方案:

  • 内置存储提供零依赖的关键事实持久化
  • 外部 Provider提供语义级别的记忆检索
  • 静态注入保证稳定的跨会话上下文
  • 动态 prefetch保证当前对话的相关性
  • 记忆围栏防止 Prompt 注入风险
  • sync/pre_compress 钩子保证 Provider 不会在错误的时机写入

这个设计的本质是:把 Agent 从「无状态对话」变成「有状态服务」。每次会话开始时看到的不是空白画布,而是基于历史积累的上下文——这让 Agent 表现得更"懂你"。

下一篇,我们将深入 Hermes 的工具调度系统——Agent 如何在调用 LLM 之前发现哪 50 个工具可用、如何在运行时动态加载/卸载工具集。