Hermes 架构全解Hermes Architecture, in Full第 8 章 · 14 章Chapter 8 of 14
全文目录Contents
  1. 0 · 项目全景与代码地图
    1. 0.1 这个软件是什么
    2. 0.2 目录地图
    3. 0.3 从这张地图能读出的三件事
    4. 0.4 一条消息的完整旅程
    5. 0.5 全文章节索引
  2. 1 · 网关层
    1. 1.1 它解决什么问题
    2. 1.2 平台适配器抽象基类
    3. 1.3 消息事件的归一化
    4. 1.4 网关主循环里的那些防御机制
    5. 1.5 状态消息与进度反馈
    6. 1.6 中断与恢复
    7. 1.7 网关内置钩子
    8. 1.8 这一层的代价
  3. 2 · 身份与会话路由
    1. 2.1 Profile:一个进程承载多个身份
    2. 2.2 四级路由与「具体度」打分
    3. 2.3 显式路由被拒绝的情况
    4. 2.4 SOUL.md:人格文件
    5. 2.5 会话路由:跨平台的连续性
    6. 2.6 智能体实例的缓存
  4. 3 · 主循环 ★
    1. 3.1 循环入口:三重预算闸门
    2. 3.2 每轮的准备工作:build_turn_context
    3. 3.3 中途插话:/steer
    4. 3.4 墙上时钟预算的收尾提醒
    5. 3.5 中断检查
    6. 3.6 步骤回调:给网关的观测点
    7. 3.7 循环里的其他状态
    8. 3.8 循环退出原因
    9. 3.9 一个可选的旁路:Codex 应用服务运行时
  5. 4 · 工具系统
    1. 4.1 最重要的设计:实现与投放分离
    2. 4.2 工具集的组合与解析
    3. 4.3 工具的中心分发
    4. 4.4 参数强制矫正层
    5. 4.5 工具错误消息的净化
    6. 4.6 工具结果的观测与钩子
    7. 4.7 委派上下文识别
    8. 4.8 工具定义的缓存
  6. 5 · 审批与安全红线 ★
    1. 5.1 整体结构
    2. 5.2 12 条硬红线
    3. 5.3 真正的难点:区分「命令」和「数据」
    4. 5.4 引号遮蔽:但要给「真会执行的部分」留后门
    5. 5.5 引号不能成为绕过手段
    6. 5.6 敏感路径与写入目标
    7. 5.7 sudo 标准输入守卫
    8. 5.8 性能:预编译的理由
    9. 5.9 被拦截命令的留存
    10. 5.10 智能审批:可选的模型判断
    11. 5.11 上下文感知:不同场景不同策略
    12. 5.12 这一层的定位:最外面也最薄
  7. 6 · 执行环境
    1. 6.1 七种可选环境
    2. 6.2 必须诚实说明的一件事
    3. 6.3 抽象基类里的共用逻辑
    4. 6.4 文件同步
    5. 6.5 环境的选择时机
  8. 7 · 上下文引擎 ★
    1. 7.1 它定义的是什么
    2. 7.2 生命周期
    3. 7.3 三个必须实现的方法
    4. 7.4 最精辟的设计:select 和 compress 是两个正交动词
    5. 7.5 后置观察钩子
    6. 7.6 其他可选钩子
    7. 7.7 默认参数值
    8. 7.8 用户可见状态的可控性
    9. 7.9 内置实现的体量对比
  9. 8 · 记忆系统 ★
    1. 8.1 记忆提供者接口
    2. 8.2 生命周期与钩子
    3. 8.3 接口的版本化契约
    4. 8.4 琐碎提问过滤器
    5. 8.5 记忆使用指示器
    6. 8.6 内置的全息记忆
    7. 8.7 存储层与信任分
    8. 8.8 SQLite 状态层
    9. 8.9 三种记忆的分工总结
  10. 9 · 插件系统
    1. 9.1 三个发现来源
    2. 9.2 插件能提供什么
    3. 9.3 最重要的设计:区分「可叠加能力」与「互斥策略」
    4. 9.4 插件的存储
    5. 9.5 插件与工具集的联动
    6. 9.6 插件钩子
    7. 9.7 MCP:另一条扩展路径
    8. 9.8 这套扩展体系的整体形状
  11. 10 · 委派与多智能体
    1. 10.1 为什么需要委派
    2. 10.2 深度限制:只允许一层
    3. 10.3 并发限制
    4. 10.4 子智能体的工具限制
    5. 10.5 子智能体的审批策略
    6. 10.6 运行中的控制
    7. 10.7 亲缘关系检查
    8. 10.8 看板:智能体之间的协作
    9. 10.9 委派系统为什么有 5,071 行
  12. 11 · 模型供应商与凭据池
    1. 11.1 为什么不能只支持一家
    2. 11.2 供应商适配器清单
    3. 11.3 凭据池:核心机制
    4. 11.4 PooledCredential:单个凭据的状态
    5. 11.5 「持久化」为什么必要
    6. 11.6 与其他机制的配合
    7. 11.7 供应商抽象要抽象到哪一层
  13. 12 · 定时任务
    1. 12.1 「cron」是什么
    2. 12.2 定时智能体的场景
    3. 12.3 最重要的一个类:CronPromptInjectionBlocked
    4. 12.4 定时任务的工具集收窄
    5. 12.5 失败处理
    6. 12.6 防重复执行
    7. 12.7 为什么 scheduler.py 有 367 KB
    8. 12.8 定时任务在整个架构里的位置
  14. 13 · 技能系统
    1. 13.1 技能是什么
    2. 13.2 前置元数据逐字段解释
    3. 13.3 渐进式披露:技能系统的核心机制
    4. 13.4 技能的支撑设施
    5. 13.5 技能 vs 工具 vs 插件
    6. 13.6 技能系统的一个隐含设计:可组合
    7. 13.7 15 个技能类别
    8. 13.8 全文回顾:Hermes 的整体形状
  1. 0 · The Project at a Glance, and a Map of the Code
    1. 0.1 What This Software Is
    2. 0.2 Directory Map
    3. 0.3 Three Things You Can Read Off This Map
    4. 0.4 The Full Journey of One Message
    5. 0.5 Chapter Index
  2. 1 · The Gateway Layer
    1. 1.1 The Problem It Solves
    2. 1.2 The Platform Adapter Abstract Base Class
    3. 1.3 Normalizing Message Events
    4. 1.4 The Defensive Mechanisms in the Gateway Main Loop
    5. 1.5 Status Messages and Progress Feedback
    6. 1.6 Interruption and Recovery
    7. 1.7 Built-in Gateway Hooks
    8. 1.8 What This Layer Costs
  3. 2 · Identity and Session Routing
    1. 2.1 Profiles: One Process, Many Identities
    2. 2.2 Four-Level Routing and the “Specificity” Score
    3. 2.3 When an Explicit Route Is Rejected
    4. 2.4 SOUL.md: The Persona File
    5. 2.5 Session Routing: Continuity Across Platforms
    6. 2.6 Caching Agent Instances
  4. 3 · The Main Loop ★
    1. 3.1 The Loop Entry: A Triple Budget Gate
    2. 3.2 Per-Turn Preparation: build_turn_context
    3. 3.3 Mid-Turn Interjection: /steer
    4. 3.4 The Wall-Clock Budget Wrap-Up Reminder
    5. 3.5 The Interrupt Check
    6. 3.6 The Step Callback: An Observation Point for the Gateway
    7. 3.7 Other State Inside the Loop
    8. 3.8 Why the Loop Exited
    9. 3.9 An Optional Bypass: The Codex App Server Runtime
  5. 4 · The Tool System
    1. 4.1 The Most Important Design Decision: Implementation Separated from Exposure
    2. 4.2 Composing and Resolving Toolsets
    3. 4.3 Central Tool Dispatch
    4. 4.4 The Argument Coercion Layer
    5. 4.5 Sanitizing Tool Error Messages
    6. 4.6 Observing Tool Results, and Hooks
    7. 4.7 Recognizing a Delegation Context
    8. 4.8 Caching Tool Definitions
  6. 5 · Approval and Safety Red Lines ★
    1. 5.1 The Overall Structure
    2. 5.2 The 12 Hard Red Lines
    3. 5.3 The Real Difficulty: Telling “Command” from “Data”
    4. 5.4 Quote Masking: But Leave a Way In for “the Part That Really Executes”
    5. 5.5 Quoting Is Not a Bypass
    6. 5.6 Sensitive Paths and Write Targets
    7. 5.7 The sudo Stdin Guard
    8. 5.8 Performance: Why Precompile
    9. 5.9 Retaining Blocked Commands
    10. 5.10 Smart Approval: An Optional Model Judgment
    11. 5.11 Context Awareness: Different Situations, Different Policies
    12. 5.12 Where This Layer Sits: Outermost, and Thinnest
  7. 6 · Execution Environments
    1. 6.1 Seven Environments to Choose From
    2. 6.2 One Thing That Has to Be Said Plainly
    3. 6.3 Shared Logic in the Abstract Base Class
    4. 6.4 File Synchronization
    5. 6.5 When the Environment Is Chosen
  8. 7 · The Context Engine ★
    1. 7.1 What It Defines
    2. 7.2 Lifecycle
    3. 7.3 The Three Methods You Must Implement
    4. 7.4 The Sharpest Design Decision: select and compress Are Two Orthogonal Verbs
    5. 7.5 The Post-Turn Observation Hook
    6. 7.6 Other Optional Hooks
    7. 7.7 Default Parameter Values
    8. 7.8 Control Over User-Visible Status
    9. 7.9 Size Comparison of the Built-in Implementation
  9. 8 · The Memory System ★
    1. 8.1 The Memory Provider Interface
    2. 8.2 Lifecycle and Hooks
    3. 8.3 The Interface's Versioned Contract
    4. 8.4 The Trivial-Prompt Filter
    5. 8.5 The Memory-Usage Indicator
    6. 8.6 The Built-in Holographic Memory
    7. 8.7 The Storage Layer and Trust Scores
    8. 8.8 The SQLite State Layer
    9. 8.9 Summing Up the Division of Labor Among Three Kinds of Memory
  10. 9 · The Plugin System
    1. 9.1 Three Discovery Sources
    2. 9.2 What a Plugin Can Provide
    3. 9.3 The Most Important Design Decision: “Stackable Capabilities” vs. “Mutually Exclusive Strategies”
    4. 9.4 Plugin Storage
    5. 9.5 How Plugins Tie into Toolsets
    6. 9.6 Plugin Hooks
    7. 9.7 MCP: The Other Extension Path
    8. 9.8 The Overall Shape of This Extension System
  11. 10 · Delegation and Multi-Agent
    1. 10.1 Why Delegation Is Needed
    2. 10.2 The Depth Limit: One Level Only
    3. 10.3 Concurrency Limits
    4. 10.4 Tool Restrictions on Subagents
    5. 10.5 Approval Policy for Subagents
    6. 10.6 In-Flight Control
    7. 10.7 The Lineage Check
    8. 10.8 Kanban: Collaboration Between Agents
    9. 10.9 Why the Delegation System Is 5,071 Lines
  12. 11 · Model Providers and the Credential Pool
    1. 11.1 Why You Can't Support Just One
    2. 11.2 The Provider Adapter Roster
    3. 11.3 The Credential Pool: The Core Mechanism
    4. 11.4 PooledCredential: The State of a Single Credential
    5. 11.5 Why “Persistent” Is Necessary
    6. 11.6 How It Works with Other Mechanisms
    7. 11.7 How Deep the Provider Abstraction Should Go
  13. 12 · Scheduled Tasks (Cron)
    1. 12.1 What “cron” Is
    2. 12.2 Scenarios for Scheduled Agents
    3. 12.3 The Single Most Important Class: CronPromptInjectionBlocked
    4. 12.4 Narrowing the Toolset for Scheduled Tasks
    5. 12.5 Failure Handling
    6. 12.6 Preventing Duplicate Runs
    7. 12.7 Why scheduler.py Is 367 KB
    8. 12.8 Where Scheduled Tasks Sit in the Overall Architecture
  14. 13 · The Skill System
    1. 13.1 What a Skill Is
    2. 13.2 The Front Matter, Field by Field
    3. 13.3 Progressive Disclosure: The Core Mechanism of the Skill System
    4. 13.4 The Infrastructure Around Skills
    5. 13.5 Skills vs. Tools vs. Plugins
    6. 13.6 An Implicit Design in the Skill System: Composability
    7. 13.7 The 15 Skill Categories
    8. 13.8 Looking Back Across the Book: The Overall Shape of Hermes

