Skip to main content

03 - 冲突、遗忘与时间

前置02 篇第三节 —— 纯追加写入之后,库里会同时存在两条矛盾的记忆。

本篇回答:这两条矛盾的记忆,谁说了算;以及那条不算数的,是该删掉还是留着。

本篇会用到的词

意思
有效时间(valid time)一个事实在现实中成立的时间段。用户 5 月 1 日搬家,"住上海"的有效时间从 5 月 1 日开始
事务时间(transaction time)系统知道这件事的时间段。用户 6 月 10 日才说搬家了,这条记录的事务时间从 6 月 10 日开始
双时间轴(bi-temporal)同时记录上面两条时间线。少了任一条都会有查不出来的问题,本篇第三节给例子
作废(invalidate)把一条记忆标成"从某时起不再成立",但记录本身留着。区别于删除
软删除只加一个 deleted = true 标记。它比作废弱:不带时间信息,答不了"三月份的时候是什么情况"

一、事实变化的四种形态

"新旧冲突"其实是四件不同的事,处理方式完全不同:

判断标准:旧的那条"当时"是不是对的改正「我说错了,是牛奶过敏」旧的当时就是错的作废,并标原因=纠错不能当成"以前对现在不对"变更「我从北京搬到上海了」旧的当时是对的作废,并记下变更时点历史查询仍要能查到北京补充「我还对芒果过敏」两条同时成立直接新增,不作废任何条目误判成变更会丢过敏源撤回「删掉你记的我的病史」合规诉求,不是事实变化必须真删,含向量与历史表作废在这里不合规前三种由抽取模型判断,第四种必须走独立的、不经过模型的删除接口 —— 让模型判断"要不要真删"是不可接受的设计。
把「补充」误判成「变更」是线上最有代价的一类错误:用户说了第二种过敏源,系统却把第一种作废了。提示词里必须显式区分这两者,不能指望模型默认分得清。

二、为什么不能硬删除

面对"变更",最直觉的做法是把旧记录 DELETE 掉。三条理由让它站不住:

  1. 判错不可逆。抽取模型在只看到十条相关记忆时判定"这条该删",一旦执行就没有第二次机会。02 篇里 mem0 退回纯追加,主要就是这个原因
  2. 历史查询查不了。"我上个月说的那家店叫什么"是一个完全合理的问题。删掉之后,这个问题永远答不出来
  3. 审计断链。线上出了问题要复盘"这次回答为什么带偏了",需要知道当时读回了哪几条记忆。记录被删掉之后,复盘只能靠猜

替代方案是作废:记录留着,但标上"从某个时刻起不再成立"。它比软删除多一样东西 —— 时间。软删除只能回答"现在还算不算数",作废能回答"三月十五号那天算不算数"。

三、双时间轴:两条时间线缺一不可

Graphiti(Zep 的开源内核)在每条实体边上放了四个时间字段:

# graphiti_core/edges.py,EntityEdge 类
class EntityEdge(Edge):
name: str = Field(description='name of the edge, relation name')
fact: str = Field(description='fact representing the edge and nodes that it connects')
# ...
expired_at: datetime | None = Field(...) # 事务时间的终点:系统什么时候不再认为它成立
valid_at: datetime | None = Field(...) # 有效时间的起点:现实中什么时候开始成立
invalid_at: datetime | None = Field(...) # 有效时间的终点:现实中什么时候不再成立
reference_time: datetime | None = Field(...)
# created_at 定义在父类 Edge 上,是事务时间的起点:系统什么时候第一次记下它

四个字段两两配对:created_at / expired_at系统的时间线valid_at / invalid_at现实的时间线

同一件事在两条时间线上的起止点不一样 —— 差出来的那一段就是记忆出错的窗口现实时间线valid_at / invalid_at住北京住上海5/1 真正搬家系统时间线created_at / expired_at系统认为:住北京系统认为:住上海4/2 首次记下6/10 用户说了40 天盲区:系统持有一条已经不成立的记忆只记一条时间线的后果:只记现实时间,答不出"我们当时为什么给了错的建议"(系统当时确实以为在北京);只记系统时间,答不出"用户到底哪天搬的"(只知道 6/10 告诉我们的)。两条都要,是因为两类问题都会被真实问到。
盲区无法消除,只能缩短 —— 它取决于用户什么时候主动说。能做的是在读取时把这个不确定性暴露出来,而不是让模型以为记忆里的地址一定是最新的。

3.1 四种查询,各自读哪两个字段

你要问的用哪两个字段典型场景
现在成立的事实有哪些invalid_at IS NULL AND expired_at IS NULL每次对话前取回记忆,最常用
3 月 15 日那天成立的事实valid_at <= '3/15' AND (invalid_at IS NULL OR invalid_at > '3/15')回溯历史、复盘
系统在 3 月 15 日以为成立的事实created_at <= '3/15' AND (expired_at IS NULL OR expired_at > '3/15')复盘"当时为什么给出那个建议"
这条事实是什么时候被改的查同一实体对上所有边的 created_at 序列审计、给用户看变更历史

第三行是双时间轴真正不可替代的地方。复盘一次错误回答时,你需要的不是"现在的正确答案",而是"当时系统手上有什么" —— 只有事务时间能给出这个。

3.2 reference_time 是干什么的

EntityEdge 上还有第五个时间字段 reference_time。它记的是产生这条边的那段对话本身发生的时间,对应 EpisodicNode.valid_at

它和 valid_at 的区别:用户 6 月 10 日说"我五月一号搬的家",这条边的 valid_at 是 5 月 1 日(事实成立的时间),reference_time 是 6 月 10 日(说这话的时间)。检索时按"最近提到过的"排序要用前者还是后者,结果不一样 —— 04 篇会用到这个区分。

