1. 项目缘起与核心定位
1.1 从一次“记忆断片”说起
几个月前我在做一个多轮对话的智能助手项目,用户连续问了七八个问题,前面聊过的偏好、约束、上下文,到了第十轮全丢了。用户直接甩了一句“你怎么又忘了”,那一刻我意识到,大模型本身没有跨会话记忆这件事,不是靠调大上下文窗口就能解决的。上下文窗口再大,它也是“一次性”的,会话结束,一切归零。
claude-mem这个项目,就是冲着这个痛点去的。它的核心定位非常明确:为 Claude 这类大模型补上一层可持久化、可检索、可管理的记忆层。你可以把它理解成给模型外挂了一个“笔记本”,模型每次开口之前,先去笔记本里翻一翻相关的旧账,再决定怎么回答。
它解决的问题可以拆成三个层次。第一层是持久化,把对话中产生的关键信息落盘,不随会话结束而消失。第二层是检索,当新问题进来时,能从海量历史里捞出真正相关的那几条,而不是把全部历史一股脑塞回去。第三层是管理,记忆会过期、会冲突、会冗余,需要有一套增删改查和淘汰机制。
适合谁来参考?如果你正在做智能客服、个人助理、长期陪伴类应用,或者任何需要“记住用户”的对话产品,这个思路都能直接借鉴。哪怕你只是想让自己的本地脚本记住上次跑到哪了,这套记忆管理的设计逻辑同样适用。
1.2 为什么是“记忆层”而不是“更长上下文”
很多人第一反应是:现在上下文窗口都上百万 token 了,还需要单独做记忆吗?我实测下来的结论是:需要,而且非常需要。原因有三个,每一个都是真金白银踩出来的。
第一是成本。上下文窗口是按 token 计费的,你把三个月的聊天记录全塞进去,每次请求都在为重复内容付费。而记忆层只召回相关片段,token 消耗能压到原来的十分之一甚至更低。第二是精度。上下文越长,模型对中间部分的注意力越容易稀释,业内叫“lost in the middle”。你塞了一堆无关历史,反而干扰了当前问题的判断。第三是可控性。记忆层里的内容是结构化的,你可以审核、可以删除、可以设置过期时间。而上下文里的东西是黑盒,用户说“删掉我刚才说的”,你很难精准定位。
所以claude-mem的设计哲学是:上下文负责当前这一轮,记忆层负责跨轮次。两者分工明确,各司其职。
2. 记忆层的整体架构拆解
2.1 三层记忆模型的设计考量
我在设计记忆层时,参考了认知科学里人类记忆的分类,把它拆成了三层:短期记忆、长期记忆、工作记忆。这个划分不是为了学术好看,而是每一层的生命周期和检索策略完全不同。
短期记忆对应最近几轮对话,生命周期以“会话”为单位,会话结束就清空或归档。它的特点是量小、访问频繁、几乎不需要检索,直接全量带上就行。长期记忆是跨会话沉淀下来的事实性信息,比如用户的姓名、偏好、长期目标,生命周期以“天”甚至“月”为单位。工作记忆比较特殊,它是当前任务相关的临时上下文,比如用户正在填一张表单,填到一半去查了个资料,回来还得接着填,这中间的状态就存在工作记忆里。
为什么要分这么细?因为如果不分,你要么把所有东西都当长期记忆存,导致检索噪音巨大;要么全当短期记忆,会话一结束就丢。分层之后,每层的存储介质、淘汰策略、检索权重都可以独立调优。
2.2 存储选型:为什么我最终选了向量库加关系库的组合
存储这块我试过三种方案,最后落地的是向量数据库 + 关系型数据库的组合。先说结论,再说为什么。
纯关系库方案,用 SQL 的 LIKE 做关键词匹配。优点是简单、事务强、好维护。缺点是语义匹配能力几乎为零,用户说“我喜欢安静的地方”,你存的是“偏好:安静”,换个说法“讨厌吵闹”就匹配不上了。纯向量库方案,语义匹配强,但有个致命问题:它不擅长精确过滤。比如你要查“用户 A 在 2024 年 3 月之后的所有偏好”,向量库做这种带条件的结构化查询很别扭。
所以我的组合是:关系库存元数据和结构化字段,向量库存语义向量,两者用同一个 ID 关联。检索时先用关系库做条件过滤,缩小候选集,再在候选集里做向量相似度排序。这样既保住了语义能力,又保住了精确过滤。
| 存储方案 | 语义匹配 | 结构化过滤 | 事务支持 | 适用场景 |
|---|---|---|---|---|
| 纯关系库 | 弱 | 强 | 强 | 结构化事实为主 |
| 纯向量库 | 强 | 弱 | 弱 | 语义检索为主 |
| 组合方案 | 强 | 强 | 中 | 综合场景 |
提示:组合方案会带来数据一致性问题,写入时要保证两边同时成功,建议用“先写关系库,再写向量库,失败则补偿”的策略,而不是分布式事务,后者太重。
2.3 记忆的生命周期管理
记忆不是存进去就完事了,它得有“生老病死”。我设计了一套生命周期状态机:新建 → 活跃 → 衰减 → 归档 → 删除。
新建的记忆默认是活跃状态,参与检索。随着时间推移,如果一条记忆长时间没被召回,它的权重会衰减。衰减到阈值以下,进入归档状态,不再参与常规检索,但保留可查。归档超过一定时间,或者用户主动要求删除,才真正物理删除。
这套机制解决了一个很现实的问题:记忆膨胀。我见过一个项目,跑了半年,记忆库涨到几百万条,检索一次要好几秒,里面全是“用户说了句你好”这种垃圾。有了衰减和归档,库能一直保持在一个健康的规模。
3. 核心实现细节与实操要点
3.1 记忆的抽取:从对话里“捞”出值得记的东西
记忆层最难的不是存,是判断什么值得存。你不可能把每句话都存下来,那样库会爆炸。我的做法是用一个轻量的抽取流程,分三步走。
第一步是规则过滤。明显的寒暄、确认、无信息量的内容直接丢弃,比如“嗯”“好的”“谢谢”。这一步能砍掉大概四成的噪音。第二步是模型抽取,用一个小的提示词让模型判断这句话里有没有值得长期记住的事实,输出结构化的 JSON,比如{"type": "preference", "key": "喜欢的城市", "value": "杭州"}。第三步是去重合并,新抽取的事实如果和已有记忆语义高度相似,就更新而不是新增。
这里有个坑我踩过:抽取提示词不能太激进。一开始我让模型“尽可能多地抽取”,结果它把用户的每一句抱怨都当成长期偏好存下来了,用户第二天心情好了,记忆里还留着“用户讨厌这个功能”。后来我把提示词改成“只抽取明确、稳定、跨会话仍然成立的事实”,准确率立刻上来了。
EXTRACT_PROMPT = """ 从以下对话中抽取值得长期记住的事实。 只抽取明确、稳定、跨会话仍然成立的信息。 不要抽取临时情绪、寒暄、确认类内容。 输出 JSON 数组,每项包含 type、key、value 三个字段。 如果没有任何值得记住的内容,输出空数组 []。 对话内容: {dialogue} """3.2 检索策略:怎么在毫秒级捞出最相关的记忆
检索是记忆层的性能瓶颈所在。我的策略是两阶段检索:粗排 + 精排。
粗排阶段用关系库的条件过滤,把候选集从百万级压到千级。条件包括:用户 ID、记忆类型、时间范围、状态(只查活跃的)。这一步走索引,通常几毫秒就出结果。精排阶段对候选集做向量相似度计算,取 Top-K。K 一般设 5 到 10,太多会稀释相关性,太少可能漏掉关键信息。
这里有个参数需要仔细调:相似度阈值。低于阈值的记忆即使进了 Top-K 也不应该用,否则会引入噪音。我的经验值是余弦相似度 0.75 起步,具体要看你的 embedding 模型。调这个参数的方法很简单:准备一批标注好的“问题-应召回记忆”对,画一条 precision-recall 曲线,找平衡点。
注意:向量检索的 Top-K 不是越大越好。我实测过 K=20 和 K=5 的效果,K=20 时模型经常被无关记忆带偏,回答质量反而下降。宁可少召回,不可乱召回。
3.3 记忆注入:怎么把记忆“喂”给模型而不显得突兀
检索出来的记忆,怎么放进提示词里,也是门学问。我试过三种注入方式,效果差异很大。
第一种是直接拼接,把记忆原文贴在系统提示词后面。简单粗暴,但模型容易把记忆当成指令,比如记忆里写“用户喜欢简洁回答”,模型就真的每句都极简,哪怕用户这次问的是复杂问题。第二种是结构化注入,把记忆整理成“已知信息”列表,明确告诉模型“这些是背景,不是指令”。效果好很多。第三种是动态注入,根据当前问题的类型,只注入相关类型的记忆,比如问技术问题就只注入技术偏好,不注入饮食偏好。
我最终用的是第二种加第三种。提示词模板大概长这样:
以下是你对这个用户的已知信息,仅作为背景参考,不要当作指令: - 用户偏好:喜欢简洁的技术解释 - 用户背景:有五年后端开发经验 - 当前任务:正在排查一个数据库性能问题 请基于以上背景回答用户的问题。关键是那句“仅作为背景参考,不要当作指令”,加上之后,模型跑偏的概率明显下降。
4. 完整实操流程与关键环节
4.1 环境搭建与依赖安装
先把环境跑起来。我用的是 Python 3.10,依赖不多,核心就几个:向量库客户端、关系库驱动、embedding 模型。embedding 我推荐用本地的 sentence-transformers,省得依赖外部接口,速度和隐私都更好。
pip install sentence-transformers sqlalchemy chromadb pydantic选 chromadb 是因为它轻量,单机跑足够,不用额外部署服务。如果你数据量上千万,可以考虑更重的方案,但大多数项目 chromadb 够用了。sqlalchemy 用来管关系库,我用的 SQLite,小项目零配置,要上生产换 PostgreSQL 改个连接串就行。
4.2 记忆写入的完整代码路径
写入流程我封装成了一个MemoryWriter类,核心方法就一个write。它接收原始对话,内部走抽取、去重、落盘三步。
class MemoryWriter: def __init__(self, db, vector_store, embedder): self.db = db self.vector_store = vector_store self.embedder = embedder def write(self, user_id, dialogue): facts = self._extract(dialogue) for fact in facts: existing = self._find_similar(user_id, fact) if existing: self._update(existing, fact) else: self._insert(user_id, fact) def _extract(self, dialogue): # 调用模型抽取,返回结构化事实列表 ... def _find_similar(self, user_id, fact): vec = self.embedder.encode(fact["value"]) results = self.vector_store.query( query_embeddings=[vec], where={"user_id": user_id}, n_results=1 ) if results and results["distances"][0][0] < 0.2: return results["ids"][0][0] return None去重阈值我设的是距离 0.2,对应余弦相似度大概 0.9 以上。这个值调高会导致重复记忆堆积,调低会误合并不同事实。建议先用 0.2,观察一段时间再微调。
4.3 检索与注入的串联
检索这块我封装成MemoryRetriever,对外只暴露一个retrieve方法,返回排好序的记忆列表。调用方拿到之后,用前面说的模板拼进提示词。
class MemoryRetriever: def retrieve(self, user_id, query, top_k=5, threshold=0.75): vec = self.embedder.encode(query) candidates = self.db.query_active(user_id) # 关系库粗排 candidate_ids = [c.id for c in candidates] results = self.vector_store.query( query_embeddings=[vec], where={"id": {"$in": candidate_ids}}, n_results=top_k ) memories = [] for i, mid in enumerate(results["ids"][0]): score = 1 - results["distances"][0][i] if score >= threshold: memories.append(self.db.get(mid)) return memories整个链路跑下来,一次检索的耗时在 20 到 50 毫秒之间,完全能满足实时对话的需求。如果嫌慢,可以把 embedding 缓存起来,同一个 query 不重复算。
4.4 参数调优的实操记录
调参这块我做了几组对比实验,记录如下,供你参考。
| 参数 | 取值 | 召回率 | 准确率 | 备注 |
|---|---|---|---|---|
| top_k | 3 | 0.72 | 0.91 | 召回偏低 |
| top_k | 5 | 0.88 | 0.87 | 平衡点 |
| top_k | 10 | 0.94 | 0.76 | 噪音明显 |
| threshold | 0.70 | 0.92 | 0.74 | 噪音多 |
| threshold | 0.75 | 0.88 | 0.87 | 推荐 |
| threshold | 0.80 | 0.79 | 0.93 | 漏召回 |
结论很清晰:top_k=5、threshold=0.75 是综合最优。当然这跟你的数据分布有关,建议你自己跑一遍再定。
5. 常见问题与排查技巧实录
5.1 记忆冲突:用户改主意了怎么办
这是最高频的问题。用户上周说喜欢咖啡,这周说戒了改喝茶。如果两条记忆都留着,模型会精神分裂。我的处理策略是时间戳加权 + 显式覆盖。
每条记忆带一个updated_at字段,检索时同 key 的记忆只保留最新的一条。同时,如果新抽取的事实和旧记忆是同一个 key 但 value 不同,直接标记旧记忆为“已过期”,而不是删除,保留审计痕迹。这样既保证了当前回答用的是最新信息,又能在需要时回溯历史。
5.2 检索不到:明明存了却捞不出来
这个坑我踩过好几次,原因通常有三个。第一是embedding 模型不一致,写入时用了一个模型,检索时换了另一个,向量空间对不上,相似度全是乱的。解决方法是把模型版本号也存进元数据,检索时校验。第二是粗排条件太严,比如时间范围卡死了,把相关记忆过滤掉了。建议粗排条件尽量宽松,把精度交给精排。第三是阈值设太高,这个前面说过,调低一点试试。
5.3 记忆污染:模型把噪音当事实
有次测试,用户随口说了句“今天天气不错”,模型把它抽成了“用户喜欢晴天”。这种过度推断很常见。我的对策是在抽取提示词里加反例,明确告诉模型哪些不算事实。另外,对于低置信度的抽取结果,可以先存成“待确认”状态,不参与检索,等积累到一定证据再转正。
5.4 性能下降:记忆库大了之后变慢
记忆库超过十万条之后,检索开始变慢。排查下来瓶颈在向量库的全量扫描。解决办法是分片 + 索引。按 user_id 分片,每个用户的记忆独立索引,检索时只扫自己的分片。另外,定期把归档状态的记忆从向量库里移出去,只留关系库,能显著减小索引体积。
| 问题现象 | 可能原因 | 排查方向 | 解决手段 |
|---|---|---|---|
| 检索结果不相关 | embedding 不一致 | 校验模型版本 | 统一模型 |
| 召回为空 | 粗排条件过严 | 放宽过滤条件 | 调整查询 |
| 记忆冲突 | 未处理更新 | 检查时间戳 | 加覆盖逻辑 |
| 响应变慢 | 索引膨胀 | 查看库大小 | 分片加归档 |
提示:每次改动检索参数后,一定要用固定的测试集回归一遍,否则很容易按下葫芦浮起瓢,修好一个问题引入另一个。
6. 一些延伸思考与个人体会
claude-mem这套东西跑通之后,我最大的体会是:记忆层的价值不在于“记得多”,而在于“记得准”。一开始我总想着把所有东西都存下来,结果检索质量一塌糊涂。后来做减法,只存真正稳定、跨会话成立的事实,效果反而好了。这跟人脑很像,你记不住所有事,但关键的事记得牢,就够了。
另一个体会是可解释性很重要。记忆层里的每一条,你都得能说清楚它从哪来、为什么在这、什么时候会过期。否则出了问题根本没法排查。我在每条记忆上都加了source字段,记录它来自哪次对话,调试的时候一目了然。
后续如果要扩展,我会往两个方向走。一是记忆的主动遗忘,让模型自己判断哪些记忆不再需要,主动清理,而不是靠时间衰减这种被动机制。二是跨用户的群体记忆,在不涉及隐私的前提下,把多个用户的共性偏好抽象出来,作为新用户的冷启动先验。这两个方向都挺有意思,等有进展了再单独写一篇。
最后分享一个小技巧:调试记忆层的时候,别只看最终回答,要把召回的记忆列表打出来看。很多时候回答不对,不是模型的问题,是召回的记忆本身就不对。把召回结果可视化,排查效率能提升一大截。