8 · 记忆系统 ★

这一章讲 Hermes 怎么「跨会话记住事情」。它由三部分组成:一个可插拔的提供者接口、一个内置的全息记忆实现、以及一个 SQLite 状态层。

8.1 记忆提供者接口

「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.6)
├── honcho/           外部服务
├── hindsight/        外部服务
├── mem0/             外部服务
├── byterover/        外部服务
├── openviking/       外部服务
├── retaindb/         外部服务
├── supermemory/      外部服务
├── query_rewrite.py  查询改写(通用辅助)
└── config_schema.py

8.2 生命周期与钩子

"""
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(压缩前提取)是整套设计里最关键的一个:

上下文压缩是有损的、不可逆的。 压缩之后,那 200 条消息里的细节就永久丢失了。 ★ 所以必须在压缩发生之前,给记忆系统一次 "把值得长期保存的东西提取出来"的机会。 压缩前 → on_pre_compress(messages) → 提取事实存进长期记忆 压缩后 → 原文没了,但提取出来的事实还在 这是"短期上下文"和"长期记忆"之间唯一的交接点。

on_delegation(task, result)(委派观察)解决的是:子智能体的工作过程不在父的上下文里(这正是子智能体的价值,见第 10 章)。但子智能体可能发现了值得长期记住的事实。这个钩子让父侧的记忆系统能观察到子任务的输入和结果。

backup_paths()(备份路径)是一个很实在的运维考虑:hermes backup 命令需要知道记忆提供者把数据存在哪些额外的磁盘位置,才能完整备份。

8.3 接口的版本化契约

# 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 把它变成显式失败:要么提取成功再压缩,要么就别压缩。

而用一个「版本号常量」而不是直接改接口,是为了让老提供者继续能跑。提供者声明自己支持哪个版本,宿主据此选择调用方式。

8.4 琐碎提问过滤器

这是一个很小但很实用的优化:

# 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」(这样两者永远不会漂移)。如果各自实现一份,迟早会出现「核心认为琐碎、提供者认为不琐碎」的不一致。

8.5 记忆使用指示器

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 条记忆」)。

8.6 内置的全息记忆

plugins/memory/holographic/,四个文件:holographic.py(HRR 数学运算)、store.py(SQLite 存储)、retrieval.py(检索)、__init__.py(提供者实现)。

HRR 是什么

「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

不需要懂数学也能理解用途:

  • 绑定把「键」和「值」粘成一个向量,比如把「用户的编辑器」和「Vim」绑起来
  • 解绑是逆运算,给一个键能取回对应的值
  • 打包把很多条记忆压成一个向量,用一个向量代表整个类别

源码还说明了选相位编码的理由:「Phase encoding is numerically stable, avoids the magnitude collapse of traditional complex-number HRRs, and maps cleanly to cosine similarity.」(相位编码数值稳定,避免了传统复数 HRR 的幅值塌缩,而且能干净地映射到余弦相似度。)

最值得注意的工程决策:用 SHA-256 而不是随机数

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 条记忆后的信噪比」。

把自己方案的容量上限写成一个可调用的函数暴露出来,是很成熟的做法。它承认了「这个方法有边界」,并且让使用者能测出边界在哪。

8.7 存储层与信任分

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 编码是确定性的、不受模型版本影响,但维度是一个参数 —— 改了还是要重算。这个函数把这件事变成一次显式的、可控的操作。

8.8 SQLite 状态层

除了记忆,还有一套更大的状态存储:

文件大小职责
hermes_state.py682 KB主状态存储
hermes_state_search.py116 KB全文检索(会话历史搜索)
hermes_state_schema.py75 KB数据库结构定义与迁移
hermes_state_common.py37 KB共用逻辑
hermes_state_portability.py37 KB可移植性 —— 导出/导入,跨机器迁移

hermes_state_portability.py 的存在是「不绑定笔记本」这个主张的落地:你在本机试用,觉得不错,要迁到云服务器上 —— 会话历史、记忆、配置都得能整体搬过去。

session_search 这个工具(在核心工具清单里)让模型可以搜索自己过往的对话 —— 对应 README 里那句「searches its own past conversations」。它靠的就是 hermes_state_search.py 的 FTS5 索引。

8.9 三种记忆的分工总结

类型存在哪特征
指令性记忆
人格、规范、偏好
SOUL.md / USER.md
AGENTS.md / .hermes.md
纯文本,全量加载进系统提示词。人类可读可编辑
事实性记忆
谁是谁、什么时候做了什么
SQLite facts
+ HRR 向量 + FTS5 索引
需要检索。词法 + 向量混合,带信任分排序
过程性记忆
上次这个问题怎么解的
会话历史 + FTS5 索引 通过 session_search 工具由模型主动检索

这个三分法是这一章最值得带走的东西。

很多项目把所有记忆一股脑塞进向量数据库,结果是:用户改了偏好设置不能立刻生效(要等重新索引)、用户看不到自己的偏好被存成了什么、而且检索出来的偏好是片段化的。

指令性记忆不该用检索。它应该是纯文本、全量加载、人类可读可 review 的。

8 · The Memory System ★

This chapter covers how Hermes “remembers things across sessions.” It has three parts: a pluggable provider interface, a built-in holographic memory implementation, and a SQLite state layer.

8.1 The Memory Provider Interface

“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.”

In plain terms: memory providers give the agent persistent recall across sessions. The MemoryManager enforces “only one external provider,” to prevent tool-schema bloat and memory backends that conflict with each other.

Two reasons behind the “only one” limit

ReasonExplanation
Tool-schema bloat Every memory provider can expose its own tools to the model (get_tool_schemas()). Install three and you get three sets of “search memory” and “write memory” tools — the model gets confused about which one to use, and every set eats resident tokens
Backend conflicts Two providers each maintain their own model of “who the user is,” and the two may contradict each other. Which one do you write to? Which one do you believe when reading? There is no right answer

So memory providers, like context engines, are a “mutually exclusive strategy” rather than a “stackable capability.” Chapter 9 develops this distinction.

Eight optional backends

plugins/memory/
├── holographic/      ★ built-in: HRR holographic memory (see 8.6)
├── honcho/           external service
├── hindsight/        external service
├── mem0/             external service
├── byterover/        external service
├── openviking/       external service
├── retaindb/         external service
├── supermemory/      external service
├── query_rewrite.py  query rewriting (shared helper)
└── config_schema.py

8.2 Lifecycle and Hooks

"""
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)      — per-turn clock tick, with runtime context
  on_session_end(messages)                    — extraction at session end
  on_session_switch(new_session_id, **kwargs) — mid-process session ID rotation
  on_pre_compress(messages) -> str            — ★ extraction before context compaction
  on_memory_write(action, target, content, metadata=None)
                                              — mirror writes to the built-in memory
  on_delegation(task, result, **kwargs)       — parent-side observation of subagent work
  backup_paths() -> list[str]                 — extra on-disk paths to include in backups
