Skip to content
围炉聊科技微信公众号二维码关注公众号,获取新文章推送
添加我为微信朋友扫描二维码,添加我为朋友

Agent 的记忆是怎么工作的:12 个框架源码拆解

做智能体基建一直在思考如何做好记忆,好在现在有很多开源框架可以学习。这次我让AI直接把 12 个开源框架的源码克隆到本地帮我整理了他们是怎么做的。

一、记忆写入:Agent 怎么把"经历"存下来

一段对话结束后,什么值得留下?按存储形态,12 个框架可以分成五派,从最简单的开始看起。

1.1 会话派:只存原文

最"笨"的一派,也最可靠:完整转录,照单全收

Claude Agent SDK 把每个会话的 transcript 落成 append-only 的 JSONL 文件。写入端有个批量冲刷器,攒够一定量才落盘,阈值是硬编码常量:

python
# src/claude_agent_sdk/_internal/transcript_mirror_batcher.py
MAX_PENDING_ENTRIES = 500   # 攒 500 条
MAX_PENDING_BYTES = 1 << 20 # 或 1MiB,先到先刷

纯 append 意味着每一步都只写末尾,天然支持崩溃恢复。但它还有一招:每条转录落库时,同步"增量折叠"一份摘要 sidecar,而不是事后回读全文重新总结。

mermaid
sequenceDiagram
    participant LLM as 对话
    participant Store as SessionStore
    participant Sum as Summary sidecar
    LLM->>Store: append(单条转录行)
    Store->>Sum: 增量折叠摘要字段
    Note over Sum: summary / lastPrompt / customTitle<br/>last-wins 增量,不重读历史
    Sum-->>Store: 更新会话元信息

其中最常用的几个字段(如 summarylastPromptcustomTitle)都做 last-wins:新值直接覆盖旧值,不用回读整份转录。这是"用空间换时间"的典型——转录是完整原文,摘要只是轻量索引。

OpenAI Agents SDK 是同一个思路,但做成了统一抽象。Session 只暴露四个方法:

python
# src/agents/memory/session.py
class Session(Protocol):
    async def get_items(self, ...): ...    # 读
    async def add_items(self, ...): ...    # 写
    async def pop_item(self, ...): ...     # 弹
    async def clear_session(self, ...): ...# 清

底层可以是 SQLite(两张表:agent_sessions 存会话元信息、agent_messages 存消息,外键级联删除),也可以是 OpenAI 服务端会话(消息存在云端)。调用方只对着四个方法编程,存储后端随便换。

为什么先讲这一派:它是所有"抽取出记忆"框架的地基。先有完整原文,才有资格谈抽取、谈遗忘。后面几派做的所有事,本质上都是对这一份原文的加工。

1.2 文件派:记忆就是几个 Markdown 文件

区别于完整转录,这一派认为记忆不是"什么都留",而是"只留整理过的东西",载体就是人类能直接读懂、能用 Git 管理的 Markdown 文件。

Hermes Agent 是代表。它的长期记忆就是两个文件:MEMORY.md(上限 2200 字符)和 USER.md(上限 1375 字符)。写入前有四道闸门,全程用确定性规则而不是 LLM 判断:

mermaid
flowchart TD
    A[add 被调用] --> B{内容为空?}
    B -->|是| C[拒绝]
    B -->|否| D{注入扫描}
    D -->|是| C
    D -->|否| E{文件锁内重新读盘}
    E --> F{是否精确重复}
    F -->|是| G[拒绝: 已存在]
    F -->|否| H{新总量超预算?}
    H -->|是| I[报错要求先 replace 合并]
    H -->|否| J[追加整写]

核心逻辑直接用代码说话:

