时效加权 + 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,是设计问题。核心有两点:
- 没有时效性。TF-IDF 只看关键词重叠,不看时间远近。一周前的关键信息效力等于昨天刚说的废话。
- 没有多样性。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 优化本身值得单独开一篇。
收尾
这一篇做了什么:
- 发现两个问题:时效性缺失和多样性不足
- 用指数衰减解决时效问题,半衰期参数让调参有物理意义
- 用MMR 多样性重排解决信息茧房问题,Jaccard 相似度让它零外部依赖
- 组合成 Reranker pipeline,集成到 MemoryInjector 中
下一篇预告:基于 Embedding 的语义检索。到那时候,现在的 TF-IDF 就有对比对象了——看看向量检索在实际效果上能提升多少。喜欢可以关注。
本系列是「TS Agent 运行时核心」的子系列「记忆系统增强」。之前的内容在 betterpursue.blogspot.com 可以找到。
评论
发表评论