Agent 记忆出现重复和矛盾怎么优化:我实测了6种去重和更新策略,找到了低成本维护记忆质量的方法

很多Agent 记忆系统用久了会出现积累重复和矛盾记忆的问题。为了解决这个问题,本文实测了 mem0、MemOS、signetai 等记忆方案,对比了 6 种去重与更新策略,最终找到一种既能维护好记忆质量,又能平衡 LLM 调用成本的最佳实践。

Agent 记忆主要有两种:一种是文件记忆,agent 直接读写自己维护的 markdown 文件;另一种是外挂长期记忆系统,后端调用 LLM 从对话中抽取事实写入记忆库,后续对话按相关性检索注入上下文,mem0 是这类系统中使用较广的一个。本文讨论的是后者,在记忆持续积累过程中,新写入的记忆和库里的旧记忆难免重复甚至矛盾,能不能做好去重和更新,直接决定过时、冗余、矛盾的记忆会不会污染上下文,影响用户体验。 以下案例来自我自己的 OpenClaw 会话,记忆模块为 mem0 2.0.7。跨 4 次对话中,我先后四次提到自己住在北京。对话结束后检查记忆库,仅"住在北京"一个事实就存出了 4 条记忆,措辞各不相同:

  1. User recently moved to Beijing (搬到了北京), likely around early July 2026 in connection with their job change
  2. User lives in Beijing (住在北京), China, as of July 12, 2026
  3. User recently moved to and is currently living in Beijing (搬到并现居北京), mentioned around July 12, 2026
  4. User recently moved to Beijing for residence (迁居北京) as of around July 2026

这个积累过程的记忆库状态变化如下图: 很多记忆系统都支持去重和更新,但对近义、包含关系、矛盾关系记忆的复杂情况处理仍然是一个难点,没有一个完美的解法。

实际设计时,通常涉及两个维度上的选择:

第一个维度是判定新抽取的记忆是否与旧记忆重复:存储新记忆前,是否需要先判定它与库中已有记忆重复?怎么找出重复的已有记忆?

第二个维度是判定抽取的新记忆是重复的之后如何处理:用新记忆直接覆盖旧记忆?还是调用LLM将新旧两条融合为一条?

不同产品有不同策略,本文以 mem0、MemOS、signetai 为例展开分析。实验部分在开源项目 NeatMem 上复现(github.com/kanhaoning/... ),文中命令均可直接运行。

一、mem0

mem0 (开源版 2.0.7) 没有独立的去重步骤,去重措施是在记忆抽取prompt中,要求LLM避免重复抽取。其抽取、存储记忆的流程如下:

1. 加载历史消息

mem0维护了一个存储会话记录的sqlite数据库, 每次抽取记忆前会先从sqlite数据库加载最近 10 条消息,填充到抽取记忆的prompt中,帮助LLM基于上下文背景抽取出更完整的记忆。

2. 检索相关旧记忆

将新消息转为向量,在记忆库中检索最相关的 10 条旧记忆,加载到抽取记忆的prompt中, 让LLM一方面参考旧记忆补充的上下文,另一方面避开重复抽取出已经有的记忆。

3. 调用LLM抽取记忆

构建system prompt和user prompt调用LLM抽取记忆,其中user prompt是抽取记忆所需的素材,模板如下:

text 复制代码
## Summary
(历史对话的用户画像摘要)

## Last k Messages
(最近 10 条消息,用于补全新消息中的指代)

## Recently Extracted Memories
(本会话已抽取的记忆,最多 20 条)

## Existing Memories
(第 2 步检索到的相关旧记忆,10 条)

## New Messages
(本次新消息,抽取内容只准来自这里)

## Observation Date
(对话发生日期,相对时间据此解析成具体日期)

## Current Date
(当前日期)

mem0 只填其中四节:Last k Messages、Existing Memories、New Messages,以及两个日期;Summary 和 Recently Extracted Memories 两节在默认路径下留空(这两节只在mem0闭源版会填充)。

system prompt 包含一系列抽取记忆的要求,其中与去重相关的规则有三条,与更新相关的有一条:

  • 规则 1:

    Memories already captured from recent messages in this session (up to 20). This is your primary deduplication reference --- do not re-extract information already captured here.

    Recently Extracted Memories 是主要的去重参照,已经抽取过的信息不要重复抽取。但如上所述,开源版这里的Recently Extracted Memories没有传入。

  • 规则 2:

    Memories currently in the system relevant to this conversation. ... Use these ONLY for deduplication and linking --- do NOT extract new memories from Existing Memories. If new information in New Messages is semantically equivalent to an Existing Memory with no meaningful new context, skip it.

    Existing Memories 只准用于去重和关联,如果新消息中的信息与某条旧记忆语义等价、且没有新的有效信息时,跳过。

  • 规则 3(方向相反):

    When in doubt, extract. A slightly redundant memory is far less costly than a missing one. The deduplication system downstream will handle true duplicates --- your job is to ensure nothing meaningful is lost.

    不确定是否重复就抽取,一条略冗余的记忆代价远小于漏抽一条,下游去重系统会处理真正的重复。

  • 规则 4(针对更新场景):

    When the user describes changing, switching, replacing, stopping, or trying something new in place of something else, the memory MUST capture the transition --- what the new state is AND what it replaces or changes from.

    用户描述切换、替换、停止某件事时,记忆必须把从旧到新的变化写完整,新状态是什么、取代了原来的什么,写进同一条。