python
# tools/memory_tool.py
def add(self, target: str, content: str):
    content = content.strip()
    if not content:
        return {"success": False, "error": "Content cannot be empty."}
    # 1. 注入扫描:拒绝把提示词/敏感指令当记忆收进来
    scan_error = _scan_memory_content(content)
    if scan_error:
        return {"success": False, "error": scan_error}
    with self._file_lock(self._path_for(target)):
        # 2. 锁内重新读盘,避免多会话互相覆盖
        entries = self._entries_for(target)
        # 3. 精确去重:完全相同的字符串不收第二遍
        if content in entries:
            return self._success_response(
                target, "Entry already exists (no duplicate added).")
        # 4. 预算控制:超了不让写,引导先合并再写
        new_total = len(ENTRY_DELIMITER.join(entries + [content]))
        if new_total > self._char_limit(target):
            return self._consolidation_failure({...})

四个细节值得单独说:

  1. 注入扫描:写进去的如果是"你是一个…"这类内容,会污染后续所有读取。所以写入前必须扫描,拒绝接收这类注入。
  2. 锁内重读add 会把整个文件重写一遍,如果直接基于缓存写,两个会话同时写就会互相覆盖。先拿锁、重读磁盘、再追加,是防丢数据的底线。
  3. 精确去重:用 content in entries 判断,不用 LLM 去"判断两句话意思是否一样"——确定性规则,零幻觉。
  4. 预算控制MEMORY.md 有 2200 字符硬上限,写超了直接报错让你先合并。这等于强迫记忆"必须精简",而不是无限膨胀。

OpenClaw 是同一路线:记忆就是 MEMORY.md / USER.md / 按日期组织的 memory/<日期>.md。Letta(原 MemGPT)更进一步,把记忆文件用 git 备份——记忆即文件,还能版本回滚(这一条来自官方文档)。

文件派的优点显而易见:人类可读、可审、可版本化。代价是容量天花板低,只能靠预算硬控"什么能进门"。

1.3 抽取派:让 LLM 当记忆管家

比文件派更进一步:用 LLM 把对话"转写"成结构化记忆。代表是 Mem0 和 LangMem,而这两家恰好代表了这条路上的一次路线反转

Mem0 的旧版算法是:LLM 对每一条事实判定 ADD / UPDATE / DELETE / NONE,倾向覆盖更新。新版把整个逻辑推翻了,改成一条 8 阶段的批量管道:

mermaid
flowchart TD
    A[新消息] --> B[Phase0 收集最近10条消息]
    B --> C[Phase1 向量检索已有记忆 top-10]
    C --> D[Phase2 单次 LLM 事实抽取<br/>ADD-only 只增不覆盖]
    D --> E[Phase3 批量向量化]
    E --> F[Phase4/5 md5 哈希确定性去重]
    F --> G[Phase6 批量落库]
    G --> H[Phase7 批量实体链接<br/>语义分 ≥0.95 归并]
    H --> I[Phase8 落消息+遥测]

反转到哪了?两件事:

一是从"三态判定"回归"只增不覆盖"。旧版让 LLM 决定哪条该更新、哪条该删——LLM 的判断本身就成了幻觉放大器,"你以为它在更新记忆,其实它在编"。新版要求 LLM 只抽取新增事实,绝不覆盖旧记忆,把"该不该改"的判断权从 LLM 手里收走。

二是用哈希代替 LLM 做去重。去重是记忆系统最容易错的地方:"这两句话意思是同一个吗?"让 LLM 回答这个问题,代价高还不稳。Mem0 直接用确定性哈希:

python
# mem0/memory/main.py —— 去重阶段核心
existing_hashes = set()
for mem in existing_results:
    h = mem.payload.get("hash") if mem.payload else None
    if h:
        existing_hashes.add(h)

seen_hashes = set()  # 本批内去重
for mem in extracted_memories:
    text = mem.get("text")
    mem_hash = hashlib.md5(text.encode()).hexdigest()
    if mem_hash in existing_hashes or mem_hash in seen_hashes:
        continue  # 完全相同的文本直接丢弃
    seen_hashes.add(mem_hash)
    # ... 继续落库

完全一样的文本,hash 一定一样,直接丢掉;语义相近但字面不同,则靠第 7 步的实体链接去归并——实体语义分 ≥0.95 指向已有实体就合并,否则新建。哈希管"相等",实体管"相近",各司其职。

