转载--Hermes Agent 05 | 记忆系统(上):内置记忆的冻结快照模式与 agent-curated 策展
找到匹配的会话后,不是直接把原始对话扔回给主模型——那太长了。它用一个辅助模型(Gemini Flash)对每个匹配的会话做摘要():加载匹配会话的完整对话记录以匹配位置为中心,截断到 ~100,000 字符(发给 Gemini Flash,用一个聚焦的 summarization prompt 生成摘要返回带元数据的摘要结果用便宜的辅助模型(Gemini Flash)来压缩长对话,再把短摘要喂给
原文连接:Hermes Agent 05 | 记忆系统(上):内置记忆的冻结快照模式与 agent-curated 策展
记忆的价值不在于记住一切,而在于知道什么值得记住。
记忆 ≠ 会话历史
先澄清一个最常见的混淆。
很多人第一次听到"Agent 记忆",会以为就是"把聊天记录存下来"。不是。聊天记录是会话历史(session history),记忆是从会话中提炼出的持久知识。
会话历史的问题是太长了。一次 20 轮的对话可能产生 50,000 token 的原始记录——工具调用、返回的文件内容、中间思考、最终回答全混在一起。你不可能把这些东西塞进下一次对话的系统提示词里。
记忆要做的事是:从海量会话数据中,提炼出几百个 token 的持久知识,注入到每一次对话的系统提示词里。 它是一种有损压缩——用极小的空间承载"跨会话的认知连续性"。
Hermes Agent 的内置记忆系统由两个文件组成:MEMORY.md 和 USER.md。我们先看它们长什么样。
MEMORY.md 与 USER.md:双文件分工
这两个文件存储在 ~/.hermes/memories/ 目录下,都是纯文本 Markdown:
|
文件 |
用途 |
字符上限 |
约合 Token |
|---|---|---|---|
| MEMORY.md |
Agent 的个人笔记——环境事实、项目约定、工具怪癖、踩坑经验 |
2,200 字符 |
~800 |
| USER.md |
用户画像——偏好、沟通风格、工作习惯、角色信息 |
1,375 字符 |
~500 |
为什么分成两个文件而不是一个?因为信息的生命周期不同。
USER.md 里存的东西变化极慢——用户的名字、角色、编码风格偏好、时区。这些信息一旦写入,可能几个月都不变。
MEMORY.md 里存的东西变化更快——当前项目结构、发现的 API 怪癖、昨天踩的坑。这些信息的半衰期以天计。
分开存储让 Agent 能独立管理两种不同节奏的知识更新。
字符上限的设计也很讲究。 为什么用字符数而不是 token 数?因为 Hermes Agent 是模型无关的——它支持 200+ 模型,每个模型的 tokenizer 不同,同样的文本在 Claude 里是 800 token,在 GPT 里可能是 750。用字符计数是唯一确定性的度量(memory_tool.py:17):
Character limits (not tokens) because char counts are model-independent.
2,200 + 1,375 = 3,575 字符,按平均 2.75 字符/token 估算,总共约 1,300 token。这个预算看起来很小,但记住——这 1,300 token 会注入到每一轮 API 调用的系统提示词里。20 轮对话就是 26,000 token 的额外开销。预算再大一倍,开销就翻倍。
这两个数字在 hermes_cli/config.py:658 定义,用户可以通过 config.yaml 覆盖:
memory_char_limit=mem_config.get("memory_char_limit", 2200),
user_char_limit=mem_config.get("user_char_limit", 1375),
§ 分隔符:简单到不能再简单的存储格式
打开一个实际的 MEMORY.md 文件,你会看到类似这样的内容:
User's project is a Rust web service at ~/code/myapi using Axum + SQLx
§
This machine runs Ubuntu 22.04, has Docker and Podman installed
§
User prefers concise responses, dislikes verbose explanations
每条记忆之间用 §(section sign)分隔。准确地说,分隔符是 \n§\n——前后各一个换行(memory_tool.py:47):
ENTRY_DELIMITER = "\n§\n"
为什么不用 JSON、YAML 或 SQLite?
因为记忆要注入系统提示词。 系统提示词是纯文本。如果用 JSON 存储,注入时还得做序列化/反序列化——多一层转换就多一层出错的可能。用 § 分隔的纯文本,读出来就是提示词内容,写回去就是存储格式。零转换开销。
§ 这个字符的选择也有意思:它在自然语言中几乎不会出现(你上次在对话里打 § 是什么时候?),所以作为分隔符不会引发误切分。代码里有一条注释专门解释了这个选择(memory_tool.py:397-398):
# Use ENTRY_DELIMITER for consistency with _write_file. Splitting by "§"
# alone would incorrectly split entries that contain "§" in their content.
注意这里说的是用完整的 \n§\n 来分割,而不是单独的 § 字符——这样即使某条记忆的内容里碰巧包含 §(比如引用法律条文),也不会被误切分。
冻结快照:记忆系统最核心的设计决策
现在来到整个记忆系统最重要的设计决策——冻结快照(frozen snapshot)。
先看 MemoryStore 类的文档字符串(memory_tool.py:95-104):
class MemoryStore:
"""
Bounded curated memory with file persistence. One instance per AIAgent.
Maintains two parallel states:
- _system_prompt_snapshot: frozen at load time, used for system prompt injection.
Never mutated mid-session. Keeps prefix cache stable.
- memory_entries / user_entries: live state, mutated by tool calls, persisted to disk.
Tool responses always reflect this live state.
"""
这段注释里藏着整个架构的灵魂。翻译成人话就是:
记忆系统同时维护两份状态——一份"冻结的"用于系统提示词注入,一份"活的"用于工具响应。两份状态在 session 中会分叉。
来看具体的机制。
加载:在 session 开始时拍快照
load_from_disk() 是记忆的入口(memory_tool.py:114-130):
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)
self.memory_entries = self._read_file(mem_dir / "MEMORY.md")
self.user_entries = self._read_file(mem_dir / "USER.md")
# Deduplicate entries (preserves order, keeps first occurrence)
self.memory_entries = list(dict.fromkeys(self.memory_entries))
self.user_entries = list(dict.fromkeys(self.user_entries))
# Capture frozen snapshot for system prompt injection
self._system_prompt_snapshot = {
"memory": self._render_block("memory", self.memory_entries),
"user": self._render_block("user", self.user_entries),
}
注意最后三行:它把当前的记忆内容渲染成格式化的文本块,存进 _system_prompt_snapshot 字典。这就是"拍快照"的动作。
这个快照在 run_agent.py:1302 被触发,也就是 AIAgent.__init__() 初始化时:
self._memory_store = MemoryStore(
memory_char_limit=mem_config.get("memory_char_limit", 2200),
user_char_limit=mem_config.get("user_char_limit", 1375),
)
self._memory_store.load_from_disk()
注入:每轮 API 调用都用同一份快照
每次发 API 调用前,系统提示词的构造会从快照里取记忆块(run_agent.py:3723):
if self._memory_store:
if self._memory_enabled:
mem_block = self._memory_store.format_for_system_prompt("memory")
if mem_block:
prompt_parts.append(mem_block)
if self._user_profile_enabled:
user_block = self._memory_store.format_for_system_prompt("user")
if user_block:
prompt_parts.append(user_block)
而 format_for_system_prompt() 返回的永远是 session 开始时拍的那份快照(memory_tool.py:330-341):
def format_for_system_prompt(self, target: str) -> Optional[str]:
"""
Return the frozen snapshot for system prompt injection.
This returns the state captured at load_from_disk() time, NOT the live
state. Mid-session writes do not affect this. This keeps the system
prompt stable across all turns, preserving the prefix cache.
"""
block = self._system_prompt_snapshot.get(target, "")
return block if block else None
加粗的关键词:NOT the live state。
这意味着什么?假设一个 session 里发生了这样的事情:
-
Session 开始,MEMORY.md 里有 3 条记忆,快照冻结
-
第 5 轮,Agent 调用
memory(action="add", ...)添加了一条新记忆 -
新记忆立刻写入磁盘(MEMORY.md 现在有 4 条)
-
但第 6 轮的系统提示词里,记忆块仍然只有 3 条——用的是步骤 1 拍的快照
-
大多数情况下,要到下一个 session 开始时,或者当前长会话触发 context compression、重建 system prompt 之后,新的快照才会包含这第 4 条
中间这个 gap 是故意的。
换句话说,冻结快照不是"整个进程生命周期内绝不变化",而是在当前 prompt build 周期内保持不变。源码里 _invalidate_system_prompt() 会在上下文压缩后重新 load_from_disk(),因此压缩续接出来的新 session,会重新拍一份快照。
为什么不在 session 中实时同步?
答案是三个字:前缀缓存(prefix cache)。
现代 LLM 提供商(Anthropic、OpenAI、Google)都实现了前缀缓存——如果连续两次 API 调用的系统提示词相同,第二次调用可以复用第一次缓存的 KV 状态,大幅减少计算量。
Hermes Agent 的系统提示词由多层组成(按 website/docs/developer-guide/prompt-assembly.md 的描述):
1. Agent 身份(SOUL.md)
2. 工具行为指引
3. Honcho 静态块(如启用)
4. 可选 system message
5. 冻结的 MEMORY 快照 ← 这里
6. 冻结的 USER 快照 ← 和这里
7. 技能索引
8. 上下文文件(AGENTS.md 等)
9. 时间戳 / session ID
10. 平台提示
如果记忆块在 session 中实时变化,系统提示词在 memory(action="add") 那一轮之后就会发生变化——前缀缓存失效。从那一轮开始,后续所有 API 调用都无法复用之前缓存的 KV 状态,每一轮都要从头计算完整的系统提示词。
这笔账算起来很简单:
|
场景 |
每轮缓存的系统提示 token |
20 轮 session 总节省 |
|---|---|---|
|
冻结快照(不变) |
~全部命中缓存 |
最大化 |
|
实时同步(第 5 轮改了记忆) |
前 5 轮命中,后 15 轮从头算 |
损失 75% |
冻结快照的代价是"本 session 新写入的记忆,模型在系统提示词层面看不到"。但这个代价有缓解措施——工具响应里能看到。
工具响应:活的那一份
当 Agent 调用 memory(action="add", ...) 之后,工具返回的响应里包含当前的实际状态(memory_tool.py:345-360):
def _success_response(self, target: str, message: str = None) -> Dict[str, Any]:
entries = self._entries_for(target) # 当前活状态,不是快照
current = self._char_count(target)
limit = self._char_limit(target)
pct = min(100, int((current / limit) * 100)) if limit > 0 else 0
resp = {
"success": True,
"target": target,
"entries": entries, # ← 包含刚添加的新条目
"usage": f"{pct}% — {current:,}/{limit:,} chars",
"entry_count": len(entries),
}
if message:
resp["message"] = message
return resp
所以,虽然系统提示词里的记忆是冻结的,但 Agent 在调用完记忆工具后,能在工具响应里看到完整的当前状态。这条信息会留在对话的 messages 列表里,后续轮次模型也能参考。
这是一个两层设计:
|
层 |
状态 |
用途 |
更新时机 |
|---|---|---|---|
|
系统提示词 |
冻结快照 |
每轮注入,前缀可缓存 |
下一次 prompt rebuild(通常是下个 session,也可能是压缩续接后) |
|
工具响应 |
活状态 |
Agent 确认写入成功 |
实时 |
|
磁盘文件 |
活状态 |
持久化,下次 session 读取 |
实时 |
信息新鲜度 vs 推理效率——冻结快照模式选择了效率。 代价是本 session 内的记忆变更不会反映在系统提示词里,但通过工具响应和对话历史,Agent 实际上并不会"忘掉"刚写入的东西。
memory 工具的三个动作
记忆的读写由一个统一的 memory 工具完成(memory_tool.py:484-533)。它有三个动作:add、replace、remove。
没有 read 动作。 为什么?因为记忆已经在系统提示词里了——Agent 天然就能"看到"自己的记忆,不需要额外的工具调用来读取。这是冻结快照模式的一个附带好处:省掉了一次工具调用的开销。
add:追加新条目
def add(self, target: str, content: str) -> Dict[str, Any]:
content = content.strip()
if not content:
return {"success": False, "error": "Content cannot be empty."}
# 安全扫描
scan_error = _scan_memory_content(content)
if scan_error:
return {"success": False, "error": scan_error}
with self._file_lock(self._path_for(target)):
self._reload_target(target) # 加锁后重新读磁盘
entries = self._entries_for(target)
if content in entries: # 精确去重
return self._success_response(target, "Entry already exists.")
new_entries = entries + [content]
new_total = len(ENTRY_DELIMITER.join(new_entries))
if new_total > limit: # 超限拒绝
return {"success": False, "error": f"Memory at {current:,}/{limit:,} chars..."}
entries.append(content)
self.save_to_disk(target)
return self._success_response(target, "Entry added.")
几个值得注意的细节:
1. 加锁后重读磁盘。 _reload_target() 在获取文件锁之后、修改之前,先从磁盘重新读取最新状态。为什么?因为 Hermes Agent 支持多平台同时在线(Telegram + CLI + Slack),不同入口的 Agent 实例可能同时操作同一份记忆文件。"锁后重读"是经典的 read-modify-write 安全模式。
2. 精确去重。 if content in entries 是精确匹配——内容完全相同才判定为重复。这避免了 Agent 在多轮对话中反复添加同一条记忆。
3. 超限拒绝而不是自动淘汰。 当新条目会导致超过字符上限时,add 不会自动删除旧条目来腾空间——它返回一个错误,提示 Agent "先 replace 或 remove 现有条目再添加"。这把策展决策权交给了 Agent,而不是用一个机械的 FIFO/LRU 策略。
replace:子串匹配替换
def replace(self, target: str, old_text: str, new_content: str) -> Dict[str, Any]:
...
with self._file_lock(self._path_for(target)):
self._reload_target(target)
entries = self._entries_for(target)
matches = [(i, e) for i, e in enumerate(entries) if old_text in e]
if not matches:
return {"success": False, "error": f"No entry matched '{old_text}'."}
if len(matches) > 1:
unique_texts = set(e for _, e in matches)
if len(unique_texts) > 1:
return {"success": False, "error": "Multiple entries matched. Be more specific."}
...
replace 用子串匹配来定位要替换的条目——old_text 不需要是完整内容,只需要是某条记忆的唯一子串。这个设计非常务实:
-
Agent 不需要记住完整的旧内容就能更新它
-
只要提供一个足够独特的片段(比如 "dark mode")就能定位到目标条目
-
如果子串匹配到多条,返回错误要求更具体——宁可拒绝也不猜测
remove:同样的子串匹配
remove 的匹配逻辑和 replace 一样——子串定位、多匹配拒绝、锁后重读。不再赘述。
原子写入:不要让读者看到半成品
记忆文件的写入是原子的(memory_tool.py:402-431):
@staticmethod
def _write_file(path: Path, entries: List[str]):
content = ENTRY_DELIMITER.join(entries) if entries else ""
# 在同一目录下写临时文件
fd, tmp_path = tempfile.mkstemp(
dir=str(path.parent), suffix=".tmp", prefix=".mem_"
)
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
f.write(content)
f.flush()
os.fsync(f.fileno()) # 刷到磁盘
os.replace(tmp_path, str(path)) # 原子替换
except BaseException:
os.unlink(tmp_path) # 失败就清理临时文件
raise
为什么不直接 open("w") 写入?代码注释(memory_tool.py:407-409)解释得很清楚:
Previous implementation used open("w") + flock, but "w" truncates the
file *before* the lock is acquired, creating a race window where
concurrent readers see an empty file.
open("w") 会先把文件清空再写入——在清空和写入之间有一个时间窗口,其他进程如果这时候读文件,会读到空内容。tempfile + os.replace 避免了这个问题:os.replace() 在同一文件系统上是原子操作,读者要么看到旧文件,要么看到新文件,不会看到空文件。
这不是过度工程。 Hermes Agent 的 Gateway 模式下,Telegram、Slack、CLI 可能同时在跑,都可能触发记忆写入。没有原子写入,记忆文件在高并发下会腐化。
记忆注入的安全扫描
记忆有一个特殊的安全风险:它会被注入到系统提示词里。
一条被污染的记忆,相当于一条持久化的提示词注入——它会在每一个 session 的每一轮对话里生效。攻击者如果能诱导 Agent 写入一条恶意记忆,就获得了跨会话的持久控制。
Hermes Agent 在每次 add 和 replace 前都会跑一次安全扫描(memory_tool.py:80-92):
def _scan_memory_content(content: str) -> Optional[str]:
# 检测不可见 Unicode 字符(零宽空格、bidi override 等)
for char in _INVISIBLE_CHARS:
if char in content:
return f"Blocked: invisible unicode U+{ord(char):04X}"
# 检测威胁模式
for pattern, pid in _MEMORY_THREAT_PATTERNS:
if re.search(pattern, content, re.IGNORECASE):
return f"Blocked: matches threat pattern '{pid}'."
return None
威胁模式包括 12 条正则(memory_tool.py:55-71),覆盖三类攻击:
|
类别 |
示例模式 |
拦截的攻击 |
|---|---|---|
|
提示词注入 |
ignore previous instructions
、 |
角色劫持、指令覆盖 |
|
数据外泄 |
curl.*$KEY
、 |
通过 shell 命令泄露凭证 |
|
持久化后门 |
authorized_keys
、 |
SSH 后门植入 |
不可见字符的检测覆盖了 10 种 Unicode 字符(memory_tool.py:74-77):零宽空格(U+200B)、零宽非连接符(U+200C)、bidi override(U+202D/E)等。这些字符在渲染时看不见,但可以被用来绕过文本匹配规则。
这套扫描是确定性的——纯正则,没有 LLM 参与。 它的优点是运行成本低、行为可审计、不会因为模型波动而忽严忽松;但边界也很明确:规则之外的变体仍可能漏检,过宽模式也可能误伤无害文本。它更像一个 lightweight guard,而不是完备的安全审计器。
FTS5 会话搜索:跨会话的"之前怎么处理的"
记忆解决了"Agent 的持久知识",但有些信息不适合放进记忆——比如"上周二我们调试那个 Docker 权限问题时具体做了什么"。这种信息太细碎、太具体,放进 2,200 字符的 MEMORY.md 太浪费。
Hermes Agent 用 SQLite FTS5 全文索引提供了另一条回忆路径:会话搜索(session search)。
存储层:每条消息自动索引
所有会话消息都存储在 ~/.hermes/state.db(SQLite,WAL 模式)。hermes_state.py:36-91 定义了核心 schema:
CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL REFERENCES sessions(id),
role TEXT NOT NULL,
content TEXT,
tool_call_id TEXT,
tool_calls TEXT,
tool_name TEXT,
timestamp REAL NOT NULL,
...
);
FTS5 虚拟表建立在 messages 表之上(hermes_state.py:93-112):
CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5(
content,
content=messages,
content_rowid=id
);
三条触发器保证索引与数据实时同步——INSERT、DELETE、UPDATE 任意操作后,FTS5 索引自动更新。不需要手动维护索引。
查询层:语法消毒 + 上下文快照
用户或 Agent 可以通过 session_search 工具搜索历史会话。搜索支持 FTS5 的完整语法(hermes_state.py:1019-1022):
-
简单关键词:
docker deployment -
精确短语:
"exact phrase" -
布尔运算:
docker OR kubernetes、python NOT java -
前缀匹配:
deploy*
但用户的查询不能直接扔进 FTS5——它的语法很脆弱,一个未闭合的引号就能导致 sqlite3.OperationalError。_sanitize_fts5_query()(hermes_state.py:938-988)做了六步消毒:
-
提取平衡的引号短语,用占位符保护
-
去除未配对的 FTS5 特殊字符(
+、{}、()、"、^) -
折叠连续的
*****,去除行首的* -
去除行首/行尾的悬空布尔操作符(
AND、OR、NOT) -
给包含连字符和点号的词加引号(
chat-send→"chat-send",避免被 FTS5 拆成chat AND send) -
恢复占位符
这六步让 FTS5 能安全地接受几乎任何形式的用户输入。
搜索结果不是返回原始消息——而是附带了上下文窗口(匹配消息前后各 1 条消息)和 FTS5 生成的高亮摘要(hermes_state.py:1056-1074):
SELECT
m.id, m.session_id, m.role,
snippet(messages_fts, 0, '>>>', '<<<', '...', 40) AS snippet,
...
FROM messages_fts
JOIN messages m ON m.id = messages_fts.rowid
JOIN sessions s ON s.id = m.session_id
WHERE messages_fts MATCH ?
ORDER BY rank
LIMIT ? OFFSET ?
snippet() 函数用 >>> 和 <<< 标记匹配位置,最多返回 40 个 token 的上下文——足够 Agent 判断这条结果是不是想要的,又不会占用太多 token。
摘要层:Gemini Flash 做翻译官
找到匹配的会话后,session_search_tool.py 不是直接把原始对话扔回给主模型——那太长了。它用一个辅助模型(Gemini Flash)对每个匹配的会话做摘要(session_search_tool.py:297-476):
-
加载匹配会话的完整对话记录
-
以匹配位置为中心,截断到 ~100,000 字符(
MAX_SESSION_CHARS = 100_000) -
发给 Gemini Flash,用一个聚焦的 summarization prompt 生成摘要
-
返回带元数据的摘要结果
这里有一个重要的经济学决策: 用便宜的辅助模型(Gemini Flash)来压缩长对话,再把短摘要喂给昂贵的主模型。主模型看到的是 1,000 token 的摘要而不是 50,000 token 的原始对话——节省的 token 费用远超辅助模型的调用成本。
如果辅助模型不可用(没配置、超时、报错),会退回到直接截取原始对话前 500 字符作为预览——总有兜底。
会话溯源:子 session 归并到父
一个容易被忽略的工程细节:Hermes Agent 的对话历史不是扁平的。一个用户会话可能因为上下文压缩、子 Agent 委派而分裂成多个 session(父子关系通过 parent_session_id 链接)。
搜索时,子 session 的匹配结果会被归并到父 session(session_search_tool.py:348-371):
def _resolve_to_parent(session_id: str) -> str:
"""Walk delegation chain to find the root parent session ID."""
visited = set()
sid = session_id
while sid and sid not in visited:
visited.add(sid)
session = db.get_session(sid)
parent = session.get("parent_session_id")
if parent:
sid = parent
else:
break
return sid
这样用户搜索时看到的是"一次完整的对话",而不是被碎片化的 session 片段。同时,当前正在进行的 session 及其所有祖先/后代会被排除——因为 Agent 已经有当前对话的上下文了,搜索自己没有意义。
核心权衡:一张决策表
把 Hermes Agent 记忆系统的核心权衡总结成一张表:
|
设计决策 |
选择 |
代价 |
收益 |
|---|---|---|---|
|
冻结快照 vs 实时同步 |
冻结 |
本 session 新记忆不会立刻进入当前 prompt build |
最大化前缀缓存稳定性 |
|
字符限制 vs token 限制 |
字符 |
不同模型的实际 token 数略有偏差 |
模型无关,确定性 |
|
超限拒绝 vs 自动淘汰 |
拒绝 |
Agent 需要额外的工具调用来腾空间 |
策展决策权交给 Agent |
|
子串匹配 vs ID/全文匹配 |
子串 |
匹配到多条时需要额外交互 |
Agent 不需要记住完整旧内容 |
|
纯文本 § 分隔 vs 结构化格式 |
纯文本 |
不支持嵌套/字段 |
零序列化开销,读即注入 |
|
确定性扫描 vs LLM 审计 |
确定性 |
覆盖面有限,可能漏检或误伤 |
运行成本低、可审计、结果稳定 |
|
FTS5 + 辅助模型 vs 向量数据库 |
FTS5 |
语义匹配不如向量搜索 |
零外部依赖、零额外成本 |
每一个"代价"都有对应的缓解措施,每一个"收益"都能用数字量化。 这是工程取舍的典范——不存在"既要又要"的完美方案,只有在约束条件下找到的最优平衡。
实战:观察冻结快照的行为
光讲原理不够,我们实际验证一下冻结快照模式。
第一步:观察记忆的系统提示词注入
启动一个交互式新 session:
hermes chat
进入会话后输入:
请告诉我你的 MEMORY 里现在有什么内容
Agent 会直接引用它在系统提示词里看到的记忆块——这些就是 session 开始时拍的快照。这里不能用 hermes chat -q,因为 -q 是单次查询,执行完就退出,没法继续在同一个 session 里追问。
第二步:在 session 中写入新记忆
接着在同一个 session 里:
请记住:我的项目使用 Poetry 管理依赖,Python 版本是 3.12
Agent 会调用 memory(action="add", target="memory", content="...")。工具响应里你能看到新条目已经出现在 entries 列表里。
第三步:验证系统提示词未变
继续在同一 session 里问:
你的系统提示词里的 MEMORY 部分现在有几条记忆?
如果 Agent 诚实地回答,它会说——数量跟 session 开始时一样。刚刚添加的那条在工具响应里能看到,但不在系统提示词的冻结快照里。
第四步:开一个新 session 验证
退出当前 session,开一个新的:
hermes chat -q "你的 MEMORY 里有关于 Poetry 的记录吗?"
现在 Agent 能在系统提示词里看到那条关于 Poetry 的记忆了——因为新 session 重新拍了快照。如果你的上一个长会话中途触发了 context compression,那么这条记忆也可能已经在压缩续接后的新 prompt 里出现。
这就是冻结快照模式的完整生命周期:写入即持久化,但注入延迟到下一次 prompt rebuild。最常见是下一个 session,也可能是压缩后的 continuation session。
Agent-Curated 策展:为什么不是"自动保存一切"
最后聊一个设计哲学层面的选择。
很多记忆系统采用"自动保存一切"策略——把每轮对话的关键信息自动提取并保存。Hermes Agent 的内置记忆不这么做。
看 MEMORY_SCHEMA 的工具描述(memory_tool.py:486-508):
WHEN TO SAVE (do this proactively, don't wait to be asked):
- User corrects you or says 'remember this'
- User shares a preference, habit, or personal detail
- You discover something about the environment
- You learn a convention, API quirk, or workflow
- You identify a stable fact useful in future sessions
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.
Agent 被指导去主动判断什么值得记住——不是机械地保存一切,也不是被动地等用户说"请记住"。这是一种agent-curated(Agent 策展) 模式。
策展的核心约束来自于空间限制:2,200 + 1,375 = 3,575 字符,大约能放 10-15 条精炼的记忆。空间这么小,每一条都要"值得"。
当空间满了,Agent 不能简单地追加——它必须审视现有记忆,决定哪些可以合并、哪些已经过时可以删除、哪些需要更新。这个过程本身就是一种"认知策展":用有限的空间承载最有价值的知识。
这也解释了为什么 add 超限时返回错误而不是自动淘汰——淘汰决策需要理解上下文,这是 LLM 擅长的事。一个 FIFO 策略可能会删掉"用户讨厌 verbose 输出"这条极重要的偏好,只因为它是最早写入的。让 Agent 自己决定删什么,效果远好于任何机械策略。
更多推荐



所有评论(0)