时效加权 + MMR 多样性重排:让 Agent 记住真正重要的

上一篇我们用 TF-IDF 风格的关键词检索实现了长期记忆的注入。代码跑通了,测试全绿了。

但放到真实对话里一试,问题就冒出来了。

先讲两个翻车场景

场景一:该记住的没记住

用户三周前说:"我平时周末喜欢去徒步,特别是走越野路线。"

Agent 记住了这条偏好,存进了长期记忆。一切正常。

但今天用户说的是:"最近在写一个 Node.js 后端,想加个缓存层,用 Redis 还是 Memcached?"

Agent 检索记忆,找到了什么?全是对"Node.js""后端"这些词的匹配。那条"喜欢徒步"的偏好——虽然记录了用户更深层的长期特征,但跟当前 query 无关,被排到了很后面。

Agent 给出的回复礼貌但敷衍。它不知道用户是个户外爱好者,不知道推荐建议要考虑用户喜欢"走出去"的性格。

场景二:重复的记忆挤占了 slot

用户一连说了好几条和技术栈相关的事:

  • "我决定用 TypeScript 重写这个项目"
  • "TypeScript 的类型系统真好用"
  • "准备用 TypeScript 写后端 API"
  • "不过我也在学 Python 做数据分析"

Agent 检索记忆,内存里四条全匹配。由于只取 top 3,三条关于 TypeScript 的记忆占了全部名额。Python 那条虽然也匹配,但被挤掉了。

Agent 以为用户是纯 TypeScript 开发者,完全不知道用户也在拓展方向。

这两个翻车不是 bug,是设计问题。核心有两点:

  1. 没有时效性。TF-IDF 只看关键词重叠,不看时间远近。一周前的关键信息效力等于昨天刚说的废话。
  2. 没有多样性。top N 检索天然偏向信息茧房。相似的记忆把 slot 占满了,不同维度的信息就进不来。

这一篇就来解决这两个问题。


时效加权:给时间一个位置

解决问题一的思路很直观:不是所有记忆条目生而平等,时间越近的应该越重要。

但怎么加权是个技术活。

三种候选曲线

我动手之前列了三种方案:

线性衰减:score = 1 - t / T

最简单。但断崖效应很明显——过了 T 时间直接就变成 0 了,不够平滑。而且刚记录的那一瞬间和一天后差别很大,一个月和两个月后反而没差别。

对数衰减:score = 1 / log(t + e)

对近期变化很敏感,但远期的拖尾太长。一条 3 年前的记忆和对一条 10 年前的记忆,分数几乎一样。这不合理。

指数衰减:score = e^(-t / τ)

介于两者之间。衰减速率随时间变化——先快后慢。刚发生的记忆衰减得快,越久远的变化越平缓。这和人类的遗忘曲线是匹配的(Ebbinghaus 曲线本质上就是指数型的)。

而且指数衰减有个很好的物理意象:半衰期。

半衰期是个很好的参数

半衰期的概念从物理学借来:经过一个半衰期,记忆的"重要性"衰减到原来的一半。

设半衰期为 H,则 τ = H / ln(2)。这样当 t = H 时,score = e^(-H / (H/ln2)) = e^(-ln2) = 0.5。

所以"7 天半衰期"的意思是:7 天前的一条记忆,重要性是新记忆的一半。14 天前是四分之一。

衰减曲线(半衰期 7 天):

score
 1.0 | ●
 0.8 |
 0.6 |          ●
 0.5 |----------●-------------- 半衰期点
 0.4 |
 0.25|                    ●
 0.0 ●------------------------→ 时间
     0   7天    14天    21天

这个参数很直观。用户说"让一周前的记忆重要性减半",你直接调成 7 天就行。不用解释什么 λ、α、β。

代码实现:

function exponentialDecay(timestamp: number, halfLife: number): number {
  const elapsed = Date.now() - timestamp;
  if (elapsed <= 0) return 1;
  // τ = halfLife / ln(2),保证半衰期时刻 score = 0.5
  const tau = halfLife / Math.LN2;
  return Math.exp(-elapsed / tau);
}

怎么和现有的相关度分数结合

有了时效分数,怎么和原始的 Jaccard 相关度结合起来?

直接替换不行。如果只看时间,那最相关的永远是上一条消息,失去了检索的意义。