LangMem 走的是另一条更"重"的路:把记忆操作本身建模成工具调用。它基于 Trustcall 的 create_extractor,把"新建 / 更新 / 删除"变成可供 LLM 多步迭代调用的工具,最后用 Done 工具显式收尾。它在记忆指令里写了两条留存准则,值得单独拎出来:

保留 surprising(模式偏离的意外信息)与 persistent(被反复强化的信息);拒绝保存假信息。

这句话点破了一个通用原则:记忆里最值钱的是"反常识的"和"被验证过的",而不是流水账。在设计记忆 prompt 时,这两条准则可以直接抄。

1.4 图谱派:记忆要能表达"关系"

前几派存的都是"点"(一句话、一条事实),图谱派想存"点和点之间的边"——谁认识谁、谁在什么时候对谁做了什么。

Graphiti 的写入流程是最完整的样本:

mermaid
sequenceDiagram
    participant In as 新事实 episode
    participant LLM as 抽取+消歧
    participant G as 知识图谱
    In->>LLM: extract_nodes 抽实体
    LLM->>LLM: resolve 与历史实体消歧
    LLM-->>G: 实体节点 (含 uuid 映射/重复清单)
    In->>LLM: extract_edges 抽关系边
    LLM-->>G: resolved_edges 新边
    LLM-->>G: invalidated_edges 被新事实作废的旧边
    Note over G: 新事实来了,旧边失效,<br/>点保留、连接断开

精华在 extract_edges 这一步会返回三类边:解析成功的 resolved_edges被新事实作废的旧边 invalidated_edges、以及全新的 new_edges。这就是图谱记忆的精髓——新事实来了,不是覆盖旧记录,而是把旧边失效,演进轨迹完整保留。比如"张三在 A 公司"这条边,如果来了"张三去了 B 公司",旧边被标记失效,新边建立,而不是把旧记录抹掉。

Cognee 是语义图谱 + 分层记忆的组合:每次 agent 运行的轨迹(trace)先写入 step,再按配置周期性触发 memify 批量沉淀进语义图谱。它还有一个务实的安全默认——多个召回作用域里,toolscode 只有显式 opt-in 才纳入,默认不收录,防止把工具调用细节和代码污染进长记忆。

1.5 流水线派:写入是一整条工业化管道

CrewAI 把"记忆写入"拆成了一条带 LLM 决策的流水线,这可能是 12 个框架里写入端最重的一个:

mermaid
flowchart LR
    A[batch embed 批量向量化] --> B[批内余弦去重 ≥0.98 直接丢]
    B --> C[并行找历史相似记忆]
    C --> D[LLM 并行分组分析<br/>把记忆归 A/B/C/D 类]
    D --> E[产出 consolidation plan 去重/合并计划]
    E --> F[执行计划: delete/update + bulk insert]

每一步都是独立决策,而且去重用的是余弦相似度阈值(≥0.98 视为重复丢弃),不是 LLM 判断,也不是 MD5——正好补上 Mem0 没覆盖的那层"语义重复"。

CrewAI 还特意把写入放到后台线程串行化(_save_pool),写不放主路径——记忆写入再重,也不能拖慢对话响应。

写入端小结

派别存什么写入成本代表
会话派完整原文低(append)Claude SDK / OpenAI SDK
文件派整理后的要点低(工具直接写)Hermes / OpenClaw / Letta
抽取派LLM 抽取的事实中(每次 LLM)Mem0 / LangMem
图谱派实体 + 关系边高(抽取 + 消歧)Graphiti / Cognee
流水线派LLM 复核过的事实池高(多步 LLM)CrewAI

越靠后越不丢信息、结构越强,代价是写入成本和幻觉风险越高。没有对错,只有匹配。

二、记忆检索:怎么把对的记忆捞回来

库存有了,查询来了,凭什么这几条被召回而不是那几条?这是 12 个框架差异最集中的地方。

2.1 加权打分公式:记忆的"排名算法"

最经典的来自 Stanford 的 Generative Agents。它的检索打分公式写在 retrieve 模块里,三个分量算完后归一化,再非等权合成:

