自从把 Claude API 接进自己的几个小项目之后,我最大的感受不是“模型真聪明”,而是“它怎么这么健忘”。昨天刚交代完的项目背景、上午敲定的命名规范、甚至前一轮对话里明确说过的偏好,只要会话一断开,统统归零。每次都得重新贴一遍背景说明,这体验实在太割裂了。后来我干脆动手写了 claude-mem——一个给 Claude 补上长时记忆的轻量级中间层。它的核心目标就一句话:让模型在跨会话场景下记住你曾经告诉过它的一切,而不是每次都从零开始。原理说穿了并不玄乎,本质就两步——把历史上下文向量化存起来,下一次对话时按语义召回再注入 prompt。但这东西真正做起来才发现,记忆的存储结构、召回策略、注入位置,每一个环节都有大量细节要抠。这篇文章我会把 claude-mem 的完整设计思路和实现过程摊开讲,从架构到代码,再到实测里的坑和调优经验,希望对正在做 Claude 应用、或者有类似记忆层需求的开发者有实际帮助。
1. 为什么我会动手写 claude-mem:无状态 API 的痛
1.1 模型能力强,不代表业务上的“记忆能力”强
先聊清楚一个概念,Claude 这类大语言模型本身是没有任何持久化状态的。你每次调用 API,传过去的 messages 数组就是它能看到的全部世界;API 调用一结束,上下文就被清空了。这不是模型“笨”,而是架构设计如此——无状态设计让模型可以水平扩展、负载均衡,但也意味着它在业务层面天然地“不记得”任何东西。
如果你只是做一次性问答(比如翻译、总结、转写),无状态完全够用。可一旦碰上带连续性的业务场景,比如“让 AI 做我的长期写作搭档”“让 AI 记住产品文档后回答新员工提问”“做一个能记住用户口味和禁忌的私人助理”,无状态就会立刻变成灾难。我最早试过的替代方案很朴素:把所有历史对话全部塞进 messages 里重新发给模型。结果不难猜到——对话超过几轮之后,token 数直接爆炸,费用翻着涨;更麻烦的是,Claude 的处理窗口被堆满后,反而会“迷失”在垃圾信息里,对真正重要的指令判断力大幅下降,甚至开始重复回答。用整段历史当记忆,看着省事,其实是最笨的办法。
1.2 哪些场景是刚需,哪些只是伪需求
动手写 claude-mem 之前,我先把“记忆”这个需求拆了一遍。不是所有场景都需要长期记忆:
- 刚需场景:跨会话的客户支持、个人知识库问答、连续多日的项目助理、角色扮演类应用、需要记住用户偏好的推荐系统。这些场景的共同点是“历史信息会反复影响后续决策”,缺失任何一段关键历史,回答质量就会暴跌。
- 伪需求场景:一次性信息抽取、单轮翻译、代码片段生成。这些任务上下文短、一次性输入输出即可完成,硬加记忆层只会拉高延迟和成本,没有任何收益。
做完这个拆解,我确认了自己要解决的也就是前者。于是给自己定了几个目标:记忆要能自动沉淀,不能靠每次手工维护;召回要快,不能给对话增加明显延迟;检索要准,别把无关历史翻出来干扰当前回答;最后还要具备基本的遗忘机制,毕竟记忆过期和不存在一样可怕。
2. 核心架构:把记忆变成可检索的数据,而不是堆在 prompt 里
2.1 三层记忆结构设计
设计 claude-mem 时我参考了认知科学里很常见的记忆分层思路,把记忆拆成三层,分别用不同的机制管理:
| 层级 | 存储介质 | 更新频率 | 典型内容 |
|---|---|---|---|
| 会话内上下文 | 调用时临时拼接 | 实时 | 当前这轮对话的完整交互过程 |
| 工作记忆 | Redis / 内存缓存 | 秒级~分钟级 | 正在进行的任务上下文、临时结论 |
| 长期记忆 | 向量数据库 | 每次对话结束后异步更新 | 用户偏好、项目背景、历史决策记录 |
会话内上下文不用多解释,就是普通 API 调用的 messages。工作记忆负责解决“同一个任务、连续多轮”的情况,它可以放在 Redis 里,key 按 session_id 存,过期时间设置为几小时到一天,避免占用长期存储。长期记忆则是 claude-mem 最核心的部分——把所有有价值的信息向量化之后存进向量库,需要的时候按相似度召回。
为什么非得分层?因为如果所有东西都进长期记忆,检索时相似度会把短期频繁出现的信息和真正重要的长期偏好混在一起,导致记忆“串味”。分层之后,短期高频率信息走缓存,长期信息走向量库,二者互不干扰,召回结果干净得多。
2.2 向量库与嵌入模型选型:别一上来就上最重的
claude-mem 现在的实现用的是 Chroma 作为向量存储,嵌入模型则选用 BGE-M3 的本地版本。选型的时候我做了三轮对比,最后才定下来。
向量数据库对比:
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| Chroma | 轻量、零配置、支持持久化 | 分布式能力偏弱 | 单机/中小规模项目 |
| FAISS | 检索性能极快 | 没有内置存储管理 | 高性能计算场景 |
| Qdrant | 功能全、支持过滤条件 | 需要单独部署服务 | 生产级推荐系统 |
| Milvus | 海量数据能力强 | 部署运维成本高 | 大规模知识库 |
claude-mem 面向的是个人项目和中小型应用,单用户对话数据量很难突破百万条向量,Chroma 够用,省掉一切运维负担。等哪天数据量真的大到需要分布式了,再迁移也不迟。
嵌入模型的选择则更关键。我一开始图省事用了云端嵌入 API,接入很容易,但后来发现两个问题:一是每个 token 都要花钱,高频写入时成本涨得飞快;二是数据要送到第三方服务做向量化,对隐私敏感场景不友好。后来换成本地 BGE-M3,单条文本的向量化延迟在毫秒级别,离线跑也没有任何成本。如果你对中文语义召回质量要求特别高,也可以考虑 bge-large-zh 或 text2vec-large-chinese 这类中文专门优化的模型,嵌入维度更高,效果会更好。
2.3 记忆的完整生命周期:从对话结束到下次召回
claude-mem 的完整链路可以概括为六步:切分 → 提取 → 嵌入 → 存储 → 召回 → 注入。写作模板给你具体秒懂 Clutter 官方人设:
- 切分:每轮对话结束后,把 messages 按主题和长度切成片段,保证每段内容内聚且不超过嵌入模型的 token 上限;
- 提取:让 Claude 自己把这段对话“压缩”成要点——这是记忆质量的源头,很多细节必须在这里保住;
- 嵌入:把提取出的要点做 embedding,生成向量;
- 存储:向量连同原文、时间戳、会话 ID、元信息一起写进 Chroma;
- 召回:新的对话开始时,把当前问题也做 embedding,从向量库里检索最相关的 top-k 个历史记忆;
- 注入:将召回内容格式化后拼入 system prompt,让模型在回答时把记忆当作已知背景。
这条链路里,最容易被低估的是切分和提取。如果直接把原始对话全文塞进嵌入模型,片段边界很容易切断语义,导致召回结果支离破碎。更靠谱的做法是让 Claude 对每轮对话做摘要式提取,提取出的信息已经过了一轮语义提炼,向量化之后的可检索性远高于原文。代价是多一次 API 调用,但换来的记忆质量和召回命中率提升非常明显。
3. 关键代码实现:让 Claude 真正记住你是谁
3.1 会话切分与摘要提取:记忆质量从源头决定
每轮对话结束,我会先根据时间间隔和话题连续性做切分。时间超过 30 分钟或者对话主题明显切换了,就强制分段。然后调 Claude 生成一段结构化摘要,记录这段对话里的关键信息点。
MEMORY_EXTRACT_PROMPT = """ 请阅读下面的对话记录,提取需要长期记住的信息。 只提取事实、偏好、决策、约定,不要提取寒暄。 输出格式为 Markdown 列表,每条信息尽量精简,不超过 20 个字。 对话记录: {conversation_text} """ def extract_memory(conversation_text: str, client): resp = client.messages.create( model="claude-3-5-sonnet-latest", max_tokens=500, system="你是一个记忆提取器,负责从对话中提炼需要长期保存的信息。", messages=[{"role": "user", "content": MEMORY_EXTRACT_PROMPT.format( conversation_text=conversation_text )}] ) return resp.content[0].text这一步的核心目标是压缩。一段几千 token 的对话,经过提取后可能只剩几十个 token 的核心要点。压缩带来两个直接好处:向量库里的每条记录都是高密度信息,召回时不浪费容量;检索出来的内容也不会带着大量寒暄和语气助词干扰模型判断。
我踩过的一个坑是:一开始让 Claude 每轮对话都提取摘要,结果产生大量重复内容。用户连续三轮都在聊同一个项目,提取结果高度重合。后来我加了“去重前置”逻辑——先检索已有记忆库,如果新摘要和已有记录相似度超过 0.9,就用合并替代新增。这样既避免了信息冗余,又能在新信息出现时自然覆盖旧记录。
3.2 向量化写入:Chroma 持久化与元数据设计
切分提取完之后,就该写向量库了。我选择 Chroma 的 PersistentClient 模式,数据直接落盘,重启不丢。
import chromadb from chromadb.config import Settings client = chromadb.PersistentClient( path="./claude_mem_db", settings=Settings(anonymized_telemetry=False) ) collection = client.get_or_create_collection( name="long_term_memory", metadata={"hnsw:space": "cosine"} ) def write_memory(memory_text: str, session_id: str, timestamp: int): embedding = embed_model.encode(memory_text).tolist() memory_id = f"{session_id}_{timestamp}_{hash(memory_text) % 10000}" collection.add( ids=[memory_id], embeddings=[embedding], documents=[memory_text], metadatas=[{ "session_id": session_id, "timestamp": timestamp, "memory_type": "extracted_summary" }] )这里有个设计细节值得展开:我把“原始对话”和“提取后的记忆摘要”分开处理。向量库里存的是摘要和它的向量,原始完整对话存到 JSONL 日志文件里用于溯源。为什么这么设计?一是向量库查询时如果返回大段原文,token 消耗会很夸张;二是万一需要核对上下文,日志文件能兜底。检索时取摘要,排查问题时取原文,各司其职。
元数据设计也很有讲究。session_id 用来标记来源会话,timestamp 帮助做时间衰减,memory_type 目前标记了三种类型:用户偏好、项目背景、决策记录。标记类型有什么用?召回阶段可以做过滤,比如用户问“我之前说过什么偏好”,就直接过滤 memory_type='user_preference',大大降低检索噪声。
3.3 召回与注入:让 Claude 的 prompt 里长出一段“记忆区”
召回说起来简单,就是拿当前问题去向量库做相似度搜索,但注入的方式直接影响最终效果。我试过三种注入位置:追加到 system prompt 末尾、夹在 messages 数组中间、作为独立的 system 条目。实测下来最稳的是作为 system prompt 里的一个独立区块,位于指令说明之后、示例之前。
MEMORY_INJECT_TEMPLATE = """ 以下是从长期记忆中恢复的相关背景信息。 这些信息来自你与用户过去的对话,请自然地融入回答中,不要主动提及“记忆库”。 如果背景信息与当前问题无关,请忽略它们。 <memory> {retrieved_memories} </memory> {original_system_prompt} """ def recall_and_inject(query: str, system_prompt: str, top_k: int = 5): query_embedding = embed_model.encode(query).tolist() results = collection.query( query_embeddings=[query_embedding], n_results=top_k, include=["documents", "metadatas", "distances"] ) memory_block = "\n".join([ f"- {doc}" for doc in results["documents"][0] ]) return MEMORY_INJECT_TEMPLATE.format( retrieved_memories=memory_block, original_system_prompt=system_prompt )召回条数 top_k 我一般设 3 到 5 条。设太少了关键信息可能漏掉;设太多了 prompt 变长,模型会被大量背景干扰,反而降低对当前问题的专注度。这里的平衡点需要在真实场景里反复调,我后面会详细讲。
注入时的措辞我特意加了两点约束:一是“自然地融入回答,不要主动提及记忆库”,否则模型会蠢蠢地告诉你“根据我的记忆库”之类的话,用户体验差且暴露实现细节;二是“如果背景信息与当前问题无关,请忽略它们”,这是兜底策略,召回结果不可能永远 100% 准确,给模型一个容错出口。实测加了这个“忽略权限”之后,无关记忆对回答质量的伤害下降非常明显。
4. 实测效果与调优:检索质量决定记忆层的生死
4.1 一次有记忆与无记忆的对比测试
代码跑通之后,我做了几轮对比测试。测试环境是同一个 Claude 模型,同一个用户提问,区别只在于是否启用 claude-mem 恢复记忆。
测试过程是这样的:第一天和模型聊了 20 分钟,内容包括“我叫小林,目前在做智能家居项目,主控用 ESP32,后端用 Python FastAPI,经常半夜调试,所以我希望代码注释写得特别详细”。会话结束,记忆入库。第二天新开一个对话,问模型“我们要做的新固件应该用什么语言写主控逻辑”。
无记忆状态下,Claude 直接给出了通用的嵌入式开发建议,虽然正确但完全没有针对性;有记忆之后,模型明确提到了“根据你之前说在用 ESP32 和 Python 的偏好”,并主动往 MicroPython 的方向细化建议,甚至连注释风格都按用户习惯做了适配。这种“记得你是谁”的差别,数据上是无法量化的,但体验差距是碾压性的。
再测了一个稍复杂的场景:我在对话里提过“公司内部的规定是不能在产品文档里出现缩写,一律要写全称”,然后隔了一天问“帮我校对新写的产品介绍文案”。无记忆模式对这些缩写毫无察觉,而带记忆的模式会自动展开解释每一个缩写,完全按约定执行。这说明 claude-mem 解决的不只是“记住事实”,还包括“记住规则”。
4.2 参数调优:窗口大小、召回数量和相似度阈值
调优阶段,我重点盯了三个参数。
第一个是切分窗口大小。一开始我把窗口设得很大(每 2000 token 切一段),想减少 API 调用次数,结果摘要质量明显下滑,因为一段对话里包含多个子话题时,Claude 的摘要会丢掉低频的小话题。后来把窗口降到 500 到 800 token,一个窗口基本只容纳一个连续主题,摘要的召回质量显著回升。代价是调用次数变多,但记忆准确性的优先级更高。
第二个是召回数量 top_k。我做了个简单的压力测试:用 50 条记忆做底库,分别用 top_k=1、3、5、8 进行问答,人工判断回答质量。结果是 top_k=3 到 5 最优,5 以上开始出现“信息过载”,模型会试图把不相干记忆也塞进回答里,反而显得啰嗦。这里的关键教训是:检索质量永远优先于检索数量,宁可少召回几条,也不要塞进一堆噪声。
第三个是相似度阈值。Chroma 返回的距离值在不同嵌入模型下量纲不太一样,我用 BGE-M3 时 cosine 距离 0.25 以内才算“高度相关”。设阈值的好处是避免召回一堆相似度只有 0.6 的边缘记忆,强行注入只会干扰模型。实际操作中,阈值要结合真实数据分布来定,我会先跑一批测试查询,把相似度分布统计出来再确定 threshold,不要拍脑袋。
4.3 三件我必须提醒你的事:踩过坑才有发言权
坑一:记忆污染是真实存在的。有一段时间我把所有对话都无条件写入记忆库,结果模型开始“学会”了一些临时性的错误信息。比如用户某天抱怨“这个功能太难用了,我下周就把它砍掉”,结果这个“砍掉功能”的表述被当成重要决策记了下来,后面几天的对话里模型一直在假设功能已经不存在了。解决办法是给摘要提取增加了一个情感判断:情绪化表达、临时性吐槽、反事实假设不进入长期记忆,只有确定性陈述和明确的决策指令才够格入库。
坑二:异步写入是必须的,不能阻塞主对话流程。第一版 claude-mem 在每轮对话结束后同步调用摘要提取和向量写入,用户要等两三秒才能看到回复。后来改成异步任务队列(先返回对话结果,后台再慢慢记忆),用户完全无感,也彻底避免了对话被记忆写入拖垮的情况。
坑三:重复记忆和过时信息的清理频率比想象中高。用户的信息是动态变化的,上个月用户说“我用 Mac 开发”,这个月可能换成 Windows。如果两条记忆同时存在,模型就不知道该信哪个。所以我在写入新记忆时增加冲突检测:先搜索同主题旧记忆,如果内容和旧记录冲突,就把旧记录标记为 inactive,再写入新记录。这个“新覆盖旧”策略,才真正让记忆库保持长期可用。
5. 把 claude-mem 部署到生产环境前,必须先想清楚这些
5.1 数据安全和隐私边界:别把用户的话随手存库
这是整个 claude-mem 里最不能妥协的一部分。记忆库存的是用户真实说过的内容,里面大概率有隐私信息:姓名、联系方式、住址、公司内部信息,甚至比这更敏感的东西。我的处理原则是“敏感信息只使用,不记忆”。
具体做法分三步:第一步,摘要提取阶段就在 prompt 里明确要求 Claude 不要输出任何个人隐私信息(姓名、邮箱、电话、身份证号等),用“用户”“对方”这类占位符替代;第二步,写入向量库之前跑一遍轻量 PII 正则过滤作为兜底;第三步,给记忆库文件的权限设置严格限制,默认不开放给任何外部服务访问。很多人觉得“就这么点数据没必要”,但用户的数据只要出了问题就是大事故,这个风险不值得冒。
另外还得考虑用户的“被遗忘权”。我实现了一个简单的 API,支持按 session_id 或按时间范围删除记忆。这不仅是合规要求,也是产品信任的基础。用户明确说“别再记住我说过的 XXX”,系统就必须能真正做到,而不是嘴上答应。
5.2 成本与延迟:记忆不是免费的
claude-mem 的隐性成本容易被低估。第一块是摘要提取时的 API 调用费用,每轮对话多一次请求,虽然 summary 的 token 量不大,但长期高频运行积少成多。第二块是向量库的存储和检索,本地跑 Chroma 没有存储费用,但如果嵌入模型也调云端 API,每次写入和召回都要付 embedding 费用。第三块是注入记忆后 prompt 变长带来的 token 增量,top_k=5、每条摘要 50 token,每轮多大概 300 token 的输入成本。
控制成本我做了三件事:摘要提取的模型用更便宜的档位(比如 claude-haiku),因为摘要任务对推理能力要求不高;对话内容在写入前先做去重,重复相似内容直接丢弃,拒绝无效入库;每隔一段时间做一次记忆归档,超过 90 天且从未被召回过的记忆先转到冷存储,不再参与在线检索。实测这么跑下来,印象里记忆模块带来的额外开销能压缩到总 API 成本的 15% 以内,在可接受范围。
5.3 遗忘机制:能记也要能忘,这才是完整的记忆
记忆系统做久了你会发现,遗忘比记忆更难。没有遗忘机制的记忆库会逐渐变成一个垃圾堆:过时的信息、互相矛盾的记录、没人关心的历史细节堆积在一起,最终让检索质量全面恶化。
claude-mem 的遗忘机制我设计了三个层级:
- 时间衰减:每条记忆有一个 score,随存放时间逐渐衰减。衰减速度和记忆类型挂钩,决策类记忆衰减慢,日常闲聊类记忆衰减快;
- 召回激活:每次成功被召回且被模型实际应用,score 会得到加成,相当于是“用进废退”;
- 归档清理:低于设定阈值的记忆自动标记为 inactive 并迁移到冷存储,不参与检索,只保留溯源能力。
这个机制的出发点很简单:让记忆库保持“小而精锐”的状态。被用到的记忆越来越强,没人用的记忆慢慢淡出。它和人类记忆的工作原理很相似,正因为如此,才不至于让模型在被陈旧信息干扰的同时,还能把真正重要的背景信息牢牢抓住。
写在最后:一点关于“记忆”的体会
claude-mem 做到现在,我对“给大模型加记忆”这件事最大的感触是:技术实现并不复杂,向量检索、摘要提取、prompt 注入,每块都是成熟技术。难的是对“什么该记住、什么该忘记”的把握。太贪婪地记住一切,会让记忆库里塞满噪声;太吝啬地选择性忘记,又会让真正关键的信息在需要时缺席。我现在的原则很简单:确定性事实和无歧义的规则坚决入库,情绪和临时性的内容一律丢弃,新信息优先覆盖旧信息。这套策略支撑我跑了几个月的真实项目,虽然还不能说完美,但已经比无状态的 Claude 好了太多。如果你也在给 Claude 或者其他大模型做记忆层,希望这篇文章里的选型思路、代码实现和踩坑记录能帮你少走几步弯路。