规则 1、2 要求避免重复,规则 3 要求多抽。但规则 3 所承诺的"下游去重系统",在实际流程中并不存在,在抽取记忆后直接存入记忆库。

这一设计的直接后果是只能避免生成明显冗余的记忆,容易生成与旧记忆同义不同表达、包含关系、矛盾关系的新记忆存储到记忆库, 缺乏一个兜底环节来拦截重复记忆。开头的案例正是如此,多次"住在北京"的消息措辞不同,在抽取环节没有被判定为与存在的记忆重复,进而抽取了4 条"住在北京"的重复记忆存入记忆库。

但 add-only 的优势也是实在的。降低丢失信息的风险、写入侧零额外调用,延迟和成本最低,架构上也不需要维护判定与合并逻辑。更重要的是,规则 4 给了 add-only 处理更新的手段:新记忆把从旧记忆的完整变迁写出来,召回时新旧记忆同时出现,LLM 看到新记忆就能判断旧记忆已过时,不采用它回答。至于重复冗余的代价,体现在召回的过时记忆造成误导上以及LLM浪费思考token从包含冗余、过时的记忆中筛选出部分正确记忆上;但是对评测(比如mem0在LoCoMo评测默认召回200条记忆用于回答)的准确率的影响并不明显,当正确的记忆和过时记忆、重复记忆被一并召回时,LLM仍然能找出最正确的记忆答对问题。

二、MemOS 如何判定重复

与 mem0 相反,MemOS(2.0.23)把判定是否重复做成一个独立步骤:新记忆存入到记忆库时不做查重,直接入库;之后由后台线程逐个新记忆调用LLM判定是否和已有记忆重复,根据判定结果执行合并、归档等操作。注意这套后台线程默认不开启(reorganize=False),开启后才有下面的流程。判定流程如下:

1. 初筛候选对

对每条新记忆,先用向量相似度从已有记忆中召回一批相似度达到阈值(硬编码为0.8)的候选旧记忆,对每个新旧记忆对逐对调用LLM判定。

2. 三分类判定

对每条候选记忆单独调用一次LLM,判定它与新记忆关系属于以下三者中的哪一个。prompt 中的定义如下:

contradictory: The two statements describe the same event or related aspects of it but contain factually conflicting details.

redundant: The two statements describe essentially the same event or information with significant overlap in content and details, conveying the same core information (even if worded differently).

independent: The two statements are either about different events/topics (unrelated) OR describe different, non-overlapping aspects or perspectives of the same event without conflict (complementary).

关系 判定标准(大意) 处理方式
contradictory(矛盾) 同一事件,但事实细节冲突 优先较新、或模型判断更可信的信息;无法调和则按时间戳删旧留新
redundant(冗余) 同一事件/信息,核心相同、措辞可不同 保留双方独有细节,合并成一条更完整的记忆
independent(无关) 不同事件,或同一事件的不同方面且无冲突 不处理,两条都保留

3. RESOLVER 融合

判定为contradictory(矛盾)或redundant(冗余)的新旧记忆对,再调用一次 LLM 处理。RESOLVER prompt 中的规则是:

If the statements are redundant, merge them by preserving all unique details and removing duplication, forming a richer, consolidated version.

If the statements are contradictory, attempt to resolve the conflict by prioritizing more recent information, higher-confidence data, or logically reconciling the differences based on context. If the contradiction is fundamental and cannot be logically resolved, output No.

LLM判定调和成功时,直接将新旧2条记忆融合为一条新记忆返回并入库,两条旧记忆标记为归档,并保留指向融合结果的关联,可追溯。矛盾无法调和时,LLM 返回 No,此时按时间戳删旧留新,旧条目直接删除。

MemOS设计的三分类比"是否重复"的二元判定精细,矛盾与冗余分流处理,旧记忆归档而非删除。但精细分类有一个前提:重复记忆对先要通过相似度阈值才能进入判定环节和后续处理,这种架构下相似度阈值的选择是一个难点,如果相似度阈值太高,可能导致大量重复记忆漏处理,如果相似度阈值太低,将导致LLM调用量显著增加,§六 将实测探究。

三、signetai 如何整批判定

