原文连接: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 里发生了这样的事情:

  1. Session 开始,MEMORY.md 里有 3 条记忆,快照冻结

  2. 第 5 轮,Agent 调用 memory(action="add", ...) 添加了一条新记忆

  3. 新记忆立刻写入磁盘(MEMORY.md 现在有 4 条)

  4. 但第 6 轮的系统提示词里,记忆块仍然只有 3 条——用的是步骤 1 拍的快照

  5. 大多数情况下,要到下一个 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)。它有三个动作:addreplaceremove

没有 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

you are nowdisregard your rules

角色劫持、指令覆盖

数据外泄

curl.*$KEY

wget.*$TOKENcat .env

通过 shell 命令泄露凭证

持久化后门

authorized_keys

~/.ssh

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 kubernetespython NOT java

  • 前缀匹配:deploy*

但用户的查询不能直接扔进 FTS5——它的语法很脆弱,一个未闭合的引号就能导致 sqlite3.OperationalError_sanitize_fts5_query()hermes_state.py:938-988)做了六步消毒:

  1. 提取平衡的引号短语,用占位符保护

  2. 去除未配对的 FTS5 特殊字符+{}()"^

  3. 折叠连续的 *****,去除行首的 *

  4. 去除行首/行尾的悬空布尔操作符ANDORNOT

  5. 给包含连字符和点号的词加引号chat-send → "chat-send",避免被 FTS5 拆成 chat AND send

  6. 恢复占位符

这六步让 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):

  1. 加载匹配会话的完整对话记录

  2. 以匹配位置为中心,截断到 ~100,000 字符(MAX_SESSION_CHARS = 100_000

  3. 发给 Gemini Flash,用一个聚焦的 summarization prompt 生成摘要

  4. 返回带元数据的摘要结果

这里有一个重要的经济学决策: 用便宜的辅助模型(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 自己决定删什么,效果远好于任何机械策略。

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