这一章讲 Hermes 怎么「跨会话记住事情」。它由三部分组成:一个可插拔的提供者接口、一个内置的全息记忆实现、以及一个 SQLite 状态层。
「Memory providers give the agent persistent recall across sessions. The MemoryManager enforces a one-external-provider limit to prevent tool schema bloat and conflicting memory backends.」
译:记忆提供者让智能体拥有跨会话的持久回忆能力。MemoryManager 强制「只能有一个外部提供者」,以防止工具 schema 膨胀和记忆后端互相冲突。
| 理由 | 说明 |
|---|---|
| 工具 schema 膨胀 | 每个记忆提供者都可以向模型暴露自己的工具(get_tool_schemas())。装 3 个就有 3 套「搜索记忆」「写入记忆」工具 —— 模型会困惑该用哪个,而且每套都占常驻 token |
| 后端冲突 | 两个提供者各自维护一份「用户是谁」的模型,可能互相矛盾。而且写入时该写哪个?读取时听谁的?没有正确答案 |
所以记忆提供者和上下文引擎一样,属于「互斥策略」而非「可叠加能力」。这个区分在第 9 章会展开。
plugins/memory/
├── holographic/ ★ 内置:HRR 全息记忆(见 8.3)
├── honcho/ 外部服务
├── hindsight/ 外部服务
├── mem0/ 外部服务
├── byterover/ 外部服务
├── openviking/ 外部服务
├── retaindb/ 外部服务
├── supermemory/ 外部服务
├── query_rewrite.py 查询改写(通用辅助)
└── config_schema.py
"""
Lifecycle (called by MemoryManager, wired in run_agent.py):
initialize() — connect, create resources, warm up
system_prompt_block() — static text for the system prompt
prefetch(query) — background recall before each turn
sync_turn(user, asst) — async write after each turn
get_tool_schemas() — tool schemas to expose to the model
handle_tool_call() — dispatch a tool call
shutdown() — clean exit
Optional hooks (override to opt in):
on_turn_start(turn, message, **kwargs) — 每轮的时钟滴答,带运行时上下文
on_session_end(messages) — 会话结束时的提取
on_session_switch(new_session_id, **kwargs) — 进程中途的会话 ID 轮转
on_pre_compress(messages) -> str — ★ 上下文压缩前的提取
on_memory_write(action, target, content, metadata=None)
— 镜像内置记忆的写入
on_delegation(task, result, **kwargs) — 父侧观察子智能体的工作
backup_paths() -> list[str] — 备份时要包含的额外磁盘路径
"""
hermes-agent/agent/memory_provider.py
on_pre_compress(messages) -> str(压缩前提取)是整套设计里最关键的一个:
on_delegation(task, result)(委派观察)解决的是:子智能体的工作过程不在父的上下文里(这正是子智能体的价值,见第 10 章)。但子智能体可能发现了值得长期记住的事实。这个钩子让父侧的记忆系统能观察到子任务的输入和结果。
backup_paths()(备份路径)是一个很实在的运维考虑:hermes backup 命令需要知道记忆提供者把数据存在哪些额外的磁盘位置,才能完整备份。
# Version 1 is the historical, implicit contract every provider is already
# on: best-effort on_pre_compress() with the raw message list. Version 2 is
# the opt-in fail-closed checkpoint contract (normalized evidence handoff +
# strict-mode failure propagation).
PRE_COMPRESS_CHECKPOINT_API_VERSION = 2
译:版本 1 是历史上的隐式契约,每个已有的提供者都在用:拿到原始消息列表、尽力而为地做 on_pre_compress。版本 2 是可选加入的「失败即闭合」检查点契约(归一化的证据交接 + 严格模式下的失败传播)。
| 版本 1(尽力而为) | 版本 2(失败即闭合) | |
|---|---|---|
| 输入 | 原始消息列表,提供者自己解析 | 归一化的证据交接 —— 宿主先整理好格式 |
| 提取失败时 | 静默继续,压缩照常进行 → 数据就这么丢了 |
失败会传播上去,严格模式下会阻止压缩 → 宁可不压缩,也不丢数据 |
为什么这个升级是必要的:版本 1 有一个静默数据丢失的风险 —— 记忆提取失败了(网络问题、服务宕机),但压缩照常执行,于是那批消息的原文和提取结果同时消失。而且没有任何人会发现。
版本 2 把它变成显式失败:要么提取成功再压缩,要么就别压缩。
而用一个「版本号常量」而不是直接改接口,是为了让老提供者继续能跑。提供者声明自己支持哪个版本,宿主据此选择调用方式。
这是一个很小但很实用的优化:
# Prompts that carry no semantic signal — trivial acknowledgements, greetings,
# slash commands, empty input. Single source of truth shared by the core
# per-turn prefetch gate and provider-side classifiers so the two can never
# drift apart.
TRIVIAL_PROMPT_RE = re.compile(
r'^(yes|no|ok|okay|sure|thanks|thank you|y|n|yep|nope|yeah|nah|'
r'hi|hey|hello|yo|sup|'
r'continue|go ahead|do it|proceed|got it|cool|nice|great|done|next|lgtm|k)'
r'[\s!?.:;,"\'~…—–()\[\]{}<>*&^%$#@!+=` ]*$',
re.IGNORECASE,
)
def is_trivial_prompt(text: Optional[str]) -> bool:
"""Callers use this to skip memory-provider prefetch/injection on turns
that carry no semantic signal — saving a blocking network round-trip
and preventing stale user-model context from derailing one-word replies."""
if not text: return True
stripped = text.strip()
if not stripped: return True
if stripped.startswith("/"): return True # 斜杠命令
return bool(TRIVIAL_PROMPT_RE.match(stripped))
用户说「好的」「谢谢」「继续」这类话时:
「The alternation is anchored and may only be followed by whitespace or punctuation, so words that merely START with a trivial word ("k8s", "yolo", "note", "hindsight") do NOT match, while trailing-punctuation variants ("hi!", "hey.", "thanks :)", "done???") do.」
译:这个选择分支是锚定的,后面只允许跟空白或标点,所以那些「仅仅以琐碎词开头」的词("k8s"、"yolo"、"note"、"hindsight")不会匹配,而带尾部标点的变体("hi!"、"hey."、"thanks :)"、"done???")会匹配。
| 输入 | 判定 | 为什么 |
|---|---|---|
ok / thanks :) / done??? | 琐碎 | 整句就是一个确认词加标点 |
k8s 集群怎么配 | 不琐碎 | 虽然以 k 开头,但后面跟的是字母不是标点 |
note this down | 不琐碎 | 同上,no 后面跟的是 te |
hindsight 那个服务 | 不琐碎 | hi 后面跟的是 ndsight |
注意注释里提到的「单一真相来源」:这个正则被核心的每轮预取闸门和提供者侧的分类器共享,「so the two can never drift apart」(这样两者永远不会漂移)。如果各自实现一份,迟早会出现「核心认为琐碎、提供者认为不琐碎」的不一致。
INDICATOR_GLYPH = "🧠" # 默认字形;各提供者可以用自己的品牌标记覆盖
# (比如 Hindsight 用 "👁️")
@dataclass(frozen=True)
class RecallStatus:
"""Summary of what a provider's most recent prefetch injected this turn.
…so the agent can emit a deterministic, model-independent
"memory was used" indicator. ``count`` is the number of discrete
memories injected; ``0`` means content was injected but has no discrete
count (e.g. a synthesized reflect answer), which the indicator renders
generically rather than as "0 memories".
"""
provider_label: str
count: int
glyph: str = INDICATOR_GLYPH
用户需要知道「这次回答用到了我的历史记忆吗」。有两种做法:
而 count == 0 那个特殊情况的处理也很细:有些提供者注入的不是「N 条离散记忆」,而是一段综合出来的回答。这时显示「0 条记忆」会误导用户以为没用上,所以要渲染成通用形式(比如「🧠 使用了记忆」而不是「🧠 0 条记忆」)。
plugins/memory/holographic/,四个文件:holographic.py(HRR 数学运算)、store.py(SQLite 存储)、retrieval.py(检索)、__init__.py(提供者实现)。
「Holographic Reduced Representations (HRR) with phase encoding. HRRs are a vector symbolic architecture for encoding compositional structure into fixed-width distributed representations. This module uses phase vectors: each concept is a vector of angles in [0, 2π).」
译:带相位编码的全息缩减表示。HRR 是一种向量符号架构,用于把「组合结构」编码进固定宽度的分布式表示里。本模块使用相位向量:每个概念是一个由 [0, 2π) 区间内的角度组成的向量。
引用的两篇论文:Plate (1995) 和 Gayler (2004)。
def bind(a, b): # 绑定 = 循环卷积 = 逐元素相位相加
return (a + b) % _TWO_PI
# 把两个概念绑定成一个复合向量。
# 结果与两个输入都不相似(数学上叫"准正交")
def unbind(memory, key): # 解绑 = 循环相关 = 相位相减
return (memory - key) % _TWO_PI
# unbind(bind(a, b), a) ≈ b (差一个叠加噪声)
def bundle(*vectors): # 打包 = 叠加 = 复指数的圆均值
complex_sum = np.sum([np.exp(1j * v) for v in vectors], axis=0)
return np.angle(complex_sum) % _TWO_PI
# 结果与每个输入都相似;
# 能容纳 O(√dim) 项,超过就开始退化
def similarity(a, b): # 相似度 = 相位余弦,范围 [-1, 1]
return float(np.mean(np.cos(a - b)))
hermes-agent/plugins/memory/holographic/holographic.py
不需要懂数学也能理解用途:
源码还说明了选相位编码的理由:「Phase encoding is numerically stable, avoids the magnitude collapse of traditional complex-number HRRs, and maps cleanly to cosine similarity.」(相位编码数值稳定,避免了传统复数 HRR 的幅值塌缩,而且能干净地映射到余弦相似度。)
def encode_atom(word: str, dim: int = 1024) -> "np.ndarray":
"""Deterministic phase vector via SHA-256 counter blocks.
Uses hashlib (not numpy RNG) for cross-platform reproducibility.
Algorithm:
- Generate enough SHA-256 blocks by hashing f"{word}:{i}" for i=0,1,2,...
- Concatenate digests, interpret as uint16 values via struct.unpack
- Scale to [0, 2π): phases = values * (2π / 65536)
- Truncate to dim elements
"""
values_per_block = 16 # 每个 SHA-256 摘要 32 字节 = 16 个 uint16
blocks_needed = math.ceil(dim / values_per_block)
uint16_values = []
for i in range(blocks_needed):
digest = hashlib.sha256(f"{word}:{i}".encode()).digest()
uint16_values.extend(struct.unpack("<16H", digest))
phases = np.array(uint16_values[:dim], dtype=np.float64) * (_TWO_PI / 65536.0)
return phases
同一个词(比如 "docker"),在任何机器、任何 Python 版本、任何进程里,编码出来的 1024 维相位向量完全一致。
好处:
· 向量可以直接存进 SQLite 的二进制字段
· 可以跨机器同步
· 彻底避开了「换了 embedding 模型就要重算全库」这个运维噩梦 —— 这是所有基于神经网络嵌入的记忆方案最大的痛点
代价:它是词袋级的符号组合,完全没有语义理解能力。"docker" 和 "container" 的相似度接近 0,因为它们是两个不同的字符串,哈希结果毫无关系。
所以它必须和全文检索配合,而不是替代它。这是一个很清醒的定位:用零成本的确定性方法解决「组合结构」问题,把「语义理解」问题留给别的手段。
def snr_estimate(dim: int, n_items: int) -> float: ...
因为 bundle() 打包运算只能容纳约 √维度 项 —— 1024 维大约在 32 项之后就开始退化。这个函数估算「在给定维度下塞进 N 条记忆后的信噪比」。
把自己方案的容量上限写成一个可调用的函数暴露出来,是很成熟的做法。它承认了「这个方法有边界」,并且让使用者能测出边界在哪。
CREATE TABLE IF NOT EXISTS facts (...) -- 事实,带 trust_score 和 category
CREATE TABLE IF NOT EXISTS entities (...) -- 实体(人、项目、技术名词)
CREATE TABLE IF NOT EXISTS fact_entities (...) -- 事实↔实体 多对多关联
CREATE INDEX IF NOT EXISTS idx_facts_trust ON facts(trust_score DESC);
CREATE INDEX IF NOT EXISTS idx_facts_category ON facts(category);
CREATE INDEX IF NOT EXISTS idx_entities_name ON entities(name);
CREATE VIRTUAL TABLE IF NOT EXISTS facts_fts -- ★ FTS5 全文索引
CREATE TABLE IF NOT EXISTS memory_banks (...) -- 按 category 聚合的 HRR 打包向量
hermes-agent/plugins/memory/holographic/store.py
class MemoryStore:
def add_fact(...)
def search_facts(...)
def update_fact(...)
def remove_fact(fact_id: int) -> bool
def list_facts(...)
def record_feedback(self, fact_id: int, helpful: bool) -> dict # ★ 反馈闭环
def _extract_entities(self, text: str) -> list[str]
def _resolve_entity(self, name: str) -> int
def _link_fact_entity(self, fact_id: int, entity_id: int) -> None
def _compute_hrr_vector(self, fact_id: int, content: str) -> None
def _rebuild_bank(self, category: str) -> None
def rebuild_all_vectors(self, dim: int | None = None) -> int
@classmethod
def release_all_under(cls, directory) -> int
def close(self) -> None
def __enter__ / __exit__ # 支持 with 语句
设想一个真实场景:智能体在第一次会话里误以为「这个项目用的是 npm」,把这条记忆存了下来。实际上项目用的是 pnpm。
如果没有信任分衰减机制,这条错误记忆会永久污染后续所有会话 —— 每次智能体都会先读到「这个项目用 npm」,然后执行 npm 命令,然后失败,然后困惑。
有了 record_feedback(fact_id, helpful):这条记忆被证明误导之后,信任分下降;而索引 idx_facts_trust ON facts(trust_score DESC) 保证了检索时高信任分的排前面。最终它沉底、不再被召回。
记忆系统必须有自我纠错的能力,否则它是负资产。
rebuild_all_vectors(dim) 的存在也值得注意:如果要调整向量维度(比如从 1024 提到 4096),需要重算全库。虽然 SHA-256 编码是确定性的、不受模型版本影响,但维度是一个参数 —— 改了还是要重算。这个函数把这件事变成一次显式的、可控的操作。
除了记忆,还有一套更大的状态存储:
| 文件 | 大小 | 职责 |
|---|---|---|
hermes_state.py | 682 KB | 主状态存储 |
hermes_state_search.py | 116 KB | 全文检索(会话历史搜索) |
hermes_state_schema.py | 75 KB | 数据库结构定义与迁移 |
hermes_state_common.py | 37 KB | 共用逻辑 |
hermes_state_portability.py | 37 KB | 可移植性 —— 导出/导入,跨机器迁移 |
hermes_state_portability.py 的存在是「不绑定笔记本」这个主张的落地:你在本机试用,觉得不错,要迁到云服务器上 —— 会话历史、记忆、配置都得能整体搬过去。
而 session_search 这个工具(在核心工具清单里)让模型可以搜索自己过往的对话 —— 对应 README 里那句「searches its own past conversations」。它靠的就是 hermes_state_search.py 的 FTS5 索引。
| 类型 | 存在哪 | 特征 |
|---|---|---|
| 指令性记忆 人格、规范、偏好 |
SOUL.md / USER.mdAGENTS.md / .hermes.md |
纯文本,全量加载进系统提示词。人类可读可编辑 |
| 事实性记忆 谁是谁、什么时候做了什么 |
SQLite facts 表+ HRR 向量 + FTS5 索引 |
需要检索。词法 + 向量混合,带信任分排序 |
| 过程性记忆 上次这个问题怎么解的 |
会话历史 + FTS5 索引 | 通过 session_search 工具由模型主动检索 |
这个三分法是这一章最值得带走的东西。
很多项目把所有记忆一股脑塞进向量数据库,结果是:用户改了偏好设置不能立刻生效(要等重新索引)、用户看不到自己的偏好被存成了什么、而且检索出来的偏好是片段化的。
指令性记忆不该用检索。它应该是纯文本、全量加载、人类可读可 review 的。