第三家产品 signetai(0.123.22)在另一个组合上:用每条抽取的新记忆检索到一批候选旧记忆,将这一条新记忆和一批旧记忆加载到一个判定重复prompt中,判定候选记忆是否有与新记忆重复的,如果有则找出其中的一条;对于找出的重复记忆,用新记忆来替换掉旧记忆。写入时的完整流程如下:

1. 调用LLM抽取事实

从对话中抽取事实与实体。与 mem0 不同,抽取 prompt 不含任何去重要求,只负责抽取。

2. 惊讶度门控(不调用LLM)

写入前先算新记忆与库中同类型(偏好、决策、事件等,按写入时的类型标签区分)记忆的最大余弦相似度,惊讶度 = 1 − 最大相似度;惊讶度低于阈值(即库中已有几乎一样的内容)直接丢弃,不进后续流程。约束、报错、决策类内容直接放行。这一层是纯向量计算的近似重复拦截,零模型调用。

3. 检索候选,一次调用判定动作

通过门控的每条事实,用 BM25 + 向量混合检索召回最相关的 5 条旧记忆,与新记忆加载到prompt中调用一次LLM,直接输出动作。prompt 中的动作定义如下:

  • add: New fact has no good match, should be stored as new memory
  • update: New fact supersedes or refines an existing candidate (specify targetId). Ensure the merged result is self-contained
  • delete: New fact contradicts/invalidates a candidate (specify targetId)
  • none: Fact is already covered by existing memories, skip

判定为 update 或 delete 时,必须同时返回目标旧记忆的 id,指明被更新或被删除的是哪一条,用于下一步处理。

4. 覆盖执行

判为 update 时,不调用LLM融合记忆,直接用新记忆整条覆盖旧记忆的内容;覆盖前把旧版本的整行数据存为完整 JSON 快照,写入独立的冷存储表,事后可查回。判为 delete 同样先存快照再软删除。覆盖写入的是第 1 步抽取出的事实原文(判定阶段只输出动作和目标 id,不生成文本),旧记忆细节能保留多少取决于新记忆自身写了多少细节。

四、策略总结

mem0 完全不判定重复,MemOS 逐对判定重复来融合记忆,signetai 整批判定重复并覆盖重复记忆。回到引言的两个维度,本文总结出每个维度的策略。

判定维度上常见有三种方式:

  • Add-Only(不做重复判定):通过Prompt要求LLM不要抽取和已有记忆重复的记忆,抽取新记忆全部直接写入。无额外调用成本,低信息丢失风险,代价是Prompt对免重复抽取约束力有限,库中冗余持续累积。
  • PointWise(逐条判定是否重复):将新抽取记忆与每条高相似度的旧记忆单独比对,对逐条判定关系是"矛盾(contradict) / 冗余(redundant) / 无关(independent)"。判定粒度更细,代价是调用量随候选对数成倍增长(后文给出具体数字)。
  • ListWise(整批判定是否重复):写入前,将新记忆与检索到的一批旧记忆一并调用LLM,在一次调用中完成判定:是否存在重复、与哪条重复、应如何处理。每批仅需一次调用,成本较低。

三种方式中,前三节的 mem0 属于 Add-Only,MemOS 属于 PointWise,signetai 属于 ListWise。

PointWise 与 ListWise 的调用差异如下图: 处理维度上,判定重复后有两种做法:

  • Replace(覆盖):用新记忆整条替换旧记忆。实现简单,但新记忆未涉及的旧细节会随之丢失。
  • Rewrite(重写):由LLM将新旧两条记忆合并为一条。细节保留最完整,代价是每次更新多一次融合调用,且融合质量随模型波动。

五、各路线实测速览

为了隔离模型、Prompt 和检索实现的干扰,准确归因于"去重策略"本身,本文使用开源项目 NeatMem 进行复现。NeatMem 是和mem0同类的记忆系统,支持将判定方式(Add-Only / PointWise / ListWise)、处理方式(Replace / Rewrite)在内多个策略配置为独立参数,从而在同一套代码基准上对比不同路线。同时也增加 NeatMem 自己的去重方案(多目标 ListWise + Rewrite,§八 详述)。

实验设置:LoCoMo 长对话问答基准,写入模型、嵌入模型、评分模型全部相同,每个配置独立跑 5 遍取均值,每遍都从空的记忆库开始、把全部对话重新写入后再评测;检索侧统一关闭重排序、返回 top 200,与 mem0 评测的配置对齐;问答与评分也沿用 mem0 评测相同的 prompt,差异只剩去重策略本身。四条路线各对应一条评测命令(需先配置模型密钥,见附录第 2 条,5 行 export 命令即可;LoCoMo 评测集已随 PyPI 包内置,无需单独下载):

Add-Only(mem0 路线)

bash 复制代码
# 去重整体关闭,抽取结果直接写库;结果目录四个配置各用一个
neatmem evaluate --runs 5 --top-k 200 \
  --rerank off \
  --no-dedup \
  --output-dir runs/add-only

