🥰个人主页:会编程的土豆(欢迎来访)
💎作者简介:后端学习者
❄️个人专栏:数据结构与算法,数据库,leetcode
✨那些你一个人走过的夜路,终将化作照亮未来的光

上一篇讲"怎么写进去",这一篇讲"怎么不让它烂掉"。

四个参数:0.80 / 0.95 / 0.995 / 30+0.3。所有数值和算法均来自代码,文中会纠正两个流传很广但错误的说法。


0. 一句话结论

记忆治理不是四道独立的开关,而是一条有严格顺序的流水线:

衰减(让时间生效)
  ↓
去重 / 合并(收敛重复)
  ↓
淘汰(清理垃圾)
  ↓
保护(撤销误删)

顺序不能换——先衰减再淘汰,否则淘汰判定用的是"去年的重要性"。


1. 记忆会烂掉的三种方式

病症

表现

后果

重复

"我叫张三"说了五遍,存了五条

召回时同一事实挤占多个 TopK 名额

碎片

"喜欢美式" + "每天早上一杯美式"

单条信息量低,任何一条都不足以支撑回答

变旧

三个月前说"想学吉他",早就放弃了

模型拿着过时信息做判断

四个参数正好对应:

  • 0.95 → 治重复

  • 0.80 → 治碎片

  • 0.995 → 治变旧

  • 30 + 0.3 → 决定哪些该彻底扔掉


2. 参数总表

SimilarityThreshold: 0.80   // 合并阈值
DedupThreshold:      0.95   // 去重阈值
TTLDays:             30     // 过期天数
DecayRate:           0.995  // 每日衰减系数
MinImportance:       0.3    // 淘汰最低重要度
TriggerInterval:     5      // 每写入 N 条触发一次合并

注意最后那个 TriggerInterval: 5——治理不是定时的,是攒够 5 条新记忆才跑一次。这是为了把治理成本摊薄,避免每写一条就全库扫一遍。


3. 0.95 去重:不是"覆盖",是"吸收"

3.1 先纠正一个说法

常见说法是"0.95 以上直接去重,物理丢弃新来的,或者覆盖旧的"。

都不对。

真实行为是:新条目不插入,旧条目被"吸收"了新条目的元数据。

if sim >= m.consolidationCfg.DedupThreshold {
    if importance > m.Items[i].Importance {
        m.Items[i].Importance = importance        // 只升不降
    }
    m.Items[i].LastAccessed = time.Now()          // 刷新访问时间
    if category != "" && (m.Items[i].Category == "" || m.Items[i].Category == "general") {
        m.Items[i].Category = category            // 分类更具体则覆盖
    }
    if slotHint != "" && m.Items[i].SlotHint == "" {
        m.Items[i].SlotHint = slotHint            // 槽位提示只补空
    }
    if len(tags) > 0 {
        m.Items[i].Tags = mergeTags(m.Items[i].Tags, tags)  // 标签求并集
    }
    return false                                   // 不插入新条目
}

3.2 为什么这样设计

① importance 取 max,不取新值

因为重复陈述不应该降低原有条目的价值。用户又说了一遍"我对花生过敏",不会让这条变得"更不重要"。

② 分类"更具体则覆盖",general 视为最弱