"""

hermes-agent/agent/memory_provider.py

Three hooks worth calling out

on_pre_compress(messages) -> str (pre-compaction extraction) is the single most important piece of the whole design:

Context compaction is lossy and irreversible. Once it happens, the details in those 200 messages are gone for good. ★ So before compaction happens, the memory system must get one chance to "pull out whatever is worth keeping long-term." Before compaction → on_pre_compress(messages) → extract facts into long-term memory After compaction → the originals are gone, but the extracted facts remain This is the single handoff point between "short-term context" and "long-term memory".

on_delegation(task, result) (delegation observation) solves this problem: a subagent's work happens outside the parent's context (that is precisely the point of subagents; see chapter 10). But the subagent may have discovered facts worth remembering long-term. This hook lets the parent-side memory system observe the subtask's input and result.

backup_paths() (backup paths) is a very practical operational concern: the hermes backup command needs to know which extra on-disk locations a memory provider stores its data in before it can make a complete backup.

8.3 The Interface's Versioned Contract

# 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

In plain terms: version 1 is the historical, implicit contract that every existing provider is already on: get the raw message list, do on_pre_compress on a best-effort basis. Version 2 is the opt-in “fail-closed” checkpoint contract (a normalized evidence handoff plus failure propagation in strict mode).

The substantive difference between the two versions
Version 1 (best-effort)Version 2 (fail-closed)
Input The raw message list; the provider parses it itself A normalized evidence handoff — the host tidies up the format first
When extraction fails Silently carries on; compaction proceeds as usual
→ the data is simply lost
The failure propagates upward; in strict mode it blocks compaction
→ better not to compact than to lose data

Why this upgrade was necessary: version 1 carries a risk of silent data loss — memory extraction fails (network trouble, a service outage), but compaction runs anyway, so the original text of those messages and the extraction result vanish at the same time. And nobody ever notices.

Version 2 turns that into an explicit failure: either extraction succeeds and then you compact, or you don't compact at all.

Using a “version-number constant” rather than changing the interface outright is what keeps old providers running. A provider declares which version it supports, and the host picks its calling convention accordingly.

8.4 The Trivial-Prompt Filter

This is a small but very practical optimization:

# 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         # slash command
    return bool(TRIVIAL_PROMPT_RE.match(stripped))

What it saves

When the user says something like “ok,” “thanks,” or “continue”:

  • It saves one blocking network round-trip — retrieval from an external memory service goes over the network, typically a few hundred milliseconds
  • It keeps a stale user model from derailing a one-word reply — the user just said “ok,” but you injected three paragraphs of their history into the context, and the model may inexplicably start talking about that material

The regex's anchoring design

“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.”

In plain terms: the alternation is anchored and may only be followed by whitespace or punctuation, so words that “merely begin with a trivial word” ("k8s", "yolo", "note", "hindsight") do not match, while variants with trailing punctuation ("hi!", "hey.", "thanks :)", "done???") do.

InputVerdictWhy
ok / thanks :) / done???TrivialThe whole thing is one acknowledgement word plus punctuation
k8s cluster setupNot trivialIt starts with k, but what follows is a character, not punctuation
note this downNot trivialSame idea: no is followed by te
hindsight serviceNot trivialhi is followed by ndsight

Note the “single source of truth” mentioned in the comment: this regex is shared by the core per-turn prefetch gate and the provider-side classifiers, “so the two can never drift apart.” If each kept its own copy, sooner or later you would get the inconsistency where “the core thinks it's trivial, the provider thinks it isn't.”

8.5 The Memory-Usage Indicator

INDICATOR_GLYPH = "🧠"     # default glyph; providers can override with their own brand mark
                            # (Hindsight uses "👁️", for example)

@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
The phrase “deterministic, model-independent” is the key

The user needs to know, “did this answer draw on my history?” There are two ways to do it:

  • Let the model say so itself — “based on our earlier conversation…” → unreliable. The model may forget to say it, or say it when no memory was used at all
  • Have the system generate a deterministic indicator from “what was actually injected” → always accurate

The handling of the special case count == 0 is thoughtful too: some providers inject not “N discrete memories” but a single synthesized answer. Showing “0 memories” would mislead the user into thinking nothing was used, so it renders generically instead (say, “🧠 memory used” rather than “🧠 0 memories”).

8.6 The Built-in Holographic Memory

plugins/memory/holographic/, four files: holographic.py (the HRR math), store.py (SQLite storage), retrieval.py (retrieval), and __init__.py (the provider implementation).

What HRR is

“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π).”

In plain terms: Holographic Reduced Representations with phase encoding. HRR is 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 the interval [0, 2π).

The two papers cited: Plate (1995) and Gayler (2004).

The three core operations

def bind(a, b):        # bind = circular convolution = element-wise phase addition
    return (a + b) % _TWO_PI
    # Binds two concepts into one composite vector.
    # The result resembles neither input (mathematically, "quasi-orthogonal")

def unbind(memory, key):   # unbind = circular correlation = phase subtraction
    return (memory - key) % _TWO_PI
    # unbind(bind(a, b), a) ≈ b   (up to superposition noise)

def bundle(*vectors):  # bundle = superposition = circular mean of complex exponentials
    complex_sum = np.sum([np.exp(1j * v) for v in vectors], axis=0)
    return np.angle(complex_sum) % _TWO_PI
    # The result resembles every input;
    # holds O(√dim) items, degrades beyond that

def similarity(a, b):  # similarity = phase cosine, range [-1, 1]
    return float(np.mean(np.cos(a - b)))

hermes-agent/plugins/memory/holographic/holographic.py

You don't need the math to understand what these are for:

  • Bind glues a “key” and a “value” into one vector — say, binding “the user's editor” to “Vim”
  • Unbind is the inverse: given a key, it recovers the corresponding value
  • Bundle squeezes many memories into one vector, so a single vector can stand for a whole category

The source also explains why phase encoding was chosen: “Phase encoding is numerically stable, avoids the magnitude collapse of traditional complex-number HRRs, and maps cleanly to cosine similarity.” (That is, it holds up numerically, sidesteps the magnitude collapse of traditional complex-number HRRs, and maps neatly onto cosine similarity.)

The most notable engineering decision: SHA-256 instead of random numbers

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                    # each SHA-256 digest is 32 bytes = 16 uint16 values
    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
What this choice means in engineering terms

The same word (say, "docker") encodes to exactly the same 1024-dimensional phase vector on any machine, any Python version, any process.

The upside:
· Vectors can be stored straight into a SQLite binary field
· They can be synced across machines
· It completely sidesteps the operational nightmare of “we swapped the embedding model, now recompute the entire database” — the biggest pain point of every memory scheme built on neural embeddings

The cost: it is bag-of-words-level symbolic composition with no semantic understanding whatsoever. The similarity between "docker" and "container" is close to 0, because they are two different strings and their hashes have nothing to do with each other.

So it must work alongside full-text search, not replace it. That is a clear-eyed positioning: use a zero-cost deterministic method to solve the “compositional structure” problem, and leave the “semantic understanding” problem to other means.

An honest capacity ceiling

def snr_estimate(dim: int, n_items: int) -> float: ...

Because the bundle() operation can only hold about √dim items — at 1024 dimensions, it starts degrading after roughly 32 items. This function estimates “the signal-to-noise ratio after packing N memories into a given number of dimensions.”

Exposing your own scheme's capacity ceiling as a callable function is a mature move. It admits that “this method has limits,” and lets users measure where those limits are.

8.7 The Storage Layer and Trust Scores

CREATE TABLE IF NOT EXISTS facts (...)          -- facts, with trust_score and category
CREATE TABLE IF NOT EXISTS entities (...)       -- entities (people, projects, technical terms)
CREATE TABLE IF NOT EXISTS fact_entities (...)  -- fact↔entity many-to-many links
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 full-text index
CREATE TABLE IF NOT EXISTS memory_banks (...)  -- HRR bundle vectors aggregated per category

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     # ★ feedback loop
    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__       # supports the with statement
Trust scores are a necessity, not a nicety

Picture a real scenario: in its first session, the agent wrongly concludes “this project uses npm” and stores that as a memory. The project actually uses pnpm.

Without a trust-score decay mechanism, that one wrong memory permanently poisons every later session — each time, the agent first reads “this project uses npm,” runs an npm command, fails, and gets confused.

With record_feedback(fact_id, helpful): once the memory is shown to be misleading, its trust score drops; and the index idx_facts_trust ON facts(trust_score DESC) guarantees that high-trust facts sort first at retrieval time. Eventually it sinks to the bottom and stops being recalled.

A memory system must be able to correct itself; otherwise it is a liability.

The existence of rebuild_all_vectors(dim) is also worth noting: if you want to change the vector dimension (say, from 1024 up to 4096), the whole database has to be recomputed. SHA-256 encoding is deterministic and immune to model versions, but the dimension is a parameter — change it and you still recompute. This function turns that into a single explicit, controlled operation.

8.8 The SQLite State Layer

Beyond memory, there is a larger state store:

FileSizeResponsibility
hermes_state.py682 KBMain state store
hermes_state_search.py116 KBFull-text search (searching session history)
hermes_state_schema.py75 KBDatabase schema definitions and migrations
hermes_state_common.py37 KBShared logic
hermes_state_portability.py37 KBPortability — export/import, migrating between machines

The existence of hermes_state_portability.py is where the “not tied to your laptop” claim becomes real: you try it on your local machine, like it, and want to move it to a cloud server — session history, memory, and configuration all have to move over as a whole.

And the session_search tool (on the core tool list) lets the model search its own past conversations — matching the README's line, “searches its own past conversations.” It relies on the FTS5 index in hermes_state_search.py.

8.9 Summing Up the Division of Labor Among Three Kinds of Memory

TypeWhere it livesCharacteristics
Instructional memory
Persona, conventions, preferences
SOUL.md / USER.md
AGENTS.md / .hermes.md
Plain text, loaded in full into the system prompt. Human-readable and human-editable
Factual memory
Who is who, what was done when
The SQLite facts table
+ HRR vectors + FTS5 index
Needs retrieval. Hybrid lexical + vector search, ranked by trust score
Procedural memory
How this problem was solved last time
Session history + FTS5 index Actively retrieved by the model through the session_search tool

This three-way split is the most valuable thing to take away from this chapter.

Many projects dump every kind of memory into a vector database, and the result is: a user changes a preference and it doesn't take effect immediately (you wait for re-indexing), the user can't see what their preferences were stored as, and the preferences that come back from retrieval are fragmented.

Instructional memory should not go through retrieval. It should be plain text, loaded in full, and human-readable and reviewable.