本节摘要:本章从外循环的技能端切换到记忆端——外循环成果的另一个沉淀器官。Hermes 的本地记忆是有界策展式(bounded curated)的:
MEMORY.md(agent 的个人笔记:环境事实/项目约定/工具怪癖)上限 2200 字符,USER.md(用户画像:偏好/沟通风格/期望)上限 1375 字符——用字符数而非 token 数做预算,因为字符数与模型无关。为什么必须有界?因为记忆每个会话都全文注入系统提示,无界的记忆会持续膨胀并污染 prompt;有界逼迫"写入即策展":超限时拒绝写入并当场要求合并/删除(每轮最多失败 3 次后优雅降级,记忆副作用永远不能挡住用户回复)。落盘为§分隔的纯文本条目,由tools/memory_tool.py(1326 行)的 MemoryStore 管读写:文件锁+原子替换+外部漂移检测+注入扫描。注入采用冻结快照模式:开局拍快照进系统提示,会话中写盘不改已组好的 prompt——为的是第 6 章的缓存铁律。agent/memory_manager.py则作为 run_agent.py 的单一集成点编排内置存储与外部 provider。
内容来源:原项目源码
tools/memory_tool.py(MemoryStore/add/replace/remove/_render_block/漂移防护)、agent/memory_manager.py(编排器)、agent/memory_provider.py(ABC)、website/docs/developer-guide/prompt-assembly.md(volatile 层)。
⚠️ 注意:记忆条目在进入系统提示前要过 threat_patterns 严格扫描(注入/渗出模式):命中的条目在快照里被替换为
[BLOCKED: ...]占位符,但活状态保留原文——静默丢弃会把攻击藏起来不让用户看见;用户可以用 memory(action=remove) 自行删除。字符数 2200/1375 是memory_char_limit/user_char_limit的默认值,硬编码在两处构造点。
阅读完本节,你应当能够:
tools/memory_tool.py:1-21 的模块 docstring 一句话定调:"Provides bounded, file-backed memory that persists across sessions",并且特意解释 "Character limits (not tokens) because char counts are model-independent"。两个商店的构造参数(178-179 行):
178 memory_char_limit: int = 2200, 179 user_char_limit: int = 1375,
为什么这么小?因为记忆的注入方式是每会话全文进系统提示(下一节的三层组装里它属于 volatile 层)。2200 字符约几百 token,对任何上下文窗口都只是零头;如果不设限,一个"什么都记"的 agent 会在几十个会话后把系统提示撑成记忆垃圾场——每一条过时条目都在稀释当前任务的注意力。有界的本质是把遗忘变成写入路径的一部分:想进新的,就得先合并旧的、删掉不重要的。操作面刻意收窄为单一 memory 工具的三动作(add/replace/remove),replace/remove 用短唯一子串匹配定位条目而非 ID——对模型最自然。条目分隔符是 \n§\n(节号);多条目拼接后的总字符数才是限额对象。磁盘上的文件就是人眼可读的清单:
(~/hermes/memories/MEMORY.md 示意) 用户的主力机是 macOS,服务器是 Debian 12 § 项目 atlas 用 pytest + make lint,入口 src/atlas/main.py § DeepSeek 的 tool_calls 偶发空 content,需容错解析
(注入系统提示后的渲染块示意) ══════════════════════════════════════════ MEMORY (your personal notes) [31% — 688/2,200 chars] ══════════════════════════════════════════ 用户的主力机是 macOS,服务器是 Debian 12 § 项目 atlas 用 pytest + make lint ...
MemoryStore 维护两份并行的状态(160-165 行 docstring):_system_prompt_snapshot——load 时一次性冻结,"Never mutated mid-session. Keeps prefix cache stable";memory_entries/user_entries——工具调用改的活状态,每次变更立即落盘,工具响应始终反映活状态。开局加载(237 行起):
239 def load_from_disk(self): 240 """Load entries from MEMORY.md and USER.md, capture system prompt snapshot. ... 245 the system prompt. We scan each entry for injection/promptware 246 patterns at snapshot-build time — ANY hit replaces the entry text 247 in the snapshot with a placeholder like ``[BLOCKED: …]``, so a 248 poisoned-on-disk memory file (supply chain, compromised tool, 249 sister-session write) cannot inject into the system prompt. ... 255 The live memory_entries / user_entries lists keep the 256 original text so the user can still SEE poisoned entries ... 257 silently dropping them would hide the attack from the user.""" ... 263 self.memory_entries = self._read_file(mem_dir / "MEMORY.md") 264 self.user_entries = self._read_file(mem_dir / "USER.md") 266 # Deduplicate entries (preserves order, keeps first occurrence) 267 self.memory_entries = list(dict.fromkeys(self.memory_entries)) ... 276 self._system_prompt_snapshot = { 277 "memory": self._render_block("memory", sanitized_memory), 278 "user": self._render_block("user", sanitized_user), 279 }
注意"毒化条目"的三条可能来路:供应链、被攻破的工具、姐妹会话并发写。扫描是确定性的(从磁盘字节出发),所以快照整个会话字节稳定——前缀缓存不变量在记忆层同样成立。渲染块带使用率表头(_render_block,755 行):
763 limit = self._char_limit(target) 764 content = ENTRY_DELIMITER.join(entries) 765 pct = min(100, int((current / limit) * 100)) if limit > 0 else 0 ... 770 header = f"{MEMORY_BLOCK_HEADERS['user']} [{pct}% — {current:,}/{limit:,} chars]" 772 separator = "═" * 46 773 return f"{separator}\n{header}\n{separator}\n{content}"
模型每轮都能看到"USER PROFILE (who the user is) [37% — 512/1,375 chars]"这样的水位线——策展压力对模型可见。
add(415 行起)是策展逻辑最完整的样本:
442 # Reject exact duplicates 443 if content in entries: 444 return self._success_response(target, "Entry already exists ...") 446 # Calculate what the new total would be 447 new_entries = entries + [content] 448 new_total = len(ENTRY_DELIMITER.join(new_entries)) 450 if new_total > limit: 451 current = self._char_count(target) 452 return self._consolidation_failure({ 453 "success": False, 454 "error": ( 455 f"Memory at {current:,}/{limit:,} chars. " 456 f"Adding this entry ({len(content)} chars) would exceed the limit. " 457 f"Consolidate now: use 'replace' to merge overlapping entries into " 458 f"shorter ones or 'remove' stale or less important entries (see " 459 f"current_entries below), then retry this add — all in this turn." 460 ), 461 "current_entries": entries, 462 "usage": f"{current:,}/{limit:,}", 463 })
超限不是报错完事:错误消息就是策展指令——"本回合内先 replace 合并或 remove 腾位,再重试 add",并附上全部现存条目供参考。replace(470 行起)同样在替换超限时要求缩短或腾位,且子串匹配到多条不同条目时拒绝("Be more specific")。这套"限额+谈判"让记忆质量由每次写入时的就地权衡保证,而不是指望某个后台任务定期清理。
工具的行为规范直接写在 schema 描述里(docstring:"Behavioral guidance lives in the tool schema description")——模型在读工具列表时就知道什么该记:用户偏好、环境细节、工具怪癖、稳定约定;也知道什么不该塞进记忆(一次性任务内容、可从上下文推出的东西)。这套指引与第 4-03 节评审提示词的 memory 分工("who the user is and what the current situation and state of your operations are")互为表里:工具面管写入时的即时判断,评审面管轮后的复盘补录。两路写入最终汇于同一对文件、同一个限额。
把一个跨会话的完整生命周期串起来看连续性:会话 A 开局 load_from_disk 读两文件、拍快照、渲染进系统提示——A 从第一轮起就"记得"用户;A 中途若干次 add/replace/remove 即时落盘,但 prompt 里的快照纹丝不动;会话 A 结束。会话 B 开局重新 load_from_disk——这时读到的是 A 留下的最新磁盘状态,新快照自然包含 A 学到的一切。连续性由"每次开局重读磁盘"保证,稳定性由"会话内快照冻结"保证,两个性质互不拖累。读写路径上的防御密度是本模块最显著的特征。①读失败哨兵(_read_raw_checked,778 行):文件存在但读不出(含无效 UTF-8)返回 read_ok=False,调用方必须中止——"a transient read failure would let them persist over — and wipe — the on-disk memory";②外部漂移检测(_reload_target):改写前重读磁盘,发现无法经解析器往返的内容(patch 工具追加、shell 追加、手编、并发会话写入)就拒绝变更、落 .bak.<ts> 快照并告知操作者;③文件锁(_file_lock):独立 .lock 文件上的排它锁(fcntl/Unix,msvcrt/Windows);④原子替换:写临时文件后 os.replace,读者要么看到完整旧文件要么完整新文件。还有 BOM 处理:读用 utf-8-sig 剥掉记事本留下的 U+FEFF(否则首条目的匹配/去重永久损坏,#10878)。
策展谈判有失败预算:_MAX_CONSOLIDATION_FAILURES_PER_TURN = 3(196 行)。同一轮内合并失败(超限/零匹配)超过 3 次,后续失败降级为终态结果——"Stop retrying memory calls — leave memory unchanged for now and continue with your reply to the user. The fact can be saved in a later turn"(#42405:脆弱的 replace/add 不能把轮次循环到预算耗尽、压掉用户回复)。冻结快照还有一个跨模块的尾巴:MEMORY_BLOCK_HEADERS 常量(87 行)被显式导出给 agent/conversation_compression.py——压缩器用它检测"记忆块还在 prompt 里但条目已被清空"的残留场景,两个模块靠这两个表头字符串保持锁定("keep in lockstep")。agent/memory_manager.py 则是 run_agent.py 的唯一集成点:构造时 add_provider(同时只允许一个外部插件 provider——防止工具 schema 膨胀与后端打架,第二个注册直接警告拒绝),系统提示拼 build_system_prompt(),轮前 prefetch_all(user_message),轮后 sync_all + queue_prefetch_all,关停时 shutdown_all 带五秒排空超时——"A wedged provider must never block process teardown indefinitely"。内置 MEMORY.md/USER.md 与外部 provider 的关系是互补而非互斥:内置负责小而硬的事实,provider(下一节)负责大规模语义检索。
💡 循环要点:Hermes 记忆观的精髓是少即是久——2200/1375 字符的硬顶让"记什么"从存储问题变成策展问题:每次写入都被迫与既有条目竞争,合并与淘汰发生在写入时而非灾难后。冻结快照保住了缓存前缀,四道读写闸保住了数据,3 次失败预算保住了用户体验。跨会话连续性不靠记忆多,靠记忆准。
[BLOCKED:] 占位,活状态保留供用户检视删除。使用率% 水位表头。下一节展开外接记忆后端:8 种可插拔 provider 横向对比、provider 插件接口,以及 SQLite FTS5 会话全文搜索与 native/fts5_cjk 中文分词 C 扩展。