四、作废不是删除:边的四类节点与失效

Graphiti 的图里有四类节点,graphiti_core/nodes.py 里定义:

节点类型存什么会不会被作废
EpisodicNode一段原始输入(消息 / JSON / 文本 / 三元组),带 valid_at不作废。原始输入是既成事实
EntityNode从输入里识别出的实体:人、地点、产品不作废。实体本身不会"不成立"
CommunityNode若干实体聚成的社区,用于宽泛查询重算而非作废
SagaNode跨多个 episode 的长线摘要,带 last_summarized_episode_valid_at增量重算

作废只发生在边上EntityEdge),这是一个有意的设计:

同一次"用户搬家了",作用在节点上和作用在边上,影响面差很多❌ 把「北京」当成一条记忆去删用户北京住在用户父母也住在连带失去"父母住北京"—— 这条并没有变✅ 只给「用户 → 住在 → 北京」这条边填 invalid_at用户北京住在(已作废)上海住在(生效中)北京节点还在,父母那条边不受影响这也是"记忆不该建模成一条条独立文本"的主要论据:文本条目之间没有共享结构,改一条只能靠语义相似度去猜还要改哪些。
纯向量方案没有这个层次 —— 一条记忆就是一段文本,「用户住北京」和「用户父母住北京」是两段独立文本,没有任何机制保证只改前者。这是图结构在记忆场景下最实在的一个好处。

代价也要说清楚:图方案的写入要多做一步"这条新事实跟哪些已有边冲突"的判断,这是一次额外的模型调用;而且需要一个图数据库。对于只做"用户偏好"这类扁平事实的产品,这套结构是过度设计。

五、遗忘:三种策略与各自的代价

不做遗忘的记忆库,跑三个月之后会同时出现三个症状:检索变慢、注入的记忆里无关条目占比上升、存储账单线性增长。

策略规则代价
按时间过期(TTL)超过 N 天未被访问的条目删除会误删低频但关键的事实。「对花生过敏」可能半年没被检索到,但一次都不能丢
按命中率淘汰统计每条记忆被检索命中的次数,长期为 0 的删除命中率受检索策略影响:一条从没被召回过的记忆,可能只是嵌入模型对它不友好,不代表它没价值
按容量上限每用户最多 N 条,超了淘汰最旧的上限定多少没有理论依据,且"最旧"和"最不重要"经常不是一回事

实践里可行的组合是分级

# 按记忆类型给不同的保留策略 —— 用一套规则处理所有记忆一定会误删
RETENTION = {
# 安全 / 健康类:永不自动过期,只能由用户显式撤回
# 这类记忆的漏报代价远高于存储成本
"safety": {"ttl_days": None, "protected": True},

# 稳定偏好:一年不被访问才淘汰,且淘汰前降级为"待确认"而非直接删
"preference": {"ttl_days": 365, "protected": False},

# 情景记忆:数量最大、单条价值最低,90 天滚动淘汰
# 它的作用是当少样本示例,旧例子的边际价值确实随时间衰减
"episodic": {"ttl_days": 90, "protected": False},
}

分级的前提是抽取时就打好类型标签 —— 这是 02 篇里"抽取阶段丢掉的信息事后补不回来"的又一个例子。

5.1 作废的条目要不要一起清理

作废(invalid_at 有值)和遗忘(记录被删除)是两回事,但会互相影响:已作废的边在库里堆积,同样会拖慢检索。

可行的做法是两段保留期:作废后先留 90 天用于审计和回溯查询,之后归档到冷存储(对象存储里的一份 JSON),主库里删掉。这样"三月十五号那天成立的是什么"依然能答,只是要走一次冷查询。

六、这一层要花多少钱

冲突消解不是免费的。以图方案为例,每次写入新事实时要判断它跟哪些已有边冲突:

动作频次开销
检索可能冲突的已有边每条新事实一次一次向量检索,几十毫秒
让模型判断是"变更"还是"补充"每条新事实一次一次模型调用,输入含 N 条候选旧边
写入新边 + 更新旧边的 invalid_at每次判定为变更时一次图写事务

这就是 02 篇里 mem0 取消独立巩固调用之后省掉的那部分开销。取舍很清楚

  • 走纯追加(mem0 现在的默认):写入便宜,冲突推给检索侧解决,检索时要按时间排序并让模型自己判断哪条新
  • 走写入时消解(Graphiti):写入贵,但检索出来的就是"当前成立"的集合,模型不需要再判断

选哪个看读写比。一次会话写入几条、读取几十次的场景(聊天助手)应该把成本放在写入侧;批量灌数据、偶尔查询的场景(把历史工单灌进记忆)反过来。

七、小结

  • "冲突"其实是四件事:改正、变更、补充、撤回。把补充误判成变更会丢事实,把撤回当成作废会不合规
  • 不要硬删除,除非是用户显式撤回;作废保留了历史查询和审计能力,而软删除做不到
  • 双时间轴的两条线各自回答不同的问题:现实时间答"用户哪天搬的",事务时间答"我们当时为什么答错"
  • 作废作用在关系(边)上而不是实体(节点)上,才不会连带影响无关事实
  • 遗忘必须分级:用一套 TTL 处理所有记忆,一定会删掉一条不能删的
  • 冲突消解放写入侧还是检索侧,按读写比决定

下一篇:04 - 读取路径:向量、图与结构化,把纯追加留下的那一堆并存条目,在几十毫秒内挑出该用的三到十条。

← 回到 专题索引