python
# reverie/backend_server/persona/cognitive_modules/retrieve.py
# 权重:gw = [0.5, 3, 2],注释里写明 "test out different weights"
for key in recency_out.keys():
    master_out[key] = (
        persona.scratch.recency_w   * recency_out[key]   * gw[0]  # 0.5
        + persona.scratch.relevance_w * relevance_out[key] * gw[1]  # 3
        + persona.scratch.importance_w * importance_out[key] * gw[2]  # 2
    )

三个分量:

  • relevance(相关性):当前查询和某条记忆的 embedding 余弦相似度,权重最高(3);
  • importance(重要度):该条记忆的 poignancy(重要度评分,由重要事件 + 情感强度决定),权重 2;
  • recency(近因):指数衰减,越久远得分越低,访问过后还会回弹——权重只有 0.5,最低。

注意它的 recency 实现是纯工程化的:

python
# 按时间倒序排在后面的记忆,recency 指数衰减
recency_vals = [persona.scratch.recency_decay ** i
                for i in range(1, len(nodes) + 1)]

越靠后的记忆,recency_decay ** i 越小,天然地"最近的最热"。而且因为 last_accessed 会参与排序,一条记忆一旦被召回过,它的近因又回来了——使用本身会刷新热度,这一点和人类很接近。

CrewAI 把同样的三要素升级成了可配置的复合标量,把"时间半衰"做得更严谨:

python
# lib/crewai/src/crewai/memory/types.py
def compute_composite_score(record, semantic_score, config):
    age_seconds = (datetime.utcnow() - record.created_at).total_seconds()
    age_days = max(age_seconds / 86400.0, 0.0)
    decay = 0.5 ** (age_days / config.recency_half_life_days)

    composite = (
        config.semantic_weight   * semantic_score
        + config.recency_weight * decay
        + config.importance_weight * record.importance
    )
    return composite

0.5 ** (age_days / half_life) 就是工程化的艾宾浩斯遗忘曲线:默认半衰 30 天,记忆 30 天前的重要度衰减一半。一条记忆每次被召回,CrewAI 的存储层还会刷新它的 last_accessed(touch),让 recency 分数"续命"——被用的记忆不会老。

两代公式的共同结论:旧的不一定不重要,重要度和相关性必须参与排名,纯按时间排是偷懒。

2.2 多路融合:不再只靠一个索引

单一索引召回总会有死角。头部框架的共识是多路召回 + 融合重排

Graphiti 是最完整的:图遍历、向量相似、全文检索三路并行,然后融合。融合算法用 RRF(Reciprocal Rank Fusion),实现极其简洁:

python
# graphiti_core/search/search_utils.py
def rrf(results: list[list[str]], rank_const=1, min_score=0):
    scores: dict[str, float] = defaultdict(float)
    for result in results:                     # 每一路检索结果
        for i, uuid in enumerate(result):      # 按排名
            scores[uuid] += 1 / (i + rank_const)  # 名次越前贡献越大
    scored = sorted(scores.items(), key=lambda t: t[1], reverse=True)
    return [u for u, s in scored if s >= min_score]

RRF 的神奇之处:各路结果都不需要相同量纲——图的 BFS 返回序列、向量的相似度排行、全文的 BM25 排行,各自只贡献一个"名次",融合后天然可比。它完全不看绝对分数,只收敛"每个记忆在几路里都靠前"。

融合只是第一步,Graphiti 之后还要做 MMR 多样性重排maximal_marginal_relevance):在保留"相关"的同时惩罚"与已选结果太相似的候选",避免捞回十条意思几乎一样的记忆。最后还能用图距离、提及次数继续微调。

mermaid
flowchart LR
    Q[查询] --> BFS[BFS 图遍历]
    Q --> VEC[向量相似度]
    Q --> FT[全文检索]
    BFS --> RRF[RRF 倒数排名融合]
    VEC --> RRF
    FT --> RRF
    RRF --> MMR[MMR 多样性重排]
    MMR --> R[结果]

CrewAI 的召回则是按查询复杂度动态路由:短查询直接跳过 LLM 分析(省钱捷径),命中置信度低时走"增广查询"深挖,高时走"综合"。多路结果合成时还会标注 evidence_gaps——连"证据缺口"都给你列出来,这条设计对调试记忆非常有用。

