9 · 记忆系统与扩展体系
9.1 记忆:两种截然不同的答案
先说清楚问题:回顾第 1.1 节,大语言模型完全没有记忆。一场会话结束,一切归零。那么「让智能体记住上次的事」这件事,必须由外部程序实现。
Claude Code —— 文件即记忆
CLAUDE.md—— 项目级的指令文件,按目录层级嵌套加载(子目录的会追加到父目录的后面)memdir/—— 一条记忆一个文件,外加一个MEMORY.md作为索引目录- 对话记录存成 JSONL 文件(每行一条 JSON),用
--resume参数可以回放恢复 - 召回机制:提前预取 + 让模型判断相关性(函数名
startRelevantMemoryPrefetch) - 没有向量、没有数据库。记忆就是人类可读、可以用 git 管理的纯文本。
Hermes —— 结构化记忆栈
hermes_state.py(698 KB)—— 用 SQLite 数据库存储,配合 FTS5 全文索引- 全息记忆插件 —— HRR 相位向量 + 事实与实体关系图 + 信任分数
MemoryProvider抽象基类 → 8 种可插拔的外部记忆服务- 四个身份文件:
SOUL.md(人格设定)、MEMORY.md、USER.md、AGENTS.md - 召回机制:FTS5 词法检索 + 向量相似度 + 模型摘要
什么是 FTS5?Full-Text Search 版本 5 的缩写,是 SQLite 数据库内置的全文搜索引擎。它做的是「词法检索」—— 按关键词精确匹配,就像用 Ctrl+F 在文档里搜词。它不理解语义,搜「容器」不会命中「Docker」。
9.2 Hermes 的全息记忆值得单独看
这是整个代码库里最「学术」的一块。它用的是一种叫 HRR(Holographic Reduced Representations,全息缩减表示)的技术 —— 属于「向量符号架构」这一类方法。
先说清楚它想解决什么问题
常规的记忆检索有两条路:
| 方法 | 怎么工作 | 缺点 |
|---|---|---|
| 词法检索 FTS5 |
按关键词精确匹配 | 不理解同义词。搜「容器」搜不到「Docker」 |
| 向量检索 embedding |
用一个神经网络把文字转成一串数字(向量),意思相近的文字向量也相近 | 换了那个神经网络,全库的向量就得重算。这是所有向量记忆方案最大的运维噩梦 |
HRR 走的是第三条路:用确定性的数学运算把符号组合成向量,不需要神经网络。
三个核心运算
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
# 把多个向量合并成一个,结果与每一个输入都相似。
# 能容纳大约 √维度 个项,超过就开始退化
def similarity(a, b): # 相似度 = 相位余弦,范围 [-1, 1]
return float(np.mean(np.cos(a - b)))
hermes-agent/plugins/memory/holographic/holographic.py
不需要懂数学也能理解这三个运算的用途:
- 绑定把「键」和「值」粘成一个向量,比如把「用户的编辑器」和「Vim」绑成一个
- 解绑是绑定的逆运算,给一个键能取回对应的值
- 打包把很多条记忆压成一个向量,用一个向量代表整个类别
最值得注意的工程决策:用 SHA-256 而不是随机数
def encode_atom(word: str, dim: int = 1024):
"""Deterministic phase vector via SHA-256 counter blocks.
Uses hashlib (not numpy RNG) for cross-platform reproducibility.
"""
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]) * (_TWO_PI / 65536.0)
译:通过 SHA-256 计数器分块,生成确定性的相位向量。用 hashlib 而不是 numpy 的随机数生成器,是为了跨平台可复现。
(SHA-256 是一种哈希算法:同样的输入永远得到同样的输出,而且输出看起来像随机数。dim: int = 1024 表示向量有 1024 个维度。)
同一个词,比如 "docker",在任何机器、任何 Python 版本、任何进程里,编码出来的 1024 维相位向量完全一致。
这意味着:
· 记忆向量可以直接存进 SQLite 的二进制字段
· 可以跨机器同步
· 不存在「换了 embedding 模型,全库要重算」这个运维噩梦
代价是:它是「词袋级」的符号组合,完全没有语义理解能力。"docker" 和 "container" 的相似度接近 0,因为它们是两个不同的字符串,哈希结果毫无关系。
所以它必须和 FTS5 配合使用,而不是替代它。这是一个很清醒的定位:用零成本的确定性方法解决「组合结构」问题,把「语义理解」问题留给别的手段。
存储层的设计也值得看
CREATE TABLE facts (...) -- 事实表,带信任分数和分类
CREATE TABLE entities (...) -- 实体表(人、项目、技术名词)
CREATE TABLE fact_entities (...) -- 事实与实体的多对多关联
CREATE INDEX idx_facts_trust ON facts(trust_score DESC); -- 按信任分排序的索引
CREATE VIRTUAL TABLE facts_fts ... -- FTS5 全文索引
CREATE TABLE memory_banks (...) -- 按分类聚合的 HRR 打包向量
并且有一个反馈闭环:
def record_feedback(self, fact_id: int, helpful: bool) -> dict:
# 根据这条记忆是否有帮助,调整它的 trust_score(信任分数)
设想一个真实场景:智能体在第一次会话里误以为「这个项目用的是 npm」,把这条记忆存了下来。实际上项目用的是 pnpm。
如果没有信任分数衰减机制,这条错误记忆会永久污染后续所有会话 —— 每次智能体都会先读到「这个项目用 npm」,然后执行 npm 命令,然后失败,然后困惑。
有了 record_feedback:这条记忆被证明误导之后,信任分下降,排序沉底,最终不再被召回。记忆系统必须有自我纠错的能力,否则它是负资产。
还有一个体现工程成熟度的函数:snr_estimate(dim, n_items) —— 估算「在给定维度下塞进 N 条记忆后的信噪比」。因为 bundle() 打包运算只能容纳大约 √维度 个项,1024 维大约在 32 项之后就开始退化。
把自己方案的容量上限写成一个可调用的函数暴露出来,这是很成熟的做法。它承认了「这个方法有边界」,并且让使用者能测出这个边界在哪。
9.3 Claude Code 的记忆预取:藏在流水线里的优化
// query.ts,主循环入口处
using pendingMemoryPrefetch = startRelevantMemoryPrefetch(
state.messages, state.toolUseContext)
这一行代码有三个细节值得注意:
| 细节 | 为什么这样设计 |
|---|---|
| 每个用户轮次只触发一次,不是每次循环迭代都触发 | 源码注释:「the prompt is invariant across loop iterations, so per-iteration firing would ask sideQuery the same question N times」 译:用户的提问在整个轮次的多次循环迭代中是不变的,所以每次迭代都触发会向侧查询问同一个问题 N 次。 |
| 消费点从不阻塞 | 它只检查 settledAt 字段(是否已完成)—— 没完成就跳过,下次迭代再看。一个轮次里有几次迭代,它就有几次机会。绝不等待。 |
using 声明 |
这是 JavaScript 的一个较新语法(显式资源管理),保证无论函数从哪条路径退出,这个预取任务都会被正确清理。生成器函数有很多退出路径(正常返回、抛异常、被外部关闭),漏掉任何一条就会资源泄漏。 |
消费时还要用 readFileState 做过滤 —— 模型自己已经读过、写过、改过的记忆文件,不再重复注入一遍。而这个 readFileState 是跨迭代累积的,所以能过滤掉早期迭代里读过的文件。
回顾第 1.6 节:模型流式返回一个完整回复要 5 到 30 秒。这段时间你的 CPU 基本闲着。
Claude Code 在这个时间窗里塞了至少三件事:
- 记忆预取 —— 判断哪些历史记忆和当前问题相关
- 技能发现预取 —— 判断哪些技能文件可能有用
- 上一批工具的摘要生成 —— 用一个更便宜的小模型(Haiku)给工具执行结果生成摘要
源码里留了一个数字:技能发现预取的完成率大于 98%(预取本身耗时 250 到 573 毫秒,而轮次时长是 2 到 30 秒)。
「藏在主流程延迟下的旁路计算」几乎是免费的,但只有一个前提:消费点必须设计成「好了就用,没好就算了」,绝不能阻塞主流程等它。一旦开始等,这个优化就变成了负优化。
9.4 扩展体系:四种扩展点
「扩展点」的意思是:让第三方(或用户自己)在不修改主程序源代码的前提下,往系统里添加能力。
| 类型 | Claude Code | Hermes |
|---|---|---|
| 技能 Skill |
SKILL.md 文件 + 文件头部的元数据(frontmatter)可以声明自己需要哪些钩子、允许用哪些工具 通过 SkillTool 被模型调用 |
skills/<分类>/<名字>/SKILL.md15 个分类,全部是纯 Markdown 文本 三个工具:列出技能、查看技能、管理技能 |
| 插件 Plugin |
pluginLoader.ts(107 KB)有插件市场和引用版本追踪 |
3 个发现来源(下面详述) 插件可以注册工具、钩子、命令行子命令 |
| MCP 模型上下文协议 |
支持标准输入输出和 HTTP 两种传输方式 工具名加前缀 mcp__服务名__工具名支持延迟加载、支持向用户索取信息 |
同样两种传输方式 前缀 mcp_<服务名>_<工具名>支持选择性加载、自动重载 自带 67 个可选的 MCP 服务 |
| 钩子 Hook |
10 类事件:工具执行前 / 工具执行后 / 工具失败后 / 用户提交提问 / 会话开始 / 通知 / 结束前 / 压缩前 / 采样后 / 权限请求 |
gateway/hooks.py + 内置钩子目录模型调用前 / 工具调用后 / 每步事件 / 审批钩子 |
技能的本质:把「知识」变成「可寻址的能力」
两家的技能都是 Markdown 文本文件,核心机制都是渐进式披露:
Claude Code 甚至有一个专门的函数 estimateSkillFrontmatterTokens(skill)(估算技能头部元数据的 token 数)—— 因为所有技能的头部元数据都是常驻上下文的,装 100 个技能的固定成本必须可测量。
而这和第 5.2 节的工具延迟加载是完全相同的模式:
一句话概括:目录常驻(便宜),内容按需展开(贵)。
| 场景 | 常驻的部分 | 按需加载的部分 |
|---|---|---|
| 工具 | 名字 + 关键词 | 完整的参数格式说明 |
| 技能 | 名字 + 一句话描述 | 完整的技能正文 |
| MCP | 服务列表 | 该服务下的具体工具 |
| 记忆 | MEMORY.md 索引 | 具体的记忆文件内容 |
这四个场景是同一个设计模式的四次应用。面试里能把它们归纳成一条原则,比逐个描述强得多 —— 因为它证明你看到的是模式,不是细节。
Hermes 的插件发现三个来源
~/.hermes/plugins/ 用户级(对这台机器上的所有项目生效)
./.hermes/plugins/ 项目级(跟着代码仓库走,团队共享)
pip entry points 包级(用 pip install 安装某个包就自动生效)
而且 Hermes 区分了两类插件,这是一个容易被忽略但很重要的设计点:
可叠加的:工具插件、平台适配器插件。装 10 个就有 10 份能力,互不冲突。
互斥的(Hermes 里叫「单选」插件):记忆提供者、上下文引擎。
为什么这两类必须区别对待?因为记忆提供者和上下文引擎是「策略」而不是「能力」。同时装两个上下文引擎没有任何意义 —— 一个说要压缩、一个说不压缩,系统该听谁的?
所以 Hermes 在插件系统层面就把它们标记成单选,装第二个时直接报错,而不是留到运行时产生诡异行为。
在你自己的插件系统里,这个区分要在设计阶段就做出来。否则用户装了两个策略插件,你的系统会以某种未定义的方式工作,而且极难排查。
9.5 Hermes 的网关层:Claude Code 完全没有的一层
gateway/run.py 是单个文件 1.55 MB,是 Hermes 最大的模块。它解决的是 Claude Code 根本不面对的问题:一个长期在线的智能体,如何被 22 个不同的聊天平台以统一的方式触达。
gateway/
├── run.py 长驻主循环
├── platforms/base.py BasePlatformAdapter 抽象基类,333 KB
├── platform_registry.py 平台注册表
├── profile_routing.py 多身份路由(一个进程可以承载多个不同人格的智能体)
├── delivery.py 消息投递
├── delivery_ledger.py 投递账本(防止重复发送)
├── restart.py 重启逻辑
├── restart_loop_guard.py 重启风暴防护(防止崩溃-重启-崩溃的无限循环)
├── memory_monitor.py 内存占用监控
├── agent_cache_pressure.py 智能体缓存压力管理
├── drain_control.py 优雅排空(关闭前把手头的消息处理完)
└── builtin_hooks/ 内置钩子
那个抽象基类 BasePlatformAdapter 抽出的能力差异非常细致:
def max_message_length_for_chat(chat_id) -> int # 这个平台单条消息最长多少字
def supports_draft_streaming(...) -> bool # 支持"草稿式流式更新"吗
def prefers_fresh_final_streaming(...) -> bool # 偏好重发最终版本吗
def streaming_overflow_limit() -> Optional[int] # 流式更新的溢出上限
def enforces_own_access_policy() -> bool # 平台自己管权限吗
def authorization_is_upstream() -> bool # 授权在上游完成吗
def format_tool_event(event, *, mode='all') -> str # 工具事件怎么渲染显示
class EphemeralReply(str): ... # 带过期时间的临时回复
hermes-agent/gateway/platforms/base.py
不同聊天平台的能力差异非常大:
- Slack 支持编辑已发出的消息 → 可以流式更新同一条消息,用户看到文字逐渐生长
- 短信 不支持编辑 → 只能发新消息,流式输出根本没法做
- Discord 单条消息上限 2,000 字;Telegram 是 4,096 字
- 企业微信 自己有一套完整的权限体系;IRC 完全没有权限概念
把这些差异抽象成「能力查询方法」,而不是写成 if platform == 'slack' 的分支 —— 这是这个适配层能撑住 22 个平台的根本原因。
新增一个平台 = 实现一组能力声明。而不是往主流程里再加一堆分支判断。
这个模式的通用价值:当你要适配 3 个以上的外部系统时,就该把「它们的差异」抽象成一组问题,让每个适配器自己回答,而不是在主流程里做分支。
先区分三种「记忆」,别一股脑塞进向量数据库:
- 指令性记忆(用户偏好、团队约定、项目规范)—— 这类不该用检索。它应该是人类可读、可以审阅、可以用 git 管理的纯文本,每次全量加载。理由:用户改了要立刻生效,而且必须能看到自己写了什么。Claude Code 的
CLAUDE.md就是这个定位。 - 事实性记忆(谁是谁、什么时候做了什么决定)—— 这类需要检索。词法检索(FTS5)+ 向量检索混合,优于纯向量,因为事实里有大量专有名词,而向量检索恰好对专有名词不敏感。
- 过程性记忆(上次这个问题是怎么解决的)—— 本质是会话历史检索,存对话记录 + 全文索引。
然后讲两个容易被忽略的工程点:
- 记忆需要信任衰减。Hermes 用
trust_score加record_feedback(fact_id, helpful):被证明有用的记忆上浮,误导过人的沉底。没有这个机制,一条早期的错误记忆会永久污染后续所有会话。 - 注入要去重。模型自己刚刚读过的文件,不要再作为「记忆」注入一遍。Claude Code 用跨迭代累积的已读文件状态来过滤 —— 注意是跨迭代累积,只看本次迭代会漏掉早期读过的。
如果对方问到向量方案的选型,可以提 Hermes 的 HRR 作为一个有意思的对照:用 SHA-256 确定性生成原子向量,同一个词在任何机器、任何版本上编码结果完全一致 —— 彻底避开了「换 embedding 模型要重算全库」这个运维噩梦。代价是它是词袋级的符号组合,没有语义泛化能力(「docker」和「container」相似度接近 0),所以必须配合词法检索使用,而不是替代它。这个权衡本身就很值得讨论。