文章目录
- 1 为啥AI Agent一定要搞记忆?现实坑多到离谱
- 2 整体架构:解耦独立记忆子系统
- 3 四层金字塔记忆模型 L0‑L3
- 4 双存储引擎,SPI插拔式设计
- 5 写入主链路,踩坑踩出来的代码逻辑
- 6 主动Push召回,别搞被动Pull模式
- 6.1 召回流水线
- 6.2 异步回写队列MemoryWritebackQueue
- 7 S1可解释混合打分:为啥叫match,不叫similarity
- 8 记忆治理闭环:抽取、合并、遗忘淘汰
- 9 和对话链路怎么集成?MCP接入层
- 10 12条工程设计原则,血泪总结
- 11 最后唠两句
P.S. 目前国内还是很缺AI人才的,希望更多人能真正加入到AI行业,共同促进行业进步,增强我国的AI竞争力。想要系统学习AI知识的朋友可以看看我精心打磨的教程 传送门http://blog.csdn.net/jiangjunshow,教程通俗易懂,高中生都能看懂,还有各种段子风趣幽默,从深度学习基础原理到各领域实战应用都有讲解,我22年的AI积累全在里面了。注意,教程仅限真正想入门AI的朋友,否则看看零散的博文就够了。
1 为啥AI Agent一定要搞记忆?现实坑多到离谱
大模型本身天生就是无状态选手,很多人没意识到这点。
你跟它聊天,它能记住东西,完全是靠把所有历史对话一股脑塞进上下文窗口。
会话一关,或者对话太长撑爆窗口,直接原地失忆,跟鱼的记忆一模一样,七秒过后啥都忘干净。
网上一堆demo做Agent记忆,简单到离谱:拿个向量库,文本丢进去完事,就号称搞定长期记忆。
上线跑两天直接傻眼。就跟你往衣柜疯狂塞衣服,只管塞不管整理,最后找一件卫衣得扒拉半小时。
真实业务场景,至少要搞定三类头疼问题:
- 1.1 跨会话长期记忆。用户喜好、之前说好的约定,新开对话还得能用,不能每次聊天都要重新自我介绍一遍。谁聊天也不想每次上来都重复“我不吃香菜”。
- 1.2 推理主动召回。该想起来的信息,Agent自己主动捞出来,别等着用户反复提醒。总不能每次都要用户说“还记得我上次跟你说的吗”。
- 1.3 记忆腐烂治理。会记错、会重复存、混进去敏感信息,还有一大堆一辈子都用不上的垃圾记忆。这些脏东西不收拾,Agent越跑越蠢。就跟浏览器缓存,堆多了干啥都卡。
市面上很多方案,把召回完全甩给大模型自己处理。模型想起来就看,想不起来直接忽略。这能叫记忆吗?这叫开盲盒。
我做这套agent‑memory模块,核心目标不是单纯把数据存下来。
重点是召回准、能治理、出错不会把整个对话搞崩,出问题还能溯源查清楚。线上开发,能出问题之后查得到原因,比啥都香。
2 整体架构:解耦独立记忆子系统
先给大家捋清楚整体链路。
上层调用方五花八门:对话链路、管理后台UI、治理API全都可以来调用记忆模块。
但是所有请求,全部打到agent‑memory核心模块,绝不允许到处写读写逻辑。
很多新手写项目,到处散落写DB的代码,后面改存储,要改几十处文件,改到怀疑人生。
模块内部职责分得明明白白:接入层、写入服务、治理门面、召回构建器、异步回写队列、打分器、审计埋点组件。
存储搞了双库设计,H2 OLTP做主库,Arrow OLAP当副库。
- 运行时读写操作全部走H2主库,追求响应速度。
- Arrow副库只拿来做后台离线分析,每分钟靠增量ETTL同步数据过去。
读写严格分离,线上推理流量和离线分析互不打架。
业务代码只依赖门面接口MemoryStorageFacade。底层换成MySQL、RocksDB,业务代码一行不用改。SPI扩展是真的香,不用到处硬编码存储实现。
划重点:全部落库操作,只能走AiMemoryService这唯一写入出口。脱敏、白名单、重要性评估全部集中在这里处理。不要到处复制粘贴脱敏逻辑,复制代码就是bug的温床。
3 四层金字塔记忆模型 L0‑L3
记忆不要平铺一堆记录堆在表里。搞成分层金字塔,越往上信息密度越高,召回优先级也越高。
- 3.1 L0 RawLog原始对话日志
完整原始会话痕迹,带上traceId用来全局溯源回放。
**这一层不会参与召回!**只用来留痕排错。别啥都往模型塞,原始日志token爆炸能把钱包烧穿。线上排查bug的时候,这一层就是救命稻草。
- 3.2 L1 Atomic原子记忆
最小不可拆分记忆单元,分三类:session_var会话变量、user用户记忆、global租户全局记忆。
会参与召回打分,也支持遗忘淘汰。这里踩过大坑:global类型记录,存的不是userId,实际存的tenantId租户ID。
写查询的时候,如果不加过滤直接按userId查全部,租户之间数据直接串。线上一出这种跨租户泄露bug,年终奖原地拜拜。
- 3.3 L2 Scene场景块
会话摘要块,附带关联一批L1的id列表。每次会话启动优先加载,充当对话骨架。相当于给这一次聊天做的读书笔记。
- 3.4 L3 Persona用户画像
存放用户人设、偏好、生活习惯,跨会话稳定,支持版本迭代演进。召回优先级最高。
相当于我们系统里面的用户档案,这部分内容不能随便淘汰丢掉。
4 双存储引擎,SPI插拔式设计
很多人一上来就各种猛上重量级数据库,其实没必要。
业务层 (AiMemoryService / MemoryManager / Analytics) │ 只依赖门面 ▼ MemoryStorageFacade ├── oltp() ──▶ OltpMemoryRepository ──▶ H2OltpStorageProvider ──▶ H2 MVStore 文件库 └── olap() ──▶ OlapAnalyticsRepository ─▶ ArrowOlapStorageProvider ─▶ Arrow IPC 文件这套SPI抽象是通用组件,别的模块也能拿来复用。想切换存储,只需要实现Provider接口,注册Bean,改一行配置,业务完全不用动。
副库选Arrow加Calcite纯Java实现,没有JNI依赖。用过DuckDB JNI的同学应该懂痛,版本稍微不对,环境直接炸,打包部署各种玄学问题。
主库扛线上实时CRUD召回;副库专门跑离线时序、溯源分析。两边负载彻底隔开,离线跑大查询不会把线上接口拖垮。
5 写入主链路,踩坑踩出来的代码逻辑
所有写记忆统一入口saveUserMemory,这里坑点密度堪称全项目天花板,一步顺序写错,线上悄悄出bug,还很难复现。
publicMap<String,Object>saveUserMemory(StringtenantId,StringuserId,Stringcategory,Stringcontent,Doubleimportance){requireText(tenantId,"租户标识不能为空");requireText(userId,"用户标识不能为空");StringnormalizedCategory=hasText(category)?category.trim():"custom";if(!isCategoryAllowed(normalizedCategory)){// ① 白名单校验returnMap.of("allowed",false,"reason","记忆类别不在白名单内...");}ImportanceScoreassessed=importanceScorer.score(content,normalizedCategory);// ② 重要性的评估【脱敏前】StringsafeValue=sanitizeIfEnabled(content);// ③ 敏感脱敏if(!hasText(safeValue)){returnMap.of("allowed",false,"reason","内容为空或全部为敏感信息...");}Stringid=IdUtil.fastSimpleUUID().substring(0,12);StringtraceId="trace-"+id;longts=System.currentTimeMillis();doublevalue=resolveImportance(importance,assessed);oltp.saveRawLog(L0RawLog.forInsert(traceId,"user-"+userId,tenantId,ts,"system","USER_MEMORY:"+normalizedCategory+"="+safeValue,null,null));// ④ L0 溯源留痕if("persona".equals(normalizedCategory)||"preference".equals(normalizedCategory)){oltp.savePersona(L3Persona.create(id,userId,normalizedCategory,safeValue,1,ts,tenantId,value));}else{oltp.saveAtomicMemory(L1AtomicMemory.create(id,traceId,"user-"+userId,userId,normalizedCategory,safeValue,ts,tenantId,value));}// 返回体诚实:importanceSource = scored | explicitMap<String,Object>recordMap=record(id,safeValue,ts,Map.of("userId",userId,"category",normalizedCategory));returnresult(applyImportance(recordMap,importance,assessed),content,safeValue);}有一个千万不能颠倒的执行顺序:重要性打分,必须拿脱敏之前的原文运算。
如果你先脱敏再打分,手机号变成138****8000,规则识别直接失效。敏感信息会被系统当成高权重记忆存进去。bug安安静静潜伏,你测半年都测不出来。
返回结果也不要糊弄调用方,返回importanceSource标记,区分是系统自动算出来分数,还是外部调用方显式传入。
不然调用者分不清,到底自己传的参数有没有生效,排错直接两眼一抹黑。就跟接口返回永远“成功”,实际啥都没干,纯纯害人。
6 主动Push召回,别搞被动Pull模式
很多记忆库是Pull模式,模型想读才去读。模型脑子一“走神”就忘掉关键记忆。
我们这里玩Push模式,推理之前主动把合适记忆注入进去。
6.1 召回流水线
recallFragment 作为入口,调用MemoryManager.recall。
- SQL下推查询当前用户L3画像加上L1原子记忆数据;
- 逐条跑MemoryScorer打分;
- 按照分数、最近访问时间、时间戳做稳定排序,截取topK;
- 最后过滤掉低于minScore阈值的记录,组装片段注入prompt,同时写L0留痕。
三条血的教训,三条注入铁律:
- ① 必须加冲突声明:仅供参考,若与用户当前陈述冲突,以用户当前陈述为准。用户改了喜好,旧记忆不能还在那瞎指挥。
- ② 如果啥记忆都没召回,什么文本都不要注入。千万别写个“暂无记忆”占位,凭空污染上下文token,还搞坏prompt缓存。写demo无所谓,线上token钱一分一分烧。
- ③ 记忆内容注入到系统提示词,不要直接往messages数组塞SYSTEM消息。框架会直接拒绝,一命中记忆对话直接报错失败,复现还时有时无,调得人心态爆炸。
6.2 异步回写队列MemoryWritebackQueue
大模型把回答返回给用户之后,整个http链路就结束了。事实抽取更新记忆这种重活,放到后台异步队列处理。
publicclassMemoryWritebackQueueimplementsAutoCloseable{publicstaticfinalintMAX_PENDING=1000;// 队满即拒绝新任务privatefinalBlockingQueue<Task>queue=newLinkedBlockingQueue<>(MAX_PENDING);privatefinalExecutorServiceworker;publicMemoryWritebackQueue(MemoryManagermemoryManager,MemorySpanRecorderrecorder){this.worker=Executors.newSingleThreadExecutor(r->{Threadt=newThread(r,"agent-memory-writeback");t.setDaemon(true);// 守护线程:退出不卡进程returnt;});this.worker.submit(this::consume);}publicintsubmit(Tasktask){booleanaccepted=queue.offer(task);if(!accepted){dropped.incrementAndGet();log.warn("[memory-writeback] 队列积压已达上限 {}, 丢弃本次回写 traceId={}",MAX_PENDING,task.traceId());recorder.recordDroppedWriteback(...);// 丢弃也必须可归因return-1;}returnqueue.size();}}这里有几个取舍,都是踩坑总结:
任务对象直接带上本轮完整消息体,不要只存traceId。会话侧traceId和链路traceId两套体系,如果只存id,后台查原始记录查不到,回写直接静默空转。日志啥错都不报,功能就是不生效,阴间bug天花板。
队列满了拒绝新任务,而不是丢掉老任务。老任务已经排队很久,扔了等于前面等待全部白费。宁可丢掉最新几轮记忆,也不能让队列无限膨胀把服务拖垮。
异步任务就算抽取逻辑抛异常,只打印警告日志,绝对不能反向影响已经返回给用户的对话结果。记忆只是附加增强功能,不能让记忆模块故障搞崩整个主业务。就好比浏览器插件崩了,不能让浏览器直接闪退。
7 S1可解释混合打分:为啥叫match,不叫similarity
很多项目直接上向量相似度做召回,中文场景坑巨多。一堆“的、了、是”高频虚词疯狂干扰结果。随便一句查询,跟一堆八竿子打不着的记忆算出高相似度。
我们这套打分是纯函数,全部数值归一化0‑1区间。
score = 0.5 × match + 0.3 × decay + 0.2 × importance match = 0.6 × bigramOverlap(query, content) + 0.4 × (content 含 query ? 1 : 0) decay = 0.5 ^ (Δt / halfLife)match用二元字符bigram覆盖率,不依赖分词,不用大模型,结果完全可复现。命名老老实实叫match,不吹语义相似度,诚实命名,出问题才好排查。
decay代表记忆衰减,记忆越久没被访问,分数慢慢往下掉。时间差值统一量化到整小时。
为啥要量化?如果拿原始毫秒时间戳参与计算,毫秒微小抖动,两次运行同一份数据,算出来分数小数点后十几位飘来飘去,单元测试永远不稳定,你跑一遍过,CI流水线跑一遍失败,能把测试工程师逼疯。一小时带来的衰减误差几乎可以忽略不计。
MemoryScorer设计成纯函数,不持有可变状态,不内部读系统时钟,时间参数全部由调用方传入。同一套输入,输出结果必须完全一模一样,这叫可回归。单元测试才能写得住。
权重配置在服务启动阶段做强校验。权重相加不等于1,直接抛出异常终止启动。绝不允许服务成功启动,但打分逻辑完全失效。很多项目配置写错,服务还开开心心跑,业务全错,等到上线才发现。配置校验越早做越好。
召回、记忆合并、淘汰清理,全部共用同一个Scorer实例。不要搞多套打分逻辑。不然出现:低分记录不会被注入prompt,但又不会被淘汰清理,脏数据越堆越多。两套标准打架,谁来都修不动。
8 记忆治理闭环:抽取、合并、遗忘淘汰
记忆不能只写不删不整理,跟家里房间一样,只买东西不扔垃圾,最后下不去脚。
MemoryManager在基础增删改查之上编排5个核心动作,完成记忆新陈代谢。
| 动作 | 组件 | 简单说明 |
|---|---|---|
| 召回 | MemoryScorer | 前面讲的打分召回逻辑 |
| 事实抽取 | FactExtractor | 规则模式默认开启,LLM抽取模式默认关闭 |
| 记忆合并 | MemoryMerger | 复用match算法,同一用户同一类别,内容近似就合并,减少重复记忆 |
| 淘汰清理 | MemoryEvictor | 复用同一打分器,低分并且过期记忆清理掉,带安全阀保护 |
| 访问统计 | AccessCounter | 进程内计数,定时批量落库,不阻塞查询接口 |
事务边界要拎清楚:召回只读;抽取合并淘汰逐条处理,允许部分成功。不要给一堆记录套大事务。一部分处理成功一部分失败,大事务一回滚,全部白干,放大数据损失。线上千万不要迷信大事务万能。
加安全阀:重要性≥0.9或者显式标记的记忆,不会被淘汰清理。核心用户画像不能被自动清理逻辑误删掉。
9 和对话链路怎么集成?MCP接入层
记忆模块和对话模块是平级兄弟组件,没有编译依赖。通过HTTP JSON‑RPC 2.0的MCP协议交互。
对话模块调用MemoryMcpClient,发起memory_write、memory_read、memory_delete、memory_search工具调用。
MCP接入层会走MemoryMcpAccessGuard做鉴权。配置token就校验Authorization头;不配置token,就只允许本机回环访问。
会话侧每一轮对话,会调用appendTurn保存会话消息,写入L0事件流,支持服务重启会话回放。
publicsynchronizedvoidappendTurn(StringsessionId,StringuserMessage,StringassistantMessage,intmaxMessages){if(!hasText(sessionId)||maxMessages<=0)return;SessionMemorysession=loadSession(sessionId);if(session==null)session=newSessionMemory();session.messages.addLast(newAiChatMessage("user",userMessage));session.messages.addLast(newAiChatMessage("assistant",assistantMessage));trimMessages(session.messages,maxMessages);session.revision++;storeSession(sessionId,session);recordTurnEvent(sessionId,userMessage,assistantMessage);// 落 L0, 可回放}privatevoidrecordTurnEvent(StringsessionId,StringuserMsg,StringassistantMsg){longts=System.currentTimeMillis();StringtraceId="convo-"+IdUtil.fastSimpleUUID().substring(0,12);if(hasText(userMsg))eventLog.saveRawLog(L0RawLog.forInsert(traceId,sessionId,null,ts,"user",userMsg,null,null));if(hasText(assistantMsg))eventLog.saveRawLog(L0RawLog.forInsert(traceId,sessionId,null,ts,"assistant",assistantMsg,null,null));}这里又一个大坑:写记忆时候填的tenantId、sessionId,读记忆的时候必须完全一模一样。
如果两边参数不一样,管理面板看着永远是空数据,还不会抛出任何异常。这种静默错误,排查能耗掉你一整天时间。最好写自动化校验脚本提前发现。
10 12条工程设计原则,血泪总结
很多看起来非常啰嗦、多余的约束,都是为了解决线上记忆慢慢腐坏的问题。很多人写demo全部忽略这些,跑demo美滋滋,上线就翻车。
- 10.1 强制隔离维度编译期约束:仓储方法tenantId、userId必须入参,不提供只有userId的重载,漏传直接编译报错,不要留运行时才炸的口子。
- 10.2 唯一写入出口,脱敏白名单重要评估全部收拢一处,拒绝到处复制粘贴逻辑。复制一次代码,复制一堆潜在bug。
- 10.3 记忆属于增强附加能力。记忆逻辑不管哪里失败,只打警告日志,不能阻断主对话链路。不能辅助组件把主业务搞挂。
- 10.4 配置阈值启动期校验。权重总和、半衰期、topK参数非法直接终止服务启动。不要让服务带着错误配置跑业务流量。
- 10.5 打分器纯函数,无状态,时钟时间由调用方传入,保障打分结果可回归,单元测试能稳定跑。
- 10.6 区分同步异步相位。链路结束之后的异步任务,不要再写链路span,只允许追加写审计表。
- 10.7 审计表append‑only,不支持update、delete。事件记录只追加不改,方便事后审计溯源。
- 10.8 召回、合并、淘汰,全部使用同一个打分器实例,杜绝多套标准打架。
- 10.9 三条注入铁律严格执行:冲突声明、空召回不注入、注入内容落L0留痕。
- 10.10 读写两边参数同源。写入、读取sessionId、tenantId必须保持一致。
- 10.11 删除接口返回真实影响行数,同时操作L1、L3多张表。
- 10.12 访问计数不要同步写库,进程内存先累加,定时批量落库,不拖慢查询接口响应。
11 最后唠两句
实现一个能存记忆的模块,网上随便抄抄代码半天就能写出来。
但是做一套长期稳定、不会越跑越乱的Agent记忆系统,难度直接上一个大台阶。
千万不要把记忆当成简单的存储桶。要当做一套独立子系统看待,强调隔离、可解释、故障隔离、可审计溯源。
存进去只是入门,召回准确、可控治理、故障不会雪崩、出问题能够查清楚,这才是生产环境真正刚需。
很多demo给你展示的效果都特别漂亮,但是没有处理线上的脏数据、异常边界、数据污染。真要上生产,这些啰嗦的约束一个都省不掉。
P.S. 目前国内还是很缺AI人才的,希望更多人能真正加入到AI行业,共同促进行业进步,增强我国的AI竞争力。想要系统学习AI知识的朋友可以看看我精心打磨的教程 传送门http://blog.csdn.net/jiangjunshow,教程通俗易懂,高中生都能看懂,还有各种段子风趣幽默,从深度学习基础原理到各领域实战应用都有讲解,我22年的AI积累全在里面了。注意,教程仅限真正想入门AI的朋友,否则看看零散的博文就够了。