记忆存储:向量、键值、图与驱逐

M6
深入解析 · 记忆与上下文工程

记忆存储:向量、键值与图的取舍——外加驱逐。

你用来持久化记忆的后端,决定了你能向它提出什么样的问题。向量库回答"什么相似",键值库回答"什么正好是这个",图库回答"什么相互关联"。大多数生产级记忆系统需要三者中的两种,按记忆类型路由。无论哪种后端,一个无界的存储都会让检索退化——驱逐是设计的一部分,不是事后补丁。

STEP 1

三种访问模式,三种后端。

  • 向量库(Chroma、Qdrant、pgvector……)——在嵌入上做近似最近邻。优势:模糊的、语义的回忆——"找出与这个情形相似的记忆"。劣势:无精确查找、无关系结构,索引被近重复项塞满时回忆质量退化。
  • 键值库(Redis、一张表,甚至一个 dict)——按已知键精确检索。优势:user:tz → "Europe/Berlin" 是 O(1)、无歧义、就地更新极其简单。劣势:你必须知道键;无相似度,无发现。
  • 图库(Neo4j、三元组库)——实体与带类型的关系。优势:多跳——"谁报告了那个被这次迁移修复的缺陷"。劣势:抽取昂贵且脆弱,而且大多数智能体记忆本质上不是关系型的。

把后端映射到记忆类型,而非映射到时髦。语义事实 → 键值(精确、就地更新)。情景记忆 → 向量(按情形相似度回忆)。程序性 → 以任务类型为键的键值。只有当查询确实是多跳关系型时才动用——它是成本最高的选项,也是最常被过早采用的那个。

STEP 2

异构后端之上的统一记忆接口。

智能体不应知道或在意一条记忆住在哪个后端。在一个接口背后按 kind 路由;这也让你能在不改动智能体代码的情况下替换后端。

# memory/store.py
class MemoryStore:
    def __init__(self, vec, kv, embed):
        self.vec = vec      # episodic: similarity recall
        self.kv = kv        # semantic/procedural: exact key
        self.embed = embed

    def write(self, m: Memory) -> None:
        if m.kind is Kind.EPISODIC:
            self.vec.upsert(self.embed(m.text), m.__dict__)
        else:                            # SEMANTIC / PROCEDURAL
            # Key by stable identity → update in place,
            # never accumulate contradictory copies.
            self.kv.put(m.key(), m.__dict__)

    def recall(self, cue: str, kind: Kind, k=5):
        if kind is Kind.EPISODIC:
            return self.vec.search(self.embed(cue), k=k)
        return self.kv.get_prefix(kind.value)   # scoped exact

kv.put(m.key(), ...) 这条路径是默默的功臣。按键写入的语义记忆在事实改变时就地更新——用户的时区是一个条目,而不是一堆不断增长、需要检索器去消歧的矛盾观察。这一个设计选择消除了一整类"智能体相信两个相互冲突的东西"的缺陷。

STEP 3

无界存储的失效,以及为什么驱逐是必须的。

每一个"就全部留着"的记忆系统都会退化。机制是具体的:当向量索引被语义聚集的近重复项(同一事件四十种略微不同的措辞)塞满时,最近邻搜索返回一簇紧密的冗余低价值命中,把那条真正有用的记忆挤出去。检索 recall@k 随存储增长而下降。更多记忆让智能体更笨。

"存储很便宜所以全部留着"对存储成立,对检索不成立。无界记忆存储的成本不是磁盘——而是坍塌的回忆精度,以及一个运行越久越明显变差的智能体。在任何规模上,有界、经过策展的记忆都胜过详尽的记忆。

STEP 4

驱逐与衰减策略。

借鉴缓存设计,并带一个记忆特有的转折:相关性,而不只是时近性或频率。

  • TTL/年龄衰减——情景记忆随时间失去显著性;低于某下限即被驱逐(或先摘要再驱逐)。语义记忆老化——它被更新取代,而非被时间取代。
  • 按访问的 LRU——retrieval-augmented-memory 中的 last_used 刷新意味着从不被回忆的记忆冷却并成为驱逐目标。频繁有用的记忆保持驻留。
  • 显著性加权——被显式标记重要的记忆(用户说"永远记住")无论年龄或访问如何都抵抗驱逐。显著性是寿命的下限。
  • 冗余坍缩——最具记忆特性的策略:周期性地聚类近重复项,并把每个簇合并成一条规范记忆。这是反思(memory-types)兼任垃圾回收。
# memory/evict.py
def decayed_salience(m, now) -> float:
    if m.kind is not Kind.EPISODIC:
        return m.salience                 # semantic: no time decay
    age = now - m.last_used
    return m.salience * 0.5 ** (age / (14 * 86400))

def sweep(store, now, floor=0.15, max_n=50_000):
    items = store.all()
    for m in items:
        if decayed_salience(m, now) < floor:
            summarize_then_drop(store, m)   # distil, not delete
    if store.count() > max_n:                # hard cap backstop
        evict_lowest_score(store, store.count() - max_n)

驱逐几乎总应是先摘要再丢弃,而非删除。先把被驱逐记忆的持久残留提炼进语义记忆(或一份粗粒度归档摘要)。只对真正的噪声做硬删除——客套话、完全重复、被取代的取值。你是在压缩过去,不是在抹除它。

STEP 5

实践中如何选择。

一个覆盖绝大多数智能体的务实默认:语义和程序性记忆用键值,情景记忆用向量,向量层做按衰减驱逐,键值层做就地更新。只有当你有确实属于多跳关系型的具体查询,并且已测得把它们压平进向量或键值会损失真实准确率时,才加入图。

不要因为听起来有原则就从图开始,也不要因为听起来简单就从单个巨大向量库开始——前者过度工程,后者会悄悄腐烂。存储是基础设施;按记忆类型路由,给每一层设界,让 evaluating-memory 告诉你一个后端选择何时真的在让你损失准确率。