2.3 全文检索派:不用向量也能找

Hermes 证明了向量不是唯一解。它的会话记忆检索直接用 SQLite 内建的 FTS5 全文索引,建表 SQL 就写在源码里:

sql
-- hermes/hermes_state_common.py
CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5(
    content, tool_name, tool_calls,
    content='messages',      -- 外部内容表,不冗余存一份
    content_rowid='id'
);

排序走 FTS5 默认的 BM25 相关性算法(可选按时间戳混合排序)。对中日韩等 CJK 语言,分词器不友好,Hermes 额外建了一张 trigram 分词索引表:

sql
CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts_trigram USING fts5(
    content, tool_name, tool_calls,
    content='messages_fts_trigram_src',
    content_rowid='id',
    tokenize='trigram'       -- 三元组分词,中文也能捞
);

trigram 把文本切成连续三个字符的片段,不依赖中文分词词表,对"精确匹配"友好。CJK 查询还保留一条 LIKE 回退路径,防止 FTS 布尔语法在某些查询上直接崩掉(Hermes 专门做了特殊字符清洗)。

为什么值得注意:实体名、命令、代码片段这种精确匹配场景,全文检索的命中率和可解释性都优于向量语义检索,而且零额外依赖。做记忆检索前先想清楚——你要捞的是"意思相近"还是"名字一样",两者的工具完全不一样。

2.4 注入口径:捞回来也得塞得进上下文

召回只是开始,还有一道容量闸门:检索结果怎么进 prompt

Cognee 把检索回来的图谱记忆截断到 4000 token 再注入(常量 MAX_MEMORY_CONTEXT_LENGTH),多出来的宁可不要。Hermes 则用 <memory-context> 围栏把召回内容包起来,并显式告诉模型"这是已召回的长期记忆、不是用户输入"——既防记忆污染上下文,也防注入攻击通过记忆库渗透。

这道闸门常常决定记忆质量的上限:捞回 10 万 token 却塞不进上下文窗口,等于没捞。截断策略、围栏标记、容量预算,是检索端最容易忽略、但最容易翻车的三件事。

三、遗忘与巩固:记忆不是越存越好

存太多会稀释相关性,还会把预算撑爆。成熟框架处理"旧记忆"的主流做法有三条:睡眠巩固、压缩合并、声明式遗忘。

3.1 睡眠巩固:把记忆搬进长期区

最"拟人"的做法是把记忆巩固做成后台定时任务——晚上睡觉的时候,大脑才把白天的经历整理进长期记忆,Agent 也一样。

OpenClaw 的实现叫 Dreaming,默认用 cron 每天凌晨 3 点跑:

typescript
// openclaw/src/memory-host-sdk/dreaming.ts
export const DEFAULT_MEMORY_DREAMING_FREQUENCY = "0 3 * * *"; // 每天 03:00

// light 阶段:轻量扫尾去重
const DEFAULT_MEMORY_LIGHT_DREAMING_LOOKBACK_DAYS = 2;
const DEFAULT_MEMORY_LIGHT_DREAMING_LIMIT = 100;
const DEFAULT_MEMORY_LIGHT_DREAMING_DEDUPE_SIMILARITY = 0.9;

// deep 阶段:决定"是否提升为长期记忆"
export const DEFAULT_MEMORY_DEEP_DREAMING_LIMIT = 10;
export const DEFAULT_MEMORY_DEEP_DREAMING_MIN_SCORE = 0.75;
export const DEFAULT_MEMORY_DEEP_DREAMING_MIN_RECALL_COUNT = 3;        // 被召回≥3次
export const DEFAULT_MEMORY_DEEP_DREAMING_MIN_UNIQUE_QUERIES = 3;      // 命中≥3个不同查询
export const DEFAULT_MEMORY_DEEP_DREAMING_RECENCY_HALF_LIFE_DAYS = 14; // 时间半衰 14 天
const DEFAULT_MEMORY_DEEP_DREAMING_MAX_AGE_DAYS = 30;

