切换主题
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: 更新会话元信息其中最常用的几个字段(如 summary、lastPrompt、customTitle)都做 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({...})四个细节值得单独说:
- 注入扫描:写进去的如果是"你是一个…"这类内容,会污染后续所有读取。所以写入前必须扫描,拒绝接收这类注入。
- 锁内重读:
add会把整个文件重写一遍,如果直接基于缓存写,两个会话同时写就会互相覆盖。先拿锁、重读磁盘、再追加,是防丢数据的底线。 - 精确去重:用
content in entries判断,不用 LLM 去"判断两句话意思是否一样"——确定性规则,零幻觉。 - 预算控制:
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 批量沉淀进语义图谱。它还有一个务实的安全默认——多个召回作用域里,tools 和 code 只有显式 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 composite0.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);这一套设计里藏着三个值得抄的语义:
- 增量 / 幂等:只读水位线之后被改过的日账,已处理过的历史永远不会被 LLM 重读。合并天然幂等——同一批新日账重跑一次,结果一致;水位不依赖"记忆内容"本身,所以 MEMORY.md 被改成什么样都不影响后续增量正确性。
- 失败可重试:合并失败或水位推进失败时,水位线不动,下次触发仍会包含这批未处理的新日账,不会漏数据;最坏情况是重复合并一次,但由于日账 append-only、拿到的始终是最新内容,不会把 MEMORY.md 覆盖回旧状态。
- 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 |
四、落地取舍:三档组合拳
拆完源码,落到自己的项目上怎么选?针对个人开发 / 独立开发者最常遇到的三个场景,总结了三档组合拳:
最小可用:文件记忆 + 全文检索,零依赖 抄 Hermes:一个
MEMORY.md+ 一个 SQLite FTS5 索引。重点抄它三招——写前预算、精确去重、原子批量。个人助手场景,一个文本文件就能撑住,还能交给 Git 管版本。事实要准:ADD-only + 确定性去重 + 实体链接 抄 Mem0 新版:LLM 只做抽取、不做更新判断;去重用 MD5 哈希和余弦阈值,不用 LLM 判断重复;实体链接做聚合。如果记忆要当"事实库"用,这条路线反幻觉能力最强。
关系要清:时序图谱 + 边失效 抄 Graphiti:消歧 + 边失效建模人物/事件的前后关系。代价是 LLM 抽取成本高,关系不密集的场景不值当——聊天记录里没有图。
三条从源码里长出来的批判视角:
- 别迷信"LLM 管理记忆"。Mem0 从"LLM 三态判定"回归"ADD-only + 确定性去重"是个强信号:让 LLM 判断该增该删该改,判断本身就成了幻觉放大器。确定性规则(哈希、余弦阈值、字符预算)往往比再烧一轮 LLM 更可靠。
- 记忆维护必须独立于对话主路径。OpenClaw 的 cron dreaming、AgentScope 的三管道、OpenAI 的 compactor——没有一家是在每次对话里顺带整理记忆的,全是后台任务。自建记忆层时,把巩固/压缩/遗忘单独做成异步 job,别塞进每次请求的 hot path。
- 写比读更值得下功夫。12 个框架里,检索算法花花肠子最多,但真正决定记忆质量的往往是写入端的预算、去重和清洗。写入脏了,检索算法再好也捞不回来。
落地顺序建议:先存原文(会话派)→ 再上抽取(抽取派)→ 最后才考虑图谱(图谱派)。一上来就上图谱,是自建记忆层最容易翻车的一步倒。
结语
记忆不是算法炫技,是工程取舍。
附录:信息源清单
| 框架 | 仓库 |
|---|---|
| Generative Agents | github.com/joonspk-research/generative_agents |
| Mem0 | github.com/mem0ai/mem0 |
| Graphiti | github.com/getzep/graphiti |
| LangMem | github.com/langchain-ai/langmem |
| AgentScope v2 | github.com/agentscope-ai/agentscope-java |
| OpenClaw | github.com/openclaw/openclaw |
| Letta | github.com/letta-ai/letta-code |
| Cognee | github.com/topoteretes/cognee |
| CrewAI | github.com/crewAIInc/crewAI |
| Claude Agent SDK | github.com/anthropics/claude-agent-sdk-python |
| OpenAI Agents SDK | github.com/openai/openai-agents-python |
| Hermes Agent | github.com/NousResearch/hermes-agent |
文中所有代码片段均取自上述仓库当前 main 分支对应文件、AgentScope 官方记忆文档(java.agentscope.io/v2/zh/docs/harness/memory.html)。


