先交代一个背景:我最近在做一个长期运行的对话 Agent,一开始图省事,把用户偏好、历史对话、工具调用记录全部切块、embedding、塞进向量数据库,觉得这样就有了"记忆"。结果跑了两个星期,问题一个接一个冒出来:召回结果张冠李戴、旧需求反复污染新上下文、用户明确说"换方案"之后老方案还是阴魂不散。最后我不得不把架构推倒重来,也彻底想明白了一件事——Agent Memory 不能只靠向量数据库,它应该是一个由多种存储和检索机制组合出来的系统。
这篇文章就是那次重构的完整复盘。我会从翻车现场讲起,说清楚向量检索的能力边界,再把认知科学里的 Atkinson-Shiffrin 记忆模型映射到工程架构上,最后给出一个可落地的混合检索实现,以及我在 Qdrant、Milvus、Redis 这些存储选型上踩过的坑。适合正在设计 Agent 记忆模块、或者被"上下文永远不够用"折磨的开发者参考。
1. 把 Agent 的全部记忆塞进向量库之后,我翻车了
1.1 第一次全量向量化的结果:上下文互相污染
我的第一个方案很简单粗暴:所有需要记住的内容——用户说过的话、Agent 自己总结的结论、工具返回的结果——按固定长度切块,经过 embedding 模型变成向量,写入向量数据库。每次对话开始前,把用户当前的问题也 embedding 一下,做 top-k 相似度检索,把命中的记忆块拼进 system prompt。
听起来很顺理成章对吧?第一周确实能用,但很快暴露出一个致命问题:记忆之间互相污染。
举个具体场景。用户在 3 月 10 日让我帮他调研云厂商的定价,我当时生成了一个对比表格,结论是"A 厂商性价比最高"。3 月 15 日用户又说"我们决定用 B 厂商,因为老板喜欢他们的售后"。这两段记忆在向量空间里高度相似——都在讲云厂商、定价、选型。于是后续任何关于"云厂商"的提问,都会同时召回这两段内容。Agent 有时会说出"根据您之前的对比,A 厂商更合适"这种话,直接激怒用户。
更麻烦的是,向量检索没有"版本"概念。同一个话题,用户改了三次需求,向量库里就存着三个版本的记忆,它们彼此相似、互相干扰,但检索时无法判断哪条是最新的、哪条已经被用户明确否决。这还只是污染问题,后面还有更深的坑。
1.2 排查过程:为什么相似度检索会把记忆搅成一锅粥
当时我第一个反应是"是不是 embedding 模型不够好",于是换了好几个模型反复实验,发现结果大同小异。真正的原因不是模型,而是相似度语义和记忆有效性根本是两回事。
我做了个实验:把用户说过的 1000 条历史记录全部检索一遍,人工标注每一条召回结果"是否有用"。数据是这样的:
| 召回情况 | 占比 | 典型例子 |
|---|---|---|
| 语义相关但时效过期 | 32% | 用户已经放弃的旧方案 |
| 语义相关但对象错误 | 21% | 项目 A 的结论被用于项目 B |
| 真正有效的记忆 | 27% | 用户偏好、明确决策 |
| 完全不相关 | 20% | 纯噪音 |
也就是说,纯靠向量相似度,真正有效的命中率不到三分之一。原因在于:向量检索天然只做"语义匹配",它不知道这条记忆是什么时候产生的、属于哪个项目、用户后来有没有推翻它、它的重要程度如何。这些信息全部要靠额外的元数据来承载,而元数据恰恰是我当时完全没有设计的。
1.3 我总结出的核心结论:记忆系统不是检索系统
那次翻车让我明白一个道理:Agent Memory 是一个存储系统 + 生命周期管理系统 + 检索系统的综合体,向量数据库只是其中检索环节的一种索引方式。
存储层面要区分短期的会话上下文、长期的事实偏好、程序性的操作流程;生命周期层面要做写入、合并、衰减、遗忘、冲突解决;检索层面要结合语义召回、关键词精确匹配、规则硬过滤,最后还要有重排。这些东西单靠一个向量库根本撑不起来。
2. 向量数据库擅长什么,不擅长什么
2.1 向量检索引擎的本质是"语义相似度排序"
要搞清楚为什么不能只靠向量库,先得明白向量检索的本质。它做的事情非常简单:把文本、图片或其他内容映射成一个高维向量,然后用余弦相似度或内积去算两个向量之间的距离,再按距离排序返回 top-k。
这个机制擅长的是"语义模糊匹配"——你说"帮我找个靠谱的律师事务所",和记忆中"找过一家处理合同纠纷的律所"在字面上不完全一致,但向量距离很近,能召回来。这是关键词搜索很难做到的。另外它天然支持多语言和同义改写,这些都是它的舒适区。
但你要注意,"相似"不代表"有效"。向量相似度衡量的是"文本在语义上接近",不是"这条记忆在当下语境里有价值"。一条被用户推翻的旧决策,语义上可能和当前问题极其相似,但它就是无效的,甚至是有害的。
2.2 精确过滤、时间衰减、冲突更新:向量库的三个硬伤
我总结下来,向量数据库作为记忆存储,有三大硬伤。
第一,无法做精确过滤。像"只要 2024 年 6 月之后的数据""排除用户明确否定的内容""只检索 userId=9527 的记忆"这类硬性条件,向量数据库本身做不好。虽然有 metadata filter,但它的底层实现是"先按向量相似度召回,再过滤",本质上只是后置裁剪,召回阶段照样会把不该出现的内容捞进来,只是最后不给你看而已。过滤条件复杂的时候,召回质量下降非常明显。
第二,没有时间衰减和遗忘机制。记忆是有时效性的。用户昨天的临时讨论,今天可能就毫无价值;三个月前的决策,现在可能已经被推翻。向量数据库里的每一条记录都是平等的,没有"权重随时间和使用频率变化"的概念。就算你在 metadata 里存了时间戳,也只是把"是否失效"的判断责任推给了检索层。
第三,更新和删除极其别扭。传统的数据库里,UPDATE 一条记录是常态;向量数据库里你要更新一条记忆,只能先根据 id 删除旧向量,再重新计算 embedding 写入新向量。删除操作尚可,但是"合并两条记忆""给一条记忆增加权重""标记一条记忆为已否决"这类细粒度更新,几乎都做不到,只能整条覆盖。
2.3 索引开销与写入放大:被低估的工程成本
还有一个容易被忽视的问题:索引开销。向量索引不像 B+Tree(B+树索引)那样只占少量空间,HNSW 这类图索引在内存里吃得很凶。我当时往 Qdrant 里写了大概 20 万条记忆,每条 1536 维向量,结果内存占用直接冲到几个 GB,索引构建时的 CPU 开销也不小。
更要命的是写入放大:每次写入一条新记忆,都需要调用 embedding 模型做推理,如果该条记忆还需要同步更新到倒排索引、关系图谱、结构化数据库里,那一次写入就是多次开销。在对话场景下,每次用户交互都可能产生 3-5 条新记忆,高频写入场景下,向量库很容易成为性能瓶颈。
3. 把 Atkinson-Shiffrin 记忆模型搬进 Agent 架构
3.1 认知科学里的三级记忆模型到底在说什么
Atkinson-Shiffrin 模型,也叫多重存储模型,是 1968 年提出的经典记忆理论。它把人脑记忆分成三个阶段:感觉记忆、短期记忆、长期记忆。
- 感觉记忆:持续不到一秒的原始感官信息,绝大多数直接被丢弃。
- 短期记忆:容量有限(通常认为 7±2 个组块),通过复述才能保持,不加工就会快速遗忘。
- 长期记忆:容量几乎无限,经过编码和巩固之后形成,可以持续数小时到数年。
这个模型最关键的一个观点是:记忆不是"存进去就完事",它必须经历一个从短期到长期的巩固过程,而且遗忘是系统设计的一部分,不是故障。后来也有很多认知科学改进,比如加入工作记忆的概念、强调提取对巩固的作用,但大框架至今仍是理解记忆的好起点。
我当时看到这个模型的第一反应是:这不就是 Agent 上下文管理的标准范式吗?上下文窗口是短期记忆,记忆库是长期记忆,中间的"遗忘"机制不是 bug,而是防止系统被噪音淹没的必需品。
3.2 从 XMem 到 Chat Agent:跨领域都在用这套分层
有意思的是,这个模型不仅存在于理论里。计算机视觉里的视频对象分割模型 XMem 就显式地使用了 Atkinson-Shiffrin 模型——它把视频历史分成工作记忆、长期记忆,用独立的记忆网络来管理"哪些过去帧的信息值得保留"。这不是巧合,而是所有需要长期上下文感知的系统都会遇到的共同问题:信息太多,必须分层;重要性不同,必须区分处理。
我在重构 Agent Memory 的时候,思路和 XMem 几乎一致:短期会话里放最近的交互,情境中即时使用;定期把短期内容归纳、总结、提炼成长期记忆;长期记忆再做进一步分级。这个模式能直接对齐认知科学和已有的视觉记忆实践,说明它不是某个人的拍脑袋设计,而是被验证过的通用先验。
3.3 落地映射:工作记忆、情景记忆、语义记忆、程序记忆各放哪
参考 Atkinson-Shiffrin 和更细的长期记忆分类,我把 Agent 的记忆分成四层:
| 记忆类型 | 存储介质 | 更新频率 | 生命周期 | 典型内容 |
|---|---|---|---|---|
| 工作记忆(短期上下文) | Redis / 内存 | 极高 | 单轮或几轮对话 | 当前任务状态、对话历史、临时变量 |
| 情景记忆(episodic) | 数据库 + 向量索引 | 中 | 数小时到数月 | 之前做过什么任务、用户说过什么具体的话 |
| 语义记忆(semantic) | 关系型数据库 | 低 | 长期 | 用户偏好、项目事实、领域知识 |
| 程序记忆(procedural) | 代码 / DSL 文件 | 极低 | 随代码 | Agent 的操作流程、工具使用习惯 |
这套分层的核心原则是:不同记忆的读写频率和生命周期不同,不应该用同一种存储和同一种检索方式。工作记忆追求极低延迟,放 Redis;情景记忆需要语义召回,才是向量数据库的主场;语义记忆要求强一致和精确过滤,应该放 PostgreSQL/SQLite;程序记忆本质上是一段可执行的逻辑,跟向量检索根本不沾边。
我在项目里把短期工作记忆做成了"会话窗口",每次对话结束,脚本自动把当前会话总结成三条以内的情景记忆写入长期层;语义记忆则靠一个固定结构表维护;只有情景记忆真正走了向量检索。这样既保留了语义召回的能力,又解决了前面说的污染问题。
4. 记忆的真正核心在向量之外:元数据、关系与遗忘
4.1 每条记忆都该有一张"身份证"
如果要给想要重构记忆系统的朋友一个最重要的建议,我会说:先从设计记忆的元数据开始,而不是先选向量库。一条记忆如果没有元数据,它就是一堆漂浮的向量数字,召回了也不知道是谁的、什么时候的、还可不可信;有了元数据,检索和过滤才能真正生效。
我设计的最小元数据集合是这样的:
- memory_id:全局唯一标识,更新、删除都靠它。
- user_id / project_id:归属信息,隔离不同用户、不同项目的记忆。
- created_at / updated_at:时间戳,用于时效判断和衰减计算。
- access_count / last_access_at:访问频率,用于重要性评分。
- importance:显式重要性分数,可由 Agent 在写入时评定。
- status:正常 / 待确认 / 已否决 / 已过期,用于过滤废弃记忆。
- tags:结构化标签,辅助检索。
有了这些字段,之前那个"云厂商选型"污染问题的解决方案就变得很简单:每次检索前,先把 status='superseded' 或者 status='user_rejected' 的记忆排除掉;再按用户 ID 做硬隔离;最后按时间窗口过滤。这些动作全部发生在向量检索之前,属于规则硬过滤,根本不该让向量库来做。
4.2 用关系层解决"相关但不相像"的问题
向量检索有个天然缺陷:它只能发现"文本相似"的相关性,发现不了"逻辑相关"。举一个真实例子:用户说"我儿子今年要高考,最近在准备志愿填报"。一周后用户问"你觉得学人工智能有前途吗"。这两句话在向量空间里距离可能很远——一个在讲高考志愿,一个在问行业前景——但它们其实是同一件事:用户在为孩子的专业选择搜集信息。
这种逻辑相关性,向量库别想靠相似度算出来,它需要关系图谱来承接。我在方案里给记忆加了一个轻量的关系层:把"人物"(孩子)、"事件"(高考)、"兴趣"(人工智能)抽成实体,然后用边把它们连起来。当用户提出新问题时,不仅做语义检索,还会尝试解析问题中的实体,然后沿着图里的关系找到关联记忆。
实现上不需要重型图数据库,一个带 JSONB 字段的关系型数据库就行,比如 PostgreSQL 里存节点表和边表,查询时做两跳以内的遍历。真正需要图数据库的场景很少,别过度设计。
4.3 遗忘策略:怎么处理冲突、过期和低频记忆
遗忘是记忆系统设计里最容易被忽略、也最容易出彩的部分。大脑遗忘有规律:不重要、不常用的记忆优先丢失;冲突信息中,新信息覆盖旧信息。Agent 也应该有同样的规则。
我在项目里实现了三种遗忘机制:
- 冲突消解:当用户明确提出"不要 A 方案,改成 B 方案"时,系统给 A 对应的记忆标记 status='user_rejected',同时给 B 生成一条新记忆,并记录 rejects 指向 A 的 memory_id。这样后续检索永远看不到 A。
- 衰减打分:每条记忆有一个综合分数 = 重要性 × 衰减系数(created_at) × 访问频率系数。低于阈值的记忆进入"归档状态",不再参与常规检索,减少噪音。这个衰减可以用简单的指数函数实现:score = importance × e^(-λ × age_in_days) × (0.5 + access_count)。
- 定期压缩:每 N 条同主题的旧情景记忆,自动触发一次"归纳总结",生成一条更高层次的语义记忆,原始细碎记录降权。
有人会担心遗忘会不会把关键信息丢了。我的策略是"归档而不是删除"——降权、归档、需要时通过显式查询恢复,这和大脑的"遗忘但不彻底删除"是一样的。
4.4 结构化事实该进数据库,而不是 embedding
还有一个经常被误解的地方:不是所有记忆都应该向量化。用户的姓名、生日、订阅计划、所在城市、项目预算这类结构化事实,用关系型数据库存起来才是正解。它们必须精确匹配、强一致、随时更新,向量检索带来的语义模糊只有坏处没有好处。
比如用户搬家了,从北京改到上海。在 SQLite 里就是一条 UPDATE user SET city='shanghai' WHERE id=...,秒级完成,之后所有查询都是读到的上海。但如果把"用户所在城市"也存成向量记忆,旧记忆"住在北京"和新记忆"住在上海"会同时存在,向量检索时可能同时召回,然后 Agent 就会说出"您住在上海,之前似乎也在北京居住过"这种荒谬的话。
我的经验是:凡是能结构化的、需要精确的事实,一律走传统数据库;凡是开放性的、语义关联的、无法预定义 schema 的,才走向量和全文检索。这个判断标准帮我省了非常多麻烦。
5. 混合检索的落地实现:双通道召回与上下文组装
5.1 整体架构:硬过滤、向量召回、关键词召回、重排
确定了分层模型之后,检索流程也需要重做。我现在的检索链路是四段式:硬过滤 → 双通道召回 → 融合排序 → 重排组装。
第一步,硬过滤。拿到用户查询后,先做实体识别和分析,解析出 user_id、project_id、时间范围、要排除的实体(比如"不要已否决的方案")。这一步把候选集从几百万条直接缩小到几千条。
第二步,双通道召回。一个通道是向量检索,负责找语义相似;另一个通道是倒排索引(BM25)或者干脆用数据库里的 LIKE/全文检索,负责找关键字精确匹配。两个通道各取 top 50。
第三步,融合排序。用 RRF(Reciprocal Rank Fusion, 倒数排名融合)把两个通道的结果合并成一个列表。具体算法是:对每条文档在两个通道里的排名取倒数,然后求加权和,排名越靠前,分数越高。
第四步,重排组装。按元数据做最后修剪:过滤 status 非法的、强制排序(比如重要事件靠前、时间近的靠前)、按上下文窗口预算裁剪 top-k,最终拼装成 prompt 里的 context。
5.2 具体实现:RRF 融合和元数据过滤的代码骨架
这里给出一个核心的 Python 实现骨架,去掉业务细节,保留关键逻辑:
import math from typing import List, Dict def rrf_fusion(vector_results: List[str], keyword_results: List[str], k: int = 60) -> Dict[str, float]: """ vector_results / keyword_results 是两条通道返回的 memory_id 列表 按排名计算 RRF 分数,返回 { memory_id: score } """ scores: Dict[str, float] = {} for rank, mem_id in enumerate(vector_results): scores[mem_id] = scores.get(mem_id, 0.0) + 1.0 / (k + rank + 1) for rank, mem_id in enumerate(keyword_results): scores[mem_id] = scores.get(mem_id, 0.0) + 1.0 / (k + rank + 1) return scores def hard_filter(memory_records: List[Dict], user_id: str, exclude_statuses: List[str]) -> List[Dict]: """ 硬过滤:只保留当前用户的记忆,剔除已否决/已过期的记录 """ filtered = [] for rec in memory_records: if rec.get("user_id") != user_id: continue if rec.get("status") in exclude_statuses: continue filtered.append(rec) return filtered def build_context(memory_records: List[Dict], max_tokens: int = 2000) -> str: """ 按重要性 + 时间倒数排序,裁出不超过 token 预算的上下文块 """ now = time.time() def score(rec): importance = float(rec.get("importance", 1.0)) age = now - float(rec.get("created_at", now)) access_bonus = math.log(float(rec.get("access_count", 1)) + 1.0) return importance / (1.0 + age / 86400.0) + access_bonus ranked = sorted(memory_records, key=score, reverse=True) context_parts, used = [], 0 for rec in ranked: block = f"[{rec.get('tags', '')}] {rec.get('content', '')}" tokens = len(block) / 3 # 粗略按字符估算 token if used + tokens > max_tokens: break context_parts.append(block) used += tokens return "\n".join(context_parts)这个实现虽然简化了不少,但已经把最重要的事情体现出来了:向量和关键词是并列通道,元数据是硬性的,最终的上下文按重要性做预算裁剪。实际项目中,你可以在硬过滤阶段加 SQL 条件直接下推到数据库,让过滤发生在召回之前,而不是之后。
5.3 Memory Bank 的工作流:Session 写入、归纳、沉淀
热词里提到的 "Sessions + Memory Bank" 工作流我很喜欢,它和我的设计思路完全一致。我落地的流程是这样的:
- Session 收集:Agent 在对话中先把所有信息放在短期工作记忆(Redis)里,不做任何持久化,保证响应速度。
- Session 结束触发固化:聊天结束或达到一定轮数后,后台任务把 Session 里的内容做摘要归纳,形成 1-5 条高信息密度的候选记忆。
- 与已有记忆合并:候选记忆先和长期层搜索比对,如果发现同主题、可合并的旧记忆,就做合并;如果发现冲突,走用户确认流程或者按规则判赢。
- 写入存储:分层写入。结构化事实进 SQLite,情景性描述 embedding 后写向量库,关系信息更新关系表。
- 异步巩固:低优先级任务定期执行遗忘策略、压缩归纳、数据清理。
这个工作流的关键点是:长期记忆不是实时写入的,而是有延迟的固化。这个延迟是特性,不是 bug——它给了系统一个缓冲期,避免把用户的每句闲聊都当成长时记忆存起来。
5.4 实测效果对比:纯向量 vs 混合方案
我在同样的数据集上用两套方案跑了评估。数据集大概是 500 次真实对话产生的 2 万条记忆,测试时用 100 个真实问题进行检索,人工判断召回内容是否有用:
| 方案 | 精确率(召回的多少是有用的) | 用户否定记忆出现次数 | 平均响应延迟 |
|---|---|---|---|
| 纯向量 top10 | 28% | 15 | 180ms |
| 向量 + 元数据过滤 | 55% | 3 | 185ms |
| 向量 + BM25 + RRF + 元数据 | 72% | 1 | 230ms |
| 完整混合 + 重排 | 81% | 0 | 260ms |
可以看到,单靠向量召回,精准率不到三成;加上元数据硬过滤后直接翻倍;混合检索再往上提升;最后的重排把精确率拉到 80% 以上,并且彻底消灭了"用户已否定但还出现"的记忆。代价只是延迟多了 80ms,在绝大多数对话场景里完全可以接受。
6. 工程落地中那些绕不开的坑
6.1 第三方记忆框架的内存崩溃:0xc0000005 排查始末
有一段时间我想偷懒直接用现成的记忆框架,试过一个叫 "ekko-memory" 的开源项目。装好之后一跑训练流程,进程直接崩掉,退出提示是process exited with code 3221225477 / 0xc0000005 (memory access violation)。
这个错误码本质是 Windows 下的访问违例,通常是程序访问了非法的内存地址,常见于 C/C++ 扩展的数组越界、空指针解引用或者 ABI 不匹配。排查过程很痛苦:
- 第一步,确认不是自己的代码问题。写了一个最小复现脚本,只调用框架的 API,照样崩,说明问题在依赖。
- 第二步,查看崩溃模块。用调试器抓 crash dump,定位到某个 .pyd 文件。
- 第三步,发现这个 .pyd 是用特定版本的编译器构建的,和当前 Python 环境的 ABI 不兼容,一旦调用就会写坏内存。
- 第四步,找框架维护者确认,发现他们只测了 Linux 和特定 Python 小版本,Windows 支持完全是实验性的。
这个坑给我的教训是:引用现成的记忆框架之前,先看它的平台兼容性、维护活跃度,以及核心 C/C++ 扩展是否和你的运行环境构建一致。不然你会在排错上花的时间比自己写一个还多。后来我干脆基于成熟组件自己搭,控制在几百行代码,反而更可控。
6.2 Python 读大记忆文件被 MemoryError:流式加载与内存预算
做记忆系统必然要处理"把历史记忆加载进上下文"的场景。我一开始偷懒,用json.load(open("memory.json"))一次把全部记忆读进内存,结果在记忆文件超过 1GB 后经常报MemoryError,最夸张的一次是 OOM 直接把进程杀掉了。
排查后发现两个问题:
- 问题一:整个文件被一次性塞进内存,JSON 解析还会产生 2-3 倍的内存膨胀,纯属浪费。
- 问题二:memory 目录下还有 embedding 模型、向量索引,系统总内存本来就吃紧,叠加后直接爆。
解决方案也很简单:把记忆文件改成按 Session 分片存储,每个 Session 一个文件,加载时用流式 JSON 解析或者只读取当前需要的 Session;再给记忆加载设置一个内存预算,超过预算就用 LRU(最近最少使用)策略从内存里淘汰旧 Session,只在检索时按需读盘。永远不要让"加载全部记忆"成为默认行为。
6.3 Java 侧 OOM:用 MAT 定位堆内堆外泄漏
另一个同事负责给 Agent 做企业版 Java 服务端,遇到了典型的OutOfMemoryError: insufficient memory,当时以为加 -Xmx 就能解决,结果加到 8G 还是挂。最后用 Eclipse Memory Analyzer(MAT)做 heap dump 分析才定位到问题。
MAT 分析的关键步骤:
- 启动参数加
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/path/to/dump,让 JVM 在 OOM 时自动导出 heap dump。 - 用 MAT 打开 dump 文件,先看Overview -> Biggest Objects by Retained Size,找出占用最大的对象。
- 点开看 Reference Chain(引用链),定位到是哪个业务对象把它拽在内存里。
我们最终发现,问题不在堆内——堆里有个缓存占了大头,但真正致命的是堆外内存。向量索引的 native 内存、线程栈、直接缓冲区(DirectByteBuffer)都不受 -Xmx 控制。解决方式是隔离部署:把向量索引的 native 内存部分拆成独立进程,通过 gRPC 访问,这样 Java 进程的堆外内存压力骤减。记住:OOM 不一定是堆的问题,先看 MAT 再决定调哪里。
6.4 向量存储选型:Qdrant、Milvus、Redis、FAISS 到底怎么挑
前面讲的都是"不能只靠向量库",但不是说向量检索完全不需要。关键是怎么在合适的场景选合适的向量存储。我做了一个选型决策表,直接给结论:
| 方案 | 适合场景 | 优点 | 痛点 |
|---|---|---|---|
| FAISS(本地) | 单机、小规模、低延迟 | 部署简单、全内存、性能最强 | 无持久化、无元数据过滤、需要自研管理 |
| Qdrant | 中等规模、需要元数据过滤 | 自带过滤、持久化、REST/gRPC 接口、部署方便 | 索引内存占用不小,配置需调优 |
| Milvus | 大规模、分布式、高可用 | 扩展性好、功能全、生态完善 | 组件多、部署重、运维成本高 |
| Redis 向量模块 | 已有 Redis、需要毫秒级响应 | 无额外组件、延迟极低、融合现有缓存架构 | 向量规模受限、过滤能力弱、数据量大后内存贵 |
| 关系型库的 pgvector | 已有 PostgreSQL、中小规模 | 统一存储、SQL 过滤强、事务一致 | 向量检索性能一般,不适合超大规模 |
我的个人建议:中小团队、单机部署、数据量在百万级以内,直接上 Qdrant 或者 pgvector;数据量大到需要分布式,再考虑 Milvus;如果主要是短期记忆和会话缓存,Redis 就够了。千万别一上来就上最重的分布式方案,很多项目的记忆数据量其实连百万都不到,分布式完全是给自己找运维麻烦。
6.5 记忆系统可观测性:别等用户发现"它忘了"
最后一条经验可能很多人想不到:记忆系统最需要的不是性能优化,而是可观测性。Agent 是不知道自己"忘了"什么的,但用户会知道。如果记忆系统召回质量出了问题,用户只会觉得"这个 AI 怎么这么蠢,明明说过的事不记得",而你作为开发者却毫无线索。
所以我在系统里加了一套记忆审计日志,每次检索都记录:
- 用户查询原文。
- 检索时用了哪些过滤条件。
- 向量通道和关键词通道各召回了什么。
- 最终拼接进 prompt 的上下文是什么。
这套日志的价值非常大。出问题的时候,你可以一条条回放某个用户的所有检索过程,看到他说的某句话为什么没被召回,是硬过滤把它滤掉了,还是向量距离太远,还是重排时被挤掉了。定位一次"失忆"问题的速度从小时级降到了分钟级。
另外,我还会定期对记忆做一致性校验:比如找出 status 已经变成 user_rejected 但还被反复检索的高频记忆,说明过滤条件没有生效;再比如统计每个用户的记忆总量和召回率,异常波动往往意味着 embedding 模型或者向量数据出了问题。
最后再分享一个小技巧
如果你正在设计自己的 Agent Memory,我强烈建议先把"记忆应该分层、过滤、遗忘"这些架构问题想清楚,再决定用什么存储组件。向量数据库是好工具,但它只是记忆系统里的一块拼图。你真正想要的不是一个能搜到相似文本的引擎,而是一个能在正确的时间、以正确的粒度、把对用户真正有用的信息递到 Agent 面前的系统。
我现在的架构里,向量库承担的检索请求已经降到了总量的四成,但整体效果反而好了非常多。这中间的差别,就是元数据、关系层、遗忘策略和混合检索一起补上的。按这个思路做,虽然初期多写几百行代码,但后面省下来的排错时间,会远远超过你当时的投入。