// rem 阶段:跨会话模式归纳
const DEFAULT_MEMORY_REM_DREAMING_LOOKBACK_DAYS = 7;
const DEFAULT_MEMORY_REM_DREAMING_MIN_PATTERN_STRENGTH = 0.75;

三阶段对应人类睡眠的三个阶段:

mermaid
flowchart LR
    subgraph Light[Light 浅睡阶段]
        L1[看近2天的短期记忆<br/>去重 + 就近整理<br/>相似度阈值 0.9]
    end
    subgraph Deep[Deep 深睡阶段]
        D1[候选条目逐条评估<br/>被召回 ≥3 次<br/>命中 ≥3 个不同查询<br/>时间半衰 14 天衰减]
        D2{值得提升?}
        D1 --> D2
        D2 -->|是| D3[promote 到长期记忆]
        D2 -->|否| D4[继续躺短期区或归档]
    end
    subgraph Rem[REM 阶段]
        R1[跨 7 天会话<br/>发现重复模式归纳<br/>强度阈值 0.75]
    end
    Light --> Deep --> Rem

判定一条短期记忆是否"值得长期记住",标准非常具体:被不同查询召回过 ≥3 次,而不是它当时看起来多重要——用使用频率替代主观重要度,这是把"记忆巩固"工程化的关键一笔。OpenClaw 甚至带了健康度监控:记忆系统健康度低于 0.35 自动触发 Recovery,回填候选记忆,防止记忆库"缩水"。

Letta 是同一流派的另一个代表(官方文档佐证):/sleeptime 做后台"睡眠时间"记忆巩固,/doctor 做记忆审计,MemFS 让记忆文件可回滚。它的理论根子是 MemGPT 论文的"虚拟上下文管理" + sleep-time compute——记忆维护不是每次对话顺带的,而是独立的睡眠周期

3.2 压缩与合并:长上下文怎么瘦身

上下文膨胀的常规解法是压缩,但"压成什么样"分成两条路线:合并重写 vs 摘要压缩。AgentScope v2 的 Consolidation 属于前者,而它把"长期记忆只改新增部分、不反复重读全部历史"这件事做得最扎实——靠的就是一个叫 watermark(水位线) 的机制。先看它整体是怎么组织的。

AgentScope v2 把记忆维护做成了三条独立可配的 LLM 管道

mermaid
flowchart LR
    subgraph L1[L1 日流水账]
        F[Flush<br/>每会话 append-only<br/>追加到 memory/YYYY-MM-DD.md]
    end
    subgraph L2[L2 策展长期记忆]
        C[Consolidation<br/>读 watermark 之后的新日账<br/>+ 当前 MEMORY.md<br/>LLM 合并/去重/裁剪/重写]
    end
    subgraph C2[上下文侧]
        CP[Compaction<br/>会话长上下文摘要压缩]
    end
    L1 -->|新日账| L2
    L1 -->|同步| C2

为什么 Consolidation 需要 watermark:日流水账是 append-only、只增不减的,日子一长,memory/ 下的历史文件会越来越多。如果每次合并都把全部日账 + MEMORY.md 塞给 LLM,token 成本随时间线性涨,而且会把已经处理过、又被裁剪掉的旧内容反复带回来——这就是"重复合并"的根源。watermark 要解决的正是这两个问题:只喂新增、不重读历史

watermark 是什么:它就是一个时间戳,记录"上一次 Consolidation 成功执行到的时间点"。

它存哪:默认存在一个隐藏状态文件 memory/.consolidation_state 里(每次成功合并后写入本次运行开始时刻);如果配置了分布式存储(BaseStore),则存在 memory/consolidation 命名空间、键名为 watermark 的位置,供多实例共享。

一次 Consolidation 的完整生命周期长这样:

