LLM 驱动的记忆合并:从"记下来"到"想明白"

上一篇我们用 Embedding 让 Agent 能"理解"记忆之间的语义关联。但还有一个问题悬而未决——提取出来的记忆,大部分是垃圾

这不是谦虚,是事实。SimpleConsolidationStrategy 会把每条超过 10 个字的用户消息都当回事:

"我今天去公园玩得很开心" → 存入长期记忆
"今天的天气非常好很适合出门" → 存入长期记忆
"路上看到一只可爱的流浪猫" → 存入长期记忆

每段对话都产生 N 条记忆,一周下来就是几百条。大部分是无意义的社交口水。检索时搜出一堆噪声,真正重要的用户画像反而被淹没了。

直觉上,我们需要能区分"重要"和"不重要的聊天"。关键词靠不住。能用上 LLM 的地方就别用规则,因为 LLM 真的"懂"什么是重要信息。

先回到第一性原理

记忆合并要解决三个问题:

  1. 过滤:从日常对话中挑出真正值得记住的信息
  2. 去重:同一件事被反复提及,不要重复存储
  3. 抽象:把零散的事实合并为更高层次的知识

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 合并

提取只是第一步。更微妙的问题是:新提取的事实和已有记忆之间的关系

举几个例子:

  1. 重复:已有"用户喜欢 TypeScript",新提取"用户常用 TypeScript"——重复,跳过
  2. 包含:已有"用户是软件工程师",新提取"用户主要用 TypeScript 写后端"——包含关系,合并为更精确的描述
  3. 矛盾:已有"用户住在北京",新提取"用户正在计划搬到上海"——矛盾,但这是新事实,应该覆盖旧的
  4. 无关:已有"用户喜欢吃辣",新提取"用户用 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 代码块,或者多打几个字。

解析时需要两层容错:

  1. 先尝试匹配 json ... 代码块
  2. 没找到的话,直接找 [ 和 ] 之间的内容
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 驱动的合并。每一层都在做同一件事:从"存下来"进化到"存得好"

第五篇我们会做个转折——遗忘机制的工程化。一个记性太好从不遗忘的系统,和一个什么都记不住的健忘症,哪个更可怕?下一篇聊。

评论

此博客中的热门博文

我写了半年 prompt,这是我发现的最好的技巧