03 - 冲突、遗忘与时间
前置:02 篇第三节 —— 纯追加写入之后,库里会同时存在两条矛盾的记忆。
本篇回答:这两条矛盾的记忆,谁说了算;以及那条不算数的,是该删掉还是留着。
本篇会用到的词:
| 词 | 意思 |
|---|---|
| 有效时间(valid time) | 一个事实在现实中成立的时间段。用户 5 月 1 日搬家,"住上海"的有效时间从 5 月 1 日开始 |
| 事务时间(transaction time) | 系统知道这件事的时间段。用户 6 月 10 日才说搬家了,这条记录的事务时间从 6 月 10 日开始 |
| 双时间轴(bi-temporal) | 同时记录上面两条时间线。少了任一条都会有查不出来的问题,本篇第三节给例子 |
| 作废(invalidate) | 把一条记忆标成"从某时起不再成立",但记录本身留着。区别于删除 |
| 软删除 | 只加一个 deleted = true 标记。它比作废弱:不带时间信息,答不了"三月份的时候是什么情况" |
一、事实变化的四种形态
"新旧冲突"其实是四件不同的事,处理方式完全不同:
二、为什么不能硬删除
面对"变更",最直觉的做法是把旧记录 DELETE 掉。三条理由让它站不住:
- 判错不可逆。抽取模型在只看到十条相关记忆时判定"这条该删",一旦执行就没有第二次机会。02 篇里 mem0 退回纯追加,主要就是这个原因
- 历史查询查不了。"我上个月说的那家店叫什么"是一个完全合理的问题。删掉之后,这个问题永远答不出来
- 审计断链。线上出 了问题要复盘"这次回答为什么带偏了",需要知道当时读回了哪几条记忆。记录被删掉之后,复盘只能靠猜
替代方案是作废:记录留着,但标上"从某个时刻起不再成立"。它比软删除多一样东西 —— 时间。软删除只能回答"现在还算不算数",作废能回答"三月十五号那天算不算数"。
三、双时间轴:两条时间线缺一不可
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 是现实的时间线。
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),这是一个有意的设计:
代价也要说清楚:图方案的写入要多做一步"这条新事实跟哪些已有边冲突"的判断,这是一次额外的模型调用;而且需要一个图数据库。对于只做"用户偏好"这类扁平事实的产品,这套结构是过度设计。
五、遗忘:三种策略与各自的代价
不做遗忘的记忆库,跑三个月之后会同时出现三个症状:检索变慢、注入的记忆里无关条目占比上升、存储账单线性增长。
| 策略 | 规则 | 代价 |
|---|---|---|
| 按时间过期(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 - 读取路径:向量、图与结构化,把纯追加留下的那一堆并存条目,在几十毫秒内挑出该用的三到十条。
← 回到 专题索引