mermaid
sequenceDiagram
    participant J as Schedule(后台定时)
    participant C as MemoryConsolidator
    participant FS as memory/ 日账文件
    participant LLM as LLM 合并调用
    participant W as watermark 状态

    J->>C: 触发 consolidation
    C->>W: readWatermark()<br/>(默认 .consolidation_state / 分布式 store)
    W-->>C: 上次成功时间戳 T0
    C->>FS: glob *.md 并过滤<br/>只取 modifiedAt > T0 的日账
    Note over FS: 首次运行 T0=EPOCH(1970)<br/>全部日账都算"新"
    C->>LLM: 新日账 + 当前 MEMORY.md → 合并去重裁剪
    LLM-->>C: 完整新 MEMORY.md 内容
    C->>FS: 覆盖写回 MEMORY.md
    C->>W: writeWatermark(runStart) 推进水位
    Note over W: 采用版本号 CAS 比对写入<br/>失败回退文件系统

核心逻辑对应源码里这样几段:

java
// MemoryConsolidator.java
// 1) 读水位:没记录就当 EPOCH(首跑=全量)
Instant readWatermark(RuntimeContext rc) {
    StoreItem item = store.get(WATERMARK_NAMESPACE, WATERMARK_KEY);
    if (item != null && item.value() != null) { ... return parsed; }
    return readWatermarkFromFile(rc);   // 回退读 .consolidation_state
}

// 2) 过滤:只挑 mtime 严格晚于水位线的日账
private static boolean isModifiedAfter(FileInfo fi, Instant watermark) {
    return Instant.parse(fi.modifiedAt()).isAfter(watermark);
}

// 3) 成功合并后,把水位推进到本次运行开始时刻
void writeWatermark(RuntimeContext rc, Instant runStart);

这一套设计里藏着三个值得抄的语义:

  1. 增量 / 幂等:只读水位线之后被改过的日账,已处理过的历史永远不会被 LLM 重读。合并天然幂等——同一批新日账重跑一次,结果一致;水位不依赖"记忆内容"本身,所以 MEMORY.md 被改成什么样都不影响后续增量正确性。
  2. 失败可重试:合并失败或水位推进失败时,水位线不动,下次触发仍会包含这批未处理的新日账,不会漏数据;最坏情况是重复合并一次,但由于日账 append-only、拿到的始终是最新内容,不会把 MEMORY.md 覆盖回旧状态。
  3. CAS 乐观锁防并发:当配置了分布式 store(多实例共享记忆工作区)时,推进水位走 CAS:先读当前版本号,用期望版本比对写入,冲突则重试(最多 5 次),失败回退文件系统写入。防的不是"重复合并",而是两个 worker 同时推进水位导致对方漏处理一批新日账。单实例本地部署时没有这个竞争,只走文件写入。

要特别纠正一个常见误解:Consolidation 不是简单"给 MEMORY.md 追加一段",而是把它当作唯一权威的策展文件——每次用"新日账 + 旧 MEMORY.md"让 LLM 合并、去重、裁剪后,覆盖写出完整的新 MEMORY.md(合并 prompt 里明确要求"Output the COMPLETE new MEMORY.md content (not just a diff)")。日账是流式草稿,MEMORY.md 才是跨天、跨会话的高信噪比结论,二者角色分工清晰。

更难得的是,三个环节各自独立可配模型:Flush 用便宜小模型抽事实,Consolidation 用贵的大模型做策展,Compaction 用小模型压上下文——把"贵模型"只花在真正需要判断力的地方

OpenAI Agents SDK 走"摘要压缩"路线,且做成了阈值驱动

python
# src/agents/memory/openai_responses_compaction_session.py
DEFAULT_COMPACTION_THRESHOLD = 10

def default_should_trigger_compaction(context):
    # 候选非用户消息攒到 10 条就触发压缩
    return len(context["compaction_candidate_items"]) >= DEFAULT_COMPACTION_THRESHOLD

候选消息攒够 10 条(用户消息和已压缩项不算),就调 OpenAI 服务端的 responses.compact 接口把历史压成摘要。它还不只是"一压了事":压缩后的替换是原子操作——先清空再写入新摘要,任何一步失败(异常/取消)都自动回滚到上一份完整历史。压缩过程带意外恢复,这个工程细节很值得抄。

3.3 声明式遗忘:删的是索引,不是事实

最后一种"忘"最聪明:让遗忘可撤销