PointWise + Rewrite(MemOS 路线)

bash 复制代码
# 判定方式:逐对判定;处理方式:模型融合;候选召回阈值 0.8
neatmem evaluate --runs 5 --top-k 200 \
  --rerank off \
  --dedup-detector pointwise \
  --dedup-resolver rewrite \
  --dedup-recall-threshold 0.8 \
  --output-dir runs/pointwise-rw-08

ListWise + Replace(signetai 路线)

bash 复制代码
# 判定方式:整批判定;处理方式:整条覆盖;候选召回阈值 0.4
neatmem evaluate --runs 5 --top-k 200 \
  --rerank off \
  --dedup-detector listwise \
  --dedup-resolver replace \
  --dedup-recall-threshold 0.4 \
  --output-dir runs/listwise-replace

ListWise 多目标 + Rewrite(NeatMem,默认配置)

bash 复制代码
# 去重走默认:多目标判定 + 融合 + 阈值 0.4
neatmem evaluate --runs 5 --top-k 200 \
  --rerank off \
  --output-dir runs/listwise-mt-rewrite

结果:

路线(代表产品) 判定方式 处理方式 召回阈值 LoCoMo 分数 一句话特点
Add-Only(mem0) --- --- --- 90.68% 不做去重,冗余和矛盾随使用累积
PointWise + Rewrite(MemOS) 逐条判定 融合 0.8 90.27% 更新彻底;消除盲区需降阈值至 0.4,此时判定调用约为 ListWise 同阈值的 5 倍
ListWise + Replace(signetai) 整批判定 覆盖 0.4 89.52% 调用成本低,但覆盖会丢旧细节
ListWise 多目标 + Rewrite(NeatMem,本文方案) 整批判定 融合 0.4 90.56% 判定成本与单目标持平,一次调用命中全部重复,详见 §八

(召回阈值是复现侧的调定值:三家产品的阈值定义各不相同,MemOS 是纯余弦相似度,signetai 是 BM25 与向量的混合分,无法逐一对齐,统一用 NeatMem 的余弦相似度阈值表示。)

四个分数分布在 0.895~0.907 区间,且最高的一档来自不去重的 Add-Only。路线间的差距与同一配置重复运行的波动同量级,基准分数区分不出去重策略的优劣。这正是本文的出发点,去重策略的选择依据不全是评测分数,也在库里是否堆积同义重复、过时事实是否被更新、更新时旧细节是否丢失。下面三节逐条分析不同更新路线的效果。

六、PointWise(MemOS 路线)探究

PointWise(MemOS 路线)是三种判定方式中粒度最细的:每对候选单独调用一次 LLM,模型只输出矛盾(contradict) / 冗余(redundant) / 无关(independent)三类标签,之后合并还是归档由代码按标签执行,不再经过模型(§二)。从设计上看它最接近彻底更新,但实测它的召回候选重复记忆的相似度阈值比较难调,阈值太高时,容易漏处理重复记忆,阈值太低了会判定出大量重复记忆,LLM调用量上升并且伴随评测分数下降。

Case 实测

设计一个最小的更新场景:用户先说自己长住北京,随后宣布搬到了上海。

用 NeatMem 的 demo 命令直接复现(每个 --say 为一次独立写入;模型与密钥配置同 §五,见附录第 2 条),以 PointWise + Rewrite、召回阈值 0.8(与 MemOS 同档)运行:

bash 复制代码
neatmem demo \
  --dedup-detector pointwise --dedup-resolver rewrite --dedup-recall-threshold 0.8 \
  --say "我目前住在北京海淀区,这边的房子住了三年多了,住得挺习惯的" \
  --say "我上个月搬来上海了,在徐汇区租了个公寓,离公司近多了"

第二轮消息使第一轮中"目前住在北京"的陈述过时,跑完后记忆库里两条记忆矛盾共存:

1 用户于2026年7月从北京海淀区搬到上海徐汇区,租了一间公寓,新住处离公司近了很多

2 用户目前住在北京海淀区,住了三年多(约从2023年初起),对居住环境感到习惯

这个结果与完全不去重相同,但原因不同:去重的第一步是按相似度找出可能相关的旧记忆,只有超过 0.8 的才会调用 LLM 判定是否重复。这次两条的相似度只有 0.66~0.75,没过门槛,这一步直接被跳过,新消息当作新记忆存了进去。这次运行的记忆库状态变化如下图:

矛盾的说法用词往往差很远(一条说北京、一条说上海),相似度天然偏低。这就是召回盲区:该判重的旧记忆因为相似度没过门槛,根本没进入判重的候选------判得再准,前提是先被找出来。

将阈值降至 0.4 后重跑同一场景:

bash 复制代码
neatmem demo \
  --dedup-detector pointwise --dedup-resolver rewrite --dedup-recall-threshold 0.4 \
  --say "我目前住在北京海淀区,这边的房子住了三年多了,住得挺习惯的" \
  --say "我上个月搬来上海了,在徐汇区租了个公寓,离公司近多了"

