1. AI记忆为什么越存越乱:从现象到根因
1.1 一个几乎所有人都踩过的坑
如果你正在做AI应用,尤其是带长期记忆的对话助手、个人知识库或者自动化工作流,大概率遇到过这种场景:用户上周说“我住在北京”,这周说“我搬到上海了”,系统里两条记录都在,检索的时候随机命中一条,回答就开始精神分裂。再比如,同一个项目在三次对话里被叫了三个名字——“星火计划”“Project Spark”“那个火星项目”,系统当成三个独立实体,各自存了一堆属性,最后谁也拼不出完整画像。
这不是模型不够聪明,而是记忆层的数据结构没设计好。很多人第一反应是“换个更大的模型”或者“把上下文窗口拉长”,但实测下来,问题根本不在模型,而在于记忆的写入、去重、冲突消解和更新机制这四个环节里,至少有一个是缺失的。
Graphiti 这个项目之所以值得拿出来讲,是因为它把长期记忆这件事从“向量库+时间戳”的粗糙做法,推进到了时序知识图谱的层面。它要解决的核心问题就三个:实体怎么去重、事实冲突怎么检测、旧记忆怎么被新记忆正确覆盖。这三个问题不解决,记忆存得越多,系统越乱。
1.2 为什么“存下来”不等于“记住了”
先厘清一个概念。很多开发者把“记忆”等同于“把对话历史塞进向量数据库”,检索时做相似度匹配。这套方案在短周期、低冲突的场景下能用,但一旦时间线拉长,就会出现三个典型症状。
第一个症状是实体漂移。同一个真实世界对象,在不同时间、不同上下文里被用不同措辞提及,向量检索能召回相关内容,但它不知道这些内容说的是同一个东西。结果就是知识碎片化,你问“我上次说的那个旅行计划”,系统召回五条相关记录,但没法告诉你这五条其实是一条完整计划的五个片段。
第二个症状是事实堆积。用户改了偏好、换了工作、搬了城市,旧事实没有被标记为失效,新事实只是追加。检索时新旧事实同时出现,模型只能靠概率去猜哪个更可信。这就是“事实冲突”的根源——不是没有检测,而是根本没有冲突这个概念。
第三个症状是更新失序。即使你手动做了去重,如果没有时序标记,你无法回答“这个事实是什么时候变成这样的”。长期记忆的核心不是“记住所有”,而是“记住当前有效的,并且知道它从什么时候开始有效”。
Graphiti 的设计思路,本质上是把记忆从“文档集合”升级成“带时间维度的图结构”。节点是实体,边是关系,每条边和每个属性都带有效时间区间。这样一来,去重有了依据,冲突有了检测点,更新有了时间轴。
1.3 本文要拆解的四个核心问题
接下来我会围绕四个层面展开:实体去重到底怎么做才不是“看起来像就合并”、事实冲突检测的判定逻辑和阈值怎么定、长期记忆更新时旧数据怎么处理、以及整套机制落地时有哪些只有踩过才知道的坑。每个部分都会给出可参考的实现思路和参数建议,不堆概念,直接讲怎么落地。
2. 实体去重:不是“像就合并”,而是“有证据才合并”
2.1 实体去重的三种常见做法与各自的问题
实体去重这件事,业内大致走过三个阶段。
最早是字符串精确匹配。两个实体名字完全一样就合并。问题是“北京”和“北京市”合不了,“Project Spark”和“星火计划”更合不了。召回率极低,基本只能处理最干净的数据。
然后是向量相似度合并。把实体名和描述做 embedding,余弦相似度超过某个阈值就合并。这比字符串匹配强,但引入了一个新问题:相似不等于相同。“苹果公司”和“苹果手机”向量相似度很高,但它们是两个实体。阈值调低了误合并,调高了漏合并,而且这个阈值在不同领域完全不可迁移。
Graphiti 走的是第三条路:基于图结构和上下文证据的增量去重。它不只看名字像不像,还看这个实体出现时的邻居节点、关系类型、时间上下文。两个实体如果名字相似,但一个连着“公司”类关系,一个连着“产品”类关系,那就不合并。如果名字不同,但共享多个相同邻居和关系模式,反而可能合并。
这个思路的关键在于:去重不是一次性批处理,而是写入时的增量判断。每来一个新实体,先在图里找候选,再用多维度证据打分,超过阈值才合并,否则新建节点。
2.2 候选召回:先缩小范围,再精细判断
全图扫描做去重是不现实的,节点一多就爆炸。Graphiti 的做法是分层召回候选。
第一层是名称索引召回。对实体名做标准化处理——转小写、去空格、统一中英文标点——然后建倒排索引。新实体进来,先用标准化名称查候选。这一层召回率高但精度低,目的是不漏。
第二层是向量近邻召回。对实体名加简短描述做 embedding,用近似最近邻算法取 Top-K。K 一般取 10 到 20,太小容易漏,太大增加后续计算量。这里有个细节:embedding 模型的选择很关键。通用模型在专有名词上表现一般,如果领域固定,最好用领域数据微调过的模型,或者至少用支持多语言的模型,避免中英文混用场景下召回断裂。
第三层是图邻居召回。如果新实体携带了关系信息,比如“张三 就职于 某公司”,那就用“某公司”这个邻居去反查已有图里连着同一邻居的实体。这一层精度最高,因为它利用了结构信息。
三层召回的结果做并集,得到候选集。实测下来,候选集大小控制在 20 以内比较合理,超过这个数,后续打分计算的开销会明显上升。
2.3 证据打分:四个维度决定合不合并
拿到候选集后,进入精细判断。Graphiti 的打分逻辑可以归纳为四个维度,每个维度给一个分数,加权求和后跟阈值比较。
| 维度 | 判断内容 | 权重建议 | 说明 |
|---|---|---|---|
| 名称相似度 | 标准化名称的编辑距离或向量相似度 | 0.3 | 基础分,但不能占主导 |
| 邻居重叠度 | 两个实体共享的邻居节点比例 | 0.3 | 结构证据,权重应较高 |
| 关系模式相似度 | 两个实体连出的关系类型分布是否相似 | 0.2 | 区分同名不同类实体 |
| 时间上下文一致性 | 两个实体活跃的时间区间是否重叠 | 0.2 | 避免把不同时期的同名实体误合 |
权重不是固定的,要根据领域调。比如人物类实体,名称相似度权重可以降到 0.2,邻居重叠度提到 0.4,因为重名太常见,结构信息更可靠。公司类实体则相反,名称标准化后相似度可信度较高。
阈值方面,综合得分超过 0.85 建议合并,低于 0.6 建议新建,中间区间进入人工审核队列或者暂缓合并、标记为“疑似同一实体”。这个中间区间的处理很重要,很多系统为了自动化直接二值化,结果误合并的代价远高于漏合并。漏合并只是多一个节点,误合并会把两个真实实体的信息搅在一起,后期拆分极其困难。
2.4 合并操作:不是删一个留一个
确定要合并之后,操作本身也有讲究。简单做法是把一个节点的边全部转移到另一个节点,然后删除被合并节点。但这样会丢失信息。
更稳妥的做法是保留合并历史。被合并的节点不物理删除,而是标记为“已合并到某节点”,所有边重定向到主节点,但保留原始来源标记。这样做的原因是:如果后期发现合并错了,可以追溯和回滚。长期记忆系统里,可回滚性比省空间重要得多。
另外,合并时属性的处理也要小心。两个节点都有“年龄”属性,一个 30 一个 31,直接覆盖会丢信息。正确做法是保留两个值,带上各自的时间戳和来源,让冲突消解机制去决定当前有效值。这就是下一部分要讲的内容。
实操心得:实体去重最容易被忽视的是“负样本”积累。每次人工审核判定“不合并”的案例,都应该存下来作为负样本,用于后续调整阈值或微调打分模型。没有负样本,阈值永远调不准。
3. 事实冲突检测:让新旧记忆不再打架
3.1 什么算事实冲突:三种类型要分清
事实冲突不是简单的“两个值不一样”。在实际系统里,冲突至少分三种类型,处理方式完全不同。
第一种是直接矛盾。同一个实体的同一个属性,在同一时间区间内有两个不同值。比如“张三的年龄”同时是 30 和 31。这种是硬冲突,必须消解,否则检索时必然出错。
第二种是时序演进。同一个属性在不同时间区间有不同值,比如“李四的职位”2023 年是工程师,2024 年是经理。这不是冲突,是正常的状态变化。系统要做的不是消解,而是正确标注时间区间,让检索时能按时间点取到正确值。
第三种是来源分歧。不同来源对同一事实给出不同描述,比如用户自述“我住在北京”,但某个文档里写“用户常驻上海”。这种不一定是矛盾,可能是用户有多个居住地,或者文档过时。处理方式是保留多值,标注来源和置信度,检索时按来源可信度排序。
Graphiti 的冲突检测机制,核心就是先分类,再分别处理。把时序演进误判为冲突,会导致频繁的无效消解;把直接矛盾当成多值保留,会导致检索结果不稳定。
3.2 冲突检测的触发时机与判定逻辑
冲突检测不应该在每次写入时全量扫描,那样开销太大。合理的触发时机有两个:新事实写入时和定期一致性检查时。
新事实写入时的检测是增量的。当一条新边或新属性要写入时,先查这个实体在这个属性上是否已有有效值。如果有,比较时间区间:如果新区间和旧区间不重叠,判定为时序演进,直接写入,旧值标记为历史值;如果重叠,进入冲突判定。
冲突判定的逻辑分三步。第一步看值是否真的不同,字符串完全一样直接跳过。第二步看来源是否相同,同一来源的重复写入直接去重。第三步看语义是否等价,这里需要做归一化——“北京”和“北京市”应该判定为等价,“30岁”和“30”也应该等价。归一化规则要按属性类型配置,数值型做数值比较,日期型做日期解析,文本型做标准化加同义词映射。
只有归一化之后仍然不同的,才判定为真冲突,进入消解流程。
3.3 冲突消解策略:优先级、时间戳与置信度
真冲突的消解,不能简单“新的覆盖旧的”。Graphiti 的思路是综合三个因素:来源优先级、时间戳新鲜度、置信度分数。
来源优先级是配置项。用户直接输入的事实优先级最高,系统推断的事实次之,外部导入的文档再次之。这个顺序不是绝对的,但大多数场景下成立,因为用户自述通常最准确。
时间戳新鲜度看的是事实的有效时间,不是写入时间。一条 2024 年生效的事实,比一条 2022 年生效的事实更可能代表当前状态。但要注意,如果新事实的来源优先级很低,即使时间新,也不应该直接覆盖高优先级来源的旧事实,而是应该标记为“待确认”。
置信度分数来自抽取环节。用模型抽取事实时,可以同时输出一个置信度,比如“用户明确说住在北京”置信度 0.95,“从对话上下文推断用户可能住在北京”置信度 0.6。消解时,置信度差距超过 0.3 的,高置信度优先;差距在 0.3 以内的,进入人工审核或保留多值。
实际操作中,我建议把消解结果分成三档:自动覆盖(高优先级+新时间+高置信度)、保留多值(来源分歧或置信度接近)、标记待审(低置信度冲突)。不要追求全自动,长期记忆系统里,少量人工介入换来的是整体可信度的大幅提升。
3.4 冲突检测的评估指标
怎么知道冲突检测做得好不好?不能只看准确率,因为负样本远多于正样本。建议关注三个指标。
冲突召回率:真实冲突中被检测出来的比例。这个指标低,说明系统在“装傻”,冲突漏检会导致检索结果矛盾。目标应该在 0.9 以上。
误报率:被判定为冲突但实际是时序演进或多值的比例。这个指标高,说明系统“太敏感”,会频繁触发无效消解,浪费计算资源还可能改错数据。目标控制在 0.1 以下。
消解正确率:自动消解的结果中,人工抽检认为正确的比例。这个指标直接反映消解策略的质量。初期可能只有 0.7 左右,通过积累审核数据调整权重,可以逐步提升到 0.9。
注意事项:冲突检测的阈值不要一次调到位。建议先用宽松阈值跑一段时间,收集冲突样本,人工标注后再收紧。一上来就追求高精度,很容易把召回率压到很低,后期补都补不回来。
4. 长期记忆更新:旧数据不是垃圾,是历史
4.1 更新不是覆盖,是时间轴上的追加
很多人对“更新”的理解是:新值来了,旧值删掉。这在短期缓存里没问题,但在长期记忆里是灾难。因为长期记忆的价值恰恰在于“知道过去是什么样”,而不只是“现在是什么样”。
Graphiti 的更新模型是双时间轴:一条是事实的有效时间,表示这个事实在真实世界里从什么时候到什么时候成立;另一条是系统的记录时间,表示这个事实是什么时候被写入系统的。两条时间轴分开,才能回答“截至 2024 年 1 月,系统认为用户的职位是什么”这种问题。
更新操作的本质是:旧事实的有效时间区间被闭合,新事实开启新区间。旧事实不删除,只是标记为“已失效”。检索时默认只查有效事实,但可以显式查询历史版本。
这个设计带来的好处是,任何时间点的系统状态都可以重建。如果发现某个更新是错的,可以回滚到之前的状态,而不是从备份里恢复。
4.2 失效标记与软删除的实现细节
失效标记的实现,核心是在边或属性上加两个字段:valid_from和valid_to。有效事实的valid_to为空或设为无穷大。更新时,把旧事实的valid_to设为新事实的valid_from,新事实的valid_from设为当前时间或事实声明的生效时间。
这里有个容易踩的坑:时间精度。如果系统用秒级时间戳,同一秒内多次更新会导致区间重叠。建议用毫秒级,或者在写入时加序列号,保证同一时间点的更新有确定顺序。
另一个坑是级联失效。一个实体失效了,跟它相关的边要不要失效?答案是看情况。如果实体是被合并了,边应该重定向而不是失效。如果实体是被标记为错误,那相关边应该一起失效。这个逻辑要在更新操作里显式处理,不能靠默认行为。
软删除的另一个好处是支持审计。每次更新记录操作来源、操作时间、操作前后的值,形成审计日志。这在多用户协作场景里尤其重要,出问题时能快速定位是谁改了什么。
4.3 检索时如何取到“当前有效”的记忆
更新做完了,检索端要配合。默认检索应该只返回valid_to为空或大于当前时间的事实。但实际场景里,有时候需要查历史,比如“用户上个月说的偏好是什么”。
检索接口建议支持三种模式:当前视图只查有效事实;时间点视图查指定时间点有效的事实;全历史视图返回所有版本,按时间排序。默认用当前视图,其他模式显式指定。
还有一个细节:多值属性的检索排序。如果一个属性有多个有效值(比如来源分歧保留的多值),检索时要按置信度和来源优先级排序,把最可信的放在前面。模型拿到排序后的结果,生成回答时也会更稳定。
4.4 更新频率与批量处理的权衡
长期记忆系统里,更新操作如果每条都实时写图,开销不小。尤其是高频对话场景,每秒可能几十条事实写入。这时候需要权衡实时性和吞吐量。
我的经验是分两层:热数据实时写,冷数据批量合并。最近 24 小时的事实实时写入,保证对话连贯性;超过 24 小时的事实,走批量合并流程,做去重、冲突消解和索引优化。批量任务可以放在低峰期跑,比如凌晨。
批量合并时要注意幂等性。同一批数据重复跑不能产生重复结果。实现上可以用批次 ID 加去重标记,已经处理过的批次直接跳过。
实操心得:更新操作一定要有回滚预案。我见过太多系统因为一次错误的批量更新,把用户几个月的记忆全改乱了。建议每次批量更新前做快照,更新后做一致性校验,发现问题立即回滚。快照不用全量,只存变更集的逆向操作就够。
5. 落地实操:从零搭一套可用的记忆更新流程
5.1 整体架构与数据流
把前面几部分串起来,一套完整的记忆更新流程大致是这样的:原始输入先经过事实抽取,得到实体、关系、属性及其时间信息;然后进入实体去重,确定每个实体是新建还是合并到已有节点;接着做冲突检测,判断新事实和已有事实的关系;最后执行更新写入,闭合旧事实、写入新事实、更新索引。
数据流的关键在于每一步都要保留来源和置信度信息,不能在中途丢失。很多系统抽取时带了置信度,去重时丢了,冲突消解时就没法用。信息要一路透传到底。
存储层建议图数据库加向量索引分开。图数据库存结构和时间区间,向量索引存实体和事实的 embedding,用于召回。两者通过实体 ID 关联。不要试图用一个存储解决所有问题,图查询和向量检索的优化方向完全不同。
5.2 关键参数配置参考
下面这张表是我在实际项目中总结的参数起点,不是绝对最优,但可以作为调参的基准。
| 环节 | 参数 | 建议值 | 调整方向 |
|---|---|---|---|
| 候选召回 | 向量 Top-K | 15 | 召回率低就调大,计算慢就调小 |
| 候选召回 | 名称标准化规则 | 中英文统一、去标点 | 按领域补充同义词表 |
| 去重打分 | 合并阈值 | 0.85 | 误合并多就调高,漏合并多就调低 |
| 去重打分 | 审核区间下限 | 0.6 | 根据人工审核人力调整 |
| 冲突检测 | 置信度差距阈值 | 0.3 | 差距小于此值保留多值 |
| 冲突检测 | 时间重叠判定精度 | 毫秒 | 高频写入场景必须毫秒 |
| 更新写入 | 批量合并周期 | 24 小时 | 按数据量调整 |
| 检索 | 默认返回有效事实数 | 无上限但排序 | 按置信度和时间排序 |
这些值不是拍脑袋来的。合并阈值 0.85 是在多个领域数据集上跑出来的经验值,低于这个数误合并明显上升。置信度差距 0.3 对应的是模型抽取置信度的典型分布,差距小于 0.3 时人工判断也经常分歧,所以保留多值更稳妥。
5.3 一个完整的更新案例走查
假设系统里已有事实:“张三 职位 工程师,有效时间 2023-01 至今,来源用户自述,置信度 0.9”。现在新输入:“张三最近升职成经理了”,抽取得到事实“张三 职位 经理,有效时间 2024-06 至今,来源用户自述,置信度 0.85”。
流程走一遍。事实抽取阶段,识别出实体“张三”、属性“职位”、值“经理”、时间“2024-06 至今”。实体去重阶段,“张三”在图中已存在,名称完全匹配,邻居和关系模式也一致,直接复用节点,不新建。
冲突检测阶段,查“张三”的“职位”属性,发现已有有效值“工程师”,时间区间 2023-01 至今。新事实时间区间 2024-06 至今,与旧区间重叠。值不同,来源相同,归一化后仍不同,判定为真冲突。
消解阶段,比较优先级:来源相同,都是用户自述。比较时间:新事实更晚。比较置信度:旧 0.9,新 0.85,差距 0.05,小于 0.3。综合判断,时间新但置信度略低,差距在阈值内,进入保留多值还是自动覆盖的边界。
这时候策略选择就体现价值了。如果系统配置为“时间优先”,则自动覆盖,旧事实valid_to设为 2024-06,新事实生效。如果配置为“置信度优先”,则保留多值,标记待确认。我的建议是默认时间优先,因为职位变更这类事实,时间新鲜度通常比置信度的小幅差距更重要。但如果是医疗、法律这类高精度领域,就应该置信度优先。
最终写入:旧事实闭合,新事实生效,审计日志记录这次变更。检索时默认返回“经理”,查历史能返回“工程师”。
5.4 性能与成本控制
整套流程里,最耗资源的是向量召回和模型抽取。向量召回可以用近似最近邻算法把复杂度从线性降到对数级,代价是少量精度损失,实测在记忆场景下可以接受。模型抽取如果每条都调大模型,成本很高,建议用蒸馏后的小模型做初筛,只对复杂或低置信度的样本调大模型复核。
图数据库的写入性能也要关注。高频写入时,批量提交比单条提交吞吐量高一个数量级。但批量太大会增加冲突检测的延迟,建议批量大小控制在 100 到 500 条之间,按实际压测结果调。
存储成本方面,软删除意味着数据只增不减。长期运行后,历史版本会占大量空间。建议对超过一定时间的历史版本做冷热分离,热存储保留最近 3 个月,冷存储归档更早的数据,检索时按需加载。
6. 常见问题与排查技巧实录
6.1 去重相关的典型问题
问题一:中英文混用导致去重失败。用户一会儿说“星火计划”,一会儿说“Project Spark”,名称相似度很低,向量召回也召不回来。解决办法是维护一个别名表,抽取阶段做别名归一化。别名表可以人工维护,也可以从共现关系里自动挖掘——如果两个名字经常出现在同一上下文且共享邻居,就互为别名。
问题二:同名不同实体被误合并。两个用户都叫“张三”,系统合并成一个,信息全乱。解决办法是在去重打分里加入“所属用户”或“所属会话”作为硬性隔离条件。不同用户的实体默认不合并,除非显式声明是同一人。
问题三:合并后属性冲突。两个节点合并,各有各的属性值,直接覆盖会丢信息。正确做法是合并时把所有属性值都保留,带上各自来源和时间,让冲突消解机制处理。合并操作本身不做值的选择。
6.2 冲突检测相关的典型问题
问题一:时序演进被误判为冲突。用户去年说住在北京,今年说住在上海,系统判定冲突并触发消解,其实只是搬家。解决办法是冲突检测前先做时间区间重叠判断,不重叠的直接按时序演进处理,不进冲突流程。
问题二:归一化不彻底导致假冲突。“30岁”和“30”被判为不同值。解决办法是按属性类型配置归一化规则,数值型统一转数值,日期型统一转时间戳,文本型做标准化和同义词映射。归一化规则要可配置,不同属性用不同规则。
问题三:冲突消解后检索结果不稳定。同一查询两次返回不同结果。这通常是因为多值保留时排序不稳定。解决办法是给多值一个确定的排序键,比如置信度加时间戳加来源 ID 的组合,保证每次排序结果一致。
6.3 更新与检索相关的典型问题
问题一:更新后旧值还能被检索到。检查检索逻辑是否默认过滤了valid_to非空的事实。很多系统的 bug 出在索引没同步更新,图数据库里标记了失效,但向量索引里还在。更新操作要保证图存储和向量索引原子性更新,或者至少保证最终一致。
问题二:批量更新后数据不一致。批量任务中途失败,部分更新成功部分失败。解决办法是批量任务要支持断点续跑,每批处理完记录进度,失败后从断点继续。同时每批处理前做快照,失败后可以回滚。
问题三:检索延迟随数据量增长。图查询和向量检索都要做优化。图查询加时间区间索引,向量检索用分层可导航小世界图之类的索引结构。另外,热数据缓存能显著降低延迟,最近活跃的实体和事实放内存缓存,命中率通常能到 80% 以上。
6.4 一张速查表收尾
| 现象 | 可能原因 | 排查方向 | 解决思路 |
|---|---|---|---|
| 同一实体多个节点 | 去重召回失败 | 检查别名表和向量模型 | 补别名、换领域模型 |
| 不同实体被合并 | 去重阈值过低 | 看合并日志和打分 | 调高阈值、加隔离条件 |
| 检索结果矛盾 | 冲突未消解 | 查有效事实列表 | 补冲突检测、调消解策略 |
| 旧值仍被返回 | 索引未同步 | 对比图存储和向量索引 | 保证原子更新或最终一致 |
| 更新后数据错乱 | 批量任务失败 | 查批次进度和快照 | 断点续跑、回滚重跑 |
| 检索越来越慢 | 索引缺失或缓存失效 | 看查询计划和缓存命中 | 加时间索引、优化缓存 |
这套东西搭起来不复杂,但每个环节都有细节。我的建议是先把实体去重和冲突检测做扎实,这两块是记忆质量的根基。更新机制可以先用简单的软删除加时间戳,跑起来之后再逐步优化。别一上来就追求全自动,人工审核队列在早期是必要的,积累的标注数据后期能帮你把自动化率提上去。
最后分享一个我踩过的坑:早期为了省事,把去重和冲突检测合并成一个步骤,结果两个逻辑互相干扰,去重时用了冲突检测的阈值,导致该合并的没合并。后来拆成两步,各自独立调参,问题才解决。记忆系统里,每个环节的职责边界要清晰,混在一起调参就是灾难。