记忆存储:向量、键值与图的取舍——外加驱逐。
你用来持久化记忆的后端,决定了你能向它提出什么样的问题。向量库回答"什么相似",键值库回答"什么正好是这个",图库回答"什么相互关联"。大多数生产级记忆系统需要三者中的两种,按记忆类型路由。无论哪种后端,一个无界的存储都会让检索退化——驱逐是设计的一部分,不是事后补丁。
三种访问模式,三种后端。
- 向量库(Chroma、Qdrant、pgvector……)——在嵌入上做近似最近邻。优势:模糊的、语义的回忆——"找出与这个情形相似的记忆"。劣势:无精确查找、无关系结构,索引被近重复项塞满时回忆质量退化。
- 键值库(Redis、一张表,甚至一个 dict)——按已知键精确检索。优势:
user:tz → "Europe/Berlin"是 O(1)、无歧义、就地更新极其简单。劣势:你必须知道键;无相似度,无发现。 - 图库(Neo4j、三元组库)——实体与带类型的关系。优势:多跳——"谁报告了那个被这次迁移修复的缺陷"。劣势:抽取昂贵且脆弱,而且大多数智能体记忆本质上不是关系型的。
把后端映射到记忆类型,而非映射到时髦。语义事实 → 键值(精确、就地更新)。情景记忆 → 向量(按情形相似度回忆)。程序性 → 以任务类型为键的键值。只有当查询确实是多跳关系型时才动用图——它是成本最高的选项,也是最常被过早采用的那个。
异构后端之上的统一记忆接口。
智能体不应知道或在意一条记忆住在哪个后端。在一个接口背后按 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(), ...) 这条路径是默默的功臣。按键写入的语义记忆在事实改变时就地更新——用户的时区是一个条目,而不是一堆不断增长、需要检索器去消歧的矛盾观察。这一个设计选择消除了一整类"智能体相信两个相互冲突的东西"的缺陷。
无界存储的失效,以及为什么驱逐是必须的。
每一个"就全部留着"的记忆系统都会退化。机制是具体的:当向量索引被语义聚集的近重复项(同一事件四十种略微不同的措辞)塞满时,最近邻搜索返回一簇紧密的冗余低价值命中,把那条真正有用的记忆挤出去。检索 recall@k 随存储增长而下降。更多记忆让智能体更笨。
"存储很便宜所以全部留着"对存储成立,对检索不成立。无界记忆存储的成本不是磁盘——而是坍塌的回忆精度,以及一个运行越久越明显变差的智能体。在任何规模上,有界、经过策展的记忆都胜过详尽的记忆。
驱逐与衰减策略。
借鉴缓存设计,并带一个记忆特有的转折:相关性,而不只是时近性或频率。
- 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)
驱逐几乎总应是先摘要再丢弃,而非删除。先把被驱逐记忆的持久残留提炼进语义记忆(或一份粗粒度归档摘要)。只对真正的噪声做硬删除——客套话、完全重复、被取代的取值。你是在压缩过去,不是在抹除它。
实践中如何选择。
一个覆盖绝大多数智能体的务实默认:语义和程序性记忆用键值,情景记忆用向量,向量层做按衰减驱逐,键值层做就地更新。只有当你有确实属于多跳关系型的具体查询,并且已测得把它们压平进向量或键值会损失真实准确率时,才加入图。
不要因为听起来有原则就从图开始,也不要因为听起来简单就从单个巨大向量库开始——前者过度工程,后者会悄悄腐烂。存储是基础设施;按记忆类型路由,给每一层设界,让 evaluating-memory 告诉你一个后端选择何时真的在让你损失准确率。