判定命中,记忆库融合为一条:

用户于2026年7月从北京海淀区搬到上海徐汇区,租了一间公寓,因为离公司更近;此前在北京海淀区住了三年多(约2023年初入住至2026年7月),表示住得挺习惯的。

同一场景在阈值 0.4 下的记忆库状态变化如下图:判定命中,两条融合为一条。

分数与 LLM 调用量分析

降低阈值可以消除盲区,但引入另外两重代价。其一是调用量:PointWise 的判定调用对阈值高度敏感,阈值 0.8 时每遍LoCoMo数据集约 2 千次,降到 0.4 后约 2.4 万次,上涨约 12 倍(同阈值下 ListWise 约 5 千次)。其二是评测分数:

配置(PointWise + Rewrite) 提取调用(次/遍) 判定调用(次/遍) 融合调用(次/遍) 合计 LoCoMo 分数 备注
召回阈值 0.8 1,400 2,018 576 3,994 90.27% 召回盲区,部分应判记忆对未进入判定
召回阈值 0.4 1,400 23,985 1,801 27,186 89.13% 盲区消除,但分数下降约 1.1pp、融合量升至 3 倍

综合来看,PointWise 在高阈值下存在召回盲区,在低阈值下承担更高的成本与分数损失,两个方向均不理想。剩下的出路是更换判定重复方式:signetai 的组合(ListWise 整批判定 + Replace 整条覆盖,§三)把新记忆和候选重复记忆集合一次性调用LLM判定找有没有重复的旧记忆,同阈值下判定调用约为 PointWise 的五分之一,且连判定命中后的融合调用也一并省掉。它能否同时解决盲区与成本,下一节用同一批场景实测。

七、ListWise(signetai 路线)探究

Case 实测

同一搬家场景以 signetai 组合 + 阈值 0.4 重跑(与上一节命令相比同时换了两个开关:判定方式换成 listwise、处理方式换成 replace;前者针对盲区与调用量,后者是沿用 signetai 的默认更新方式):

bash 复制代码
neatmem demo \
  --dedup-detector listwise --dedup-resolver replace --dedup-recall-threshold 0.4 \
  --say "我目前住在北京海淀区,这边的房子住了三年多了,住得挺习惯的" \
  --say "我上个月搬来上海了,在徐汇区租了个公寓,离公司近多了"

判定命中后执行覆盖更新,记忆库收敛为一条,逐字如下:

用户于2026年8月从北京海淀区搬至上海徐汇区,租了一间公寓,因离公司更近而搬家

整批候选记忆一次LLM调用判完;判定命中后不调用 LLM 融合,新记忆原文直接整条覆盖旧记忆。阈值 0.4 下召回盲区基本消除,但并非百分之百:偶有候选没过阈值、或进了批次仍被判为新增。这次运行的记忆库状态变化如下图:

盲区问题解决了,成本面下一小节用数据回答;真正的代价藏在覆盖这个动作本身。

上面搬家场景的记忆库里,"住了三年多"的历史细节已经随覆盖消失,那是与更新直接相关的旧细节。还有一种更隐蔽的损失:旧记忆里与这次更新无关的内容,也会跟着整条覆盖一起消失。

用一个早餐习惯的更新场景验证:用户先描述自己的早餐 routine(在家做手冲咖啡、配全麦面包、边吃边听雅思听力备考),随后说咖啡机坏了送修、这段时间改去楼下瑞幸买美式。雅思备考与这次更新无关,但写在同一条记忆里。

先用 replace 运行:

bash 复制代码
neatmem demo \
  --dedup-detector listwise --dedup-resolver replace --dedup-recall-threshold 0.4 \
  --say "我每天早上在家做手冲咖啡,配全麦面包当早餐,边吃边听雅思听力------我在准备年底的雅思考试" \
  --say "咖啡机坏了送修了,这段时间早上改去楼下瑞幸买美式,还是配全麦面包"

判定为更新时,replace 实际写入的内容逐字如下,旧条中"利用早餐时间听雅思听力材料备考"的信息没有进入新文本:

旧:用户每天早上在家做手冲咖啡,配全麦面包当早餐,并利用早餐时间听雅思听力材料备考

新:用户的咖啡机坏了送修,这段时间早上暂时改去楼下瑞幸咖啡买美式代替之前的家庭手冲咖啡,但仍然配全麦面包当早餐

该条记忆被整条替换,"听雅思"从这条记忆里消失。(这个 case 有随机性,如果抽取时"备考雅思"被单独拆成一条记忆,它就不随覆盖丢失)replace 运行的记忆库状态变化如下图:

同一场景换 rewrite 重跑(命令唯一的差异是 --dedup-resolver):