if category != "" && (m.Items[i].Category == "" || m.Items[i].Category == "general") {

先存进来的可能是 general(兜底分类),后来抽到明确的 identity,就应该升级。反过来,已经标成 identity 的不会被后到的 general 降级。

这是"信息密度只增不减"的原则。

③ 标签求并集,槽位提示只补空

同一件事可能带不同标签——先说是"偏好",后说是"饮食禁忌",并集之后召回时两条路都能命中。

④ content 保持不变

这是最容易忽略的一点:命中 0.95 时不合并文本,也不重算 embedding。

代价是:如果新表述更清晰,旧表述更模糊,你保留的仍然是旧的。这是"低成本优先"的取舍——重算 embedding 要调 API,高频去重时成本不可接受。


4. 0.80 ~ 0.95 合并:不是 LLM 语义合并

4.1 第二个需要纠正的说法

很多讲解会说:"0.80~0.95 的碎片,调 LLM 合并成一句更丰满的话。"

代码里没有任何 LLM 调用。 合并是纯规则拼接:

func mergeItemsPure(a, b Item, now time.Time) Item {
    base, other := a, b
    if b.Importance > a.Importance {
        base, other = b, a          // importance 高的当主干
    }
    merged := base
    merged.Importance = math.Max(base.Importance, other.Importance)
    merged.LastAccessed = now

    // 文本合并:互不包含就拼接,一方包含另一方就取长的
    if !strings.Contains(base.Content, other.Content) &&
       !strings.Contains(other.Content, base.Content) {
        merged.Content = base.Content + ";" + other.Content
    } else if len(other.Content) > len(base.Content) {
        merged.Content = other.Content
    }

    // embedding 按 importance 加权平均
    total := base.Importance + other.Importance
    for i := range base.Embedding {
        merged.Embedding[i] = (base.Embedding[i]*base.Importance +
                               other.Embedding[i]*other.Importance) / total
    }

    merged.Tags = mergeTags(base.Tags, other.Tags)
    merged.Supersedes = appendUniqueInt(merged.Supersedes, other.ID)

    return merged
}

4.2 为什么不用 LLM 合并

三个理由,一个比一个实际:

① 成本

合并在热路径上(TriggerInterval: 5,每 5 条触发一次)。一次全库两两比对可能产生几十次合并。每次合并调一次 LLM,写入延迟和费用都会失控。

② 可回溯

规则拼接保留了原文:

用户偏爱美式;用户习惯每天早上喝一杯美式

两条原文都还在,Supersedes 里还登记了被合并条目的 ID。出问题能查清"这句话是怎么来的"。

LLM 合并会把原文改写成新句子——一旦改写错了,原始信息就永久丢失了。

③ 确定性

同样的两条记忆,规则合并永远产出同样的结果。LLM 合并每次可能不一样,无法做回归测试。

4.3 拼接的边界处理

strings.Contains 那个分支值得单独说:

情况

处理

理由

互不包含

A;B

信息互补,都保留

A 包含 B

取 A

B 是 A 的子串,拼接会重复

长度相同且互相包含

取 base

等价内容,任取一个

这比"无脑拼接"多做了一步去冗余。 如果两条记忆只是详略差异,取长的即可,不必拼成一句啰嗦的话。

4.4 embedding 加权平均

merged.Embedding[i] = (a[i]*a.Importance + b[i]*b.Importance) / (a.Importance + b.Importance)

为什么按 importance 加权,不直接取平均?

因为 importance 高的那条通常是主干信息。直接平均会让退化的向量偏向"信息量低的那条",导致合并后的条目在召回时反而更难命中。

加权平均保证:重要的那条决定了合并后向量的大方向。


5. 0.995 衰减:时间是怎么"腐蚀"记忆的

5.1 公式

items[i].Importance = oldImportance * math.Pow(cfg.DecayRate, days)

即:

importance(t) = importance(0) × 0.995^天

5.2 三个关键时间点

经过天数

0.995^n

从 1.0 衰减到

30 天

0.860

0.86

100 天

0.606

0.61

240 天

≈0.300

0.30

365 天

0.161

0.16

一条初始 importance = 1.0 的记忆,要 240 天才会衰减到 0.3 的淘汰线。

不同来源的记忆到达淘汰线的时间:

来源

初始 importance

降到 0.3 需要

用户主动陈述

0.7

约 71 天

问答对派生

0.5

约 138 天

高价值事实

1.0

约 240 天

这个结论很重要:用户亲口说的事实,大约两个多月后就会进入淘汰候选区。

5.3 一个容易被忽略的写库优化

const minDecayDelta = 0.01
if oldImportance - items[i].Importance >= minDecayDelta {
    updates[items[i].ID] = ...
}

衰减变化量不到 0.01 就不写库。

为什么?因为数值很小的时候(比如 0.31 → 0.3085),变化幅度 0.0015,写进数据库毫无意义,但会产生一次 UPDATE。

这是典型的"精度换 IO":肉眼不可见的精度损失,换来数量级的写入减少。

5.4 这里有个真实的设计缺陷

衰减和淘汰用的都是 CreatedAt:

days := now.Sub(items[i].CreatedAt).Hours() / 24

而 LastAccessed 虽然被更新了(去重命中时会刷新),却不参与衰减和淘汰计算。

后果:

  • 一条用户每周都提到、每次都被召回的偏好记忆,LastAccessed 很新,但 CreatedAt 很旧 → 照样衰减,照样被淘汰

  • 更糟的是:去重命中时 importance 只在"新值更高"时才提升,而重复陈述的 importance 通常是同一个常数(用户陈述恒为 0.7),永远不会累加

所以:反复提及并不会让记忆更长寿。

这和你稿子里"没被用户反复提及,重要度掉到 0.3 以下"的说法正好相反——"反复提及"在这个实现里是无效的。

正确做法应该是:

refTime := max(items[i].CreatedAt, items[i].LastAccessed)

或者引入"提及次数"参与 importance 计算。这是一个明确可改进项。


6. 30 天 + 0.3:为什么必须是联合条件

6.1 两个反例

只看时间会怎样?

用户三年前说"我对花生过敏",importance 也衰减到 0.29 了——但它仍然是对的。按时间删,这条安全信息就没了。

只看重要度会怎样?

用户昨天随口说"今天天气不错",importance 本来就只有 0.1。按重要度删,昨天的临时信息立刻消失——但它可能还在当前语境里有用。

6.2 联合判定的真实含义

if cfg.TTLDays > 0 &&
   days > float64(cfg.TTLDays) &&
   items[i].Importance < cfg.MinImportance {
    removed[i] = true
    reasons[i] = "expired"
}

两个条件必须同时成立:

够旧(> 30 天)  AND  够低(< 0.3)

这保证了:

  • 重要的事实(importance 衰减得慢、或初始值高)→ 永远不会被时间淘汰

  • 新的记忆(哪怕 importance 很低)→ 至少能活 30 天

6.3 但"过敏史永不被删"这个承诺要打折扣

很多讲解会说"这样过敏史永远不会被删"。

在这个实现里不成立。

因为 importance 会持续衰减,240 天后 1.0 也会掉到 0.3 以下,第 241 天就会被淘汰——除非它被某个机制持续"续命"。

真实续命途径只有两个:

  1. 图中心度保护(见下一节)

  2. 有新的写入命中它,把 importance 提升(但只有"新值更高"时才提升)

所以这套系统的记忆是有"自然寿命上限"的(约 240 天 / importance 1.0)。 这是设计选择,不是 bug,但讲的时候不能说成"永不过期"。


7. 保护机制:为什么淘汰后还能"复活"

if cfg.ProtectFn != nil {
    candidates := 收集所有待删除的 ID
    protected := cfg.ProtectFn(candidates)
    for _, id := range protected {
        removed[id] = false
        delete(reasons, id)
    }
}

这是淘汰之后的最后一道闸:先按规则算出"该删谁",再让保护函数从候选里捞人。

真实实现保护的是图记忆里入度 Top3 的节点——被别的记忆指向最多的条目,通常是"用户本人""核心偏好"这类中心事实。

而且这个判定和物理删除在同一个临界区里,避免"判定完保护、删之前被另一个写入改了入度"的竞态。

设计上的价值:规则负责"差不多该删了",保护负责"但这些不能删"。


8. 冲突 → superseded:软删的真实现状

8.1 触发条件比想象中窄

var conflictDetectableCategories = map[string]bool{
    "identity": true, "preference": true, "fact": true,
}
const ( conflictMinSim = 0.75; conflictMaxSim = 0.95 )

只有三类事实参与冲突检测,且相似度必须落在 [0.75, 0.95):

  • 低于 0.75:根本不是同一条记忆,谈不上矛盾

  • 高于 0.95:已经被去重吸收,不会走到冲突判定

候选最多取 3 条(成本控制),交给 LLM 判定是否矛盾,默认判定"不矛盾"。

保守是刻意的:宁可漏判,也不误伤。 误判一条重要记忆为"已被取代",比漏判一条旧信息的代价大得多。

8.2 软删做了什么

func (m *LongTerm) MarkSuperseded(oldIDs []int, newID int) []int {
    for i := range m.Items {
        if oldSet[m.Items[i].ID] && !m.Items[i].Superseded {
            m.Items[i].Superseded = true
            m.Items[i].SupersededAt = now
            marked = append(marked, m.Items[i].ID)
        }
    }
    if newID > 0 && len(marked) > 0 {
        // 在新条目上登记 supersedes 链
        m.Items[i].Supersedes = appendUniqueInt(..., marked...)
    }
}

两个动作:

  1. 旧条目 Superseded = true + 记录 SupersededAt

  2. 新条目登记 Supersedes(替代链)

召回时默认过滤 superseded = true,所以旧公司不会再被当成现公司。

8.3 "可以瞬间改回来"是夸大

你稿子里说:

"万一用户明天说'我其实没跳槽,那是开玩笑的',系统可以瞬间把 superseded 改回 active,实现完美回溯。"

代码里没有这个能力。

全库搜索 Restore / Unsupersede / Reinstate,只在 RAG chunk 恢复里命中,没有任何"取消 superseded"的接口。

真实情况是:

  • 数据还在库里(软删不物理删除)

  • 有 SupersededAt 和 Supersedes 链,可审计

  • 但没有产品化的恢复路径,只能人工改库

"可回溯"指的是数据留存 + 审计链,不是"一键恢复"。 面试时必须这么讲,否则一追问"你怎么恢复"就断了。


9. 四个参数怎么联动:完整执行顺序

Consolidate() 被触发(每写入 5 条)
  │
  ├─ Phase 1:衰减
  │    遍历全部条目,importance *= 0.995^days(CreatedAt)
  │    变化 ≥ 0.01 才进 updates(省 IO)
  │
  ├─ Phase 2:两两比对(同用户内)
  │    跨用户直接 skip(多租户隔离)
  │    sim ≥ 0.95 → 保留 importance 高的,吸收元数据,删另一条
  │    0.80 ≤ sim < 0.95 → 规则合并,删另一条
  │
  ├─ Phase 3:淘汰
  │    days > 30 AND importance < 0.3 → 标记 expired
  │
  ├─ Phase 4:保护
  │    ProtectFn(图中心度 Top3)→ 撤销删除
  │
  └─ 产出 ConsolidationPlan
       updates(衰减 + 合并修改)
       deletes(去重 / 合并 / 淘汰)
       由 memorytx 在同一事务里落库

顺序为什么不能变:

  • 衰减必须在淘汰之前 → 否则淘汰用的是未衰减的旧值,永远达不到阈值

  • 淘汰必须在保护之前 → 否则保护函数没有"待删候选"可捞

  • 合并必须在淘汰之前 → 否则先删了碎片,合并就没对象了

这是一条有数据依赖的流水线,不是四个并行开关。


10. 调参时的现实建议

参数

想调大

想调小

注意

0.95 去重

更保守,宁可留重复

更激进,容易误删相似事实

换 embedding 模型后必须重调

0.80 合并

合并更少,碎片更多

合并更激进,可能把不同事实拼一起

中文短句的余弦分布比英文更集中

0.995 衰减

记忆更持久,但旧信息更难淘汰

淘汰快,长期事实保不住

0.99 的话 60 天就到 0.3

30 天

垃圾留得久

新记忆活不长

与 importance 联动

0.3

淘汰更保守,库会膨胀

淘汰激进,容易误删

需配合图保护

最容易出问题的是 0.80 和 0.95 的间距。

0.80~0.95 只有 0.15 的窗口。如果 embedding 分布集中(中文短句常见),大量"语义相关但不同"的事实会落进这个区间被合并,产生语义漂移。

这两个阈值必须用真实数据校准,不能拍脑袋。


11. 这套设计的短板(如实说)

#

问题

影响

1

衰减/淘汰用 CreatedAt,不用 LastAccessed

高频使用的记忆也会"自然死亡",反复提及无效

2

importance 不会累加

用户说十遍"我对花生过敏"和说一遍,寿命完全一样

3

合并不重算 embedding,去重不改 content

合并后向量可能不够准;旧表述占位

4

无 superseded 恢复接口

"可回溯"仅限数据留存,不是可操作能力

5

两两比对是 O(n²)

单用户记忆过千后,一次 Consolidate 会显著变慢

6

每 5 条触发一次全量扫描

写入路径上带了一次性延迟,量级随库增长

7

无治理效果评测

合并/淘汰后召回质量如何变化,无法量化

第 1 条和第 2 条是同一个根因:系统只用时间衡量价值,没有用"使用频率"衡量价值。


12. 面试口径(可直接背)

"记忆治理我设计成一条有顺序的四阶段流水线:衰减、去重合并、淘汰、保护。

先说两个阈值:0.95 以上我判定为重复,但不是简单丢弃新的——我保留旧条目,把新条目的 importance、分类、标签、槽位提示吸收进去,importance 只升不降,分类只从 general 往具体升,这叫信息密度只增不减。0.80 到 0.95 之间做合并,这里是纯规则合并,不调 LLM:importance 高的当主干,互不包含就拼接,一方包含另一方就取长的,embedding 按 importance 加权平均,同时在新条目上登记 supersedes 链。不用 LLM 的理由是成本、可回溯和确定性。

衰减是每天 0.995,但有个写库优化:变化不到 0.01 就不落库,用精度换 IO。

淘汰是 30 天和 importance 0.3 的联合条件,必须同时满足。这能保证重要的东西活得久、新的东西至少活 30 天。但我要补一句实话:它不是永不过期,importance 1.0 大约 240 天也会衰到 0.3,所以是有自然寿命上限的。

淘汰之后还有一道保护:图记忆里入度 Top3 的节点会被撤销删除,而且判定和删除在同一临界区,避免竞态。

冲突处理是软删。相似度落在 0.75 到 0.95、且只有 identity/preference/fact 三类才送 LLM 判定,候选最多 3 条,默认判不矛盾,宁可漏判不误伤。命中的旧条目标 superseded,数据留着、有 supersedes 链可以审计,但我没有做一键恢复接口,这块是缺口。

最后说个我自己发现的缺陷:衰减用的是 CreatedAt,不是 LastAccessed,所以用户反复提到的记忆也会按创建时间衰减,重复提及并不会延长寿命。这个应该改成取两者较新的那个。"

——最后这段主动暴露缺陷,是整段回答里最值钱的部分。


13. 结语

四个参数看似简单,真正的工程设计藏在这几个判断里:

  1. 去重不是删除,是吸收 —— 信息的密度只增不减

  2. 合并不用 LLM,用规则 —— 确定性比智能更重要,尤其在热路径

  3. 淘汰必须联合判定 —— 单一维度一定会误伤

  4. 算完"该删谁"之后还要能"捞回来" —— 保护机制是规则系统的安全阀

而最能体现工程成熟度的,不是这四个参数本身,而是知道它们的边界在哪:

  • 衰减只用时间 → 高频记忆会冤死

  • importance 不累加 → 反复陈述没有回报

  • 软删无恢复接口 → "可回溯"要打引号

能把"我做了什么"和"我没做到什么"讲得一样清楚,才是这套治理真正被吃透的标志。

Logo

更多推荐