我用组合公式:

blendedScore = jaccard * (1 - w) + recencyScore * w

w 就是 recencyWeight,取值范围 0 到 1。w = 0 时退化为纯相关度,w = 1 时只看时间。

默认 w = 0.1。这个值不大,但足够把一条一周前的关键信息拉回视野。

为什么默认这么小?因为时效不该"颠覆"相关度排序,它只是"微调"。一个三天前完全不相关的话题,不应该因为新就排在前面。


MMR 多样性重排:不要塞给我三胞胎

解决了时效问题,再看多样性问题。

问题本质

top N 检索的本质是从候选集里选出分数最高的 N 个。但如果候选集里高度相似的条目太多,top N 就变成了一堆三胞胎。

这个问题在信息检索领域早就被研究透了。一个经典的解法是 MMR(Maximal Marginal Relevance)

MMR 的核心思想

MMR 的思路很巧妙。它不直接按分数取 top N,而是一轮一轮地选——每轮选一个"最值得选"的结果加入最终集合。

评判标准有两个维度:

  • 和 query 的相关度:不能选不相关的
  • 和已选结果的不同程度:不能和已经选中的太像

公式很简洁:

MMR(d) = λ · sim(d, q) - (1-λ) · max_{s∈S} sim(d, s)

第一项 λ · sim(d, q) 鼓励选和 query 相关的结果。第二项 (1-λ) · max sim(d, s) 惩罚和已选结果过于相似的结果。λ 控制两边权重:

  • λ = 1:纯相关度排序,退化为普通 top N
  • λ = 0:纯多样性,不考虑 query,只求结果之间不相似
  • λ = 0.7:推荐值,偏相关度但仍保留多样性

用选球队来理解:你要选 5 个人组一支队。不能全选前锋(都是 TypeScript 后端记忆),也不能全选守门员(完全无关的信息)。你要在"球员能力"和"位置多样性"之间找平衡。

零 embedding 的相似度计算

MMR 需要计算两个东西:sim(d, q) 和 sim(d, s)。

既然整个项目都是零外部依赖的原则(没有向量数据库、没有 embedding 模型),那相似度用什么算?

上一篇的 InMemoryLongTermMemory 用的是 TF-IDF 风格的评分。但对于 MMR 内部的 pairwise 比较,TF-IDF 需要维护全局词频统计,太重了。

我用 Jaccard 相似度

J(A, B) = |A ∩ B| / |A ∪ B|

两个文本共有的 token 数 / 两个文本的总 token 数。

例子:
A:"用户用 TypeScript 写后端 API"
B:"用户决定用 TypeScript 开发 API"

token 化(中文逐字拆分,英文按词拆分):
A → 英文 [typescript, api],中文 [用, 户, 用, 写, 后, 端]
   去重后:{typescript, api, 用, 户, 写, 后, 端} → 7 个
B → 英文 [typescript, api],中文 [用, 户, 决, 定, 用, 开, 发]
   去重后:{typescript, api, 用, 户, 决, 定, 开, 发} → 8 个

交集:{typescript, api, 用, 户} → 4
并集:7 + 8 - 4 = 11

J(A, B) = 4/11 ≈ 0.36

Jaccard 计算快、可解释性强,不用维护词频统计。缺点是短文本上可能会有偏差,但我们的记忆条目通常是中短文本,可以接受。

MMR 的主循环

核心代码比想象中短:

function mmrSelect(
  candidates: ScoredItem[],
  lambda: number,
  numToSelect: number
): ScoredItem[] {
  const selected: ScoredItem[] = [];
  const remaining = [...candidates];

  while (selected.length < numToSelect && remaining.length > 0) {
    let bestIdx = 0;
    let bestScore = -Infinity;

    for (let i = 0; i < remaining.length; i++) {
      const score = computeMMRScore(remaining[i], selected, lambda);
      if (score > bestScore) {
        bestScore = score;
        bestIdx = i;
      }
    }

    selected.push(remaining[bestIdx]);
    remaining.splice(bestIdx, 1);
  }

  return selected;
}