bash 复制代码
neatmem demo \
  --dedup-detector listwise --dedup-resolver rewrite --dedup-recall-threshold 0.4 \
  --say "我每天早上在家做手冲咖啡,配全麦面包当早餐,边吃边听雅思听力------我在准备年底的雅思考试" \
  --say "咖啡机坏了送修了,这段时间早上改去楼下瑞幸买美式,还是配全麦面包"

同样判定为更新时,融合结果保留了旧细节:

用户的手冲咖啡机坏了,已送修中,2026年8月底暂时改为去楼下瑞幸咖啡买美式作为早餐替代,但仍保留全麦面包搭配早餐的习惯;平时(手冲咖啡机可用时)则是在家做手冲咖啡,配全麦面包当早餐,边吃边听雅思听力材料。

rewrite 运行的记忆库状态变化如下图:旧细节在融合的新记忆中保留。 这两个case表明listwise判定这一步没有出错,都正确识别出这是对那条早餐记忆的更新。丢细节出在 replace 上:覆盖不经过 LLM 融合,旧细节保留与否全押在抽取阶段------新记忆写得够全就留下,写不全就随覆盖消失。

分数与 LLM 调用量分析

回到上一节末尾的成本问题:候选变多,调用量为何反而可控?PointWise 的判定次数随候选对数线性增长:每条新记忆都要与每个候选旧记忆单独调用一次LLM;ListWise 把同一批候选合记忆并进一次调用,判定次数与候选数量解耦。Replace 再省掉判定命中后的融合调用。各配置的实测调用量如下:

配置 提取调用(次/遍) 判定调用(次/遍) 融合调用(次/遍) 合计 LoCoMo 分数
PointWise + Rewrite,阈值 0.8 1,400 2,018 576 3,994 90.27%
PointWise + Rewrite,阈值 0.4 1,400 23,985 1,801 27,186 89.13%
ListWise + Replace,阈值 0.4 1,400 5,021 0 6,421 89.52%
ListWise + Rewrite,阈值 0.4 1,400 4,997 366 6,763 90.47%
ListWise + Rewrite,阈值 0.8 1,400 1,263 183 2,846 90.75%†
Add-Only(对照) 1,400 0 0 1,400 90.68%

(调用量为 LoCoMo 5 遍的均值。提取在去重之前、与去重策略无关,各配置相同。判定调用由判定方式与阈值决定、与处理方式无关:同为阈值 0.4 的 ListWise 两行实测差不到 1%,来自库内容分化后的候选差异。分数与 §五、§六 表 3 同口径(关闭重排、top 200)。† 为另一批新跑的 5 遍均值,与其余各行的复用库不同批;与同策略阈值 0.4 的 90.47% 相差 0.28 个百分点,在跨批次重复运行的波动范围内。)

去重调用是提取调用的 1 倍(ListWise 阈值 0.8)到 18 倍(PointWise 阈值 0.4),占全流程成本的大部分。

成本上 PointWise 两个方向都不讨好:阈值 0.8 调用量低,但有召回盲区;降到 0.4 消除了盲区,代价是判定调用涨到 ListWise 同阈值的近 5 倍。

分数上,replace 与 rewrite 相差约 1 个百分点(89.52% vs 90.47%),融合调用的成本换来的是分数与 Case 实测中看到的细节保留;与不去重的 90.68% 相比,两者都在重复运行的波动范围内。分数拉不开差距,差异要看库内容。

八、多目标版 ListWise(NeatMem 路线)探究

换成 ListWise 之后,判定这一步还留有一个问题:一次调用LLM能命中几个要更新的候选记忆。差异就在 prompt 的输出格式上------标准(单目标)版只返回一个 JSON,最多命中一条记忆 {"action": ..., "targetId": ...};多目标版要求一次LLM调用评估每个候选,一次返回全部命中的候选记忆 {"judgments": [{...}, ...]}。当一条新记忆同时使多条旧记忆过时时,单目标版只能更新其中一条。融合也随之不同:多目标命中多条时,一次融合LLM调用把新记忆和全部命中旧记忆合并为一条,而不是分多次顺序融合。两种 ListWise + Rewrite 的处理差异如下图:

Case 实测

构造一个场景验证:用户先后建立两条独立习惯,每天早上一杯美式、常去楼下瑞幸买咖啡;随后一条消息同时推翻两者:改喝茶了,美式不喝了,瑞幸也很久没去了。

以 ListWise + Rewrite(单目标)、阈值 0.4 运行:

bash 复制代码
neatmem demo \
  --dedup-detector listwise --dedup-resolver rewrite --dedup-recall-threshold 0.4 \
  --say "我每天早上都会喝一杯美式咖啡" \
  --say "我常去公司楼下的瑞幸买咖啡" \
  --say "我最近改喝茶了,美式不喝了,楼下的瑞幸也很久没去了"

运行结果只更新了一条:美式条已更新,瑞幸条还是以现在时留着,与更新后的条目直接矛盾:

1 用户最近改喝茶了,不再喝美式咖啡,也很久没去公司楼下的瑞幸咖啡店买咖啡了,这一转变是从之前每天早上固定喝一杯美式咖啡的习惯改变而来的。

2 用户经常去公司楼下的瑞幸咖啡店买咖啡 ← 仍以现在时留在库里,与 1 矛盾

这次运行的记忆库状态变化如下图:一次判定只命中第 1 条,第 2 条漏判留存。

将判定方式换为多目标变体重跑(多目标已是默认配置,此处显式传参仅为对照清晰):

bash 复制代码
neatmem demo \
  --dedup-detector listwise_multitarget --dedup-resolver rewrite --dedup-recall-threshold 0.4 \
  --say "我每天早上都会喝一杯美式咖啡" \
  --say "我常去公司楼下的瑞幸买咖啡" \
  --say "我最近改喝茶了,美式不喝了,楼下的瑞幸也很久没去了"

一次调用即可同时判定两条旧记忆都是更新目标,随后一次融合调用把新记忆和两条旧记忆合并为一条写入记忆库,上述场景跑完后记忆库只剩一条:

1 截至2026年8月31日,用户此前每天早上都会喝一杯美式咖啡,并经常去公司楼下的瑞幸咖啡店购买该咖啡作为固定渠道,但最近已从每天喝美式咖啡的习惯改为了喝茶,不再饮用美式咖啡,也已经很久没有去公司楼下的瑞幸咖啡店买咖啡了。

整个过程的记忆库状态变化如下图:两条记忆先后入库,第三条消息一次调用判定命中两条,再一次融合调用把三条合并为一条。

分数与 LLM 调用量分析

多目标变体的判定次数与单目标基本持平:一次调用判定全部候选,目标数不增加判定调用。增加的是融合调用:一次判定命中多个目标时,一次融合调用把新记忆和全部命中目标合并为一条:

配置(阈值均为 0.4) 提取调用(次/遍) 判定调用(次/遍) 融合调用(次/遍) 合计 LoCoMo 分数
ListWise + Rewrite(单目标) 1,400 4,997 366 6,763 90.47%
ListWise + Rewrite(多目标) 1,400 4,931 570 6,901 90.56%

(调用量为 LoCoMo 5 遍的均值,口径与 §七 表 3 相同。)

判定调用基本持平,融合调用为单目标的 1.6 倍,合计仍约为 PointWise 同阈值(27,186 次)的四分之一;分数打平,差距在重复运行的波动范围内;更彻底的更新不需要额外的成本。

九、结论

回到引言的两个维度------如何判定、如何处理------把各配置的实测全貌放在一起:

策略 判定调用(次/遍) 融合调用 合计调用 LoCoMo 分数 库健康形态
Add-Only(mem0) 0 0 1,400 90.68% 同义、矛盾记忆随使用累积(§一 case)
PointWise + Rewrite,阈值 0.8(MemOS) 2,018 576 3,994 90.27% 更新彻底,但高阈值留召回盲区,矛盾共存(§六 case)
PointWise + Rewrite,阈值 0.4 23,985 1,801 27,186 89.13% 盲区消除,代价是 12 倍判定调用与分数下滑
ListWise + Replace,阈值 0.4(signetai) 5,021 0 6,421 89.52% 更新到位,但覆盖丢弃旧细节(§七 case)
ListWise + Rewrite,阈值 0.4(单目标) 4,997 366 6,763 90.47% 细节保留,但一条新记忆只能更新一个目标,其余漏判(§八 case)
ListWise + Rewrite,阈值 0.8(单目标) 1,263 183 2,846 90.75%† 高阈值留召回盲区,与判定方式无关,矛盾共存(§六 case 同型)
ListWise + Rewrite,阈值 0.4(多目标) 4,931 570 6,901 90.56% 一次判定命中全部目标,一次融合后库内保持一条

(调用量为 LoCoMo 5 遍的均值;提取调用 1,400 次/遍、与策略无关,未列入。† 为另一批新跑的 5 遍均值,与其余各行的复用库不同批,与阈值 0.4 同策略的差值在跨批次波动范围内。)

三个观察:

  1. 分数高不一定体验好。分数最高的两个配置,一个是不独立去重的 Add-Only(90.68%),一个是阈值 0.8 的 ListWise(90.75%),后者虽然分数最高但阈值太严格,一部分该合并的旧记忆没有进入去重的候选列表。实测记忆库里面残留的过时记忆被召回回答会明显降低体验。
  2. 成本差异在去重判定方式。PointWise 每召回一条候选记忆就单独调用一次 LLM 判定重复,阈值一降成本可能失控;ListWise 把全部候选放进一次LLM调用里一起判定重复,召回多少候选都只占一次,同阈值下LLM总调用量约为 PointWise 的五分之一,降阈值消除召回重复记忆的盲区时也只小幅上涨(0.8 的 1,263 次到 0.4 的约 5 千次)。