Graphiti 的遗忘是旧边失效——点还留着,连接断开,知识没丢,只是关系被否定了;Cognee 的 forget 统一删除图、向量和会话缓存,并重置 pipeline 状态,但保留原始文件——"删派生、留源头",需要时可以重建索引;Mem0 的 MD5 哈希去重本质也是一种遗忘:从源头杜绝重复,让记忆库自动精简。

这三种遗忘都是可逆的,删的是派生索引而不是源头事实。除非你明确要物理销毁数据,否则这应该是记忆系统唯一允许的"遗忘"方式。

3.4 小结

维度会话派文件派抽取派图谱派流水线派
存什么完整原文整理后的要点LLM 抽取的事实实体/边/溯源LLM 复核的事实池
写入成本低(append)低(工具写)中(每次 LLM)高(抽取+消歧)高(多步 LLM)
检索全文/服务端FTS/文件读向量+实体图+向量+全文(RRF)深浅双路+复合分
遗忘摘要压缩预算/覆盖哈希去重边失效(点保留)余弦去重
适用长会话存档低成本个人助手事实型知识库关系密集型重治理企业 Agent

四、落地取舍:三档组合拳

拆完源码,落到自己的项目上怎么选?针对个人开发 / 独立开发者最常遇到的三个场景,总结了三档组合拳:

  1. 最小可用:文件记忆 + 全文检索,零依赖 抄 Hermes:一个 MEMORY.md + 一个 SQLite FTS5 索引。重点抄它三招——写前预算、精确去重、原子批量。个人助手场景,一个文本文件就能撑住,还能交给 Git 管版本。

  2. 事实要准:ADD-only + 确定性去重 + 实体链接 抄 Mem0 新版:LLM 只做抽取、不做更新判断;去重用 MD5 哈希和余弦阈值,不用 LLM 判断重复;实体链接做聚合。如果记忆要当"事实库"用,这条路线反幻觉能力最强。

  3. 关系要清:时序图谱 + 边失效 抄 Graphiti:消歧 + 边失效建模人物/事件的前后关系。代价是 LLM 抽取成本高,关系不密集的场景不值当——聊天记录里没有图。

三条从源码里长出来的批判视角:

  • 别迷信"LLM 管理记忆"。Mem0 从"LLM 三态判定"回归"ADD-only + 确定性去重"是个强信号:让 LLM 判断该增该删该改,判断本身就成了幻觉放大器。确定性规则(哈希、余弦阈值、字符预算)往往比再烧一轮 LLM 更可靠。
  • 记忆维护必须独立于对话主路径。OpenClaw 的 cron dreaming、AgentScope 的三管道、OpenAI 的 compactor——没有一家是在每次对话里顺带整理记忆的,全是后台任务。自建记忆层时,把巩固/压缩/遗忘单独做成异步 job,别塞进每次请求的 hot path。
  • 写比读更值得下功夫。12 个框架里,检索算法花花肠子最多,但真正决定记忆质量的往往是写入端的预算、去重和清洗。写入脏了,检索算法再好也捞不回来。

落地顺序建议:先存原文(会话派)→ 再上抽取(抽取派)→ 最后才考虑图谱(图谱派)。一上来就上图谱,是自建记忆层最容易翻车的一步倒。

结语

记忆不是算法炫技,是工程取舍。

附录:信息源清单

框架仓库
Generative Agentsgithub.com/joonspk-research/generative_agents
Mem0github.com/mem0ai/mem0
Graphitigithub.com/getzep/graphiti
LangMemgithub.com/langchain-ai/langmem
AgentScope v2github.com/agentscope-ai/agentscope-java
OpenClawgithub.com/openclaw/openclaw
Lettagithub.com/letta-ai/letta-code
Cogneegithub.com/topoteretes/cognee
CrewAIgithub.com/crewAIInc/crewAI
Claude Agent SDKgithub.com/anthropics/claude-agent-sdk-python
OpenAI Agents SDKgithub.com/openai/openai-agents-python
Hermes Agentgithub.com/NousResearch/hermes-agent

文中所有代码片段均取自上述仓库当前 main 分支对应文件、AgentScope 官方记忆文档(java.agentscope.io/v2/zh/docs/harness/memory.html)。