function computeMMRScore(
  item: ScoredItem,
  selected: ScoredItem[],
  lambda: number
): number {
  const relevanceTerm = lambda * item.querySimilarity;

  let maxSimToSelected = 0;
  for (const sel of selected) {
    const sim = item.pairwiseSim.get(sel.item.id) ?? 0;
    if (sim > maxSimToSelected) maxSimToSelected = sim;
  }

  return relevanceTerm - (1 - lambda) * maxSimToSelected;
}

每轮遍历所有未选中的候选项,选择 MMR 分数最高的那个加入结果集。这里有个重要优化:pairwise 相似度在循环前就预先计算好、缓存起来,避免每次比较都重新 token 化和计算 Jaccard。

一个完整的运行示例

还是开头那个场景,四条记忆,query = "TypeScript":

ID 内容 Jaccard(query) 时效分数
ts1 writing TypeScript backend with Express for REST API 0.125 1.0(刚刚)
ts2 building TypeScript backend API using Express framework 0.143 1.0(刚刚)
ts3 creating TypeScript REST API service with Express 0.143 1.0(刚刚)
py analyzing data with Python TypeScript integration pipeline 0.143 1.0(刚刚)

(这里展示的排序是已按相关度降序排列后的顺序,MMR 在已排序的候选集上运行。)

三组 TS 条目的 pairwise 相似度在 0.27~0.50 之间(都有 typescript、express、api 这些词),而 py 和它们的相似度只有 0.08~0.17。

λ = 0.6 时:

第一轮(选第一个):
- ts1: 0.6 × 0.125 = 0.075
- ts2: 0.6 × 0.143 = 0.086
- ts3: 0.6 × 0.143 = 0.086
- py: 0.6 × 0.143 = 0.086

ts2 胜出。这时 selected = [ts2]。

第二轮(selected = [ts2]):
- ts3: 0.6 × 0.143 - 0.4 × 0.273 = -0.023
- py: 0.6 × 0.143 - 0.4 × 0.077 = 0.055
- ts1: 0.6 × 0.125 - 0.4 × 0.364 = -0.071

py 胜出。selected = [ts2, py]。

第三轮(selected = [ts2, py]):
- ts3: 0.6 × 0.143 - 0.4 × 0.273 = -0.023
- ts1: 0.6 × 0.125 - 0.4 × 0.364 = -0.071

ts3 胜出。最终结果:[ts2, py, ts3]。

Python 那条数据科学相关记忆被选中了。换作纯相关度排序,前三名全是 TypeScript 后端内容。


组合使用:先时效,后 MMR

两个模块各自独立,但组合使用时有个顺序问题:先时效加权,再 MMR 重排

为什么是这个顺序?

时效加权改变的是单条记忆的"价值"——它影响的是"这条记忆本身值多少分"。MMR 改变的是结果集合的构成——它在"选了哪些条目"这个层面做优化。

正确的 pipeline 是先把每条记忆的价值校准(时效加权),让旧的重要记忆浮上来、新的普通记忆沉下去,然后对校准后的候选集做 MMR 多样性选择。

反过来会怎样?先做 MMR 的话,所有记忆在"价值不均等"的情况下被分到了一样的起点,MMR 会把一条旧的、不相关的、但恰好和其他结果不相似的内容选进来。

默认配置建议

recencyWeight: 0.1     # 时效贡献 10%,轻度校准
mmrLambda: 0.7          # 偏相关度,但保留多样性
recencyHalfLife: 7天   # 一周后重要性减半
mmrCandidateWindow: 20 # MMR 候选池大小

完整的 Reranker 接口

把这些组合装进一个 DefaultReranker 里:

