
说实话我一开始做 claude-mem 这个项目纯粹是被自己逼的。那阵子我每天都在跟同一个对话模型来回确认同一批事情——接口命名规范、代码注释偏好、周报结构、甚至我习惯用什么语气接收反馈。前一天刚在对话里敲定的事第二天新开一个会话它像失忆一样全部重来。这种挫败感经历过的人都知道不是模型蠢而是它压根没有一个记性的概念。claude-mem 这个项目简单说就是给对话模型加一层长期记忆模块。它能把对话中出现的有价值信息自动抽取、结构化存储并在下一次对话开始前按需召回让模型不用重新认识你。这篇文章我会把整个项目的设计思路、核心实现、数据模型以及我在实际使用中踩过的坑完整拆开讲一遍。无论你是想给自己的智能助手加记忆还是在团队项目里做上下文管理应该都能找到可以抄作业的部分。1. 记忆缺失的本质模型为什么每次都要重新认识你1.1 无状态模型与上下文窗口的硬边界先搞清楚一个底层的、很多人容易忽略的事实目前主流的大模型接口本质上都是无状态的。你每次发送请求模型都是从零开始推理它并不存储你上一次调用时留下的任何信息。所谓的多轮对话能力靠的是我们把历史消息全部放进同一份请求里让模型在这一次推理过程中看到前面说了什么。这就带来了一个逃不掉的限制——上下文窗口是有限资源。哪怕模型窗口再大也不可能无限堆历史。我自己的真实使用场景里一个跨三天的项目讨论原始聊天记录轻松涨到两三万字。如果每次对话都把全部历史塞进去成本先不说模型自身的注意力分配会出大问题它很容易在长文本里漏掉你最想让它记住的那句话。我把这个现象类比成图书馆的借阅卡制度。你每次去借书都会换一个新的管理员。管理员能力再强也只能看你交给他的那张纸条。你上次跟他说过我借书偏好看序言和目录他没有机会记下来因为那张纸条用完就进碎纸机了。所以claude-mem 要做的事就是在碎纸机旁边加一个归档员。它不保留原始纸条而是把纸条上的关键信息提炼成结构化条目存进另一个仓库等下次你再来时能准确地把相关的旧纸条复印件递到新管理员手里。1.2 记忆系统要回答的三个问题动手写代码之前我把整个需求拆成三个问题记什么、存多久、怎么取。这三个问题看起来像废话实际上决定了后面所有技术选型。记什么是最容易翻车的部分。对话文本里真正值得长期沉淀的信息远比想象中少。比如用户随口说一句今天先这样吧这是临时状态不值得存但另一句我平时写代码习惯用类型注解这就是稳定偏好必须存。最开始我做抽取时只按句子长度和关键词过滤结果记忆库堆满了我不想写文档这种模棱两可的噪声真正有用的偏好反而被淹没了。存多久同样重要。不同记忆的保质期天生不同。项目进行中记录的问题清单可能两周后就失效了而用户的基本技术栈偏好可能两年都不过时。我最终给每条记忆都设计了独立的过期时间和优先级衰减后面会细说。怎么取则决定了体验上限。一个理想的记忆系统新会话开始时应该只召回与当前话题最相关的若干条而不是把所有历史一股脑倒给模型。这意味着不能简单按时间倒序取最近几条而要做语义相关性的召回。到这里整个系统的架构已经有了雏形先抽取、后存储、按需召回。2. claude-mem 的总体设计抽取、存储、召回三段流水线2.1 抽取层从对话里提炼可沉淀信息claude-mem 整体是三条流水线串起来的。第一条是抽取层也是整个项目里最容易被低估的部分。很多人直觉上会觉得抽取不就是把对话丢给模型让它总结一下吗实际操作起来问题非常多。首先抽取不能阻塞主对话流程。如果每一轮对话都要等抽取完成才返回回复用户会明显感觉到延迟体验直接崩。我的做法是把抽取任务丢进后台队列用一个 worker 进程异步处理这样对话回复照常返回记忆更新在后台默默进行用户基本无感知。其次抽取的粒度控制很讲究。最初我把每轮对话整体丢给模型让它输出这段对话里的所有重要信息结果它经常把多个意图混在一条记忆里比如用户喜欢 Python也讨厌冗长的文档注释这种复合条目在后缀检索和更新时都非常别扭。后来我调整了指令要求每条记忆只表达一个单一事实宁可多输出几条也不要揉成一团。抽取结果也不是直接入库。我会先让它进入一个候选区和现有记忆库做相似度对比。如果和已有记录语义重合度太高就不新建条目而是把原条目的更新时间戳刷新并追加新的来源上下文。这个去重机制非常重要否则同一偏好会在记忆库里累积几十条几乎一样的副本检索时噪声会拉低召回质量。2.2 存储层向量库与关系表的双写策略存储层的设计我前后改了三版。第一版只用了向量索引结果发现向量检索擅长语义相似召回但对这个项目是上周五决定的这个偏好来自项目经理这类结构化过滤无能为力。第二版又改成只存关系表结果用户换了个说法提问时关键词匹配经常漏掉语义相同但字面不同的记忆。最终定下来的方案是向量库 关系表双写。向量索引负责语义召回解决用户这次问法和当时聊天时用词完全不一样的问题关系表负责记录结构化元数据比如记忆类型、创建时间、来源会话、过期时间、启用状态等方便做精确筛选和管理。双写策略最大的坑是数据一致性。我第一版实现是先写向量库、再写关系表结果某天一次写入半路失败后两边数据对不上了——关系表里明明存在这条记忆向量索引里却没有导致检索时丢失记忆。后来我加了一个很实用的补偿机制所有写操作先落关系表同时生成一条操作日志再异步同步到向量库。同步过程中短期不一致可以接受但会有个定时任务定期比对两边数据总量发现差异就自动补写。这个方案谈不上优雅但对一个本地记忆库项目来说简单、可运维、不易出大问题。另外嵌入模型的选择比向量库品牌更重要。我测试过几个不同的向量方案在小规模数据下差距并不大真正决定中文召回效果的是所用向量模型对中文语义的拟合能力。换成针对性训练过的模型后类似想要异步处理和导入过程能不能放到后台跑这种语义关联召回效果稳定提升了不少。2.3 召回层在入口处组装记忆上下文第三条流水线是召回层。每次新对话开始之前claude-mem 会拿着用户的当前提问去向量索引里检索 top-k 条相关记忆同时从关系表里调出几条全局重要的个人偏好做兜底最后合并成一段记忆上下文注入系统提示词。拼装这个环节最需要克制的是上下文长度。我把记忆上下文的长度上限控制在 900 token 左右超过的部分宁可丢弃。原因是我做过一组对比实验模型上下文里的记忆越多不仅单次请求成本升高回复还容易变得机械——它会频繁引用记忆里的内容而不是把记忆当成背景知识自然使用。我印象很深的一个例子记忆太多时用户问今天天气怎么样模型居然回复根据您的偏好您更喜欢具体的温度范围因此我为您提供以下预报……非常怪异。后来我把记忆长度压到 900 token 以下同时按相关度排序、只保留信息密度最高的几条模型的表现才恢复正常。这段经历给我的启发是真正决定体验的不是能不能存下更多记忆而是能不能在合适的时候把最合适的信息送到模型面前。3. 落地关键数据结构、抽取实现和上下文合并3.1 记忆条目的字段设计与版本管理跑通架构之后代码层面有几个细节非常磨人但也正是这些细节让系统从能跑变成可靠。先看数据模型我用 Python 的 dataclass 定义记忆条目from dataclasses import dataclass, field from datetime import datetime from typing import Optional dataclass class MemoryItem: memory_id: str content: str memory_type: str # preference / fact / decision confidence: float # 0~1抽取时的置信度 source_ref: str # 来源会话ID 轮次 created_at: datetime updated_at: datetime expires_at: Optional[datetime] version: int 1 # 版本号每次冲突更新加一 enabled: bool True attributes: dict field(default_factorydict)几个看起来不起眼但其实很关键的字段我单独说下。memory_type字段必须严格区分。我把记忆分成三类preference用户偏好、fact稳定背景事实、decision已确定的技术决策。这三类信息的更新频率、过期策略、召回权重都不同。比如 decision 类信息的时效性很强项目一旦换方案旧决策就要标记失效而 preference 类信息相对稳定召回时应获得更高权重。version字段是我在设计之初完全没想到、后来靠实战救回来的。每当同一语义条目被更新version 加一旧版本不直接物理删除而是挪进历史表。AI 抽取经常因为上下文语境不同而产生错误覆盖比如模型把用户一句试探性的话当成确定偏好写了进去如果能从版本历史中找回旧版本就能一键回滚。可以说这个字段帮我止损了至少五六次。3.2 抽取服务的实现与异步处理抽取服务本质上是一次模型调用的封装。输入是对话文本输出是一组候选记忆条目。我最终稳定下来的 Prompt 长这样def extract_memories(conversation_text: str) - list[MemoryItem]: prompt f 请从以下对话文本中提取值得长期记忆的信息。 只提取三种类型 - preference: 用户明确的偏好或习惯 - fact: 用户提供的稳定背景事实 - decision: 项目中已确定的技术决策 忽略临时情绪、寒暄、一次性请求。 每条记忆只表达一个单一事实不要合并多条信息。 对每条记忆给一个 confidence (0~1)。 输出 JSON 数组不要输出任何多余内容。 对话文本 {conversation_text[:6000]} raw call_model(prompt, max_tokens2000) candidates parse_json(raw) return [MemoryItem( memory_idgen_id(), contentitem[content], memory_typeitem[type], confidencefloat(item[confidence]), source_refcurrent_conversation_id, created_atdatetime.now(), updated_atdatetime.now(), ) for item in candidates]一个我在实际调试中反复踩的坑是抽取时不能只传最近一轮对话最好连当前轮次之前的三轮一起带上。原因是很多决策有前置语境。用户先说我们准备用异步任务处理导入接着聊了别的话题最后才说那就按这个方案来。如果只拿最后一句去抽取系统根本无法知道这个方案指的是什么很容易抽出垃圾信息或干脆漏抽。带上前置语境后抽取准确率明显提高。异步处理方面我用了消息队列加定时任务。每一轮对话结束后原始文本被丢进队列worker 进程负责抽取和入库。优势是主对话链路零额外延迟代价是记忆更新有几秒到几十秒的滞后。对大多数使用场景来说这个滞后完全无感毕竟你不能指望用户刚说完一句话下一句就要考验系统有没有记住它。3.3 检索合并与去重逻辑召回阶段的代码相对简洁但过滤逻辑里有不少门道。核心函数这样写def build_memory_context(query: str, top_k: int 12, max_tokens: int 900) - str: query_vec embed(query) hits vector_db.search(query_vec, top_ktop_k) memories dedupe_and_sort(hits) merged [] used_tokens 0 for mem in memories: item_tokens estimate_tokens(mem.content) if used_tokens item_tokens max_tokens: continue merged.append(mem.content) used_tokens item_tokens return \n.join(merged)压缩逻辑看起来简单但 dedupe 函数里我额外加了一个互斥关系判断。调试中我注意到一个很有意思的现象有些记忆在语义上并不重复甚至可以说是互补的但把它们同时塞进上下文里反而会让模型产生错误理解。举例来说用户说过我不喜欢在函数里写太长注释另一条记忆是我习惯在关键模块写详细文档这两条单看都成立放一起之后模型就容易把用户理解成自相矛盾。互斥关系检测我现在靠一个简单的规则引擎如果两条记忆的内容同时包含同一主题词但在观点极性上相反就只保留置信度和时间戳综合评分更高的一条。这个方法不完美但足够应付日常对话中九成以上的认知冲突。4. 记忆质量攻坚战脏数据、冲突与遗忘机制4.1 记忆污染与置信度评估跑通 demo 之后我花掉大量时间的不是功能实现而是记忆质量的调优。没有质量控制的记忆库还不如没有记忆库。记忆污染是我踩得最狠的坑。对话文本本身不是事实陈述用户很多时候是带着试探语气在说话比如要不试试看用这个方案感觉这样做也许可以。这种句子被抽取成偏好记忆后下次对话模型就会默认用户已经确定了方案甚至主动按错误方向继续推演。我在一次项目排期讨论中就被坑过用户只是随口说了一句可能要用消息队列三天后新会话里模型居然直接把消息队列当成了既定技术方案还一本正经地给出了选型建议。为了控制误判抽取 Prompt 里强制要求模型对每条输出给置信度同时对低置信度条目采用延迟生效策略同一个信息必须在一段时间内再次被提及才升级为正式记忆。这个机制牺牲了一点记忆建立的即时性但把错误决策的概率降了一个数量级。4.2 冲突处理新旧记忆如何覆盖信息冲突几乎一定会出现。用户上周说数据库用 MySQL这周说我们决定全部切到 PostgreSQL。这时如果把老条目直接删掉万一新决策只是一次试探系统就会丢失真实信息如果保留老条目压制新条目又等于没记住变化。我的方案是给记忆条目增加状态机包含四种状态候选、生效、失效、已替代。冲突发生时新条目进入候选态旧条目继续保持生效当新条目在后续对话里得到至少一次佐证才切换为生效态并自动把旧条目标记为已替代。整个过程有记录可查系统之后如果想回溯为什么这个项目最终选了 PostgreSQL直接查记忆历史就能还原完整的决策链条。4.3 遗忘机制定期清理与优先级衰减没有遗忘机制的记忆库运行三个月后会变成垃圾场。最初我偷懒每天跑一次全量清理按 expires_at 把过期的条目删除。但很快发现另一个问题很多没设过期时间的条目也会逐渐失去价值。比如项目进行到中期时记录的目前遇到 XXX bug 正在排查等 bug 修复后就毫无意义了可它没有过期时间就会永远留在记忆库里持续污染召回结果。后来我引入了优先级衰减机制。每条记忆除了置信度还有一个 importance 分数它会随时间缓慢衰减当分数低于阈值该条目就从主记忆库挪进历史存档区不再参与召回但可以手动查询。这套机制加上阶段性的全量清理我把主记忆库的体积控制住了召回准确率也明显上升。5. 接入实战成本、延迟测试与隐私边界5.1 实测效果带记忆与不带记忆的差异当 claude-mem 从玩具变成日常工具我才真正体会到它的价值。拿我自己的工作流程做对比不带记忆的时候我每天都要重新交代三组偏好代码风格用类型注解和文档字符串、报错信息不要只贴日志而要给出定位思路、周报按结果、过程、风险三段来组织。接入 claude-mem 之后这些偏好只在第一次对话时提过一次系统就记住了之后每天的会话都能自动带出。更明显的改善是跨天项目讨论的连续性。上周和模型讨论过的接口重命名方案这周新开会话它能准确呼应当初的取舍理由而不是把已经否决的旧方案重新提一遍。这种连续感带来的体验提升比换一个参数更大的模型来得还要直接。5.2 延迟与成本抽取带来的额外开销代价当然也有主要在两方面成本和延迟。每一轮对话都额外跑一次抽取调用单轮成本大约增加 10% 到 20%具体取决于对话长度。我的解决办法是给抽取加触发条件——一个轻量规则先扫描对话文本如果没出现决策动词、偏好短语或背景介绍句就跳过抽取。实际运行下来大约有一半的日常对话是纯问答性质根本不需要沉淀记忆能省下不少调用量。延迟方面由于抽取走后台队列用户主链路几乎无感。唯一的例外是某些 API 供应商的推理结果附带中间思考内容从这些中间内容里做信息提纯比拿最终回复再做一次完整总结更省 token效果也更好。我把这个做成一个可选开关默认不开启因为不是所有接口都支持。5.3 隐私边界能记但不要什么都记隐私问题在记忆系统里绕不开。对话里可能包含用户不愿被保存的敏感信息也可能包含只属于某个临时场景的内容。我给自己定的原则是三句话最小记忆、可解释、可删除。最小记忆是指只有对后续对话有明显帮助的信息才允许入库闲聊八卦、情绪化表达一律跳过。可解释是指每一条记忆都能回溯到具体的来源会话和时间不允许出现无来源的孤魂记忆。可删除则是指所有记忆对用户完全可见随时可以批量清除包括我在演示时给的所有测试数据。技术上敏感字段的存储我做了加密处理密钥独立于应用进程保管。这个项目里我宁愿损失一部分便利也不愿让记忆变成一个没有钥匙的黑箱。任何记忆系统想被长期使用用户在信任它之前都得先确认一件事它记得的我看得到它忘掉的我能控制。做 claude-mem 这一路下来我最深的体会是记忆系统的难点从来不在存储和检索技术这些东西到处都有现成方案。真正难的是在该记什么和该忘什么之间找到平衡。什么都要记的系统最终只会变成一个噪声制造机。只有克制、精准、可干预的记忆库才配得上长期记忆这四个字。如果你也在做类似的东西我的建议是别急着写代码先把记忆的边界定义清楚后面会顺很多。