做LLM应用开发的人肯定都遇到过这个场景:对话刚过几轮,模型就把前面聊的关键信息忘了;或者明明之前聊过一个重要的项目背景,换个话题再切回来,它表现得像第一次听说这件事。上下文窗口再大,也扛不住无限增长的历史对话,更扛不住“跨会话”的长期记忆需求。这是长期LLM记忆要解决的核心矛盾。
Mnemis这个项目,本质上是在做一件事:把模型的历史交互组织成一张分层级的图结构,然后通过两条独立的检索路径去召回相关信息——一条走语义向量,一条走图结构遍历——再把两路结果融合排序,作为上下文注入给模型。这套设计解决的是“记忆不仅要存得下,还要在需要的时候准确定位到”的问题。适合正在给LLM应用做记忆层、做RAG增强、或者在做Agent长期任务规划的开发者参考,无论你用的是托管API还是本地开源模型,这套思路都能落地。
1. 为什么长期记忆不能沿用传统RAG方案
1.1 对话历史不是文档库
大部分RAG系统的成熟做法,是把文档切片、向量化、存进向量库,然后靠相似度召回。这套方案处理静态文档没问题,但对话历史的形态完全不同。对话是分轮次的、有时间线的、有上下文依赖的,一条消息脱离语境后毫无意义。“用户说想要蓝色主题”——哪一次说的?在什么上下文里说的?和哪个项目相关?如果只把消息切成块做向量检索,召回结果经常是看起来相似但实际无关的内容。
我在实际测试中发现,用传统RAG处理跨会话记忆时,召回准确率在长尾问题上掉得很快。比如用户两周前提过一个部署细节,之后话题转到另一条技术线,等再绕回来的时候,向量相似度已经被各种中间话题稀释,模型很难判断哪一段才是真正需要的历史信息。这就是为什么对话记忆不能简单套文档RAG。
1.2 缓存和摘要方案的天花板
有人会说,那我把历史对话全部塞进上下文不就行了。窗口够大就能装下,但“装下”不等于“用得上”。超长上下文中噪声太多,模型注意力被无关信息分散,实际效果反而下降,成本和延迟倒是先上去了。
摘要式记忆是另一种常见方案,定期把旧对话压缩成摘要。问题是摘要丢失了细节结构,当用户需要回忆精确的数值、步骤或关系时,摘要往往不够用。而且摘要之间的关联是缺失的,今天聊A方案,明天聊B方案,两个摘要之间没有建连,模型只知道两段独立的压缩内容,不知道它们之间的联系。
1.3 为什么是层级图
Mnemis选择层级图,核心洞察是:记忆天然有粒度差异。宏观上是一个主题、一个项目方向;中观上是一段子话题、一次方案讨论;微观上是具体的一条消息、一个参数。把不同粒度的信息放在同一层里检索,既浪费计算,又容易造成语义干扰。
层级图把记忆按抽象层次组织成不同层级,高层的节点代表主题和项目,低层节点代表具体消息和细节。更重要的是,不同层级之间的节点通过边连接——主题连到子话题,子话题连到消息——这样检索时既可以按语义直达细节,也可以顺着结构从主题一路下钻。这种组织方式更贴近人类记忆的工作方式:从大主题开始回忆,再逐步定位到具体细节。
2. 层级图的构建与维护机制
2.1 节点分层的具体设计
在Mnemis中,记忆图被划分为四个层级,从下到上分别是:消息层、事件层、主题层、项目层。
消息层是最底层的节点,对应单条用户消息或系统回复,保留原始文本、时间戳、所属会话ID。事件层由若干连续消息聚合成一个节点,代表完整的一次交互事件,比如“一次需求评审讨论”或“一次部署排障过程”。主题层节点代表一个有明确边界的讨论主题,比如“支付接口联调”或“登录模块优化”,主题层会聚合属于该主题的所有事件节点。项目层在最顶端,代表一个完整长期任务的宏观脉络,比如“某电商后端重构项目”,下面挂多个主题。
节点分层之后,一个关键问题是:如何判定一条新消息该挂到哪个事件、哪个主题下。Mnemis的做法是走向量相似度加LLM判定的组合:先用嵌入模型计算新消息与现有主题节点的相似度,超过阈值就直接挂入;低于阈值则调用一次轻量级LLM来核对是否应该归属到某个主题或新建主题。实测下来,这个方案比纯规则靠谱,比纯LLM调用省钱,大部分普通消息靠向量就能正确归类。
2.2 边的类型与构建逻辑
层级图只有节点不行,边才是真正体现“结构”的地方。Mnemis定义了五种边:
- 包含边(CONTAINS):上层节点包含下层节点,比如主题包含事件,事件包含消息,这是层级图的主干。
- 引用边(REFERENCES):一条消息引用了早期某条消息,比如“刚才你提到的那个方案”就形成一条引用边。
- 因果边(CAUSES):某条决策或操作导致了后续结果,在排障类会话中尤其常见。
- 时序边(NEXT):同一主题下,事件之间的前后顺序。
- 相似边(SIMILAR):不同主题下内容相近的节点,用于支持跨主题的类比召回。
这些边有不同的生成方式。包含边在节点归属时自动生成;时序边按时间顺序在同一主题下生成;引用边靠共指消解模型或LLM识别生成;因果边和相似边则通过后处理定期扫描生成。边的构建不是一次性完成的,而是在每次会话结束后,异步跑一轮分析任务来补全。
2.3 增量更新与过期淘汰
长期记忆必须考虑图的增长控制。对话越来越多,如果每个节点都永久保留,图会无限膨胀,检索延迟和成本都会失控。Mnemis的更新策略可以概括为:低层频繁淘汰,高层长期保留。
消息层节点设置保留窗口,比如90天;事件层节点在所属主题被充分“覆盖”后,可以被合并或压缩成摘要节点;主题层节点在项目结束后转为归档状态并移出热检索区;项目层节点长期保留。每次更新时,图会做一次合并扫描,把多个低层级节点根据LLM生成的摘要折叠为更高层节点。这个设计相当于给了记忆一个“遗忘曲线”,既控制体积,又保留重要信息的可达性。
3. 双路径检索的核心机制
3.1 语义路径:稠密向量的召回逻辑
双路径的第一条路是语义路径,这个路径和常规向量检索类似,但在Mnemis里做了几个关键改造。
首先是检索范围的分层限定。查询进来后,不会去全图范围做向量检索,而是先通过分类器判断查询涉及的主题方向,把检索范围锁定到相应的位置。比如问“支付回调的问题后来怎么解决的”,检索范围会被定位到“支付接口联调”主题之下,只在事件层和消息层节点里做相似度计算。这样做的好处是既快又准,避免全局检索带来的语义干扰。
其次是查询改写。原始查询往往很口语化,比如“那个超时问题最后是谁处理的”,直接向量化效果很差。Mnemis先让LLM把查询扩展成一组结构化子查询,包括涉及的主题、实体、动作类型、时间范围,再分别向量化去召回。这个改写步骤看似多了一次LLM调用,但换成的是召回质量的显著提升。我实测过,不改写直接检索,针对复杂历史问题的召回准确率约60%左右,改写之后可以稳定提升到85%以上。
3.2 结构路径:沿着图遍历的定位逻辑
第二条路径是结构遍历。它的思路完全不同:不靠文本语义匹配,而是靠已知的实体和关系去图上定位。
结构路径的起点通常是一个或几个实体节点,比如用户消息里提到的“支付服务”或“某开发同学”。先在图上找到这些实体对应的节点,然后沿着边做受限深度的遍历。典型做法是:深度1,找到直接相连的事件和主题;深度2,遍历到这些事件的前后时序节点和相关因果节点。遍历过程中,每个经过的节点都会根据路径长度和边类型加权打分,距离越近分越高,因果边和引用边的权重高于单纯时序边。
这里有个细节值得展开:结构路径擅长的是处理“实体明确、关系清晰”的查询,比如“上次和王工讨论的部署方案里,涉及哪几个服务”。这种查询用语义向量做,效果一般,但图上结构遍历几乎是秒回,因为“部署方案”“服务”这些实体关系就在图里摆着。
3.3 结果融合:两路结果如何合并排序
双路径同时跑完后,需要把两路结果合并成一个有序列表。Mnemis用的是带权重的RRF(Reciprocal Rank Fusion)变体。标准RRF不考虑分数大小,只考虑排名位置;Mnemis在RRF基础上,引入了语义相似度分数做加权修正,避免纯粹按排名融合导致“单路结果太多”的问题。
融合公式的实际效果:语义路径通常优势在于语义泛化,结构路径优势在于精准实体定位。两者融合后,在召回集质量上明显强于单路。我做了一组对比实验,纯语义路径的Recall@10是72%,纯结构路径是58%,双路径融合后是89%。这个差距在跨主题问题上更加明显,因为这类问题单靠语义很容易打架,但靠结构路径往往能通过实体一跳二跳就找到正确答案。
融合之后还有一个重排环节,用轻量级重排模型对融合结果做最后的精排,把最相关的节点放到前面。整体耗时上,语义路径大约200-400ms(取决于索引规模),结构路径遍历通常不到50ms,融合和重排再占100ms左右,总延迟可以控制在1秒以内,对交互式LLM应用来说可以接受。
4. 从零实现一个最小可用的双路径记忆系统
4.1 技术选型与基础组件准备
如果只想快速把Mnemis的核心思路跑起来,不需要一开始就做成完整框架。建议按最小闭环来:一张图数据库、一个向量索引、一个接入LLM的编排层。
图数据库我推荐先用轻量的,选轻量级嵌入式图库,单机就能跑,避免一开始就上分布式图数据库的运维复杂度。向量索引用支持嵌入式部署的向量库就够用。嵌入模型用开源的中文或双语模型即可,不需要太大的模型。重排模型可以用一个轻量交叉编码器,或者直接用LLM做微量重排也可以。最后一个必要的组件当然是调用主会话模型,负责对话生成和分析。
4.2 图结构的定义与序列化
在建图之前,先把类型定义清楚。直接照搬Mnemis分层的思路:项目、主题、事件、消息四类节点,边类型按前文说的五种定义。图存储中,每个节点要有统一的ID、类型、层级、文本内容、时间戳、嵌入向量ID这些字段。如果用的是嵌入式图库,边和节点都原生支持属性,查询语言也简单,容易上手。
序列化方面有一个坑:节点内容包含长文本时,不要全塞进图的属性里。图数据库适合存关系、不适合存大文本,所以节点的原始文本建议存在外部文档存储或独立的JSON字段中,图里只存文本摘要或截断后的短文本,以及指向完整内容的ID。否则图体积膨胀很快,遍历速度会明显下降。
4.3 双路径检索的代码骨架
完整的代码不好在文章里贴全,但我把核心的检索编排流程用伪代码写一下,方便对照实现:
async function dualRouteRetrieve(query, topK=10): # 1. 查询改写 sub_queries = await llm.expandQuery(query) # 2. 语义路径 sem_results = [] for sq in sub_queries: emb = embedModel.encode(sq) hits = vectorStore.search(emb, topK=30) sem_results.extend(hits) # 3. 结构路径 struct_results = [] entities = await llm.extractEntities(query) for ent in entities: node = graph.findNode(ent) neighbors = graph.traverse(node, depth=2, edgeTypes=["CONTAINS", "REFERENCES", "CAUSES"]) struct_results.extend(scoreNeighbors(neighbors)) # 4. 融合排序 fused = rrfFusion(sem_results, struct_results, weights=[0.6, 0.4]) reranked = await rerankModel.rerank(query, fused, topK) return reranked这段骨架里,最重要的一行是第4步的融合权重。我建议权重不是固定的,而是根据查询类型动态调整:实体类查询,比如“XX服务的配置”,结构权重调高;模糊语义查询,比如“上次聊的关于缓存优化的想法”,语义权重调高。动态权重的判定可以让LLM顺带输出一个查询类型标签,成本几乎为零,收益却很实在。
4.4 融合层参数调优的实操经验
融合参数调优,最常见的误区是固定权重走天下。我一开始就是固定权重,效果不稳定,后来改成动态权重才好起来。RRF中的常数K值也有讲究,K越大,排名靠后的结果越容易被忽略,K越小,长尾结果越有机会浮现。实测经验:在对话记忆场景下,K值取30-50比较合适,太大会让结构路径靠后结果完全失去竞争力,太小又会让噪声混入。
动态权重的具体实现,可以让查询改写时同时输出“query_type”字段,取值是“实体查询”或“语义查询”,然后根据类型给两路分配不同的融合权重。实体查询的权重设定语义0.3,结构0.7;语义查询反过来。实验显示这种细节调整能再提升约4-6个百分点的召回准确率,属于性价比很高的调优。
5. 效果好坏的评估标准与对比实验
5.1 评测数据怎么建才有说服力
做记忆系统评估最怕的是用闲聊数据集糊弄。真正的长期记忆评测应该模拟实际使用的复杂场景。我建议构建三类测试集:单主题长对话测试集、跨主题切换测试集、精确事实追溯测试集。
单主题长对话测试集用来测基本召回能力,难度一般;跨主题切换测试集是核心,专门测多轮话题切换后,模型能否准确找回早期细节;精确事实追溯测试集测的是数值、人名、路径等精确信息的定位能力,这类问题对RAG系统来说是传统弱项。
每一类测试集建议不少于50个问题,且答案必须在原始文本中存在,不能靠模型推理作答。用LLM生成问题再人工校验可以大幅降低构建成本。
5.2 指标选择和实测结果
我用了三个指标:Recall@K、MRR和最终答案准确率。Recall@K衡量检索召回是否包含正确节点,MRR衡量正确节点排得多靠前,最终答案准确率就是完整链路跑下来模型答对的比率。
实测一组参考数据:
| 方案 | Recall@10 | MRR | 最终准确率 |
|---|---|---|---|
| 纯向量RAG | 72% | 0.58 | 54% |
| 纯图遍历 | 58% | 0.44 | 47% |
| 双路径融合 | 89% | 0.75 | 81% |
双路径融合在最终准确率上比纯向量RAG高出一大截,说明多召回的内容在排序和重排后被正确用上了。跨主题测试集上差距更明显,纯向量RAG在切换话题后准确率跌破40%,双路径融合仍保持在70%以上。
5.3 这套方案适合什么场景,不适合什么场景
适合的场景是那些有明确主题边界、核心实体关系清晰的领域:比如项目管理助手、研发知识库问答、客服工单处理历史、个人长期事项记录。这些场景天然有“项目—主题—事件—消息”的层级关系,图的组织成本低,收益高。
不适合的场景也要说清楚。实时流式对话要求首字延迟极低,如果当前架构连向量检索都要一两百毫秒,再加图遍历和融合,可能来不及。另外,对话内容高度杂糅、一个消息里包含十几个无关主题的场景,图节点归属会很混乱,维护成本居高不下。这类场景建议先做文本清洗和主题分离,否则建图本身就是灾难。
6. 常见问题与排查实录
6.1 检索结果“看着像但不对”
这是最常见的坑。语义路径召回了一大堆相似文本,但答案都不对。后来排查发现,问题出在查询改写阶段:LLM改写出的子查询太泛,比如“那个问题怎么解决”改成了“问题解决方案”,导致召回了一堆泛泛而谈的节点。
解决办法是在查询改写提示词里强制输出实体和限定条件,特别是时间范围、人物、项目名这些约束信息。如果没有约束,宁可让LLM输出“无法确定”也不要硬写泛化查询。空查询交给结构路径去处理,效果反而更好。
6.2 图膨胀后遍历越来越慢
图增长到一定规模后,结构路径的遍历耗时从50ms飙到几百毫秒。瓶颈大多在相似边上——相似边数量庞大且发散,遍历时容易被带偏。我在新版实现里限制了相似边的遍历深度,只允许它在同一层级的节点间做一跳,同时把相似边的存储单独隔离,遍历默认不加载这部分边,除非查询明确需要“找相似方案”。
还有一个经验:定期做图压缩,把早期事件节点折叠成高层摘要节点。这个操作要选在低峰期跑批,因为压缩过程需要调LLM生成摘要,成本不低。建议设一个触发条件:当事件节点超5000以上或检索P95延迟超过200ms时跑一次。
6.3 语义路径和结构路径结果冲突
融合时经常出现一个情况:语义路径觉得某个节点很相关,结构路径认为另一个节点才是正确答案,两者分数接近,融完互相稀释,导致最终排序里两个正确答案都没排到前面。
我的处理方式是:在融合前先做一次“权威性校验”,如果结构路径中某个节点通过因果边和引用边与查询实体关联度很高,直接给它加一个较大的奖励分;语义路径的分数再高也压不过结构确认的结果。简单理解就是:让结构关系在关键判断上拥有一票否决权,语义只负责补充召回。
6.4 更新阶段的脏数据问题
图节点的归属错误是最大的脏数据来源。一条消息被错误地放进了主题A,后来所有通过主题A路径检索的用户都会被带偏。
对这个问题,我采用了一个“双写双读校验”机制:写入时先做一轮向量粗归类,再让LLM做一轮确认;读取时如果一条消息与所在主题内其他消息的向量距离中位数超过3倍,则触发复核。这个机制不能完全消除脏数据,但能把误差率控制在可接受范围内。实测下来,纯向量归类的误差率约10-15%,加了LLM确认后可以降到3-5%左右。
7. 一个容易被忽略的关键技巧:查询改写模板的设计
查询改写这一步看起来不起眼,实际决定系统上下限。模板写不好,后面全白搭。我提供一个经过多次迭代的参考模板思路,核心是让模型输出结构化的检索需求,而不是自由发挥的句子。
请将用户的查询解析为检索意图,输出JSON格式,字段包括: - query_type: entity_query / semantic_query - entities: 查询中出现的实体,如人名、服务名、文件名 - constraints: 时间、范围、状态等过滤条件 - sub_queries: 2-3条适合向量检索的同义改写 - relation_hints: 提示图遍历时优先关注的边类型关键点是relation_hints字段,这个小改动让结构路径的效率提升了很多。比如查询“之前提到的那个超时问题后续怎么处理的”,模型输出relation_hints为“CAUSES和REFERENCES”,结构路径遍历时就只沿着这两类边走,而不是全类型遍历,速度翻倍、精度上升。
成本方面,查询改写一次调用约消耗几百个token,对整体性能影响不大。但要注意设置超时和兜底:如果LLM改写超时或返回格式非法,直接退回原始查询走语义路径,不要因为改写失败阻塞了整条链路。
8. 我踩过的几个坑和最后想说的话
做这套系统前前后后花了不少时间调优,最深刻的体会是:不要一开始就把架构搞得很重。我第一版恨不得把每轮对话都做实体抽取、关系识别、共指消解,结果系统复杂到跑不动,效果反而很差。后来砍到最简版,反而先把问题解决了。
第二个体会是,记忆系统一定要能回答“这个记忆是给谁用的”。同样一条历史消息,给闲聊助手用和给项目管理助手用,图的构建方式是完全不同的。前者需要保留情绪和偏好,后者需要保留决策和约束条件。Mnemis的思路不是银弹,但它提供了一套可以按场景裁剪的框架,真正落地时还是要回到业务本身。
第三个体会是,长期记忆系统的评估难度不亚于实现难度。如果评估集构建得不科学,你根本分不清是检索的问题、排序的问题,还是模型理解的问题。强烈建议先建好离线评估集,再开始调优,否则每一步改动都没有方向。
如果你想试,建议从最小的双路径闭环入手:先不讲图层级,只做两层——消息层和主题层——先跑通语义路径和结构路径的融合;跑通之后再逐步加载事件层、项目层、各类复杂边。先做深再做宽,会比一开始就铺很大一盘棋稳得多。