去年年中,我们团队接手了一个客服场景的Agent改造。业务方投诉很有意思:用户反复反馈,同一个问题换个会话窗口就要重新讲一遍,而当时的模型上下文窗口只有8K,Agent一进入工具调用流转就更容易把关键信息冲掉。找了一圈资料之后,大家得出一个一致结论——光靠调prompt和加长上下文是不够的,得把Memory Service当成一个独立的基础设施认真做一次选型与架构设计。这篇就把我们实际构建企业级Agent Memory Service的思路、踩过的坑、以及最终沉淀下来的方案完整拆出来,给正在选型或者准备自建记忆服务的团队做个参考。
先说结论:可扩展的Memory Service不是简单选一个数据库,而是由记忆建模、存储分层、接口设计、扩展机制、治理体系组合起来的一整套系统。我会从上到下把这几个层次讲透,包括为什么有些团队用Redis存一切最后卡死在容量上,为什么有些团队全量上向量库结果召回精度和成本两头失控,以及正确的混合架构应该怎么落。
1. 为什么Agent一定要有记忆?从“无状态工具”到“有状态协作者”
1.1 没有记忆的Agent,在真实业务里寸步难行
很多团队一开始觉得Agent有没有记忆无所谓,反正模型本身有上下文窗口。但在企业级场景里,这个假设很快会被打破。拿客服场景举例:用户在第一轮对话里给了订单号、说明了自己是PLUS会员、还提到上次投诉过物流问题,这些信息如果不沉淀到记忆系统,只要会话一滚动,模型就全忘了。用户不会管你上下文窗口是8K还是128K,他只知道同一件事他得重复三遍。
更麻烦的是跨会话场景。今天聊了一半明天再来,Agent如果没有记忆,就完全不记得昨天的结论;采购平台上的Agent在一个月前记录了某客户的采购偏好,下次进入新会话时如果表现得像初次见面,客户信任感会急剧下降。这些场景里,记忆不是可选项,而是Agent能不能被业务方认可的关键门槛。
从架构角度还有一个容易被忽略的问题:无记忆的Agent是“无状态工具”,可以随便水平扩展;一旦需要记忆,它就成了“有状态协作者”。状态引入之后,一致性、隔离、持久化、数据生命周期这些问题会全部涌进来。所以记忆设计得好不好,直接影响整个Agent系统的扩展方式。
1.2 企业级场景给记忆系统提的四个硬要求
我们在项目启动前拉了一次需求清单,把业务方、算法、SRE几边的诉求汇总后,提炼出四个不能用“先做再说”糊弄过去的硬指标:
- 低延迟召回:所有记忆读取操作必须嵌入模型调用的关键路径,P99响应时间不能超过50ms。用户在那等着回复,你不可能让他等500ms去查库。
- 写后能读到:对话刚结束,用户立刻问“我刚才是不是说过XXX”,系统要能立刻答上来。这意味着写入链路的最终一致性窗口不能太大。
- 多租户强隔离:如果服务是面向多个客户的,A租户的记忆绝不能通过任何路径泄露给B租户。这个我们在后期还真踩过一次坑,后面专门讲。
- 删除与审计可追溯:用户要求清除记忆的时候必须能真正清除,包括备份和缓存副本;有纠纷的时候要能查出来哪条记忆被谁在何时读取过。
这四个要求决定了存储选型、接口设计、甚至部署方式。很多开源Demo项目的Memory实现满足不了这些要求,因为它们在单用户本地场景里跑得很欢,一旦上多租户和SLA就原形毕露。我们当时的判断是:Memory Service必须作为一个独立服务去建设,不能随手揉进Agent业务代码里。
2. 先建模再选型:记忆怎么分类、怎么表示、读写路径怎么设计
2.1 从认知科学借来的四类记忆
选型之前先做建模,这是很多团队会跳过的步骤。他们上来就问“用哪个向量数据库”,其实应该先问“我要存的是哪种记忆”。我们参考了认知科学的分类方式,把Agent记忆分成四类:
- 工作记忆:对应当前对话过程的短期状态,比如正在进行中的任务步骤、已经调用过的工具返回结果。这类记忆放在上下文窗口和进程内缓存里就够了,不需要落库。
- 情景记忆:发生过的事件和对话历史,比如“用户在2024年6月18日投诉过快递延迟”。这类记忆需要持久化,是Memory Service最核心的部分。
- 语义记忆:从历史事件中提炼出的事实和偏好,比如“用户习惯使用快捷键完成报表导出”“用户所在区域是华东”。这类记忆是结构化的,适合用关系库或向量库存储。
- 程序记忆:Agent学会的工作流、技能或操作习惯,比如“处理退款请求时优先验证订单状态”。这类记忆通常沉淀在Agent框架的Skill和编排层,Memory Service只做引用。
区分这四类记忆的价值在于,不同记忆的生命周期、访问频率、存储介质完全不同。把工作记忆硬塞进持久化存储是浪费,把语义记忆放进Redis是灾难。我们后续的存储分层就是用这个分类作为依据。
2.2 记忆怎么结构化表示
确定分类后,我们设计了统一的记忆数据模型。每一份记忆都是一个独立对象,用JSON形式承载以下字段:
{ "memory_id": "mem_8f3a2c91", "tenant_id": "tenant_acme", "user_id": "user_1001", "type": "episodic", "content": "用户反馈昨天申请的发票还没开出,希望今天处理", "source": "session_8821", "status": "active", "created_at": "2025-02-18T10:24:00Z", "last_accessed_at": "2025-02-18T10:25:00Z", "access_count": 3, "embedding_id": "vec_a12d", "ttl": "2025-08-18T00:00:00Z" }这里有几个容易被忽略的设计点。tenant_id和user_id不只是普通字段,它们是整个系统隔离和分片的基础,所有查询路径都必须强制携带。embedding_id用来关联向量索引,避免把向量数据直接塞回关系表导致表体积膨胀。access_count和last_accessed_at则是热度和LRU淘汰策略的依据,后面做缓存和归档都会用到。
除了单条记忆,我们还在聚合层维护了三种高价值构建块:会话摘要,一轮长对话结束后由模型生成的压缩总结;用户画像,由多条语义记忆聚合而来;实体图谱,记录用户、订单、商品等关键实体之间的关系。这三类构建块能显著提升召回质量,因为单条记忆太碎片,直接召回往往给模型提供不了足够上下文。
2.3 两条核心读写路径
建模完成后,我们把读写路径固定成两条Pipeline,所有功能迭代都在这两条管道上扩展:
写入路径:Session数据 -> 事件提取 -> 过滤噪声 -> 摘要/归纳 -> 脱敏 -> 判重 -> 编码 -> 持久化。不是每句话都值得进记忆库,后面接口设计章节会细讲。
读取路径:当前问题 -> 查询改写 -> 候选召回(向量+元数据过滤) -> 重排 -> 上下文组装 -> 注入Prompt。这里要强调的是,读取路径不只在Agent开场时触发,在对话中途、工具调用前、任务切换时都值得按需触发。
把读写路径想清楚之后,我们才进入存储选型。因为这个时候终于知道每条数据大概长什么样、访问模式是什么、需要什么样的索引能力了。
3. 存储选型没有银弹:向量库、关系库、KV与对象存储的边界
3.1 为什么不能只靠一种存储
很多初版设计喜欢“一个存储搞定一切”。最典型的是全量丢Redis,短期的确开发快,但一旦数据量上来,内存成本会让你哭;冷数据全挤在Redis里,热数据反而被LRU挤掉,语义检索更是完全没法做。反过来也有团队迷信向量数据库,把所有记忆都转成embedding存进去,结果精确查询(“我只想要上周三那条投诉记录”)变成噩梦,向量库在点查和过滤上效率远不如关系库,而且向量索引的存储成本大概是原文的几倍到十几倍。
我们当时的结论是:按访问模式分层,让不同存储做自己最擅长的事。短期、高热的记忆放KV,结构化事实放关系库,语义召回放向量库,原始长文扔对象存储。这是一套很朴素但很好用的思路。
3.2 各存储引擎的适配场景对照
以下是我们在选型阶段整理出来的对照表,后来也成为团队内部评审的固定材料:
| 存储 | 适用场景 | 优点 | 短板 |
|---|---|---|---|
| Redis / 其他KV | 会话级工作记忆、热点记忆缓存 | 延迟极低、TTL天然支持 | 容量受限、持久化弱、无语义检索 |
| PostgreSQL / MySQL | 用户画像、事实类语义记忆、审计日志 | 事务强、过滤和点查效率高 | 语义检索需要额外插件或落空 |
| 向量数据库(Milvus/Qdrant/pgvector等) | 语义相似召回、开放域问答记忆 | 支持embedding检索、相似度排序 | 精确过滤弱、成本高、运维复杂 |
| 对象存储(S3/MinIO等) | 完整对话原文、长文档记忆 | 便宜、几乎无限容量 | 访问延迟高,只适合冷数据 |
这里我想特别说一下pgvector。如果团队规模不大、不想额外维护一套分布式向量库,直接在已有的PostgreSQL里启用pgvector是非常务实的起点。它对几千万级别的向量召回完全够用,配合关系表的元数据过滤能力,比很多“为了向量而向量”的方案更稳。我们在MVP阶段用的就是pgvector,后期才拆出独立向量集群。
3.3 我们最终采用的混合分层架构
生产环境里我们跑了三份数据副本,但各自承担不同角色:
- 热层:进程内LRU缓存 + Redis,存储最近24小时内被高频访问的记忆,目标P99延迟低于10ms。热点记忆在Redis里直接按
memory_id读。 - 温层:PostgreSQL存记忆正文和结构化元数据,pgvector存embedding和向量索引。这个层承担绝大部分语义召回和精确查询。
- 冷层:超过30天未被访问的记忆正文和原始会话日志转存对象存储,可离线分析。召回冷记忆时会异步从对象存储捞回温层。
这套架构的核心原则是控制热数据体积、降低向量索引规模、避免单一存储变成瓶颈。冷数据不是不要,而是不让它拖累在线查询性能。选型最终的答案其实不是某一个数据库,而是“什么样数据放哪里”的一套策略。
4. 接口设计决定服务上限:写入审编、召回重排、更新与遗忘
4.1 写入接口:不是所有对话都配进记忆库
Memory Service写入接口最容易犯的错误是“全量摄入”。如果把所有对话原文一股脑入库,存储量和噪声会让你迅速失控。我们在写入路径上加了审编管线,核心是四道过滤:
- 价值过滤:寒暄、重复语气词、“嗯嗯”“好的谢谢”这类内容直接丢弃。
- 噪声过滤:工具返回的原始JSON、中间态日志、模型生成的错误纠正等,不进记忆库。
- 隐私过滤:识别手机号、身份证号、邮箱、地址等敏感信息,按规则脱敏或替换成占位符。
- 判重合并:同一个事实在不同时间重复表达,合并为一条记忆并更新
access_count和last_accessed_at,而不是新建一条。
接口形态上我们暴露的是:
POST /memories Body: { "tenant_id": "...", "user_id": "...", "session_id": "...", "events": [...] } Response: { "memory_ids": [...] }服务端收到事件流之后走审编管线,异步完成摘要、embedding、入库。这里故意做成异步,因为审编管线里有模型调用,如果同步执行会让对话接口阻塞好几百毫秒,业务方接受不了。
4.2 召回接口:纯相似度检索远远不够
召回接口我们经历过一次较大的返工。第一版只用了向量相似度topK,效果很差——模型经常把“用户不喜欢吃辣”和“用户喜欢辣的菜单推荐”混在一起,而且时间维度完全没纳入。后来我们把召回策略升级成三段式:
第一段是候选召回,用embedding相似度查出top50,同时用tenant_id、user_id、type、时间范围做强制元数据过滤。第二段是加权重排,综合相似度和新鲜度打分:score = similarity + α * recency_bonus + β * access_boost。一段记忆如果被反复访问,说明它对用户很重要,应该获得加权;近期记忆也应该比半年前的历史记忆优先级更高。第三段是上下文打包,把筛选后的记忆按角色和类型组装成适合注入Prompt的格式。
最终的召回接口长这样:
POST /recall Body: { "tenant_id": "...", "user_id": "...", "query": "...", "top_k": 8, "memory_types": ["episodic", "semantic"] } Response: { "memories": [...] }这里还做了一个小优化:召回接口支持传入当前任务的task_context,例如当前正在处理退款流程,那么服务端会把“退款相关”的事故记忆权重调高。这个功能业务反馈特别好,但实现起来需要给记忆加业务域标签,属于建模阶段的投入,越早做越省事。
4.3 更新与遗忘:记忆不能只增不改
比写入更难的是一致性维护。记忆是人类认知的近似,同一个事实可能会被新信息推翻,比如用户调整了偏好、电话号码变更、订单取消。我们采用“新证据优先 + 墓碑标记”的策略:
- 新增记忆时如果检测到高度冲突的旧记忆,不物理删除旧数据,而是将旧状态置为
superseded,并关联新记忆ID。 - 模型召回时默认只返回
status == "active"的记忆,被替代的旧记忆不再进入上下文。 - 遗忘接口支持精确删除和级联清理:
DELETE /memories/{id}不仅删除主存储记录,还要删除向量索引中的embedding、缓存中的副本、以及审计日志里对应的关联ID。我们实现了一个清理任务队列,确保删除操作最终一致。
还有一类“遗忘”是时间衰减的自然过期。每条记忆都有TTL,对话类记忆通常30天,事实类记忆90天,用户可配置。到了TTL的记忆不直接删除,而是先标记expired并转冷,给业务方留出申诉和人工修正的窗口。
5. 可扩展性不是堆机器:分片、两级缓存、异步管道与容量估算
5.1 分片键怎么定:按用户而不是按会话
Memory Service的数据天然带tenant_id和user_id,分片键的选择很关键。我们讨论过按session_id分片,很快否决了,原因很简单:跨会话召回是核心场景,按会话分片会导致一次召回需要查询多个分片,变成跨节点聚合,性能和复杂度都会失控。
最终选的是按user_id哈希分片,同一用户的所有记忆落在同一分片,召回时一次路由全部命中。多租户场景下,分片键会带上tenant_id前缀,例如hash(tenant_id + "_" + user_id) % 128,保证不同租户的用户数据不会在物理层面混布到同一分片。分片数我们预留到128,前期用32个分片跑,数据量上来后再平滑扩容。
分片还要配合索引设计。PostgreSQL侧我们建了(tenant_id, user_id)作为联合索引前缀,向量库侧则在每个分片内维护独立的collection或partition,而不是全局共享一个大集合。这样既能保证隔离,又能让单次召回只扫描一个分片的数据,性能和容量都更可控。
5.2 两级缓存:进程LRU + 分布式缓存
缓存设计是被一次线上事故逼出来的。上线初期只有Redis一层缓存,结果明星用户的高频访问一旦缓存过期,瞬间穿透打到PostgreSQL,P99直接从20ms飙到200ms。后来我们把缓存改成两级:
- 进程内LRU:每个Memory Service实例保存最近热门的记忆,容量控制在几百MB以内,命中率能达到60%以上。
- Redis二级缓存:进程内未命中后再查Redis,保存全量热点记忆,TTL设为5分钟。
- 缓存失效:写入和删除时主动推送失效消息到Redis,并配合短TTL做兜底,防止多副本间的数据不一致。
进程内缓存最怕的就是单实例内存无限增长,所以我们给LRU设了淘汰策略:优先淘汰access_count低、剩余TTL短的记忆。热点记忆的冰箱贴级存在,但也必须有“冷下来”的机制,否则热点用户会一直占着内存不放。
5.3 写入异步化与容量估算
写入链路我们走了异步管道。对话系统产生事件后,先写入消息队列(Kafka/RabbitMQ均可),Memory Service的消费端负责处理提取、摘要、embedding、入库。这样即使模型摘要服务偶发抖动,也不会拖慢对话主链路。
做容量规划的时候我们算了一笔账,这个计算过程对选型和扩容特别有参考价值。假设100万日活用户,每个用户每天产生20条值得沉淀的记忆,每天就是2000万条;假设每条记忆的正文和元数据平均1KB,主存储每天新增约20GB;向量存储更吓人,768维的embedding用float32表示是3KB,加上索引开销,每天新增约60GB到80GB。
这么一算就知道,全量记忆永久保留在热存储里完全不现实。所以我们设计的默认策略是:最近7天全量在温层,7到30天的记忆在温层但向量降精度,超过30天的自动转冷。真正常年被访问的热点记忆其实只占总量个位数百分比,把资源投给头部数据才是性价比最高的做法。
6. 企业级绕不开的硬骨头:多租户隔离、隐私合规与审计
6.1 多租户隔离:从代码到存储层层设防
多租户隔离在文档里往往只是一句“用tenant_id过滤”,但实际落地远不止这么简单。我们早期就出现过一次“串味”事故:一个测试租户在召回结果里查到了另一个租户的记忆,原因是在某条查询路径上,代码忘了拼tenant_id过滤条件,导致默认查了全局索引。
排查后我们定了一条铁律:所有存储访问必须通过统一数据访问层,禁止业务代码直接拼接查询语句。在数据访问层内部,tenant_id会作为强制条件注入每一条SQL和每一次API调用,且支持在测试阶段开启“无租户条件下直接拒绝”的防御模式。
向量库的隔离更棘手。推荐做法是每个租户使用独立的索引分区,比如Qdrant的Payload过滤不够的话,就用独立Collection。我们最终选择了逻辑隔离 + 关键租户物理隔离的混合策略:普通租户共用一个Collection但强制Payload过滤,大客户单独开Collection,数据物理隔离。
6.2 隐私保护:别以为向量不可逆
隐私问题很容易被技术团队低估。很多人觉得“embedding是一堆数字,看起来不可读”,但实际上向量是可以被逆向攻击的——只要有API能返回相似向量,攻击者可以通过枚举种子文本反推出原始内容的大致语义。我们内部专门跑过一次向量重建实验,证实了这个风险是真实存在的,不是危言耸听。
因此,对包含手机号、邮箱、身份证、具体地址等强敏感信息的记忆,我们在写入链路就做脱敏处理,规则引擎识别的直接替换成[PHONE]、[EMAIL]等占位符,原始内容不进主存储。模型做召回时拿到的是脱敏后的内容,真正需要原文的环节(如人工复核)走独立的解密服务并记录访问日志。
6.3 审计与生命周期:每一步都可追溯
企业客户对审计的要求往往比功能更严格。我们的做法是独立的审计事件流:每次记忆写入、读取、更新、删除、转冷,都异步发送一条审计事件,包含操作者服务、操作者身份、目标记忆ID、时间戳、操作结果。审计事件本身只追加不修改,单独存到按时间分区的日志存储中。
遗忘请求的合规处理是整个生命周期里最容易被做糊的环节。用户要求删除记忆时,不是主库删一条就完事,Redis缓存、向量索引、对象存储中的原始日志、模型侧的临时引用,全部要走清理链路。我们的清理任务支持级联,删除发起后会生成一个purge_job,后台逐项确认每个存储节点的副本都已清除,全部完成后才向业务方返回“删除完成”。
7. 落地节奏:从单机版到生产环境,三个阶段怎么走
7.1 第一阶段:先别上向量库,一张表也能验证价值
很多团队一上来就搭Kafka搭向量库,折腾两周还没看到业务效果。我的建议是反过来,先用最小闭环验证“记忆对Agent体验的提升”这件事成立。
我们的MVP只用了PostgreSQL一张memories表,字段就是前面那个JSON结构加上一个embedding vector(768)的pgvector列。写入流程直接同步调模型做摘要和embedding,召回就是简单的SQL查询加余弦相似度排序。这一版没有Redis、没有队列、没有分片,但已经能回答“用户上次说过什么”“用户偏好什么”这两个核心问题了。MVP阶段最重要的产出不是性能,而是业务方对“记忆确实提升体验”的认可,以及用真实流量校准审编规则。
7.2 第二阶段:引入缓存与队列,解决性能和耦合问题
MVP跑通后,我们把写入从同步改成异步,引入消息队列;把热点召回从PostgreSQL分流到Redis;把独立的向量检索能力从pgvector迁移到独立向量库集群。这个阶段的核心目标是解耦——Memory Service变成独立服务,对外只暴露三个接口,AGent业务方不需要关心底层存储。
迁库的时候我们做了双写方案:新系统上线后,写入同时写旧库和新库,读取默认走老库,灰度验证新库数据质量和召回准确率。跑了两周左右,确认新库的写入完整性超过99.9%后才切换读取流量。这里提醒一句:迁移最容易翻车的是embedding版本不一致,新库必须用与旧库完全相同的模型和版本生成向量,否则召回效果会出现“看不出原因的变差”。
7.3 第三阶段:生产加固,压测与故障注入
最后阶段是服务架构加固。我们做了多副本部署,每个分片有两个副本保证高可用;加上熔断、限流、降级策略——比如向量库抖动时自动降级为纯PostgreSQL精确查询,宁肯召回少一点,不能整个服务不可用。
压测目标定的是单分片支持每秒200次召回请求、P99低于50ms、写入吞吐每秒100条事件。为了达到这个目标,我们做了索引调优、连接池参数调整和缓存命中率优化。故障注入也做了几轮:主动kill一个分片副本、模拟向量库超时、模拟Redis集群断连,每一步都有对应的降级预案。这一轮做完,我们才敢把Memory Service正式推上生产。
8. 踩坑复盘:上线半年后我最想重做的三件事
8.1 三个真实事故,每个都值得写进排障手册
第一个事故就是之前提过的租户串味。那次是在一次灰度测试中,测试租户的Agent突然回答出了另一个客户的企业内部信息。根因是某次功能开发新增的查询接口漏了租户过滤,走了全局索引。虽然我们没有对外造成实质影响,但这件事让我定下了“默认拒绝无租户查询”的代码规范,后来又补了一层存储层的强制过滤拦截。
第二个事故是热点用户打爆缓存。某一线客服团队有用户一天发起几百次对话,该用户的记忆被频繁访问,Redis缓存一过期就同步穿透到PostgreSQL,导致那一小时整个分片响应变慢。修复方案是进程级单飞(singleflight)合并击穿请求 + 进程内热点缓存。
第三个事故是记忆无限膨胀。我们早期给测试租户设了超长TTL,结果一个月后存储量比预估多了两倍,成本报表很不好看。后来写了一个离线任务,扫描超过60天未被访问的记忆批量转冷,并在新增记忆时默认加上30天/90天两档TTL。
8.2 如果重来一次,我会把遗忘机制和声明式接口放在性能之前
上线半年后,如果让我给刚开始选型的团队一个最朴素的建议:先把记忆的生命周期管理想清楚,再做性能优化。记忆系统本质上是一个信任系统,用户敢让Agent记住自己的信息,前提是知道这些信息可以被随时查看和清除。技术上把遗忘机制做得干净彻底,比把P99从30ms优化到20ms重要得多。
另一个建议是尽早把整个Memory Service封装成声明式接口,让Agent业务方只需要描述“我要记住什么”“我要召回什么”,而不是暴露存储细节。这样一来,底层无论从PostgreSQL迁到独立向量库,还是从单租户扩到多租户,业务方都不会感到阵痛。
构建可扩展的Memory Service,说到底不是一道存储选型题,而是一道系统设计题。把记忆当作一等公民来设计,给它清晰的数据模型、合理的存储分层、完整的生命周期管理,Agent才真正有可能从一个“会回答的工具”变成一个“有记忆协作者”。也希望上面这些踩坑记录和选型思路,能给正在做同类项目的团队省掉几周调研时间。