2、Hermes 的记忆架构
2.1、三层记忆
2.1.1、 宏观视图:从 Agent 能力到记忆层级
假设你的 Agent 今天帮你分析了一个 Python 项目,学到了你偏好使用 pytest 而不是 unittest。
明天再用它时,它是否能记得这一点?
在 Hermes 中的答案是 YES,而且有三个层级的记忆机制保证这一点:

2.1.2、 精细视图:promort中的三层内存
从 LLM API 调用的视角看,Hermes 的内存系统是一个精细设计的三层架构,每层有不同的职责:

┌─────────────────────────────────────────────────────────┐
│ 第1层:系统提示词 (System Prompt) │
│ ├─ Agent 身份 (DEFAULT_AGENT_IDENTITY) │
│ ├─ 工具使用指引 (TOOL_USE_ENFORCEMENT_GUIDANCE) │
│ ├─ 任务完成指引 (TASK_COMPLETION_GUIDANCE) │
│ ├─ 内存指引 (MEMORY_GUIDANCE) │
│ ├─ Skill 指引 (SKILLS_GUIDANCE) │
│ ├─ 持久化内存内容 (prefetch_all 的结果) │
│ └─ 工具定义 (tool_schemas) │
│ ↑ 这一层在对话启动时一次性注入 │
│ ↑ 发送到 LLM 的每一次调用都包含它 │
│ ↑ 一旦发送就被缓存(Prompt Caching) │
└─────────────────────────────────────────────────────────┘
↑
│ 由系统提示词引导模型
│
┌─────────────────────────────────────────────────────────┐
│ 第2层:对话历史 (Message History) │
│ ├─ 用户消息 (role: "user") │
│ ├─ 助手响应 (role: "assistant") │
│ ├─ 工具调用结果 (role: "tool") │
│ └─ 思考内容 (reasoning, thinking) │
│ ↑ 存储在会话数据库中 │
│ ↑ 当前对话历史全量发送给 LLM │
│ ↑ 不被缓存(每次新增内容都会添加) │
└─────────────────────────────────────────────────────────┘
↑
│ 通过 session_search 查询
│
┌─────────────────────────────────────────────────────────┐
│ 第3层:跨会话记忆 (Persistent Memory) │
│ ├─ 用户偏好 ("User prefers concise responses") │
│ ├─ 项目结构 ("Project uses pytest with xdist") │
│ ├─ 工具配置 ("GitHub API rate limit is 5000/hour") │
│ └─ 持久化事实 (用户通过 memory 工具保存) │
│ ↑ 存储在 ~/.hermes/memory/ 或插件提供者 │
│ ↑ 对话启动时被预取并注入第1层 │
│ ↑ 被缓存(与系统提示词一起) │
└─────────────────────────────────────────────────────────┘
2.1.3、 三层内存的数据流:冷启动 vs 命中缓存
让我们用一个真实的对话案例来理解内存流动:
第一次对话(冷启动)
用户输入: "帮我分析这个 Python 项目"
↓
agent._build_system_prompt()
├─ 读取 DEFAULT_AGENT_IDENTITY
│ "You are Hermes Agent, an intelligent AI assistant..."
├─ 读取 MEMORY_GUIDANCE
│ "You have persistent memory across sessions..."
├─ 调用 memory_manager.prefetch_all(user_message)
│ ↓
│ 检查是否有保存的记忆 (例如: "User prefers Python analysis")
│ ↓ 返回: "" (第一次对话,没有记忆)
└─ 编译工具定义
get_tool_definitions(enabled_toolsets)
↓ 返回所有工具的 JSON Schema
最终系统提示词 (~2-5KB):
{
"role": "system",
"content": "[Agent Identity]\n[Memory Guidance]\n[Skill Guidance]\n[Tools Definitions...]"
}
↓ 第1次 API 调用 (冷启动,无缓存)
messages = [
{"role": "system", "content": "[5KB system prompt]"},
{"role": "user", "content": "帮我分析这个 Python 项目"}
]
client.chat.completions.create(
model="claude-opus-4.6",
messages=messages,
tools=tool_definitions,
...
)
响应:
{
"choices": [{"message": {
"role": "assistant",
"content": "I'll analyze the project. Let me start by...",
"tool_calls": [
{"id": "call_123", "function": {"name": "read_file", "arguments": "{\"path\": \"./README.md\"}"}}
]
}}]
}
↓ 保存到会话数据库
session.append_message("assistant", {...})
session.append_message("tool", {"tool_call_id": "call_123", "content": "# Project README..."})
↓ 继续循环调用工具...
第二次对话(命中缓存)
用户输入: "现在给我生成单元测试"
↓
agent.prefetch_all(user_message)
├─ 检查是否有保存的记忆
├─ 检查是否需要查询 session_search
└─ 返回: "[memory-context]\nUser prefers Python analysis\n[/memory-context]" (如果有的话)
agent._build_system_prompt()
└─ 返回与第一次**完全相同**的系统提示词 (byte-exact match)
这对 Prompt Caching 至关重要!
第1次 API 调用 (命中缓存):
messages = [
{"role": "system", "content": "[完全相同的 5KB 系统提示词]"}, ← Claude 识别出这部分已缓存!
{"role": "user", "content": "现在给我生成单元测试"}
]
成本计算:
第1次调用:
- 输入 tokens: ~3000 (系统提示 + 工具定义)
- 缓存写入 tokens: ~3000 × 1.25 (更贵 25%)
- 输入 tokens: ~50 (新的用户消息)
- 总费用: 3050 × 价格 + 3000 × 0.25 × 价格
第2次调用:
- 缓存命中 tokens: ~3000 × 0.1 (便宜 90%)
- 输入 tokens: ~50
- 总费用: (3000 × 0.1 + 50) × 价格 = 大幅降低!
2.2、内置记忆系统(MEMORY.md + USER.md)
2.2.1、 MEMORY.md:Agent 的学习笔记
用途 :Agent 保存关于 环境、项目、工具 的发现
# 典型的 MEMORY.md 内容
偏好使用 pytest 而不是 unittest
§
项目使用 Monorepo 结构
§
Docker 的构建命令是 `docker build -t myapp:latest .`
§
GitHub Actions 工作流文件位置:.github/workflows/
§
Python 版本:3.11+(项目最低要求)
§
什么时候 Agent 应该保存到 MEMORY.md?
Hermes 将这套规则直接写进了工具的 schema 描述中,让模型在每次 API 调用时都能看到------这是核心的行为控制机制(tools/memory_tool.py:741-764):
MEMORY_SCHEMA = {
"name": "memory",
"description": (
"Save durable information to persistent memory that survives across sessions. "
"Memory is injected into future turns, so keep it compact and focused on facts "
"that will still matter later.\n\n"
"WHEN TO SAVE (do this proactively, don't wait to be asked):\n"
"- User corrects you or says 'remember this' / 'don't do that again'\n"
"- User shares a preference, habit, or personal detail (name, role, timezone, coding style)\n"
"- You discover something about the environment (OS, installed tools, project structure)\n"
"- You learn a convention, API quirk, or workflow specific to this user's setup\n"
"- You identify a stable fact that will be useful again in future sessions\n\n"
"PRIORITY: User preferences and corrections > environment facts > procedural knowledge. "
"The most valuable memory prevents the user from having to repeat themselves.\n\n"
"Do NOT save task progress, session outcomes, completed-work logs, or temporary TODO "
"state to memory; use session_search to recall those from past transcripts.\n"
"If you've discovered a new way to do something, solved a problem that could be "
"necessary later, save it as a skill with the skill tool.\n\n"
"TWO TARGETS:\n"
"- 'user': who the user is -- name, role, preferences, communication style, pet peeves\n"
"- 'memory': your notes -- environment facts, project conventions, tool quirks, lessons learned\n\n"
"ACTIONS: add (new entry), replace (update existing -- old_text identifies it), "
"remove (delete -- old_text identifies it).\n\n"
"SKIP: trivial/obvious info, things easily re-discovered, raw data dumps, and temporary task state."
),
}
MEMORY_SCHEMA = {
"name": "memory",
"description": (
"保存持久化信息到永久记忆中,跨会话存活。"
"记忆会被注入到未来的回合中,所以保持紧凑和专注于未来仍然重要的事实。\n\n"
"何时保存(主动执行,不要等待被要求):\n"
"- 用户纠正你或说"记住这个"/"不要再那样做了"\n"
"- 用户分享偏好、习惯或个人细节(名字、角色、时区、编码风格)\n"
"- 你发现关于环境的信息(操作系统、已安装工具、项目结构)\n"
"- 你学到用户特定设置中的约定、API 怪异行为或工作流\n"
"- 你识别出一个稳定的事实,在未来会话中会很有用\n\n"
"优先级:用户偏好和纠正 > 环境事实 > 程序知识。"
"最有价值的记忆是防止用户必须重复自己。\n\n"
"不要保存任务进度、会话成果、已完成工作日志或临时 TODO 状态到记忆中;"
"使用 session_search 从过去的记录中回忆这些。\n"
"如果你发现了新的做事方式、解决了未来可能需要的问题,"
"用 skill 工具将其保存为一个 skill。\n\n"
"两个目标:\n"
"- 'user':用户是谁 -- 名字、角色、偏好、沟通风格、禁忌\n"
"- 'memory':你的笔记 -- 环境事实、项目约定、工具怪异、经验教训\n\n"
"操作:add(新条目)、replace(更新现有 -- old_text 标识它)、"
"remove(删除 -- old_text 标识它)。\n\n"
"跳过:琐碎/明显的信息、容易重新发现的内容、原始数据转储和临时任务状态。"
),
}
✅ 应该保存(优先级从高到低):
-
用户的纠正("不要用 unittest")
-
用户的偏好、习惯
-
环境发现(OS、工具版本、项目结构)
-
约定、API 特性、工作流规则
-
稳定的工程事实
❌ 不应该保存(用 session_search 查询历史):
-
任务进度("已完成了 3 个测试")
-
已完成工作日志
-
临时 TODO 状态
-
会很快过期的临时信息
2.2.2、 USER.md:对用户的认知
用途 :Agent 保存关于 你 的发现
# 典型的 USER.md 内容
偏好简洁的回答,避免冗长的解释
§
工作时间:周一到周五 9-17 点
§
使用 Python,不太熟悉 Go
§
习惯在 Slack 上接收通知
§
完成任务后倾向于要求验证而不是自动假设成功
§
✅ 应该保存:
-
你的表达偏好("偏好简洁")
-
你的工作习惯("工作时间")
-
你的技能("熟悉 Python,不熟悉 Go")
-
你的需求模式("倾向于要求验证")
❌ 不应该保存:
-
你的个人信息(名字、邮箱)
-
访问令牌或 API 密钥
-
对话中的临时指示
2.2.3、 Memory 工具的使用
通过 memory****工具管理记忆:
# 查看现有记忆
memory(action="read")
# 返回:
# MEMORY.md (234/2200 chars):
# 偏好使用 pytest 而不是 unittest
# 项目使用 Monorepo 结构
#
# USER.md (156/1375 chars):
# 偏好简洁的回答
# 工作时间:周一到周五 9-17 点
# 添加新记忆
memory(
action="add",
target="memory",
content="数据库连接字符串格式:postgresql://user:pass@localhost:5432/db"
)
# 替换记忆(模糊匹配)
memory(
action="replace",
target="memory",
old_text="偏好使用", # 模糊查找
content="优先使用 pytest 进行测试,但项目中 unittest 仍被支持"
)
# 删除记忆
memory(
action="remove",
target="memory",
old_text="临时信息" # 删除包含"临时信息"的条目
)
2.2.4、 字符限制与记忆膨胀处理
# 字符限制(不是 Token 限制)
MEMORY_CHAR_LIMIT = 2200 # 约 500-800 tokens
USER_CHAR_LIMIT = 1375 # 约 300-400 tokens
# 为什么这么小?
# - 内存会被注入系统提示,影响每次 API 调用的成本
# - 小限制强制 Agent 只记忆"信号",不是"噪音"
# - 总共 ~1200 tokens 的内存,缓存后成本很低
当记忆满时会发生什么?
Hermes 的策略是完全手动清理,没有任何自动清理机制。这是一个明确的设计决策:自动删除可能移除用户认为重要的记忆,只有用户知道什么信息该被保留。
当 MEMORY.md 接近限制时,新增操作会被直接拒绝 ,并返回所有当前条目让用户决策(tools/memory_tool.py:297-341):
def add(self, target: str, content: str) -> Dict[str, Any]:
"""Append a new entry. Returns error if it would exceed the char limit."""
# 计算如果添加这个条目,总字符数会是多少
new_entries = entries + [content]
new_total = len(ENTRY_DELIMITER.join(new_entries))
# 超过限制了?直接拒绝并返回所有条目
if new_total > limit:
current = self._char_count(target)
return {
"success": False,
"error": (
f"Memory at {current:,}/{limit:,} chars. "
f"Adding this entry ({len(content)} chars) would exceed the limit. "
f"Consolidate now: use 'replace' to merge overlapping entries into "
f"shorter ones or 'remove' stale or less important entries (see "
f"current_entries below), then retry this add --- all in this turn."
),
"current_entries": entries, # ← 返回所有条目,让模型看到并整理
"usage": f"{current:,}/{limit:,}",
}
超限时用户必须手动处理:
场景:Memory 已经 2150/2200 字符
Agent 试图添加:memory(action='add', content='新发现:使用 Black 代码格式化')
返回:
{
"success": false,
"error": "Memory at 2,150/2,200 chars. Adding this entry (28 chars) would exceed the limit...",
"current_entries": [
"偏好使用 pytest",
"项目使用 Monorepo",
"API 限制 5000/hour",
...(所有条目都会返回)
],
"usage": "2,150/2,200 chars"
}
处理步骤:
-
查看全部条目:通过 current_entries 看到所有现有条目
-
删除过时的:memory(action='remove', old_text='API 限制...')
-
合并重复的:memory(action='replace', old_text='偏好使用...', content='更短版本')
-
重试添加:空间释放后再 add
2.2.5、 内置记忆的其他约束
限制 1:无版本历史
- 如果你用 memory(action='replace', ...) 替换了某条 , 原来的内容就永远丢失了,保持简单,不引入版本控制的复杂性
限制 2:不支持搜索
-
不能像 session_search 那样查询内存
-
必须用 memory(action='read') 查看所有
-
设计决策:内存本来就很小,全量读取很快
限制 3:只支持文本
-
不能存储结构化数据(JSON、数据库记录)
-
只支持纯文本 Markdown
-
设计决策:简单、可移植、易于手动编辑
2.3、记忆的流动(Prefetch → Inject → Cache)
2.3.1、冻结快照:缓存保持的核心机制
快照的生成 (在对话启动时,tools/memory_tool.py:132-170):
def load_from_disk(self):
"""Load entries from MEMORY.md and USER.md, capture system prompt snapshot."""
mem_dir = get_memory_dir()
mem_dir.mkdir(parents=True, exist_ok=True)
# 1. 从磁盘读取实时条目
self.memory_entries = self._read_file(mem_dir / "MEMORY.md") # ← 实时
self.user_entries = self._read_file(mem_dir / "USER.md") # ← 实时
# 2. 去重
self.memory_entries = list(dict.fromkeys(self.memory_entries))
self.user_entries = list(dict.fromkeys(self.user_entries))
# 3. 创建冻结快照(扫描安全问题)
sanitized_memory = self._sanitize_entries_for_snapshot(
self.memory_entries, "MEMORY.md"
)
sanitized_user = self._sanitize_entries_for_snapshot(
self.user_entries, "USER.md"
)
# 4. 保存冻结快照(这个不会改变,整个会话都用它)
self._system_prompt_snapshot = {
"memory": self._render_block("memory", sanitized_memory), # ← 冻结
"user": self._render_block("user", sanitized_user), # ← 冻结
}
快照的实际格式(_render_block 生成,tools/memory_tool.py:482-498):
def _render_block(self, target: str, entries: List[str]) -> str:
"""Render a system prompt block with header and usage indicator."""
if not entries:
return ""
limit = self._char_limit(target)
content = ENTRY_DELIMITER.join(entries) # 用 § 分隔
current = len(content)
pct = min(100, int((current / limit) * 100)) if limit > 0 else 0
if target == "user":
header = f"USER PROFILE (who the user is) [{pct}% --- {current:,}/{limit:,} chars]"
else:
header = f"MEMORY (your personal notes) [{pct}% --- {current:,}/{limit:,} chars]"
separator = "═" * 46
return f"{separator}\n{header}\n{separator}\n{content}"
注入系统提示后的样子:
════════════════════════════════════════════
MEMORY (your personal notes) [45% --- 990/2200 chars]
════════════════════════════════════════════
偏好使用 pytest 而不是 unittest
§
项目使用 Monorepo 结构,每个子包有独立的 pyproject.toml
§
GitHub API 限制 5000/hour,需要实现速率限制
2.3.2、完整的记忆召回流程
完整调用链:
用户开始新对话
↓
agent.run_conversation(user_message)
↓
_restore_or_build_system_prompt()
├─ 如果是继续对话:从 session DB 恢复冻结快照(无 IO 开销)
└─ 如果是新对话:构建新系统提示
├─ memory_manager.prefetch_all(user_message)
│ ├─ 读取 ~/.hermes/memories/MEMORY.md
│ ├─ 读取 ~/.hermes/memories/USER.md
│ ├─ 调用所有外部记忆提供者的 prefetch()
│ └─ 返回格式化的记忆块
│
├─ 将记忆块注入系统提示
├─ 保存冻结快照到 session DB
└─ 发送给 Claude
├─ Claude 缓存这个系统提示
└─ 返回响应
↓
对话进行中:记忆不变,快照不变,缓存持续命中
↓
对话结束:
├─ memory_manager.sync_all() ← 外部提供者写入(如 Honcho 总结)
└─ memory_manager.queue_prefetch_all() ← 后台为下一个对话预取
2.3.3、MemoryManager 的实现
# agent/memory_manager.py
class MemoryManager:
"""Orchestrates the built-in provider plus at most one external provider.
The builtin provider is always first. Only one non-builtin (external)
provider is allowed. Failures in one provider never block the other.
"""
def __init__(self) -> None:
self._providers: List[MemoryProvider] = [] # 内存提供者列表
self._tool_to_provider: Dict[str, MemoryProvider] = {} # 工具到提供者的映射
self._has_external: bool = False # 是否已添加外部提供者
self._sync_executor: Optional[ThreadPoolExecutor] = None # 后台线程池
def prefetch_all(self, query: str, *, session_id: str = "") -> str:
"""预取所有记忆提供者的内容,返回一个格式化的块."""
parts = []
for provider in self._providers:
try:
result = provider.prefetch(query, session_id=session_id)
if result and result.strip():
parts.append(result)
except Exception as e:
# 一个提供者失败不影响其他
logger.debug(
"Memory provider '%s' prefetch failed (non-fatal): %s",
provider.name, e,
)
return "\n\n".join(parts)
def sync_all(self, user_message: str, assistant_message: str) -> None:
"""在对话结束时,同步到所有提供者."""
for provider in self._providers:
try:
provider.sync_turn([
{"role": "user", "content": user_message},
{"role": "assistant", "content": assistant_message}
])
except Exception as e:
logger.warning("sync_turn failed for %s: %s", provider.name, e)
def queue_prefetch_all(self, user_message: str) -> None:
"""为下一个对话在后台预取."""
def _bg_prefetch():
for provider in self._providers:
if hasattr(provider, "queue_prefetch"):
try:
provider.queue_prefetch(user_message)
except Exception as e:
logger.debug("queue_prefetch failed: %s", e)
self._submit_background(_bg_prefetch)
关键特点:
-
Builtin 提供者 总是返回冻结快照(该会话启动时读的)
-
外部提供者 可以返回动态内容(如 Honcho 的最新总结)
-
故障隔离:一个提供者失败不影响整个对话
-
后台预取:下个对话的数据已经准备好,无延迟
2.4、外部记忆插件生态
2.4.1、为什么需要外部记忆
内置记忆(MEMORY.md + USER.md)有一个根本限制:只有 2200 + 1375 = 3575 字符。
对于长期使用 Agent 的场景,这远远不够。外部记忆插件的作用是突破这个限制,并提供更智能的记忆管理能力:自动总结旧对话、语义检索、知识图谱等。
| 提供者 | 核心特性 | 适合场景 | 存储方式 | 接入难度 |
| Honcho | 通过"辩证"推理从对话中归纳用户的深层特征和偏好,生成用户画像 | 长期使用 Agent、需要 Agent 深入了解工作方式 | 本地 SQLite 数据库 | ★★☆ |
| Mem0 | 将记忆向量化,支持语义相似度检索,自动去重,智能召回 | 记忆量大、需要"找相关的"而不是"找精确的"时 | 向量数据库(Qdrant、Pinecone 等) | ★★★ |
| Hindsight | 自动将旧对话总结压缩,生成可供未来对话参考的摘要,长期上下文管理 | 有大量历史对话、想让 Agent 自动"温习"重要对话 | 本地存储(JSON/SQLite) | ★★☆ |
| SuperMemory | 将记忆组织成知识图谱,能理解记忆之间的关系和依赖 | 需要 Agent 理解知识之间的关联("工具 A 依赖 B"这样的关联推理) | 图数据库 | ★★★ |
| Holographic | 多层次记忆管理,不同重要性的信息存储在不同层级,支持分级遗忘 | 需要细粒度控制哪些记忆更"核心"、哪些可以遗忘 | 本地存储 | ★★☆ |
| OpenViking | 可扩展的开放向量存储,支持大规模部署和自定义扩展 | 企业级部署、需要自托管向量存储、大规模用户场景 | 自托管向量存储 | ★★★ |
| Byterover | 字节跳动内部技术支持,云端企业级记忆存储,支持团队协作 | 企业级场景、需要云端持久化和团队共享、内部部署 | 云后端 | ★★★ |
| RetainDB | 混合检索(向量 + BM25 + Reranking),专注于记忆的长期保留 | 需要确保记忆永久保留、跨设备同步、混合检索场景 | 云存储 | ★★★ |
|---|
# agent/memory_provider.py
class MemoryProvider(ABC):
"""所有记忆提供者必须实现的接口."""
@property
@abstractmethod
def name(self) -> str:
"""提供者的标识符,例如 'honcho', 'mem0'."""
@abstractmethod
def is_available(self) -> bool:
"""检查是否配置正确且可用(无网络调用)."""
@abstractmethod
def initialize(self, session_id: str, **kwargs) -> None:
"""初始化提供者(可以创建资源、连接、线程等)."""
def system_prompt_block(self) -> str:
"""返回要注入系统提示的静态文本(可选)."""
return ""
def prefetch(self, query: str, *, session_id: str = "") -> str:
"""预取相关记忆,返回格式化文本."""
return ""
def queue_prefetch(self, query: str, *, session_id: str = "") -> None:
"""在后台为下一个对话预取(可选)."""
pass
def sync_turn(self, user_content: str, assistant_content: str, **kwargs) -> None:
"""在对话结束时持久化本轮信息."""
pass
@abstractmethod
def get_tool_schemas(self) -> List[Dict[str, Any]]:
"""返回这个提供者暴露的工具定义(可以是空列表)."""
def handle_tool_call(self, tool_name: str, args: Dict[str, Any]) -> str:
"""处理对这个提供者的工具调用."""
raise NotImplementedError()
def shutdown(self) -> None:
"""清理关闭(刷新队列、关闭连接)."""
pass
# 可选的生命周期钩子
def on_session_end(self, messages: List[Dict]) -> None:
"""会话结束时调用(不是每个对话末尾)."""
pass
def on_delegation(self, task: str, result: str, **kwargs) -> None:
"""父 Agent 看到子 Agent 完成时调用."""
pass
2.4.2、4 个写入时机
时机 1(最主要):每轮对话正常结束后
每次用户得到响应、对话完成,Hermes 就会调用 _sync_external_memory_for_turn()(run_agent.py),同步本轮内容到外部插件,同时触发下一轮的预取:
# run_agent.py --- _sync_external_memory_for_turn()
def _sync_external_memory_for_turn(self, *, original_user_message,
final_response, interrupted, ...):
if interrupted:
return # ← 被中断的对话不写入!防止把未完成的对话污染记忆库
self._memory_manager.sync_all(
original_user_message, # 用户原始消息(不含注入的 skill 内容)
final_response, # AI 最终回答
messages=messages, # 完整消息列表(含工具调用记录)
)
# 同时触发下一轮对话的预取
self._memory_manager.queue_prefetch_all(original_user_message)
所有 sync_all 调用都在后台线程执行,不阻塞用户看到响应:
# agent/memory_manager.py
def sync_all(self, user_content, assistant_content, ...):
def _run():
for provider in providers:
provider.sync_turn(user_content, assistant_content, ...)
# 扔进后台 worker,用户不等它
self._submit_background(_run)
时机 2:Agent 主动调用 memory****工具写 USER.md 时
当 Agent 用 memory(action='add', target='user', ...) 更新用户画像时,Hermes 会同步通知外部插件,让外部插件也把这条内容镜像一份:
# agent/tool_executor.py
if function_name == "memory" and next_args.get("action") in {"add", "replace"}:
agent._memory_manager.on_memory_write(action, target, content)
以 Honcho 为例,收到通知后把 USER.md 的新内容作为"结论"写进外部存储:
# plugins/memory/honcho/__init__.py --- on_memory_write()
def on_memory_write(self, action, target, content, ...):
if target != "user": # 只镜像 USER.md,不镜像 MEMORY.md
return
def _write():
self._manager.create_conclusion(self._session_key, content)
threading.Thread(target=_write, daemon=True).start()
时机 3:会话 session 结束时( on_session_end**)**
当 session_id 轮换(/new、/reset、上下文压缩)时触发,让插件做最后的 flush:
# run_agent.py --- commit_memory_session()
self._memory_manager.on_session_end(messages)
# Honcho: flush 所有待发送消息
# Supermemory: 把整段对话作为一个完整 conversation 发送
时机 4:上下文压缩前( on_pre_compress**)**
即将压缩上下文时,先通知外部插件"你要提前提取什么重要信息吗?":
# agent/memory_manager.py
def on_pre_compress(self, messages):
for provider in self._providers:
result = provider.on_pre_compress(messages)
# 返回的内容会被加进压缩摘要的 prompt 里
2.4.3、外部记忆插件存的是什么?
以mem0和Honcho为例
Mem0:服务端 LLM 事实提取,存的是"提炼后的事实"
# plugins/memory/mem0/__init__.py --- sync_turn()
def sync_turn(self, user_content, assistant_content, ...):
messages = [
{"role": "user", "content": user_content},
{"role": "assistant", "content": assistant_content},
]
# 发给 Mem0 平台,Mem0 用自己的 LLM 从对话里提取关键事实
client.add(messages, user_id=self._user_id, agent_id=self._agent_id)
# 例如:原始对话 "我项目用 pytest" → 提取并存储 "用户的项目使用 pytest"
# 同时自动去重:再次提到 pytest 不会存两条,而是合并
Agent 也可以用 mem0_conclude 工具主动、精确地存入事实(绕过 LLM 提取,直接存原文):
# mem0_conclude 工具 --- infer=False 表示不经 LLM 提取
client.add(
[{"role": "user", "content": "用户偏好 vim 而非 VS Code"}],
infer=False, # 直接存这句话,不做推断
)
Honcho:存原始对话流,在后台做辩证推理
# plugins/memory/honcho/__init__.py --- sync_turn()
def sync_turn(self, user_content, assistant_content, ...):
session = self._manager.get_or_create(self._session_key)
# 存原始消息,不做任何提取
session.add_message("user", clean_user_content)
session.add_message("assistant", clean_assistant_content)
self._manager._flush_session(session)
# Honcho 服务端在后台用"辩证推理"从累积的对话中
# 推断出用户深层特征,更新 peer card(用户画像)
两者对比:
| Mem0 | Honcho | |
|---|---|---|
| 存储内容 | LLM 提炼的事实碎片 | 完整对话原文 |
| 谁做提取 | Mem0 平台服务端 LLM | Honcho 辩证推理引擎 |
| 自动去重 | ✅ | ❌(原文累积) |
| 深层建模 | ❌ | ✅(peer card 用户画像) |
2.4.4、外部记忆检索-预热 + 零延迟读取
整个检索分两个阶段,巧妙地把延迟"藏在"上一轮结束到下一轮开始之间:
Turn N 结束
│
└─ queue_prefetch_all() ← 后台线程立即开始检索
Mem0: 向量语义搜索 → top5 相关事实 → 缓存 _prefetch_result
Honcho: 辩证推理查询 → 推理答案 → 缓存 _prefetch_result
↓ (后台跑,用户感知不到延迟)
Turn N+1 开始
│
└─ prefetch_all() ← 读缓存,几乎零延迟
return self._prefetch_result # 直接取上一步预热的结果
Mem0 的检索逻辑(向量语义搜索 + reranking):
# plugins/memory/mem0/__init__.py --- queue_prefetch()
def queue_prefetch(self, query, ...):
def _run():
results = client.search(
query=query, # 用用户消息做语义检索
filters={"user_id": self._user_id},
rerank=True, # 有 reranking 二次精排!
top_k=5, # 只取最相关的 5 条
)
lines = [r.get("memory", "") for r in results]
self._prefetch_result = "\n".join(f"- {l}" for l in lines)
threading.Thread(target=_run, daemon=True).start() # 后台跑
Honcho 的检索逻辑(双层:静态画像 + 动态推理):
# plugins/memory/honcho/__init__.py --- prefetch()
def prefetch(self, query, ...):
parts = []
# Layer 1:基础上下文(representation + peer card)
# Honcho 对"这个用户是谁"的持久建模结果(静态,按 cadence 刷新)
base_context = self._base_context_cache # 用户画像
parts.append(base_context)
# Layer 2:辩证推理补充(针对当前问题的动态推理)
# 向 Honcho 发起"AI 问 AI"的辩证查询:
# 例如:"关于这个用户对 Python 的偏好,你知道什么?"
# Honcho 综合所有历史对话生成一个推理性答案
dialectic_result = self._prefetch_result # 上一轮预热的结果
parts.append(dialectic_result)
2.4.5、检索结果怎么注入给模型?
所有提供者的 prefetch() 返回的字符串,统一被 build_memory_context_block() 包裹:
# agent/memory_manager.py
def build_memory_context_block(raw_context: str) -> str:
return (
"<memory-context>\n"
"[System note: The following is recalled memory context, "
"NOT new user input. Treat as authoritative reference data --- "
"this is the agent's persistent memory and should inform all responses.]\n\n"
f"{clean}\n"
"</memory-context>"
)
模型实际看到的格式:
<memory-context>
[System note: The following is recalled memory context, NOT new user input.
Treat as authoritative reference data...]
## Mem0 Memory
- 用户的项目使用 pytest 测试框架
- 用户偏好简洁的代码注释风格
- 用户在 ~/code/myapi 目录下有一个 FastAPI 项目
</memory-context>
[用户的新消息]
<memory-context> 标签的作用:明确告知模型这是"记忆参考资料"而非"新的用户指令",防止模型混淆历史记忆和当前请求,这也是一种防幻觉机制(模型不会把召回的旧任务当作新任务执行)。
2.4.6、完整流程一图总结
Turn N 结束
├─ [写入] sync_all() → 后台写入 Mem0/Honcho
│ Mem0: (user, assistant) → 服务端 LLM 提取事实 → 向量存储 + 去重
│ Honcho: (user, assistant) → 原文存储 → 服务端辩证推理更新 peer card
│
└─ [预热] queue_prefetch_all() → 后台检索
Mem0: 用 user_message 做向量语义搜索 → top5 + reranking → 缓存
Honcho: 辩证推理查询 → 推理答案 → 缓存
Turn N+1 开始
└─ [读取] prefetch_all() → 读缓存(零延迟)
→ 包裹进 <memory-context> 注入本轮上下文
→ 发给 LLM,模型带着这些记忆响应用户
2.4.7、约束:一次只能启用一个外部提供者
这是 Hermes 的硬性限制。多个外部提供者同时运行会导致记忆内容相互冲突,且工具 schema 膨胀(每个提供者都会暴露自己的工具):
# agent/memory_manager.py:273-294
def add_provider(self, provider: MemoryProvider) -> None:
"""Register a memory provider.
Built-in provider (name ``"builtin"``) is always accepted.
Only **one** external (non-builtin) provider is allowed --- a second
attempt is rejected with a warning.
"""
is_builtin = provider.name == "builtin"
if not is_builtin:
if self._has_external:
existing = next(
(p.name for p in self._providers if p.name != "builtin"), "unknown"
)
logger.warning(
"Rejected memory provider '%s' --- external provider '%s' is "
"already registered. Only one external memory provider is "
"allowed at a time.",
provider.name, existing,
)
return
self._has_external = True
self._providers.append(provider)
为什么有这个限制?
-
工具 schema 膨胀:每个外部提供者都可能暴露新工具,多个提供者会显著增加每次 API 调用的成本
-
避免冲突:不同提供者的记忆格式和内容可能相互矛盾
-
保持简单:一个明确的提供者更容易调试和理解
2.5 历史会话记忆:FTS5 深度解析
Hermes 选择了 SQLite FTS5 全文检索作为历史查询的主力。
FTS5 是什么?核心原理:倒排索引
FTS5(Full-Text Search 5)的底层是倒排索引(Inverted Index),和 Elasticsearch、Lucene 用的是同一种数据结构:
正向索引(普通数据库) 倒排索引(FTS5)
消息1 → ["pytest", "用", "写"] "pytest" → [消息1, 消息5, 消息9]
消息2 → ["auth", "重构"] →→→ "auth" → [消息2, 消息7]
消息5 → ["pytest", "单元"] "单元" → [消息5, 消息8]
查询 "pytest" 的过程:
-
直接在索引里查 "pytest" 这个词项(O(1) 哈希查找)
-
返回包含该词的所有消息 ID 列表
-
用 BM25 对结果排名
整个过程没有任何神经网络参与,完全是字符串匹配 + 统计排名,和向量语义搜索是本质上不同的技术。
BM25 排名算法
FTS5 默认用 BM25(Best Match 25,1994年) 给结果排序,这是信息检索领域的经典算法:
BM25 得分 = 综合考量三个因素:
① TF(词频,Term Frequency)
"pytest" 在这条消息里出现 3 次 > 出现 1 次
→ 出现越多,得分越高
② IDF(逆文档频率,Inverse Document Frequency)
"pytest" 只在 3/1000 条消息里出现 → 高区分度 → 高权重
"的/the/a" 在每条消息里都有 → 零区分度 → 权重接近零
③ 文档长度归一化
同一个词在 10 字的短消息里出现 vs 在 500 字的长消息里出现
→ 短消息得分更高(密度更大)
直觉理解:在 1000 条历史消息里,"pytest" 只出现在 3 条------它是高区分度的词,搜到了就高度相关。"今天" 出现在 900 条------几乎没有区分度,排名靠后。
Hermes 中的建表实现( hermes_state.py:527-550**)**
FTS_SQL = """
CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5(
content -- 索引字段
);
-- 写触发器:消息一插入就自动更新索引
CREATE TRIGGER IF NOT EXISTS messages_fts_insert AFTER INSERT ON messages BEGIN
INSERT INTO messages_fts(rowid, content) VALUES (
new.id,
-- content + tool_name + tool_calls 全部建索引
COALESCE(new.content, '') || ' ' ||
COALESCE(new.tool_name, '') || ' ' ||
COALESCE(new.tool_calls, '')
);
END;
-- update/delete 触发器类似,保持索引同步
"""
不只是对话内容,工具调用的名称和参数也被索引。意味着你可以搜 "terminal git push" 直接找到调用过该命令的历史会话。
查询语法
FTS5 支持丰富的查询语法(hermes_state.py:3111-3116):
-- 关键词
messages_fts MATCH 'pytest'
-- 布尔组合
messages_fts MATCH 'pytest AND auth'
messages_fts MATCH 'pytest NOT unittest'
messages_fts MATCH 'docker OR kubernetes'
-- 精确短语
messages_fts MATCH '"oauth refactor"'
-- 前缀匹配
messages_fts MATCH 'deploy*' -- 匹配 deploy/deployment/deploying
CJK 中文的特殊处理:Trigram 索引
FTS5 默认的 unicode61 分词器不适合中文 。它会把"大别山项目"拆成独立汉字:"大 AND 别 AND 山 AND 项 AND 目",导致大量误召回。
Hermes 为此额外建了一张 Trigram 虚拟表 (hermes_state.py:552-580):
FTS_TRIGRAM_SQL = """
CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts_trigram USING fts5(
content,
tokenize='trigram' -- ← 改用 trigram 分词器
);
"""
Trigram 原理:把文本切成所有连续的 3 字符(字节)片段:
"pytest单元测试"
→ ["pyt", "yte", "tes", "est", "st单", "t单元", "单元测", "元测试"]
查询 "单元测试"
→ 拆成 ["单元测", "元测试"]
→ 找同时包含这两个 trigram 的文档
→ 精确匹配!不受分词影响
路由逻辑 (hermes_state.py:3214-3291):
is_cjk = self._contains_cjk(query)
if is_cjk:
cjk_count = self._count_cjk(query)
if cjk_count >= 3:
# 走 Trigram FTS5 表(精确子串匹配)
...
else:
# 中文字符 < 3 个,Trigram 无法匹配
# 回退到 LIKE '%关键词%' 模糊搜索
...
else:
# 英文 / 混合 → 走标准 FTS5 表(unicode61 分词 + BM25)
...
三种调用形态( tools/session_search_tool.py**)**
# 1. DISCOVERY:关键词检索(最常用)
session_search(query="auth refactor", limit=3)
# → FTS5 BM25 排名 → 按会话谱系去重 → 每条结果包含:
# - snippet: FTS5 自动高亮的匹配片段
# - bookend_start: 会话前 3 条(目标/起始)
# - messages: 匹配处 ±5 条上下文
# - bookend_end: 会话最后 3 条(结论/决定)
# 耗时:15--50ms,零 LLM 调用
# 2. SCROLL:在找到的会话内翻页
session_search(session_id="...", around_message_id=590803, window=10)
# 向前翻页:传 messages[-1].id
# 向后翻页:传 messages[0].id
# 3. BROWSE:浏览最近会话列表
session_search() # 无参数
bookend_start + messages + bookend_end 的设计很精妙:用"首尾书签 + 匹配上下文"重建了目标 → 匹配 → 结论的完整信息,却不需要加载整个会话,极大节省了 token 成本。
FTS5 vs 向量检索:本质对比
| FTS5(Hermes 默认) | 向量 RAG(Embedding) | |
|---|---|---|
| 检索基础 | 词是否出现(倒排索引) | 语义相似度(向量空间距离) |
| 能处理同义词? | ❌ "测试" ≠ "test" | ✅ 向量空间里语义相近 |
| 能处理拼写错误? | ❌(需额外配置模糊匹配) | ✅ 向量相近 |
| 精确词匹配 | ✅ 精确可靠 | ⚠️ 可能语义漂移 |
| 速度 | 极快(15-50ms,纯索引查找) | 较慢(需要 embedding 推理) |
| 外部依赖 | 无(SQLite 内置) | 需要 embedding 模型 + 向量库 |
| 存储成本 | 低(文本倒排索引) | 高(每条消息存一个高维向量) |
| 可解释性 | 高(能看到命中了哪个词) | 低(黑盒相似度分数) |
| 幻觉风险 | 低(返回原始消息原文) | 中(可能误召回语义相近但不相关的内容) |
核心洞见 :Agent 历史查询的典型需求是"上次讨论 OAuth 方案是什么"这类有明确关键词的精确查询。关键词检索反而比语义相似度更准------向量检索可能把"身份验证架构"这类语义相近但不相关的对话也召回来。而且 FTS5 是 SQLite 内置功能,历史消息已经存在 SQLite 里,加一张虚拟表就搞定,零额外依赖。
2.6 记忆的 Session 隔离粒度
内置记忆(MEMORY.md + USER.md) 和 外部插件记忆 的 session 隔离粒度完全不同:
2.6.1、内置记忆:全局共享,无 session 隔离
内置记忆存储在固定路径,所有 session 共用同一份文件:
# tools/memory_tool.py
def get_memory_dir() -> Path:
"""Return the profile-scoped memories directory."""
return get_hermes_home() / "memories" # 固定路径,不含 session_id
加载时也不区分 session:
# agent/agent_init.py
agent._memory_store = MemoryStore(...)
agent._memory_store.load_from_disk() # 直接读全局文件,无 session 参数
结论 :内置记忆是 Profile 级别共享 的------同一个 Hermes Profile 下所有 session 都共享同一份 MEMORY.md 和 USER.md。"冻结快照"只是当前 session 的读取时机策略,不代表每个 session 有独立的记忆空间。
2.6.2、外部插件记忆:可配置隔离粒度
以 Honcho 为例,提供 4 种 sessionStrategy:
| 策略 | 隔离粒度 | 含义 |
|---|---|---|
per-session(默认) |
最细 | 每次启动 Hermes 都是新 session,用 session_id 做 Honcho key |
per-directory |
中等 | 同一目录共享记忆,用目录名做 key |
per-repo |
中等 | 同一 Git 仓库共享记忆,用仓库名做 key |
global |
最粗 | 所有地方全局共用一个记忆库 |
源码实现:
# plugins/memory/honcho/client.py
def resolve_session_name(self, session_id=None, cwd=None, ...):
# per-session: 每次运行新 session
if self.session_strategy == "per-session" and session_id:
return session_id # 用 Hermes session_id 做 key
# per-repo: 一个 Git 仓库一个记忆空间
if self.session_strategy == "per-repo":
base = self._git_repo_name(cwd) or Path(cwd).name
return base
# per-directory: 一个目录一个记忆空间
if self.session_strategy in {"per-directory", "per-session"}:
base = Path(cwd).name
return base
# global: 全局一个 workspace
return self.workspace_name

