1. 项目缘起与核心定位
第一次看到 claude-mem 这个项目名,我的直觉是:这应该是一个围绕 Claude 生态做记忆管理的工具。实际拆解下来,它的核心定位确实如此——为基于 Claude 的对话系统提供持久化记忆能力,让原本“聊完即忘”的会话拥有跨会话、跨项目的上下文延续性。
这个项目解决的是一个非常具体的痛点。用过 Claude 做长期项目的人都知道,每次新开一个会话,之前积累的偏好、决策、代码约定、项目背景全部归零。你得重新解释一遍“我们用的是哪种目录结构”“上次那个 bug 的根因是什么”“这个模块为什么不用某个方案”。这种重复劳动在长周期项目里极其消耗精力。claude-mem 要做的就是把这些散落在历史会话里的关键信息抽取出来,存进一个可检索的记忆库,在需要的时候自动注入到新的上下文中。
适合谁来参考这个项目?三类人最直接受益:一是用 Claude 做长期开发协作的工程师,二是需要维护多轮复杂对话状态的产品或研究人员,三是想自己搭建 AI 记忆层、理解“上下文工程”落地方式的开发者。哪怕你只是好奇“AI 记忆到底怎么存、怎么取、怎么用”,这个项目的设计思路也值得完整走一遍。
我个人的判断是,claude-mem 的价值不在于它用了多前沿的模型,而在于它把“记忆”这件事拆成了采集、压缩、存储、检索、注入五个可独立优化的环节,每个环节都有明确的工程取舍。下面我就按这个逻辑,把整个项目的设计思路和实操细节完整拆开讲。
2. 整体架构设计与选型逻辑
2.1 为什么不做“全量历史拼接”
最朴素的做法是把所有历史对话直接拼进新会话的上下文。这个方案我早期试过,问题非常明显:token 消耗爆炸、噪声比例极高、模型注意力被稀释。一个跑了三周的开发项目,历史对话轻松超过几十万 token,全量拼接根本不现实,而且大量内容是和当前任务无关的闲聊或已废弃的决策。
claude-mem 选择的是抽取式记忆路线:不存原始对话,存经过提炼的“记忆条目”。每条记忆是一个结构化单元,包含内容、类型、时间戳、来源会话、关联标签。这个选择背后的逻辑是:记忆的价值密度远比完整度重要。人脑也是这样工作的,你不会记住每一次对话的每个字,你记住的是结论、偏好和关键事实。
提示:抽取式记忆的代价是会丢失细节。所以 claude-mem 在设计上保留了“来源指针”,需要时可以回溯到原始会话片段,这是一个很关键的补偿机制。
2.2 记忆分层模型
项目把记忆分成三个层次,这个分层是我认为最值得借鉴的设计:
| 层级 | 名称 | 内容特征 | 生命周期 | 典型示例 |
|---|---|---|---|---|
| L1 | 会话记忆 | 当前会话内的临时上下文 | 会话结束即归档 | 本轮讨论的临时变量名 |
| L2 | 项目记忆 | 与特定项目绑定的稳定事实 | 项目周期内长期有效 | 代码规范、架构决策 |
| L3 | 全局记忆 | 跨项目的用户偏好 | 永久 | 语言偏好、输出格式习惯 |
L1 不落库,只在内存里维护,避免污染长期记忆。L2 是核心,按项目隔离,检索时优先命中。L3 量最小但影响面最大,比如“用户偏好简洁回答”这种偏好,一旦写入全局记忆,所有会话都会受影响。
这个分层的工程意义在于检索范围可控。如果所有记忆混在一起,检索时噪声会很大。分层之后,检索可以按“先 L2 后 L3、L1 不参与”的策略执行,命中率和相关性都明显提升。
2.3 存储选型:为什么是本地文件加向量索引
claude-mem 没有一上来就上重型数据库,而是采用本地 JSON 文件存记忆条目 + 轻量向量索引做语义检索的组合。这个选择我觉得非常务实。
记忆条目的数量级通常在几百到几千条,这个量级用 JSON 完全扛得住,读写简单、可读性强、方便手动检查和修正。向量索引只存 embedding 和条目 ID 的映射,不存原文,索引文件很小。检索时先走向量召回 Top-K,再回表读原文,整个链路清晰。
如果换成关系型数据库,你会引入 schema 迁移、连接管理、部署依赖一堆问题,而收益在这个量级下几乎为零。如果换成纯向量数据库,又失去了记忆条目的结构化查询能力(比如“查某个项目的所有架构决策”)。所以这个混合方案是权衡后的合理选择。
注意:本地文件方案在多进程并发写入时会有竞争问题。claude-mem 的做法是写入走单进程队列,读取可以并发。如果你要改成多进程,必须加文件锁,否则记忆条目会丢。
3. 核心模块拆解与实操要点
3.1 记忆采集:从对话流里捞出值得记的东西
采集环节的核心问题是:哪些内容值得变成记忆?如果什么都记,记忆库很快变成垃圾场;如果记太少,又起不到延续上下文的作用。
claude-mem 的采集策略是规则触发 + 模型判定双通道。规则通道负责捕获明确信号,比如用户说“记住”“以后都这样”“这个项目的规范是”,这类句式直接触发采集。模型通道负责处理模糊情况,把最近几轮对话丢给一个轻量模型,让它判断“这里面有没有值得长期保留的事实”。
实操中我发现,规则通道的准确率接近百分之百,但召回率低;模型通道召回率高,但会引入误判。两者结合的效果最好。具体配置上,模型判定的阈值可以调,默认是 0.7,低于这个置信度的候选记忆会被丢弃。
采集到的原始候选还要经过一次去重和合并。比如用户在不同会话里三次提到“这个项目用四空格缩进”,应该合并成一条记忆,而不是存三条。去重用的是语义相似度,阈值设在 0.85 左右,超过就认为是同一条。
3.2 记忆压缩:把一段话压成一句话
采集到的候选记忆往往是一大段对话,直接存进去检索效率低。压缩环节要把它提炼成自包含的一句话或几句话。
这里有个关键原则:压缩后的记忆必须脱离原始上下文也能被理解。比如原始对话是“对,就用那个方案吧,之前试过另一个不行”,压缩后应该是“项目 X 的认证模块采用方案 A,方案 B 已验证不可行”。代词要还原,隐含信息要补全。
压缩用的提示词设计很讲究。我实测下来,让模型输出“主语 + 动作 + 对象 + 原因”的结构化格式,比让它自由发挥效果好很多。自由发挥容易丢信息或者加戏,结构化格式稳定得多。
# 压缩提示词的核心结构(示意) compress_prompt = """ 将以下对话片段压缩为一条自包含的记忆条目。 要求: 1. 补全所有代词指代 2. 保留决策原因 3. 输出格式:<主体> <动作/状态> <对象> [原因] 4. 不超过 50 字 对话片段: {dialog_chunk} """3.3 记忆存储:结构化条目的字段设计
每条记忆存下来长什么样,直接决定了后续检索和注入的质量。claude-mem 的条目字段设计如下:
| 字段 | 类型 | 说明 | 是否必填 |
|---|---|---|---|
| id | string | 唯一标识,时间戳加随机后缀 | 是 |
| content | string | 压缩后的记忆正文 | 是 |
| type | enum | fact/preference/decision/constraint | 是 |
| scope | enum | session/project/global | 是 |
| project_id | string | 所属项目,global 时为空 | 条件必填 |
| embedding | float[] | 语义向量 | 是 |
| source_ref | string | 来源会话和轮次指针 | 否 |
| created_at | timestamp | 创建时间 | 是 |
| last_hit_at | timestamp | 最近一次被检索命中的时间 | 是 |
| hit_count | int | 累计命中次数 | 是 |
type字段是我觉得设计得最巧的地方。不同类型的记忆在注入时优先级不同:constraint(约束)优先级最高,必须注入;preference(偏好)次之;fact和decision按相关性注入。这样避免了所有记忆平等对待导致的上下文浪费。
last_hit_at和hit_count用于记忆的热度衰减。长期没被命中的记忆会被降权,甚至归档。这个机制模拟了人类记忆的遗忘曲线,让记忆库保持精简。
3.4 记忆检索:怎么在正确的时候捞出正确的记忆
检索是记忆系统里最难的部分。捞多了噪声大,捞少了没作用。claude-mem 用的是多路召回 + 重排的策略。
第一路是向量召回,用当前对话的 embedding 去索引里找语义相近的记忆,取 Top-20。第二路是结构化召回,按当前会话的 project_id 拉取该项目的所有 constraint 类型记忆,这部分不走向量,直接全取。第三路是全局偏好召回,拉取所有 global scope 的 preference。
三路结果合并后进入重排环节。重排的打分公式大致是:
score = 0.5 * 语义相似度 + 0.2 * 类型权重 + 0.15 * 热度分 + 0.15 * 时间新鲜度类型权重里 constraint 是 1.0,preference 是 0.8,decision 是 0.6,fact 是 0.5。热度分由 hit_count 归一化得到。时间新鲜度是创建时间的指数衰减。
最终取 Top-8 注入上下文。这个数量是我反复调过的,再多会挤占正常对话空间,再少会漏掉关键约束。
提示:检索阈值不要设太死。我见过有人把相似度阈值设到 0.9,结果几乎什么都召不回。0.6 到 0.7 是比较舒服的区间,宁可多召回一点,靠重排去筛。
3.5 记忆注入:以什么形式塞进上下文
注入形式直接影响模型对记忆的利用效率。claude-mem 把记忆组织成一个独立的 system 段落,格式如下:
[项目记忆] - (约束) 该项目使用四空格缩进 - (决策) 认证模块采用方案 A,因方案 B 在高并发下不稳定 - (偏好) 用户偏好简洁回答,不需要冗长解释用列表而不是自然段,是因为模型对结构化列表的遵循度更高。每条记忆前面标注类型,让模型知道这条的强制程度。约束类记忆模型会严格遵守,偏好类会尽量遵循,事实类作为背景参考。
注入位置也有讲究。放在 system prompt 的末尾、用户消息之前,效果最好。放太前面会被后续指令稀释,放用户消息后面又太晚。
4. 完整实操流程与关键配置
4.1 环境准备与依赖安装
先把基础环境搭起来。claude-mem 本身是个轻量工具,依赖不多,但有几个版本要求需要注意。
# 创建独立环境,避免污染全局 python -m venv claude-mem-env source claude-mem-env/bin/activate # 核心依赖 pip install anthropic pip install numpy pip install sentence-transformers # 如果要用本地向量索引 pip install faiss-cpusentence-transformers用来生成 embedding,默认用的是轻量多语言模型,本地跑不需要 GPU,几百条记忆的编码几秒钟就完成。如果你记忆量特别大,可以换成更小的模型或者走 API 编码,但本地模型在隐私和成本上更可控。
4.2 初始化记忆库
第一次使用需要初始化目录结构和配置文件。
import os import json MEMORY_ROOT = os.path.expanduser("~/.claude-mem") def init_memory_store(): os.makedirs(f"{MEMORY_ROOT}/projects", exist_ok=True) os.makedirs(f"{MEMORY_ROOT}/index", exist_ok=True) config = { "embedding_model": "paraphrase-multilingual-MiniLM-L12-v2", "retrieval_top_k": 20, "inject_top_n": 8, "similarity_threshold": 0.65, "dedup_threshold": 0.85, "decay_days": 90 } with open(f"{MEMORY_ROOT}/config.json", "w") as f: json.dump(config, f, indent=2) # 全局记忆文件 global_mem = f"{MEMORY_ROOT}/global.json" if not os.path.exists(global_mem): with open(global_mem, "w") as f: json.dump([], f) init_memory_store()配置里几个参数值得说明。similarity_threshold是召回的最低相似度,低于这个值的候选直接丢弃。dedup_threshold是去重阈值,两条记忆相似度超过这个值就合并。decay_days是热度衰减周期,超过这个天数没被命中的记忆权重会明显下降。
4.3 采集与压缩的串联实现
采集和压缩在实际运行中是串联的,采集负责捞候选,压缩负责提炼。下面是一个简化但可运行的实现。
def collect_candidates(dialog_turns): """从对话轮次中采集候选记忆""" candidates = [] # 规则通道:捕获明确信号 trigger_phrases = ["记住", "以后都", "这个项目的规范", "不要再", "必须"] for turn in dialog_turns: if any(p in turn["content"] for p in trigger_phrases): candidates.append({ "raw": turn["content"], "source": turn["id"], "channel": "rule" }) # 模型通道:判定模糊情况 # 这里省略模型调用细节,核心是把最近几轮丢给模型判定 model_candidates = judge_by_model(dialog_turns) candidates.extend(model_candidates) return candidates def compress_candidate(candidate): """把候选压缩成自包含记忆""" # 调用模型压缩,提示词见 3.2 节 compressed = call_model_for_compression(candidate["raw"]) return { "content": compressed["text"], "type": compressed["type"], "scope": infer_scope(candidate), "source_ref": candidate["source"] }infer_scope这个函数负责判断记忆的作用域。如果候选里提到了具体项目名或项目相关术语,归为 project;如果是通用偏好,归为 global;其余归为 session。这个判断也可以交给模型做,但规则判断更快更稳。
4.4 检索与注入的完整链路
检索链路是整个系统里调用最频繁的部分,性能要求高。下面把多路召回和重排串起来。
def retrieve_memories(query, project_id=None): """多路召回 + 重排""" query_emb = encode(query) # 第一路:向量召回 vector_hits = vector_search(query_emb, top_k=20) # 第二路:项目约束全取 constraint_hits = [] if project_id: constraint_hits = load_project_constraints(project_id) # 第三路:全局偏好 global_hits = load_global_preferences() # 合并去重 all_hits = merge_and_dedup(vector_hits, constraint_hits, global_hits) # 重排 scored = [] for mem in all_hits: score = ( 0.5 * cosine_sim(query_emb, mem["embedding"]) + 0.2 * type_weight(mem["type"]) + 0.15 * heat_score(mem) + 0.15 * freshness_score(mem) ) scored.append((score, mem)) scored.sort(key=lambda x: x[0], reverse=True) return [m for _, m in scored[:8]] def build_injection_block(memories): """把记忆组织成注入块""" lines = ["[项目记忆]"] for mem in memories: lines.append(f"- ({mem['type']}) {mem['content']}") return "\n".join(lines)重排公式里的四个权重是我调了很多次的结果。语义相似度占一半,因为相关性是根本。类型权重占两成,保证约束类记忆不会被漏掉。热度和新鲜度各占一成半,让常用和新近的记忆有优势。
4.5 记忆热度更新与归档
每次检索命中后,要更新记忆的热度字段。这个动作看似小,但长期运行下来对记忆库质量影响很大。
def update_hit_stats(memory_ids): """更新命中统计""" now = time.time() for mid in memory_ids: mem = load_memory(mid) mem["hit_count"] += 1 mem["last_hit_at"] = now save_memory(mem) def archive_cold_memories(): """归档冷记忆""" threshold = time.time() - 90 * 86400 for mem in load_all_memories(): if mem["last_hit_at"] < threshold and mem["hit_count"] < 3: move_to_archive(mem)归档不是删除,是移到单独的归档文件。归档记忆不参与常规检索,但可以通过显式查询找回。这个设计避免了误删有价值但暂时不活跃的记忆。
5. 常见问题与排查技巧实录
5.1 记忆污染:错误记忆被反复注入
这是最常见也最头疼的问题。某条错误记忆一旦写入,每次检索都可能命中,然后被注入,然后模型基于错误信息回答,用户纠正,但错误记忆还在库里。
排查思路是先定位再修正。定位靠source_ref字段回溯到原始会话,确认这条记忆是怎么产生的。修正有两种方式:直接编辑记忆条目内容,或者删除后重新采集。
预防措施比修正更重要。我的经验是给采集环节加一道确认门槛:模型判定的候选记忆,置信度低于 0.8 的先不直接入库,而是标记为“待确认”,下次相关会话时再确认一次,两次都命中才正式入库。这样能过滤掉大部分误判。
5.2 检索召回率低:该记的没被捞出来
有时候明明库里有相关记忆,但检索就是没命中。原因通常有三个:embedding 模型不适合当前语言、相似度阈值设太高、查询本身太短缺乏语义信息。
排查时先把阈值临时调到 0.3,看能不能召回。如果能召回,说明是阈值问题,逐步往上调找到平衡点。如果还是召不回,检查 embedding 模型是否匹配你的内容语言。中文内容用多语言模型,纯英文可以用更小的英文模型。
查询太短的问题比较隐蔽。比如用户只发了“继续”,这种查询几乎没有语义信息,向量召回基本失效。解决办法是对短查询做查询扩展,把最近几轮对话拼进来一起编码,用扩展后的向量去检索。
5.3 注入过多导致回答质量下降
记忆注入不是越多越好。我实测过注入 15 条和注入 8 条的对比,15 条的时候模型明显开始忽略部分记忆,而且回答变得啰嗦,因为它试图照顾所有注入的信息。
控制注入量的关键是重排要狠。Top-8 是上限,实际很多时候只注入 3 到 5 条就够了。如果发现注入后回答质量下降,先看注入的记忆里有多少是真正相关的。不相关的记忆哪怕只有两三条,也会明显干扰。
另一个技巧是按类型分配名额。约束类最多 3 条,偏好类最多 2 条,事实和决策类共享剩余名额。这样避免某一类记忆挤占全部空间。
5.4 多项目记忆串味
如果你同时维护多个项目,记忆串味是个大问题。项目 A 的架构决策被注入到项目 B 的会话里,会导致模型给出完全错误的建议。
根本解决办法是严格的作用域隔离。检索时 project_id 必须精确匹配,不能模糊匹配。全局记忆要严格控制数量,只放真正跨项目的偏好,比如语言和输出格式,绝不放技术决策。
我踩过的一个坑是:早期为了省事,把一些“通用技术决策”放进了全局记忆,结果这些决策在不适用的项目里被注入,造成了误导。后来我把全局记忆的准入标准提高到“必须与具体技术栈无关”,问题才解决。
5.5 常见问题速查表
| 现象 | 可能原因 | 排查动作 | 解决方向 |
|---|---|---|---|
| 错误记忆反复出现 | 采集误判且未确认 | 查 source_ref 回溯 | 加确认门槛,修正条目 |
| 相关记忆召不回 | 阈值高或查询太短 | 降阈值测试 | 调阈值,做查询扩展 |
| 回答变啰嗦 | 注入过多 | 统计注入条数 | 收紧重排,限名额 |
| 项目间记忆串味 | 作用域隔离不严 | 检查 project_id 匹配 | 精确匹配,严控全局 |
| 记忆库膨胀 | 无归档机制 | 统计条目总数 | 开热度衰减和归档 |
| 写入冲突丢数据 | 多进程并发写 | 查写入日志 | 单进程队列或加锁 |
6. 记忆系统的扩展方向与个人体会
claude-mem 这套设计跑通之后,能扩展的方向其实不少。我试过其中一个:记忆的主动遗忘。不是靠热度衰减被动归档,而是定期让模型审视记忆库,主动判断哪些记忆已经过时或矛盾,标记出来让人确认。这个机制在长周期项目里很有用,因为项目演进过程中,早期决策会被推翻,如果不清理,新旧记忆会打架。
另一个方向是记忆的跨会话关联。现在每条记忆是独立的,但实际上记忆之间有依赖关系。比如“采用方案 A”和“方案 A 的配置参数是 X”是关联的。如果能把关联记忆打包注入,模型的理解会更完整。这个我还在摸索,初步想法是在存储时记录记忆之间的引用关系,检索时做一跳扩展。
最后分享一个实操中总结的小技巧:记忆条目的 content 字段尽量用陈述句,不要用疑问句或条件句。陈述句在注入时最稳定,模型直接当事实用。疑问句会让模型产生“需要回答这个问题”的倾向,条件句则可能因为条件不满足而被忽略。这个细节看起来小,但对注入效果的影响比想象中大。
我在实际使用中最大的体会是,记忆系统的难点从来不在存储和检索的技术实现,而在判断什么值得记。这个判断标准会随着使用场景不断调整,没有一劳永逸的配置。定期回顾记忆库,看看哪些记忆从来没被命中过,哪些被命中了但没起作用,根据这些反馈去调采集和重排策略,才是让系统越用越好用的关键。