export class DefaultReranker implements Reranker {
  async rerank(
    query: string,
    results: LongTermMemoryItem[],
    config?: RerankerConfig
  ): Promise<LongTermMemoryItem[]> {
    if (results.length <= 1) return results;

    // Step 1: 计算 Jaccard 相似度和时效分数
    const scoredItems = results.map(item => ({
      item,
      querySimilarity: jaccardSimilarity(query, item.content),
      recencyScore: exponentialDecay(item.metadata.timestamp, cfg.recencyHalfLife),
      pairwiseSim: new Map(),
    }));

    // Step 2: 时效加权
    if (cfg.recencyWeight > 0) {
      for (const si of scoredItems) {
        si.querySimilarity =
          si.querySimilarity * (1 - cfg.recencyWeight) +
          si.recencyScore * cfg.recencyWeight;
      }
    }

    // Step 3: 按加权分数排序,取 top N 作为 MMR 候选
    scoredItems.sort((a, b) => b.querySimilarity - a.querySimilarity);
    const candidateWindow = scoredItems.slice(0, cfg.mmrCandidateWindow);

    // Step 4: 预计算候选之间的 pairwise 相似度
    for (let i = 0; i < candidateWindow.length; i++) {
      for (let j = i + 1; j < candidateWindow.length; j++) {
        const sim = jaccardSimilarity(
          candidateWindow[i].item.content,
          candidateWindow[j].item.content
        );
        candidateWindow[i].pairwiseSim.set(candidateWindow[j].item.id, sim);
        candidateWindow[j].pairwiseSim.set(candidateWindow[i].item.id, sim);
      }
    }

    // Step 5: MMR 多样性重排
    return mmrSelect(candidateWindow, cfg.mmrLambda, cfg.maxResults);
  }
}

怎么集成到现有系统

核心设计原则:不修改 LongTermMemory 接口

搜索是搜索,后处理是后处理,职责分离。如果把重排逻辑塞进 InMemoryLongTermMemory 的 search 方法里,测试会变得复杂,将来换了 Redis 或向量数据库后端,重排逻辑又要重新实现。

集成点在 MemoryInjector 层面。在 buildContext 中,search 之后、format 之前增加一个可选的后处理步骤:

// 检索阶段
const searchMaxResults = reranker.enabled
  ? Math.max(maxMemories, mmrCandidateWindow)
  : maxMemories;

let results = await longTerm.search({
  query: queryText,
  maxResults: searchMaxResults,  // reranker 需要更多候选
});

// 重排阶段(如果启用)
if (reranker.enabled) {
  results = await activeReranker.rerank(queryText, results, {
    recencyWeight,
    mmrLambda,
    maxResults: maxMemories,  // 重排后只保留最终需要的条数
  });
}

// 格式化阶段
return formatMemoryContext(results);

这里有个细节:启用 reranker 时,search 的 maxResults 要放大到候选窗口大小。如果 search 只返回 3 条,那 MMR 的候选池里就只有 3 条可挑,多样性无从谈起。

用户用法:

// 不启用重排(和之前一样)
const injector = new DefaultMemoryInjector();

// 启用重排——一行配置
const injectorWithRerank = new DefaultMemoryInjector();
// 在 Agent 配置中多一个字段:
{
  memoryInjection: {
    config: {
      maxMemories: 5,
      reranker: {
        enabled: true,
        recencyWeight: 0.1,
        mmrLambda: 0.7,
      },
    }
  }
}

接口没变,加一个嵌套配置项就开启。不需要改已有代码。


一个被忽视的问题:query 质量

最后说个题外话。

Reranker 再怎么优化,如果 query 本身质量太差,也救不了。

buildQuery 从最近 3 条消息里拼接文本作为检索 query。但有些场景下这个消息窗口不一定能反映真正的检索意图。比如:

  • 用户刚切换话题,但窗口里还残留着上一个话题的关键词
  • 对话中有大量"嗯""好的""继续"这样的填充词,污染了 query

这个问题暂时还没有完美解法,但一个简单的改进是过滤掉过短的 user 消息(问候语、确认语),以及过滤掉 assistant 的填充性回复。

这一篇不展开了——query 优化本身值得单独开一篇。


收尾

这一篇做了什么:

  1. 发现两个问题:时效性缺失和多样性不足
  2. 指数衰减解决时效问题,半衰期参数让调参有物理意义
  3. MMR 多样性重排解决信息茧房问题,Jaccard 相似度让它零外部依赖
  4. 组合成 Reranker pipeline,集成到 MemoryInjector 中

代码在:src/memory/reranker.ts

下一篇预告:基于 Embedding 的语义检索。到那时候,现在的 TF-IDF 就有对比对象了——看看向量检索在实际效果上能提升多少。喜欢可以关注。


本系列是「TS Agent 运行时核心」的子系列「记忆系统增强」。之前的内容在 betterpursue.blogspot.com 可以找到。

评论

此博客中的热门博文

我写了半年 prompt,最后发现最好的技巧就三个