1. 长期记忆为什么会越存越乱:先搞清楚病根在哪里
先说个我自己的观察。前两年做 AI Agent 应用,最怕的不是模型不够聪明,而是模型“记性太好但账本太烂”。你给它存了几百条用户偏好、项目背景、历史决策,过了两周再问它,它要么答非所问,要么把过期的信息当成最新事实,要么同一个实体在记忆库里出现了七八个不同的名字变体,导致检索结果东一块西一块。这就是典型的“AI 记忆越存越乱”问题。
做一个 Agent 的时候,长期记忆(Long-term Memory)是不可或缺的模块。它负责跨会话保持上下文,让 AI 记住你是谁、你做过什么、你偏好什么。但一旦记忆量大起来,问题就来了:数据冗余、实体歧义、事实矛盾、旧信息覆盖不及时……很多人第一反应是“多存点,存仔细点”,结果恰恰相反——存得越多,乱得越快。为什么?因为绝大多数记忆系统的存储方式,没有考虑“记忆是需要维护的”。
我后来接触到 Graphiti 这个框架,它的设计核心就是三个关键词:实体去重、事实冲突检测、增量记忆更新。这也正是这个标题想聊透的东西。这篇文章不打算做框架源码逐行分析,而是从一个工程实践者的视角,把这三件事背后的原理、实现思路和踩坑经验讲明白。适合正在做 AI Agent 记忆模块、RAG 系统或知识图谱应用的朋友,就算你暂时没打算用 Graphiti,这套思路也值得放进架构设计里参考。
1.1 记忆混乱的三个典型症状
长期记忆系统变乱,通常逃不过三种表现。
第一种是实体重复。同一个“张伟”,在记忆里可能同时存在“张伟”、“张伟(产品经理)”、“Zhang Wei”、“产品部张总”好几个节点。如果你用的是向量检索,这问题更隐蔽——它们语义相似但 embedding 不完全一致,每次 Query 都可能召回不同的变体,结果是今天 AI 把“张伟”理解为 A,明天理解为 B,用户体验极其割裂。
第二种是事实冲突。用户上周说“我目前住在北京”,这周说“我搬到上海了”。如果记忆系统不做冲突处理,两条记录并存,AI 被问到“用户现在在哪个城市”时,就变成了掷骰子。更麻烦的是隐式冲突——用户说“我不喜欢喝咖啡”,但某次聊天记录里出现了“今天喝了杯拿铁”,AI 该信哪个?按时间?按上下文?还是按显式声明优先?
第三种是信息过时。用户的职位变了、项目状态改了、预算额度调了,但旧记录没有被标记为过期,导致 Agent 总是基于过时信息做决策。这一点比前两个更隐蔽,因为它在逻辑上不冲突,只是“不准确”,但恰恰最影响业务判断。
1.2 为什么向量检索解决不了这个问题
很多人把长期记忆做成“对话历史塞进向量库”,这能解决一部分召回问题,但解决不了结构化维护问题。向量检索擅长的是“语义相似”,但它天生不擅长回答“哪个是最新事实”“这两个实体是不是同一个人”“这两条断言是否矛盾”。
打个不严格的比方:向量记忆像一堆便利贴,你能快速找到内容相关的纸片,但便利贴之间没有钉在一起,也没有标注时间线。真正靠谱的长期记忆,应该像一个维护良好的关系数据库加上一个实时更新的“事实台账”。而 Graphiti(或者说它背后的双时间轴架构)做的事情,就是为这个台账提供一个工程上可落地的实现。
2. Graphiti 的整体设计思路:双时间轴与记忆的最终一致性
Graphiti 这个名字在 AI 记忆领域出现不久,但它借用了一个非常成熟的理念:知识图谱 + 时间感知。传统的知识图谱把实体和关系组织成网络,但大多是静态的;Graphiti 的核心改进是让整个图谱具备“时间维度”,让记忆层的每一次操作都踩在时间轴上。
我见过的很多 RAG 项目,记忆更新就是“删掉旧 doc,写入新 chunk”。听起来简单,实际操作时你会遇到两个问题:第一,你根本不知道哪些 chunk 是旧事实,因为 embedding 检索是按相似度而不是按时间线来命中的;第二,删除是粗粒度的,一条记录里可能包含了多个事实,删了就把有价值的上下文也丢了。Graphiti 换了个思路——用事件(Episode)作为基本信息载体,而不是用文本块;用实体和关系来组织信息,而不是用文档。
2.1 事件时间轴与实体时间轴:两条线各管各的事
Graphiti 最核心的设计是双时间轴。
事件时间轴(Episode Timeline):记录每一次交互、每一个信息片段何时进入系统。它像一本流水账,保证“信息来源可追溯”。你调用 Graphiti 添加信息时,默认会生成一个带时间戳的 Episode 节点,后续所有从这条信息提取出的实体和关系,都反向关联到这个事件上。
实体时间轴(Entity Timeline):记录某个实体的状态从什么时间点开始生效,到什么时间点被更新或废止。比如“张伟所在城市”这个事实,在 T1 时刻是“北京”,T2 时刻变为“上海”。Entity Timeline 让系统能回答“张伟在 T1 到 T2 期间在哪里”,而不是只能回答“张伟现在在哪里”。
这两条轴共同支撑起记忆系统的一个关键能力——时间旅行。你可以查某个时间点的记忆快照,也可以查某个事实的变更历史。这一点对 Agent 应用的价值极高,因为它让记忆不只是“可以检索”,还变成了“可以审计”。
为什么不直接用一个数据库表存“实体 + 属性 + 时间”?因为知识图谱的图结构天然适合表达多跳关系和复杂关联。比如“张伟负责的项目”和“这个项目与哪个客户有关”,在表结构里需要多次 join,在图里直接沿着边遍历就行。Graphiti 选择图作为底层存储,本质上是在为复杂关系场景铺路。
2.2 增量更新与批量重建:为什么不直接全量重建
有些知识图谱方案是“定期全量重建”,读一遍所有文档,重新抽取实体关系。这种方式在小规模数据上没毛病,但一旦数据量大,每次重建都是灾难——耗时、耗钱、还会导致记忆在重建期间不可用。
Graphiti 的做法是增量更新。新信息到来时,只处理与它相关的实体、关系和冲突,不触碰已经稳定的记忆子图。这跟增量索引的思路类似,但复杂度更高,因为图结构里的“相关实体”可能通过多跳关系连接到远处的信息。
举个实际例子:用户新增了一条消息“这周末要去杭州出差,帮我订西湖边的酒店”。系统做增量更新时,除了抽取“杭州”“周末”“西湖边酒店”这些新实体关系,还会把这条事件挂到用户的实体上,并且顺带检查和已有信息是否矛盾(比如用户上周刚说过“最近在深圳常驻”)。范围控制得当,开销就稳定,不会因为历史数据多而越跑越慢。
3. 实体去重:把同一个“人事物”在记忆里归到同一个 ID 下
实体去重听起来像是一个“数据清洗”问题,但放在 AI 长期记忆场景里,它远比传统 ETL 复杂。因为 AI 面对的是非结构化文本,同一实体可能有几十种不同表述,而且这些表述还依赖上下文才能确定是否指同一目标。
Graphiti 的实体去重策略,大体可以分成三步走:生成候选、相似度判定、合并决策。下面拆开讲。
3.1 候选生成与相似度判定:不只是算 embedding 距离
第一步是从新提取的实体集合里,找出可能和已有实体重复的候选。Graphiti 的做法是:先按实体名称做精确匹配或模糊匹配(比如相似文本索引、n-gram),再做属性层面的条件过滤。这里的小技巧是——不要只凭名称。比如“John”和“Johnny”名字相似,但一个在技术文档里指代某工程师,另一个可能是客户的名字。所以需要加上类型约束:Person 对 Person、Organization 对 Organization,避免跨类型误合并。
第二步是相似度判定。Graphiti 在实现上通常结合了两种手段:基于名称文本的相似度(字符级),以及基于向量表示的语义相似度。但更关键的是它支持“时间窗合并”——如果两个实体在同一事件中被同时提及,且上下文里存在关联线索(比如互为别名),系统会提高它们属于同一实体的置信度。
3.2 合并的不只是实体,还有关系和上下文
真正容易出问题的是合并这一步。普通系统合并实体就是把两个节点合成一个,但 Graphiti 还需要做三件事:
- 合并关系边:A 实体下的关系要迁到合并后的实体下,同时保留原始事件引用。
- 合并历史记录:实体节点的属性变更历史不能丢,合并后的实体应该能看到两条来源的时间线。
- 感知上下文:有些信息不能简单合并。比如“张伟负责项目 X”(历史)和“张伟调去项目 Y”(当前)可以合并到同一实体,但项目 X 的关系必须保留,只是标记为历史状态。
实操时,我遇到过最典型的坑是“过度合并”。两个实体名字相似、类型相同,但实际是不同的人(比如重名)。如果用纯 embedding 相似度做合并,很容易误伤。Graphiti 的应对策略是多信号综合,降低单一信号权重。我自己的经验是:对重名实体,宁可少合并,也不要错合并。因为一旦合并错了,所有关联记忆全部污染,恢复成本极高。
3.3 实操笔记:实体去重时的三个检查点
基于我在项目中实践过的经验,实体去重环节有三个检查点必须要设。
第一个检查点:进入合并前先查该实体是否已有被引用的关系边。如果一个新实体没有任何关系,只是名字有点像,先不要急着合并,因为“孤点实体”通常是噪音。第二个检查点:合并前检查两个实体的生命周期是否有重叠。比如 A 实体的信息全部在 2024 年,B 实体的信息全部在 2026 年,两者在时间上没有交叉,很可能是不同的人或物。第三个检查点:合并需要做“通知下游”。如果有别的实体关系已经引用了旧的节点 ID,合并后必须更新这些引用,否则会出现悬空指针。
4. 事实冲突检测:如何判断两条记忆谁对谁错
事实冲突检测是长期记忆系统里最硬核的部分,也是 Graphiti 区别于普通知识图谱的地方。它解决的不是“查得准不准”,而是“存得对不对”。
4.1 显式冲突与隐式冲突:两种都要管
显式冲突指两条断言在逻辑上直接矛盾,比如“用户在深圳”(T1)和“用户在北京”(T2)。隐式冲突则更隐蔽——一条说“用户不吃辣”,另一条说“用户昨晚吃了火锅”。火锅不一定代表吃辣,可能点的是鸳鸯锅,但这需要推理。
Graphiti 做了两件事:一是将知识抽象成(实体,关系,实体/值)三元组,在关系层面检测矛盾;二是利用大模型对文本进行语义推理,去发现那些逻辑上不一致但表述不同的内容。前者适合精确检测,后者适合覆盖隐式场景。
4.2 冲突不是“删旧留新”那么简单
很多人设计记忆系统时,冲突解决的默认策略是“以新为准”。这招在大部分场景下有效,但会丢失一种重要能力——保留背景线索。举个例子:用户三年前说过“我最喜欢的城市是北京”,今年说“我现在不喜欢北京了,搬到上海了”。如果直接删掉旧事实,Agent 就失去了“用户口味变化”的上下文,后续做推荐时反而无法理解用户的偏好演变。
Graphiti 对冲突的处理是版本化:旧事实不删除,而是标记为 superseded(已取代),并链接到新事实。这样 AI 检索时默认返回最新事实,但如果需要分析用户变化趋势,依然可以回溯旧版本。
我在实际项目里用的是类似思路,但加了一条规则:判断冲突是否属于“永久性”事实。比如“电话号码”这种客观信息,新号码直接覆盖旧号码是合理的;但“用户偏好”这种主观信息,我更倾向于保留历史版本。Graphiti 框架允许你通过事件类型或属性标签来实现这种差异化管理,关键是把规则显式化,不要所有冲突都一刀切。
4.3 冲突检测的实现要点与参数细节
对模型提示词的调度,Graphiti 在冲突检测时会加载相关实体及其历史事实给 LLM 判断。实操中你需要关心的是窗口大小和阈值。你不可能对每一个新事实都和全量历史做校验,所以需要限定候选冲突范围。Graphiti 的做法是:只加载该实体最近的 N 条事实,以及通过关系关联的相邻实体的事实,再做模型判定。
这个 N 值很有讲究。太小容易漏检;太大模型上下文装不下,响应延迟飙升。我试过不同规模的数据,N=10~20 条是比较合理的区间。如果你的记忆图很大,建议再通过时间过滤,优先加载最近 30 天的历史事实。这里有个经验:冲突检测的优先级应该跟着“高价值实体”走——用户在意的核心实体(人、项目、订单)优先全量校验,边缘化信息降低频率。
4.4 冲突解决的三种落地策略
- 时间优先:新事实覆盖旧事实,适合客观、易变的信息。
- 来源可信度优先:用户主动声明 > 系统推断 > 用户随口提及。比如用户明确说“我的英文名是 Kate”,优先于从邮件签名里推断出的 Katherine。
- 上下文保留:不做物理删除,只做逻辑过期,适合偏好、观点、计划类信息。
Graphiti 没有把一个策略写死,它提供了多种工具让你组合。比如实体属性层面的更新默认走“时间优先”,关系层面的变更可以走“上下文保留”。我给读者的建议是:在业务逻辑里为不同类型的事实打标,再做策略路由,这是长期记忆系统真正好用起来的关键。
5. 长期记忆的更新机制:让记忆像人一样“遗忘”与“修正”
如果说实体去重和冲突检测解决的是“存量记忆很乱”,那更新机制解决的就是“增量记忆不断进入系统后,系统如何保持有序”。Graphiti 在设计上把长期记忆更新做成了一个可持续运作的流水线,而不是一次性建好就完事。
5.1 增量索引:把新信息挂到图谱的正确位置上
新信息进入系统后,Graphiti 会执行几个动作:先按事件生成 Episode 节点,再做实体抽取和关系抽取,再和已有图谱进行关联。关联这一步的核心是“找锚点”——新信息里的实体们,哪些在图谱里已经存在?哪些是新实体?新实体需要建立新节点,已有实体则把新关系挂上去。
这个过程听起来像传统知识图谱的构建,但有一个关键差异:Graphiti 会把“事件本身”也作为图的一部分。这意味着记忆系统可以回答“这个事实是从哪条对话里来的”,而不是只能回答“这个事实是什么”。对调试和审计来说,这简直是救命功能。
实操中我强烈建议你保留这个事件链路。很多系统为了省存储,只保留三元组,导致出问题时根本无法溯源。Graphiti 虽然会增加一些存储开销,但换来的可观测性和排查能力非常值。
5.2 记忆的“半衰期”:如何让旧信息自然退场
长期记忆不全是“越多越好”,信息过时会让系统产生误导。Graphiti 的做法很务实:通过时间因子给每条事实标记活跃度。大模型不擅长精确计时,所以活跃度通常用“最后更新时间”这个量化指标来控制。当某个实体的某个属性很长时间没有被更新,模型检索时它的权重会降低。
这里有一个很实用的技巧:为不同事实类型设置不同的半衰期。用户当前所在城市这种信息,一周没更新就该怀疑;用户出生年份这种静态信息,十年不需要变。这个策略在 Graphiti 里可以通过属性元数据的配置来实现,不必每个实体都手动管理。
5.3 记忆更新的触发链路:什么情况下要更新、什么时候跳过
不是每次对话都要触发全量更新。Graphiti 工程化的优势在于它做了触发控制。简单来说,只有新信息中出现了“实体存在的新属性”“已有关系的变化”“高置信度的新关联”时,才会进入更新流程;如果只是一句无关表达、重复信息、模糊表达,系统可以选择跳过。
这个判断本身依赖 LLM。有一个问题需要注意:不要把 LLM 判断结果直接当真,要有置信度阈值。比如模型返回结果为“包含新事实”的置信度只有 0.4,那它很可能是幻觉,最好跳过;只有超过 0.7 才执行写入。这个阈值我建议你在项目初期多跑几轮标注数据再定,宁可保守一点,也不要在记忆库里写一堆错误信息。
6. 实操过程中的常见问题与排查技巧实录
最后这部分,我结合自己在 Agent 项目中尝试 Graphiti 类记忆架构时踩过的坑,整理成了一张排查速查表。这些经验不一定全部来自 Graphiti 官方文档,但完全来自真实使用场景。
6.1 常见问题速查表
| 现象 | 可能原因 | 排查思路 |
|---|---|---|
| 实体合并后大量关系丢失 | 合并时未正确迁移关系边 | 检查合并 API 是否传入include_relations=True,确认关系迁移是否走了事务 |
| 冲突检测总是漏报 | 候选集范围太小,冲突实体没有被加载进上下文 | 扩大关联实体扫描深度,从一跳扩展到两跳 |
| 模型频繁把新实体识别为已有实体 | 实体相似度阈值过低 | 提高文本相似度阈值,或者增加类型约束 |
| 同一个问题每次回答不一致 | 记忆命中不稳定,多版本事实并存 | 确认新旧事实是否都还处于 active 状态,检查 superseded 标记是否生效 |
| 增量更新越来越慢 | 每次更新都扫描了全量图谱 | 确认是否启用了时间过滤和实体局部扫描,而不是遍历全图 |
| 实体抽取出来的关系方向反了 | 提示词表达歧义 | 在实体抽取提示词里增加方向性示例,比如“A 隶属于 B”不能抽成“B 隶属于 A” |
6.2 印象最深的三个实战教训
第一个教训是不要信“最近一次更新”的绝对意义。有一次用户先说了“我在用 iPhone”,后来某天随口说“安卓的推送真难搞”,系统就把用户的手机类型更新成了安卓。但实际上前一句话只是泛指升级话题,不是事实变更。这类“非声明性信息”很容易触发误更新。我的解法是:在触发更新规则里加一个条件——“只有当新信息存在明确的主语 + 指向性动词时,才做属性更新”。
第二个教训是冲突检测别只盯着实体。关系层面的冲突同样重要。比如用户说“我负责 A 项目”,下周说“A 项目转到 B 团队了”,这两个事实不冲突,但 Agent 后续如果需要找 A 项目的 owner,可能会同时召回两个答案。你需要在关系更新时也做 to-be-obsolete 标记,把旧关系挂上“已转移”的标签,而不是留下多条平等的关系。
第三个教训是关于“历史版本查询”的性能。保留版本化事实很美好,但如果每次查询都把历史版本捞出来,延迟会很难看。最佳实践是:默认查询只读 active 状态的事实,历史版本按需展开。Graphiti 中可以用过滤器实现,但如果你是自己实现的版本化方案,这一点从第一天就要考虑进去。
6.3 一点建议:先小规模跑通再扩量
如果你正在设计 AI 长期记忆系统,我建议不要一开始就追求存得多、存得全。先拿小规模数据把“实体去重—冲突检测—增量更新”这条链路跑通,重点观察误 merge 率和更新触发准确率,这两个指标直接影响线上效果。跑一段时间后,再放大数据量。Graphiti 这类框架给了很好的底层能力,但真正的高质量记忆,还是需要你结合自己的业务场景去调规则、定策略。这个活没有标准答案,但方向对了,就不会越存越乱。