每次打开新的对话窗口,我都要重新向AI助手交代背景——我是谁、在做什么项目、上次聊到哪、有哪些未决事项。这种事情重复了十几次之后,我开始认真考虑一个问题:为什么不能给大模型装一个长期记忆?
正好那段时间我高频使用Claude做开发辅助和内容规划,每次对话稍长一点就开始遗忘前文细节,而官方原生的记忆能力又还没有完全覆盖我的使用习惯。于是我开始动手写一个正式项目,就是后来命名的claude-mem——一套给Claude助手增加持久记忆能力的本地工具。经过三个多月的迭代,这套方案已经稳定跑完了我的日常开发流、内容创作流和项目管理流,今天把这套从架构到实现的完整思路拆开讲。
如果你是重度AI助手用户,经常被“每次都要重新自我介绍”折磨,或者你正在纠结怎么给大模型搭一套靠谱的长期记忆,这篇文章值得你认真看完。里面没有那种纸上谈兵的架构师废话,全是我自己踩坑踩出来的实操结论。
1. 为什么大模型需要"记忆外挂"
1.1 上下文窗口是水杯,长期记忆才是冰箱
很多人对上下文窗口有一种误解,觉得窗口越大越好。确实,现在的模型动辄给到几万甚至几十万token的窗口,看起来能容纳海量信息。但你要理解一个本质问题:窗口是瞬时的,对话结束它就清空了。它就像一个一次性水杯,水倒进去能喝,但杯子本身不会替你保存任何东西。
更深一层的问题是,上下文窗口还不是越大越能装。窗口越大,注意力分散的问题就越明显。我做过一个粗测,把一份三万字的项目文档全部塞进上下文之后,模型在回答问题时引用关键细节的准确率,比只塞五千字核心内容时低了差不多15个百分点。这就好比你在一个堆满杂物的仓库里找一把螺丝刀,明明东西都在,反而更难找到。
所以核心矛盾就在这里:你需要的不是更大的水杯,而是一个能把关键信息保存起来、在需要时再取出来的冰箱。claude-mem做的事就是这个——把对话历史里的关键信息提取出来,做结构化存储,然后在每次新会话启动之前把相关内容“回填”到上下文里。
1.2 重复喂背景的隐性成本
你可能觉得“多打几行背景说明”没什么大不了,但实际算一笔账就清楚了。
以我自己的日常为例:一个进行中的软件项目,包含目标描述约200字、技术栈约束约150字、当前进度约200字、下次要处理的事项约150字。这些背景信息加起来700字左右,每次新开会话都要重新粘贴。一天开10次会话,就是7000字。一周就是接近5万字。这些重复输入的Token费用并不高,但浪费的时间是实打实的——我统计过,每次重新组织背景语言平均要花1到2分钟,一天就是十几分钟。
更麻烦的是信息损耗。人不是机器,每次重新描述背景都会漏掉一些细节。可能这次忘了说某个约束条件,下次忘了说某个已确认的决策,而AI助手又不会知道你漏了什么。这就是我坚持做记忆系统的直接原因——不是省那点Token费,而是确保每次对话都带着完整的上下文开场。
1.3 官方能力与第三方工具之间的缺口
我最初的想法是最懒的方案:等官方原生记忆更新。现实是,官方记忆在某些场景下确实有效,但它的交互方式偏“被动”和“黑盒”——你不知道它记住了什么、没记住什么,也没法主动管理某条记忆的优先级。而市面上的第三方对话管理工具,要么太复杂,要么绑定特定平台,想插进自己的命令行工作流里很费劲。
所以claude-mem走的是中间路线:本地优先、文件可审计、接口极简。底层依赖公开的API能力,上层做自己的对话数据管理和检索增强。这样既不会因为官方接口变动而彻底失效,又能完全掌控记忆的存取逻辑。
2. 整体架构与设计思路
2.1 三条主链路的划分
整个系统第一次设计时,我差点把记忆逻辑和对话逻辑揉在一个脚本里,结果代码越写越乱,改一个功能牵一发动全身。后来我推倒重来,按数据流向把系统拆成了三段:
- 采集链路:负责监听对话导出文件,把原始对话文本解析成结构化的消息记录
- 存储链路:负责把消息记录切分、摘要、向量化,最后存进本地索引库
- 注入链路:负责在新会话启动时检索相关记忆,拼装成系统提示词
这个划分的好处是,任何一段出问题都可以单独调试。比如存储链路索引坏了,不影响采集链路继续写原始文件;注入链路想换一套检索策略,也不需要动存储结构。
采集链路的具体实现上,我利用官方导出或者接口转发的方式把对话记录落到本地,格式统一为JSONL文本,每个消息一行。这个设计规避了二进制格式的兼容性问题,任何语言都能直接解析。
2.2 技术选型:JSON、SQLite还是向量数据库
记忆存储用什么,是我花时间最多的一次选型。我对比了三个方案:
用JSON文件存储最简单,但检索只能靠关键词精确匹配。问题在于对话里的信息往往是语义关联的。你昨天说“帮我留意预算500元以内的送礼方案,对方喜欢喝茶”,今天问“我昨天说的那个事情有什么要求”。关键词“茶”“预算”可能还在,但如果你换一种问法“昨天那个人喜欢什么来着”,关键词就全对不上了,只有语义匹配才能找回。
SQLite能做结构化查询和全文索引,但SQLite自带的FTS全文索引本质上是分词匹配,解决不了同义改写的问题。“茶叶”和“茶”能匹配,“关注”和“留意”就不一定了。
向量数据库是解决这个问题的最直接方案。先把文本用嵌入模型转成高维向量,然后通过余弦相似度计算语义距离,哪怕关键词完全不一样,只要语义接近就能命中。我在选型时重点评估了本地优先的方案,因为对话数据涉及隐私,我不想把所有内容都传到第三方服务去。
最终我选了Qdrant的本地模式作为向量索引,元数据放在SQLite里。这个组合的好处是:向量检索交给专精的引擎,结构化查询交给SQLite,两者各管一段,数据通过索引ID关联。实测在几万条记忆记录下,单次检索延迟稳定在80毫秒左右。
2.3 记忆注入的时机与力度
记忆不是越多越好。我一开始犯过“能注入就注入”的错误,每次把顶上十条记忆全部塞进系统提示词,结果模型经常在记忆堆里打转,当前任务反而被冲淡了。
后来我把注入策略改成“按需取用,宁缺毋滥”:
- 会话开始时,只注入与当前话题相关性最高的前三条记忆
- 每条记忆自带时间戳,超过30天未激活的内容优先级自动降档
- 用户在当前会话里明确说了“记住”的内容,强制置顶
这个策略的核心逻辑是:系统提示词里每多一条弱相关记忆,都会占用模型的注意力预算。尤其是长上下文中,模型对中段内容的注意力本来就比较弱,你塞进去的弱势记忆只会让模型把权重分给无关信息。
2.4 模块边界与扩展性
设计时我特意把三块做成相对独立的服务,相互之间只通过标准数据格式交互。这意味着未来如果官方API的调用方式变了,我只需要替换采集链路那一段;如果换了更好的嵌入模型,也只需要重跑存储链路里的向量化步骤,其他代码完全不用动。
3. 核心实现:三块硬骨头逐个啃
3.1 会话切分与记忆摘要生成
对话原始文件是一长串消息组成的流水,你不能直接把它全部塞进向量库。原因有两个:一是单条消息太短,切成单条存会导致向量碎片化;二是一整段长对话太长,直接向量化会丢失局部重点。
我的切分策略基于两级维度:
按时间间隔切分:相邻两条消息之间间隔超过30分钟,判定为一次话题切换。这个阈值我调了好几版,太短会导致一次完整讨论被拦腰截断,太长又会让不同话题混在一起。
按语义相似度合并:时间上连续但内容相关性很低的消息,也不该归为同一个记忆片段。我用嵌入模型计算相邻消息的余弦相似度,低于0.55就切断。
切分之后,每个片段都要生成一个摘要再入库。摘要生成依赖Claude自身的文本总结能力,Prompt模板大致是这样:
请将以下对话片段压缩成一段不超过150字的摘要, 保留:用户的核心目标、当前进度、明确的约束条件、下一步行动。 丢弃:客套话、重复表述、与主线无关的闲聊。这一步非常关键。如果直接存原文,检索命中时注入的是冗长的原始文本,既浪费Token又让模型难以快速定位重点。存摘要的好处是每个记忆片段的体积浓缩到原来的十分之一,检索命中后直接注入摘要,信息密度反而更高了。
3.2 向量化与相似度检索的细节
向量化模型我选了一个轻量的中文嵌入模型,输出维度768。选它的原因很直接:体积小,单机CPU推理单条文本几十毫秒,足够用了,没必要上重模型。
相似度检索部分,我写了大概这么一段逻辑:
def search_memories(query_text, top_k=3, threshold=0.6): # 1. 先将查询文本向量化 query_vec = embed(query_text) # 2. 在Qdrant中检索候选结果 hits = qdrant_client.search( collection_name="claude_memories", query_vector=query_vec, limit=20 ) # 3. 按阈值过滤弱相关结果 filtered = [h for h in hits if h.score >= threshold] # 4. 按分数排序截断返回 filtered.sort(key=lambda x: x.score, reverse=True) results = filtered[:top_k] return results这里最值得记住的是阈值过滤这一环。Qdrant检索返回的前20条结果里,有大量得分在0.5到0.6之间的弱相关片段。如果不过滤直接取前三条,很可能混进一些“字面相关但语义无关”的内容。我调试时遇到过一个典型case:聊“Python异步编程”时,检索出了之前聊“Python环境配置”的一段记忆,两者都有“Python”这个词,但实际讨论的内容完全不同。加阈值过滤之后,这类误命中明显减少了。
阈值本身也值得细调。我一开始定0.7,结果很多明明相关的记忆被拦在门外。后来又往下降到0.55,误命中率又升高了。综合体验下来,0.6是一个相对平衡的点,大部分场景下既不会漏掉关键记忆,也不会带回太多噪声。
3.3 系统提示词的注入模板
记忆检索出来了,最终要以什么形式进入模型,这一步决定用户体验。我经过几版迭代,最终把注入格式固定成下面这种结构:
[记忆库内容] - 用户概述:{用户自我介绍或画像信息} - 进行中事项:{与当前主题相关的任务进度} - 关键背景:{历史对话中的约束条件或明确偏好的} - 最近讨论:{最近一次相关话题的摘要} [当前任务说明] 请结合以上记忆回答用户当前的问题。 若记忆内容与用户当前表述存在冲突,一律以当前表述为准。这段提示词里,最后一条规则是我反复打磨出来的。模型容易出现“历史记忆比当前话语更权威”的错误认知,比如用户之前说过“周末跑步”,今天说“这周太累不跑了”,如果记忆里还留着“用户周末喜欢跑步”,模型可能反而会建议用户坚持跑步。加这句冲突处理规则后,模型的回答会优先尊重用户的即时表达。
注入位置也需要注意。我测试过放在系统提示词最前面、中间、最后三种位置,综合来看放在最前面效果最好。因为系统提示词本质上是一种强引导,记忆内容作为“背景事实”放到最前面,模型会更倾向于把它当作已知信息来引用。
3.4 调用链的完整落盘流程
把前面几段串起来,一次完整的“唤醒记忆”流程是:
- 用户在新会话里说了第一句话
- 系统拿到这句话,作为检索query
- 从向量库里检索出相关记忆片段
- 把记忆片段和检索query拼装成系统提示词
- 连同用户的第一句话一起发送给模型
- 模型带着历史记忆返回回答
- 回答内容和检索出的记忆片段一起写回对话记录
这里面一个很容易被忽略的细节是:即使没有命中相关记忆,也要走一遍检索流程,只是最终注入为空。这样做的好处是确保每次会话的行为路径是一致的,不会因为“没有记忆”而走不同的代码分支,给后续排查省了不少事。
4. 实操记录与参数调优
4.1 记忆条数和分数阈值的调优
我在前文提过,最初每次注入五条记忆,效果不好。后面改成动态分配才稳定下来,具体规则是:
- 分数大于0.8的记忆最多取两条
- 用户明确标注“重要”的内容强制占一条名额
- 剩余名额按分数从高到低补足,上限三条
为什么这样组合?高分段记忆通常与当前话题强相关,但一次来两条就够了,再来第三条边际收益会急剧下降。用户明确标注“重要”的记忆不一定是当前话题最相关的,但它对用户的意义很特殊,值得占一个固定名额。剩下的弱相关记忆只做补充,严格控制数量。
这套规则在不同使用场景下的表现都还不错。做技术写作时,它能准确回忆起历史讨论过的文章结构偏好;做开发排期时,它能稳稳带出上次确认过的优先级排序。
4.2 摘要生成的触发时机与增量更新
摘要生成最忌讳的是等对话结束之后再做一次全量解析。一旦会话异常中断或者导出文件损坏,中间积累的对话就全丢了。我把摘要生成拆成两步:
- 对话导出之后,立即做一次全量解析,把每段对话切成记忆片段并生成摘要
- 之后每新增一轮对话,只对最后一个片段做增量更新,不重复全量计算
增量更新的实现思路是:记录每个记忆片段的最后一条消息ID,如果新消息属于当前片段的时间窗口和语义范围,就重新生成该片段的摘要并覆盖旧版本;如果新消息切出了新话题,就为新话题建一个新的记忆片段。
这个方案的成本优势很明显。以一天50轮对话为例,全量解析一次需要嵌入向量上百条文本,增量更新只需要处理新增的十来回消息,整体耗时能压缩到原来的三分之一左右。
4.3 存储格式与迁移踩坑记录
存储层踩过最大的坑,是把所有数据堆在一个文件里。最早我把SQLite元数据和向量索引放在同一个目录下,结果有一次索引目录损坏,向量数据没丢,但元数据表对应的会话关联字段乱了一部分,导致记忆检索结果张冠李戴。
后来整改成现在的方案:SQLite只存元数据,包括会话ID、时间戳、摘要文本、激活状态四个字段;向量索引单独放在/memory/vector目录下,和元数据目录彻底隔离。
memory/ ├── meta.db # SQLite元数据 ├── vector/ │ └── collection/ # Qdrant向量索引文件 └── raw/ └── dialogs/ # 原始对话JSONL文件迁移时只需要打包整个memory目录,目标机器上重新指定路径即可。再也不用担心某个文件损坏影响全部功能。
4.4 端到端的效果对比
给这项目做了个小范围的A/B对比:同样一组闲聊型问答和历史背景回顾问题,没有记忆注入的情况下,模型完全无法回答“我上周说过什么”这类问题;注入记忆后,连续十次测试有八次能准确引用历史细节,另外两次属于数据库里压根没有对应历史记录的情况。对于记忆系统来说,这个效果已经足以支撑日常使用了。
5. 常见问题与避坑指南
5.1 上下文污染:检索到不相关记忆
这是整个项目里最常见也最难缠的问题。表面上看是检索算法不够好,实际原因往往是记忆片段切分得太粗糙。一段对话里可能聊了三件事,但摘要时只提取了其中一件,检索时自然容易召回偏差。
我的解法是给每个记忆片段增加多个标签域:话题标签(如“开发排期”“内容创作”)、实体标签(如“项目X”“某跨平台系统”)、状态标签(如“进行中”“已完成”)。检索时先按标签做一次粗过滤,再对剩下的候选做向量排序。有了这层标签筛选,误召回率降了不止一半。
5.2 隐私边界:哪些内容不该进记忆库
记忆系统不该做一个“什么都记住”的应声虫。有些内容一旦被存入向量库,未来可能在完全无关的场景下被意外激活。比如用户在对话里无意中说了身份证号、银行卡号、家庭住址,这些信息如果被向量化,未来某次检索可能就会把它们捞出来并注入给模型,这个风险无法接受。
我在采集端加了一道指纹过滤规则:
- 匹配手机号正则或身份证号的片段,直接不写库
- 包含地址、银行账号等强隐私特征的内容,只保留“用户提到了某项隐私信息”这个元信息,不保留具体内容
- 用户主动加“不要记住”的对话片段,强制从库里删除,删除是物理级删除,不是标记删除
这条规则我从一开始就写了,之后再也没有出现过隐私信息被意外注入的情况。
5.3 性能瓶颈与资源占用
向量数据量小的时候体感不明显,但一旦超过几万条记录,实时性就会肉眼可见地下降。我在五万条记忆记录下做了一次压测,单次检索延迟80毫秒左右,但加上摘要生成之后,整个链路耗时被拉到3秒以上。如果用户是在交互式对话场景里等待,这个延迟体感很明显。
优化手段做了三个:给SQLite里的会话ID字段加索引;高频记忆集的结果做本地缓存,命中缓存直接跳过检索;向量检索的limit值从20调到10,减少不必要的计算量。三轮优化之后,单次检索的延迟降到30毫秒以内,说明瓶颈主要在海量候选排序那一步,值得优先优化。
5.4 中文语义的歧义问题
最后想提一个中文环境特有的坑。中文没有空格做分词边界,“钥匙”和“药匙”这种同音词更容易让向量模型产生语义混淆。我有一次测试对话里提到“想去钓鱼”,结果检索出了一堆关于“鱼缸养护”的旧记忆,明显是“鱼”这个字的语义空间太宽导致的。
处理办法是给向量库增加一个自定义停用词表,把高频且容易产生歧义的词做加权惩罚,同时配合标签过滤减少这类误命中。这不算完美方案,但在实测里已经能把同音词误命中降低到可接受范围。
写在最后的体会
这个项目做到现在,我最大的感悟是:给大模型做记忆,真正的难点不是“存得下”,而是“取得好”。存任何一段信息都很容易,难的是判断哪些信息值得长期保留、哪些信息会随时间贬值、如何在正确的时间点把正确的内容送回对话流里。这些问题没有标准答案,只能靠反复测试和体验积累。
这个项目后续我还在继续迭代,当前在尝试给记忆增加时间衰减权重,让很久没有提及的信息自动降级,而不是永远保持在最高优先级。这套思路不仅适用于Claude,如果换其他支持函数调用的大模型,只要把采集和注入的代码适配一下,整条链路基本可以照搬。
如果你也想给常用的大模型装一套记忆系统,我的建议是从最小可用版本开始——先把对话记录落库,再写检索注入,别一开始就奔着完美架构去。先跑通,再调优,这条路我亲自验证过,走得通。