LLM 驱动的记忆合并:从"记下来"到"想明白"
上一篇我们用 Embedding 让 Agent 能"理解"记忆之间的语义关联。但还有一个问题悬而未决——提取出来的记忆,大部分是垃圾。
这不是谦虚,是事实。SimpleConsolidationStrategy 会把每条超过 10 个字的用户消息都当回事:
"我今天去公园玩得很开心" → 存入长期记忆
"今天的天气非常好很适合出门" → 存入长期记忆
"路上看到一只可爱的流浪猫" → 存入长期记忆
每段对话都产生 N 条记忆,一周下来就是几百条。大部分是无意义的社交口水。检索时搜出一堆噪声,真正重要的用户画像反而被淹没了。
直觉上,我们需要能区分"重要"和"不重要的聊天"。关键词靠不住。能用上 LLM 的地方就别用规则,因为 LLM 真的"懂"什么是重要信息。
先回到第一性原理
记忆合并要解决三个问题:
- 过滤:从日常对话中挑出真正值得记住的信息
- 去重:同一件事被反复提及,不要重复存储
- 抽象:把零散的事实合并为更高层次的知识
SimpleConsolidationStrategy 用关键词规则解决第一个问题,对后两个问题完全没招。LLM 正好是干这三件事的天花板模型。
但直接用 LLM 做记忆合并没那么简单。需要考虑:
- 成本:每条消息都调 LLM,一天的 API 费用比 Agent 本身还贵
- 延迟:LLM 调用是异步的,会拖慢 consolidate 的完成时间
- 容错:LLM 可能返回错误格式的信息,不能因为一次失败丢了整批记忆
这套权衡下来,我设计了一个两阶段架构:先提取,再合并。
架构概览
短期记忆(对话消息)
│
▼
Phase 1: LLM 提取 ──── 从消息中识别重要事实,过滤噪声
│
▼
Phase 2: LLM 合并 ──── 与新提取的事实对比已有记忆,去重 + 合并
│
▼
Phase 3: 存入长期记忆 ── 类型映射(fact→fact, preference→user_preference)
│ 重要性评分也在这个阶段写入 metadata
▼
长期记忆存储
三个阶段的职责很清晰。Phase 1 和 2 各自独立调用 LLM,但有个关键区别:提取阶段总是运行,合并阶段只在有存量记忆时才触发。
这意味着第一次 consolidate 时,只花一次 LLM 调用。之后的 consolidate 最多花两次。这个设计控制住了增量成本。
ConsolidationLLM:比 LLMProvider 更轻的抽象
在动手实现之前,先想清楚一件事:和核心模块的 LLMProvider 做区分。
核心的 LLM Provider 需要支持 tool calling、streaming、多轮对话。而记忆合并只需要一个能力:给提示词,拿回文本。
接口只有两个属性:
export interface ConsolidationLLM {
readonly name: string;
complete(system: string, prompt: string): Promise<string>;
}
轻到不能再轻。一个 complete 方法包打天下。system 是角色设定,prompt 是具体任务。返回纯文本。
这样一来,生产环境用 OpenAI,测试用 Mock,本地开发用 Ollama,都只需要实现这一个小接口。
OpenAI 的实现也很直接:
export class OpenAIConsolidation implements ConsolidationLLM {
async complete(system: string, prompt: string): Promise<string> {
const response = await fetch(`${baseURL}/chat/completions`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${this.apiKey}`,
},
body: JSON.stringify({
model: this.model,
messages: [
{ role: 'system', content: system },
{ role: 'user', content: prompt },
],
temperature: 0.3,
}),
});
if (!response.ok) {
throw new Error(`API error (${response.status})`);
}
const data = await response.json();
return data.choices[0]?.message?.content ?? '';
}
}
gpt-4o-mini 在这里完全够用,价格便宜,延迟低。不需要上旗舰模型。
Phase 1:LLM 提取
规则引擎提取事实的典型输出是"用户喜欢吃辣"。但有一连串问题——"用户其实不太能吃辣,只是喜欢那个味道而已"这种细微差别,关键词根本抓不到。
LLM 提取的核心优势是:它能理解语义层面什么值得记。
提示词的核心设计(实际代码用的是英文版,这里用中文示意关键逻辑):
你是一个 Agent 的记忆分析师。从对话中提取关于用户的重要信息,
忽略问候、确认、闲聊。
对每条事实给出:
- content: 事实描述
- type: fact | preference | decision
- tags: 关键词 2-5 个
- confidence: 0.0 到 1.0(置信度)
这个 confidence 字段是关键。LLM 对自己信心不足时会给出 0.5 以下的分数,我们的策略会直接丢弃这些低置信度的事实。这就是第一道过滤。
为了控制上下文长度,消息按批次处理(默认 10 条一批)。每批独立调用 LLM,最后合并结果。这是一个已知的 trade-off:跨批次的语义关联会丢失。比如两条消息分别在不同批次里提到同一件事,LLM 不会意识到它们相关。实际使用中 10 条一批基本够用,大多数 consolidate 场景不会超过这个量。
提取还有个好处:LLM 会天然做语义压缩。"我平时用 Python 写后端,偶尔也写写前端页面"会被提取成"用户的主要语言是 Python,全栈工程师倾向"——这比原始文本更精炼,更适合做长期记忆。
置信度到重要性的映射:
importance = 0.3 + confidence * 0.5
类型加成:preference +0.1, decision +0.05
这样 confidence 0.5 → importance 0.55,confidence 1.0 → importance 0.80。阈值默认 0.5,低于这个的就当没看见。
Phase 2:LLM 合并
提取只是第一步。更微妙的问题是:新提取的事实和已有记忆之间的关系。
举几个例子:
- 重复:已有"用户喜欢 TypeScript",新提取"用户常用 TypeScript"——重复,跳过
- 包含:已有"用户是软件工程师",新提取"用户主要用 TypeScript 写后端"——包含关系,合并为更精确的描述
- 矛盾:已有"用户住在北京",新提取"用户正在计划搬到上海"——矛盾,但这是新事实,应该覆盖旧的
- 无关:已有"用户喜欢吃辣",新提取"用户用 Vim 写代码"——各自保留
规则引擎写这些逻辑巨复杂。LLM 一句话就搞定了。
合并的提示词设计:
对每条新事实,与已有记忆对比,决定:
- keep: 新信息,直接保留
- skip: 重复或已被覆盖,跳过
- merge_into: 与已有记忆合并为更精确的描述
返回 JSON 数组,格式:
[{ "factIndex": 0, "action": "keep", "reason": "..." }]
merge_into 类型允许 LLM 生成合并后的新表述。这其实就是认知升级——把多条相关记忆抽象为一条更高层级的表述。
注意 merge_into 的语义:它生成一条新的、精度更高的合并事实,不会替换或删除已有的原始事实。mergeTargetIndex 指向的是 newFacts 数组的索引,因为合并结果最终要存入长期记忆。如果 mergeTargetIndex 未指定,默认为 factIndex。
为什么不做真正的"替换"?因为 LLM 不可靠。如果某次合并判断错了,删掉了不该删的记忆,那就永久丢失了。保留原始事实加上合并后的高精度版本,相当于做了冗余备份。后续的遗忘机制可以清理过时的原始事实。
实现细节:merge() 方法只返回"最终要存储的事实列表",实际的新增和删除由 DefaultMemorySystem 控制。这个关注点分离让测试变得更干净。
合并阶段的采样策略
合并时不会把全部已有记忆塞给 LLM。DefaultMemorySystem.consolidate() 先从长期记忆中检索最多 100 条,然后交给 merge()。merge() 内部再按时间倒序采样 mergeSearchSize 条(默认 20)送入 LLM 做对比。
为什么采样而不是全量?两个原因。一是成本——100 条已有记忆加上新事实,提示词轻松超过 8k tokens,但 gpt-4o-mini 的注意力在长上下文中会衰减,中部信息容易被忽略。二是延迟——每条额外的记忆都增加 LLM 的处理时间。20 条是成本和召回率之间的平衡点。
整合到 DefaultMemorySystem
之前的 consolidate 逻辑很简单:
const facts = strategy.extract(messages);
for (const fact of facts) { await longTerm.store(fact); }
现在扩展为:
// Phase 1: 提取
const facts = await strategy.extract(messages);
if (facts.length === 0) return;
// Phase 2: 合并
// - 从长期记忆中检索最近 100 条作为合并候选
// - 策略内部采样 mergeSearchSize(默认 20)条送入 LLM
const existingMemories = await longTerm.search({ query: '', maxResults: 100 });
const mergedFacts = await strategy.merge(facts, existingMemories);
// Phase 3: 存入(含类型映射 + 重要性写入 metadata)
for (const fact of mergedFacts) {
await longTerm.store({
content: fact.content,
metadata: {
timestamp: Date.now(),
type: fact.type === 'preference' ? 'user_preference'
: fact.type === 'decision' ? 'insight'
: 'fact',
tags: fact.tags,
importance: fact.importance,
},
});
}
类型映射的逻辑值得单独提一句:ExtractedFact.type 是 'fact' / 'preference' / 'decision',但 LongTermMemoryItem.metadata.type 用的是 'fact' / 'user_preference' / 'insight'。这个映射在 Phase 3 里做了转换。
接口演进:ConsolidationStrategy 的异步化
这里有一个不得不做的变更:把 extract() 从同步改为异步。
之前的规则策略是纯同步的规则运算,但 LLM 调用是异步的。不改接口不行。
改动很小:
// 之前
export interface ConsolidationStrategy {
extract(messages: Message[]): ExtractedFact[];
}
// 之后
export interface ConsolidationStrategy {
extract(messages: Message[]): Promise<ExtractedFact[]>;
merge(
newFacts: ExtractedFact[],
existingMemories: LongTermMemoryItem[],
): Promise<ExtractedFact[]>;
}
默认策略只需要把同步结果包一层 Promise.resolve(),对现有功能零影响。
测试策略:MockLLM 的妙用
LLM 驱动的系统最难测的就是 LLM 本身。每次调用结果不确定,依赖外部 API,CI 跑不了。
解决方案很经典:用 Mock 代替真实 LLM。
export class MockConsolidationLLM implements ConsolidationLLM {
private responses: string[];
private callIndex = 0;
async complete(_system: string, _prompt: string): Promise<string> {
const response = this.responses[this.callIndex % this.responses.length];
this.callIndex++;
return response;
}
}
预设好 LLM 会返回的 JSON,然后验证策略是否按照预期处理了这些数据。
这就是控制反转的威力——LLM 变成了可注入的依赖,测试时注入 Mock,生产注入 OpenAI。整个策略的逻辑不依赖任何外部服务。
几个测试场景:
- 增量调用时合并阶段被跳过(因为第一次 consolidate 时长期记忆为空,模拟的响应序列需要对应调整)
- 合并阶段正确应用 keep / skip / merge_into 决策
- LLM 返回非法 JSON 时优雅降级,不丢数据
- 置信度过滤正确丢弃低分事实
还有一个隐形的坑:JSON 解析容错
LLM 的结构输出格式做得不算差,但远非完美。gpt-4o-mini 偶尔会在 JSON 外面包 markdown 代码块,或者多打几个字。
解析时需要两层容错:
- 先尝试匹配
json ...代码块 - 没找到的话,直接找 [ 和 ] 之间的内容
private extractJSON(text: string): string | null {
// 尝试匹配代码块
const blockMatch = text.match(/```(?:json)?\s*([\s\S]*?)```/);
if (blockMatch) return blockMatch[1].trim();
// 尝试直接找 JSON 数组
const start = text.indexOf('[');
const end = text.lastIndexOf(']');
if (start !== -1 && end !== -1 && end > start) {
return text.slice(start, end + 1);
}
return null;
}
提取失败时不会抛异常,而是打警告日志并返回空数组。后续的合并阶段会优雅降级(graceful degradation)——保留新提取的事实,不做合并。不会因为一次 LLM 的异常格式让整个 consolidate 流程崩溃。
成本分析:到底贵不贵
用 LLM 做记忆合并,最直接的担心是成本。算笔账:
假设每次 consolidate 处理 10 条消息:
- 提取阶段:1 次 LLM 调用
- 合并阶段:最多 1 次 LLM 调用(第一次 consolidate 时跳过)
- 输入:约 2000 tokens(10 条消息 + 提示词)
- 输出:约 200 tokens(5-10 条事实的 JSON)
- 模型:gpt-4o-mini($0.15/M 输入,$0.60/M 输出)
单次 consolidate 成本:$0.0004。每天 100 次 consolidate?$0.04。
这个成本比大部分人的预期低得多。4o-mini 的价格优势让 LLM 驱动的记忆合并从"不敢想"变成了"随便用"。
真正的成本不在 API 费用,而在延迟。每次 consolidate 多等 1-2 秒,用户体验会打折扣。实际的解决办法是:consolidate 放在后台异步执行,不阻塞用户的下一条消息。
架构演进回顾
第四篇文章结束了,停下来看一眼这个记忆系统长什么样了。
用户消息
│
▼
短期记忆(滑动窗口)
│
▼
consolidate() ── 显式触发
│
├── Phase 1: LLM 提取事实
│
├── Phase 2: LLM 合并去重
│ (针对已有长期记忆,采样 20 条对比)
│
└── Phase 3: 存入长期记忆(类型映射 + 重要性写入 metadata)
│
▼
长期记忆存储
│
▼
检索 ── keyword / semantic / hybrid
│
▼
注入 Agent 循环
从写死的规则提取,到 Embedding 语义检索,到 MMR 多样性管理,再到 LLM 驱动的合并。每一层都在做同一件事:从"存下来"进化到"存得好"。
第五篇我们会做个转折——遗忘机制的工程化。一个记性太好从不遗忘的系统,和一个什么都记不住的健忘症,哪个更可怕?下一篇聊。
评论
发表评论