本文的选择是 ListWise 多目标 + Rewrite:判定成本与单目标持平、分数打平,极大消除了单目标更新不全的问题;Rewrite 的融合调用换来尽可能保留旧记忆细节。NeatMem 可直接 pip install neatmemneatmem serve 起本地服务,在Python可使用与 mem0 API和参数兼容的客户端;已有python mem0 项目把import mem0换成 import neatmem,client接入地址指向本地服务的url即可迁移,默认配置即本文方案。也可直接作为 OpenClaw / Hermes 的记忆后端接入(插件安装见 README)。代码与本文全部 case 的复现方式开源在 GitHub,如果文章对你有帮助,欢迎点个 Star 支持:github.com/kanhaoning/...

附录:复现说明

本文实验基于 NeatMem v0.5.8,全部可复现。实验配置:BM25 开、entity 关、重排关(命令中 --rerank off 显式指定)、thinking 关;除 --rerank off 外均为 v0.5.8 默认值,后续版本默认值若调整以仓库 CHANGELOG 为准:

  1. 安装pip install "neatmem[nlp]" && python -m spacy download en_core_web_smnlp 额外包提供 BM25 的词形还原,本文分数在此配置下测得;使用pip install neatmem直接安装低配版也能跑,BM25 退化为原始词匹配),或从GitHub源码 pip install .安装。

  2. 配置:在终端设置模型密钥:

    bash 复制代码
    export LLM_PROVIDER=minimax
    export LLM_API_KEY=你的key
    export LLM_MODEL=MiniMax-M3
    export EMBEDDER_PROVIDER=siliconflow
    export EMBEDDER_API_KEY=你的key

    长期使用可写入运行目录的 .env,字段相同(模板见仓库根目录 .env.example)。本文实验的写入、回答、评分模型均为 MiniMax-M3,嵌入模型为 SiliconFlow 的 BGE-M3。LLM 还支持 deepseek / dashscope / zhipu / moonshot / volcengine / openai / gemini / openrouter / siliconflow 及 OpenAI 兼容自定义端点,向量模型还支持 openai / dashscope / xinference(本地),替换对应的 provider/key/model 即可,完整配置项见 README;换用其他模型同样能跑,但绝对分数会有偏差,趋势可作参考。

  3. 数据集 :LoCoMo-10 评测集已随 PyPI 包内置,neatmem evaluate 默认加载,无需单独下载。

  4. Case 复现 :§六至 §八 的 neatmem demo 命令可直接运行,每个 --say 为一次独立输入user message,跑完打印记忆库最终内容。

  5. 分数复现 :§五 的四条 neatmem evaluate 命令,--runs 5 即独立跑 5 遍取均值;--rerank off --top-k 200 是本文的统一检索口径(关闭重排序、召回200条记忆);每条命令的 --output-dir 不同,结果、日志与配置清单各自落在对应目录下,四条全部跑完互不影响(中断后重跑同一条命令会自动续跑)。注意额度:本文全部表格加起来约 26 万次 LLM 调用。并发默认即可(本文数据在多 key 代理下以 --max-workers 20 跑出),单 key 实测不建议调高,避免触发限流。想先低成本验证环境可加 --limit 1 --runs 1:只用数据集中第 1 段长对话(全量共 10 段)、跑 1 遍,调用量约为全量的几十分之一,分数会与全量有偏差。

  6. 接入 Agentneatmem serve 监听 http://localhost:8790;Python 侧 MemoryClient(host="http://localhost:8790"),接口形态与 mem0 的客户端一致;也可直接调 HTTP 接口(/v1/memories/ 等)。

相关推荐
hans汉斯2 小时前
【计算机科学与应用】层级评论上下文依赖识别数据集构建与研究——以小红书旅游评论数据为例
人工智能·算法·yolo·目标检测·cnn·旅游
码行山野赴时序归途2 小时前
顺序表(Sequential List)详解:从数组到 C 语言实现
c语言·开发语言·数据结构·算法
Lokey8683 小时前
自注意力机制与多头注意力机制
算法
linx2953 小时前
单元七 · 零基础路线图-第 1–3 周·变量、类型与字符串
c语言·开发语言·数据结构·c++·算法
Benny_Tang3 小时前
P7514 [省选联考 2021 A/B 卷] 卡牌游戏 题解
c++·算法
residual_fan3 小时前
经验模态重构:直向工业时间序列数据的数据扩增方法
人工智能·算法·重构·数据挖掘·数据分析
aramae3 小时前
模拟实现strcpy(字符串拷贝)(C语言)
java·c语言·开发语言·算法
Benny_Tang3 小时前
「雅礼集训 2018 Day7」A 题解
数据结构·c++·算法
Doubbbbbbble云4 小时前
跳表结构在高并发系统中的应用与优势分析4
算法