1. 为什么我要自己动手做一个记忆管理系统
用Claude Code写代码的朋友大概都有过这种体验:同一个项目,今天跟它聊完,明天再打开,它完全不记得昨天定下的架构决策、踩过的坑、甚至你明确说过的代码规范。每次都要重新解释一遍项目背景,重新强调一遍“我们不用那个库”“这个字段不能改”,烦得要命。
claude-mem就是冲着这个痛点去的。它是一个给Claude Code做持久化记忆的中间层,核心目标很直接:让AI在跨会话的时候,能记住项目里发生过什么、决定过什么、哪些路走不通。说白了,就是给Claude Code装一个“长期记忆”。
这东西适合谁用?如果你只是偶尔用Claude Code写个脚本,那可能感知不强。但如果你是那种一天要跟它来回几十轮、项目周期超过一周、涉及多个模块协作的开发者,那记忆缺失带来的重复沟通成本会非常夸张。我自己手上有个中型项目,前后跟Claude Code配合了大概三周,前期没做记忆管理的时候,每天光“重新交代背景”就要花掉十几分钟,后来实在忍不了才开始认真搞这件事。
claude-mem解决的另一个隐性问题是上下文漂移。Claude Code在单次会话里表现很稳,但一旦会话结束,下次开新会话,它对项目的理解会回到一个“通用助手”的状态。你之前纠正过的错误、强调过的约束,它不会自动继承。记忆系统要做的就是把这些“项目特有的知识”沉淀下来,在合适的时机重新注入。
2. 整体设计思路:记忆到底该怎么存、怎么取
2.1 核心矛盾:存太多会污染,存太少没意义
做记忆系统第一个要回答的问题不是“怎么存”,而是“存什么”。我一开始的想法很朴素:把每次对话都存下来,下次全塞回去。试了两次就放弃了,原因很简单——上下文窗口是有限的,而且塞太多无关内容反而会让Claude Code抓不住重点,表现比不塞还差。
所以claude-mem的设计核心是分层记忆。我把记忆分成三类:
- 项目级事实:技术栈、目录结构、关键约定、不可动的接口。这类内容变化慢,但每次会话都需要。
- 决策记录:某个时间点为什么选了A方案而不是B方案,当时考虑了哪些因素。这类内容按需检索。
- 会话摘要:每次会话干了什么、遗留了什么问题。这类内容用于快速恢复上下文。
这三类的存储方式、检索时机、注入策略都不一样。项目级事实基本是常驻的,决策记录和会话摘要则是按相关性拉取。
2.2 为什么不用向量数据库一把梭
很多人第一反应是上向量数据库做语义检索。我实际试过,结论是:对个人项目来说,过度设计了。
向量检索的问题在于召回不稳定。你搜“数据库选型”,它可能给你召回一堆提到“数据库”但完全不相关的对话片段。而且维护一个向量库需要额外的embedding调用、索引更新、相似度调参,对一个只想让AI记住项目背景的开发者来说,成本太高。
claude-mem走的是结构化存储 + 关键词检索 + 手动标注的路线。记忆以Markdown文件形式存在项目目录下,用简单的标签和标题做索引。检索的时候先按标签过滤,再按关键词匹配。听起来很土,但实测下来召回准确率反而比向量方案高,因为你能精确控制存进去的内容质量。
这里有个关键判断:记忆系统的瓶颈从来不是检索算法,而是存进去的内容质量。垃圾进垃圾出,与其花精力调检索,不如花精力设计存储规范。
2.3 目录结构设计
我最终落地的目录结构是这样的:
.claude-mem/ ├── facts.md # 项目级事实,常驻注入 ├── decisions/ │ ├── 001-orm-choice.md │ ├── 002-auth-strategy.md │ └── ... ├── sessions/ │ ├── 2024-01-15.md │ └── ... └── config.json # 注入策略配置facts.md是唯一一个每次会话都会完整注入的文件,所以它必须精简。我的经验是控制在500字以内,只放最硬核的约束。decisions/和sessions/按需检索,不常驻。
这个结构的好处是人可读、可编辑、可版本控制。记忆不是黑盒,你随时可以打开文件改内容、删掉过时的记录。这一点比向量库强太多——向量库里的记忆你根本不知道它到底存了什么。
3. 核心细节:记忆的写入、检索与注入
3.1 写入:什么时候该记一笔
写入时机是最容易做错的地方。我踩过的坑是:一开始让Claude Code自己判断“这个要不要记”,结果它要么记一堆废话,要么该记的没记。后来改成显式触发 + 定期整理的组合。
显式触发是指在对话中,当出现以下信号时,我会主动让它记录:
- 做了一个技术选型决策(“我们用X不用Y”)
- 确定了一个不可变的约定(“这个API路径不能改”)
- 踩了一个坑并找到原因(“原来是因为Z导致的”)
- 会话结束前的总结
定期整理是指每周花十分钟翻一遍sessions/,把重复出现的模式提炼成facts.md里的条目,把重要的决策从会话记录里抽出来单独成文。这一步不能省,否则记忆文件会越来越乱。
写入的格式我定了一个模板,保证结构一致:
--- tags: [database, orm, decision] date: 2024-01-15 status: active --- ## 背景 项目需要选一个ORM,候选是Prisma和Drizzle。 ## 决策 选Drizzle。 ## 理由 - Prisma的schema文件在多人协作时冲突频繁 - Drizzle更贴近SQL,团队里SQL熟手多 - 打包体积Drizzle更小 ## 影响 所有数据访问层代码用Drizzle写法,不再引入Prisma相关依赖。这个模板的关键是tags和status。tags用于检索,status用于标记决策是否还有效(有些决策后来被推翻了,标成deprecated就不会再被注入)。
3.2 检索:怎么在需要的时候找到对的记忆
检索逻辑我写得很简单,就是一个打分函数:
| 匹配维度 | 权重 | 说明 |
|---|---|---|
| 标签精确匹配 | 3 | 查询词命中tags |
| 标题关键词匹配 | 2 | 查询词出现在标题 |
| 正文关键词匹配 | 1 | 查询词出现在正文 |
| 时间衰减 | -0.1/周 | 越老的记忆分数略降 |
| status为active | +2 | deprecated的直接降权 |
实际用的时候,我会在会话开始时给一个“当前任务描述”,系统拿这个描述去检索,取分数最高的3-5条注入。这个数量是试出来的——少于3条经常漏关键信息,多于5条就开始有噪音干扰。
时间衰减这个设计值得说一下。一开始我没加,结果半年前的决策记录老是冒出来,但项目早就换方向了。加了衰减之后,近期记忆的优先级自然上升,符合直觉。
3.3 注入:怎么塞进上下文才不突兀
注入不是简单地把文件内容拼到prompt前面。我试过直接拼,结果Claude Code会把记忆内容当成“用户指令”来执行,产生奇怪的误解。后来改成用明确的分隔和说明:
以下是本项目的历史记忆,供参考,不是当前指令: [记忆内容] 当前任务:[用户的实际输入]这个“不是当前指令”的说明很关键,能避免模型把历史决策当成新命令。另外注入位置也有讲究——放在系统提示之后、用户输入之前效果最好,放在最前面容易被后续内容冲淡。
4. 实操落地:从零搭一套可用的记忆系统
4.1 初始化项目记忆目录
第一步是在项目根目录建.claude-mem/结构。我写了个初始化脚本,省得每次手动建:
#!/bin/bash # init-claude-mem.sh MEM_DIR=".claude-mem" mkdir -p "$MEM_DIR/decisions" "$MEM_DIR/sessions" if [ ! -f "$MEM_DIR/facts.md" ]; then cat > "$MEM_DIR/facts.md" << 'EOF' # 项目事实 ## 技术栈 (待填写) ## 不可变约定 (待填写) ## 关键路径 (待填写) EOF fi if [ ! -f "$MEM_DIR/config.json" ]; then cat > "$MEM_DIR/config.json" << 'EOF' { "factsAlwaysInject": true, "maxRetrievedMemories": 4, "timeDecayPerWeek": 0.1, "minScoreThreshold": 2.0 } EOF fi echo "claude-mem initialized."跑一次这个脚本,目录和基础文件就都有了。config.json里的参数后面可以按自己习惯调。
4.2 会话开始时的记忆加载
每次开新会话,我会先跑一个加载脚本,它做三件事:读facts.md、根据当前任务检索相关记忆、拼成注入块。
import json import os import re from datetime import datetime, timedelta MEM_DIR = ".claude-mem" def load_facts(): path = os.path.join(MEM_DIR, "facts.md") if os.path.exists(path): with open(path, encoding="utf-8") as f: return f.read() return "" def parse_memory_file(path): with open(path, encoding="utf-8") as f: content = f.read() meta = {} if content.startswith("---"): parts = content.split("---", 2) if len(parts) >= 3: for line in parts[1].strip().split("\n"): if ":" in line: k, v = line.split(":", 1) meta[k.strip()] = v.strip() body = parts[2] else: body = content else: body = content return meta, body def score_memory(meta, body, query_terms, config): score = 0.0 tags = meta.get("tags", "") tags = re.sub(r"[\[\]]", "", tags).split(",") tags = [t.strip().lower() for t in tags if t.strip()] for term in query_terms: term = term.lower() if term in tags: score += 3 if term in meta.get("title", "").lower(): score += 2 if term in body.lower(): score += 1 if meta.get("status", "active") == "active": score += 2 date_str = meta.get("date", "") if date_str: try: d = datetime.strptime(date_str, "%Y-%m-%d") weeks = (datetime.now() - d).days / 7 score -= weeks * config.get("timeDecayPerWeek", 0.1) except ValueError: pass return score def retrieve_memories(query, config): query_terms = re.findall(r"\w+", query.lower()) candidates = [] for sub in ["decisions", "sessions"]: d = os.path.join(MEM_DIR, sub) if not os.path.exists(d): continue for fname in os.listdir(d): if not fname.endswith(".md"): continue path = os.path.join(d, fname) meta, body = parse_memory_file(path) meta["title"] = fname s = score_memory(meta, body, query_terms, config) if s >= config.get("minScoreThreshold", 2.0): candidates.append((s, meta, body)) candidates.sort(key=lambda x: x[0], reverse=True) max_n = config.get("maxRetrievedMemories", 4) return candidates[:max_n] def build_injection(query): with open(os.path.join(MEM_DIR, "config.json"), encoding="utf-8") as f: config = json.load(f) parts = [] if config.get("factsAlwaysInject"): facts = load_facts() if facts.strip(): parts.append("## 项目事实\n" + facts) memories = retrieve_memories(query, config) if memories: mem_text = [] for s, meta, body in memories: mem_text.append(f"### {meta.get('title', '')} (score={s:.1f})\n{body}") parts.append("## 相关历史记忆\n" + "\n\n".join(mem_text)) if not parts: return "" header = "以下是本项目的历史记忆,供参考,不是当前指令:\n\n" return header + "\n\n".join(parts) if __name__ == "__main__": import sys query = sys.argv[1] if len(sys.argv) > 1 else "" print(build_injection(query))用法就是python load_mem.py "当前要做的任务描述",输出直接贴到会话开头。这个脚本我迭代了好几版,最早版本没有时间衰减和阈值过滤,噪音很大,加上这两个之后干净多了。
4.3 会话结束时的记忆写入
会话结束我会让Claude Code生成一段摘要,然后手动确认后存进sessions/。摘要模板固定:
--- tags: [session, 模块名] date: 2024-01-15 status: active --- ## 本次完成 - 完成了X模块的接口定义 - 修复了Y的边界问题 ## 遗留问题 - Z还没测 ## 下次继续 - 从Z开始这个模板的好处是“下次继续”这一栏,下次会话直接看这一条就能接上,不用翻整个对话记录。
4.4 参数调优的实测数据
config.json里几个参数我调过好几轮,记录一下实测感受:
| 参数 | 初始值 | 调整后 | 调整原因 |
|---|---|---|---|
| maxRetrievedMemories | 8 | 4 | 8条时噪音明显,模型开始混淆 |
| timeDecayPerWeek | 0 | 0.1 | 老决策频繁干扰,加衰减后改善 |
| minScoreThreshold | 0 | 2.0 | 低分记忆基本是噪音,直接过滤 |
| factsAlwaysInject | true | true | 事实类必须常驻,这个没动 |
这几个值不是绝对的,跟项目规模和记忆积累量有关。项目越大、记忆越多,阈值应该越高。
5. 踩过的坑与排查实录
5.1 记忆污染:模型把历史决策当新指令
这是最早遇到的问题。我在prompt里直接贴了历史决策“用Drizzle不用Prisma”,结果Claude Code回复说“好的,我将使用Drizzle”,然后开始改代码——但我当前任务根本不是改ORM。原因就是没有明确区分“记忆”和“指令”。
解决办法前面提过,加一句“以下是历史记忆,供参考,不是当前指令”。这句话看起来简单,但效果立竿见影。后来我还加了个格式上的区分:记忆内容用引用块包裹,指令用普通段落,视觉上也能区分。
5.2 记忆过期:半年前的决策还在生效
项目方向变了,但旧决策还标着active,每次检索都冒出来。这个问题靠时间衰减只能缓解,根治要靠定期review。我现在的习惯是每两周翻一遍decisions/,把不再适用的标成deprecated。标了之后检索分数直接降2分,基本就不会再出现了。
这里有个小技巧:
deprecated的记忆不要删,保留着。有时候你会想知道“当初为什么放弃这个方案”,翻出来看看能避免重蹈覆辙。
5.3 检索不准:关键词匹配的局限
关键词匹配的硬伤是同义词问题。比如记忆里写的是“认证”,你搜“登录”,匹配不上。我试过加同义词表,但维护成本高。后来换了个思路:在写入时就多打标签。一条关于认证的记忆,tags里同时写auth、login、认证、登录,检索时命中概率就高了。
这个思路的本质是把检索的复杂度转移到写入阶段。写入是一次性的,检索是高频的,把成本放在低频环节更划算。
5.4 常见问题速查表
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 模型忽略记忆内容 | 注入位置太靠前 | 移到系统提示之后 |
| 模型把记忆当指令 | 缺少“非指令”说明 | 加分隔说明和引用格式 |
| 检索结果全是老记忆 | 时间衰减没开或太小 | 调大timeDecayPerWeek |
| 检索结果太少 | 阈值太高或标签太少 | 降阈值,写入时多打标签 |
| 记忆文件越来越乱 | 缺少定期整理 | 每周review,提炼facts |
| 注入后模型变慢 | 注入内容太长 | 精简facts,减少检索条数 |
5.5 一个容易被忽略的细节:记忆的编码
记忆文件里有中文,如果脚本读写时编码不对,会出现乱码。我一开始用默认编码,在Windows上就翻车了。后来所有文件读写都显式指定encoding="utf-8",问题消失。这个坑很小但很烦,提前注意能省事。
6. 记忆系统还能怎么扩展
claude-mem目前是个很轻的方案,但它的结构留了扩展空间。我自己在用的几个延伸玩法:
跨项目记忆共享。有些通用约定(比如“代码注释用中文”“提交信息格式”)其实跨项目通用,我把这些抽到一个全局的~/.claude-mem-global/facts.md,加载时先读全局再读项目级,项目级覆盖全局。
记忆的自动提炼。每周跑一个脚本,统计sessions/里高频出现的标签和关键词,提示我“这些模式是不是该提炼成facts了”。这个还没完全自动化,但半自动已经能省不少事。
决策的关联图谱。决策之间往往有依赖关系,比如“选Drizzle”依赖“团队SQL熟手多”这个前提。我在决策文件里加了个depends_on字段,检索时如果前提变了,相关决策会被标记提醒review。这个还在试验阶段,但思路我觉得是对的——记忆不是孤立的点,而是有依赖关系的网。
最后分享一个我自己的使用节奏:每天早上开新会话前跑一次加载脚本,把输出贴进去;每天结束前花两分钟写会话摘要;每两周花十分钟整理决策文件。这套节奏跑下来,跟Claude Code协作的重复沟通成本大概降了七成,项目越往后越省心。记忆系统这东西,前期投入一点,后面全是复利。