Hermes 的记忆管理

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"
}

处理步骤:

  1. 查看全部条目:通过 current_entries 看到所有现有条目

  2. 删除过时的:memory(action='remove', old_text='API 限制...')

  3. 合并重复的:memory(action='replace', old_text='偏好使用...', content='更短版本')

  4. 重试添加:空间释放后再 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" 的过程

  1. 直接在索引里查 "pytest" 这个词项(O(1) 哈希查找)

  2. 返回包含该词的所有消息 ID 列表

  3. 用 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.mdUSER.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