
去年年中我们团队接手了一个客服场景的Agent改造。业务方投诉很有意思用户反复反馈同一个问题换个会话窗口就要重新讲一遍而当时的模型上下文窗口只有8KAgent一进入工具调用流转就更容易把关键信息冲掉。找了一圈资料之后大家得出一个一致结论——光靠调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一层缓存结果明星用户的高频访问一旦缓存过期瞬间穿透打到PostgreSQLP99直接从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才真正有可能从一个“会回答的工具”变成一个“有记忆协作者”。也希望上面这些踩坑记录和选型思路能给正在做同类项目的团队省掉几周调研时间。