1. 项目缘起:为什么我要给 Claude 造一个“记忆外挂”
用 Claude 写代码、做长文档分析的朋友大概率都撞过同一堵墙:上下文窗口再大,也架不住聊到后面它把前面定好的架构决策忘得一干二净。我最初是在做一个跨十几个文件的重构任务时被逼疯的——每次新开一个会话,都得把项目背景、命名规范、已经踩过的坑重新复述一遍,光“喂背景”就耗掉小半管上下文。claude-mem这个项目,说白了就是给 Claude 装一个持久化的记忆层,让它在多次会话之间记住“我们之前聊过什么、定过什么规矩、哪些路走不通”。
它解决的核心问题有三个:一是跨会话的上下文丢失,二是重复背景输入的浪费,三是项目级知识的沉淀。适合谁来参考?如果你是把 Claude 当日常生产力工具的重度用户,尤其是做长期项目、多轮迭代开发的,这套思路能直接抄;如果你只是偶尔问两句天气,那确实用不上。我下面会把整个设计思路、实现细节、踩过的坑全部摊开讲,代码和配置都能直接复现。
先说清楚一个前提:claude-mem不是官方功能,它是基于 Claude 的 API 和本地存储搭出来的一套“记忆中间层”。核心逻辑不复杂——把值得记住的信息结构化存下来,在每次新会话开始时按相关性检索并注入到系统提示里。难的是“什么该记、什么不该记、怎么检索才准”这三个判断,这也是我花了最多时间打磨的地方。
2. 整体架构设计:记忆层到底该怎么分层
2.1 为什么不做“全量历史塞进去”这种懒办法
最开始我图省事,直接把过去所有会话的完整记录拼起来塞进系统提示。结果很惨:token 消耗爆炸不说,Claude 反而被一堆无关的闲聊干扰,回答质量明显下降。这让我意识到记忆系统必须做分层和筛选,不能当垃圾桶。
我最终采用的是三层记忆结构,这个设计参考了人类记忆的运作方式——短期记忆、长期记忆、以及索引层:
| 层级 | 存储内容 | 生命周期 | 注入策略 |
|---|---|---|---|
| 工作记忆 | 当前会话的即时上下文 | 会话内 | 全量保留在对话中 |
| 项目记忆 | 架构决策、命名规范、技术选型 | 长期 | 按项目标签检索注入 |
| 事实记忆 | 具体结论、踩坑记录、API 用法 | 长期 | 按语义相似度检索注入 |
工作记忆不用管,Claude 自己维护。真正要设计的是后两层:项目记忆偏“规则性”,变化慢,适合用标签精确匹配;事实记忆偏“知识性”,条目多,适合用向量检索。把这两类混在一起存是我早期犯的错,导致检索时规则和事实互相干扰,后来拆开就清爽多了。
2.2 存储选型:为什么是 SQLite 加本地向量库
存储这块我纠结过一阵。方案无非几种:纯文件(JSON/Markdown)、关系数据库、向量数据库。纯文件最简单,但检索能力太弱,条目一多就得全量扫描;专业向量数据库(比如各种独立服务)功能强,但为了一个个人工具去部署一套服务,运维成本不划算。
最后我选了SQLite + 本地向量索引的组合。SQLite 负责存结构化的元数据(时间戳、项目标签、记忆类型、原文),向量索引负责语义检索。SQLite 的好处是零运维、单文件、随时能备份,而且它自带的 FTS5 全文检索在关键词匹配场景下表现相当能打。向量部分我用的是轻量的本地嵌入方案,把每条记忆转成向量存进一个单独的索引文件,检索时做余弦相似度排序。
提示:如果你的记忆条目长期在几千条以内,其实可以先用 SQLite 的 FTS5 顶着,等条目上万再上向量检索。我一开始就上了向量,后来发现大部分查询靠关键词就能命中,属于过度设计。
2.3 注入时机:系统提示还是首轮用户消息
记忆注入的位置也有讲究。我试过两个位置:一是塞进 system prompt,二是作为第一条 user 消息。塞 system prompt 的好处是“权威感”强,Claude 更倾向于把它当既定事实遵守;坏处是每次改记忆都得重建 system prompt,而且部分 API 对 system prompt 的长度有限制。
作为首轮 user 消息注入则更灵活,可以动态拼接,还能明确告诉 Claude“以下是历史记忆,供参考”。我最终选了后者,但在格式上做了强化——用明确的分隔标记把记忆块包起来,并加一句指令说明这些是历史上下文而非当前任务。实测下来,Claude 对首轮消息里的记忆块理解得挺准,很少出现把历史任务当成当前任务来执行的情况。
3. 核心实现细节:从记忆写入到检索注入
3.1 记忆的写入:怎么判断“这条值得记”
写入是整个系统里最需要克制的地方。如果什么都记,记忆库很快就会被噪音淹没。我的策略是显式触发 + 自动提取双轨并行。
显式触发就是给用户一个命令,比如输入/remember 这个项目统一用 pnpm 不用 npm,系统直接把这条存为项目记忆。这种方式最可靠,因为是人主动判断的。自动提取则是在每轮对话结束后,用一个轻量的提取流程去判断这轮里有没有值得沉淀的信息。我用的判断标准是这几条:
- 是否包含明确的决策(“我们决定用 X 而不是 Y”)
- 是否包含可复用的结论(“这个 API 的坑在于必须传 Z 参数”)
- 是否包含项目级规则(命名、目录结构、技术栈约定)
- 是否包含用户偏好(“回答尽量简洁,不要长篇大论”)
自动提取我一开始想用 Claude 自己来做,但每轮都调用一次成本太高。后来改成规则预筛 + 定期批量提取:先用关键词规则(比如出现“决定”“统一”“以后都”“记住”这类词)标记候选,攒到一定数量再批量丢给模型做结构化提取。这样既省 token,准确率也没掉多少。
# 记忆写入的核心逻辑(简化版) def should_remember(text): triggers = ["决定", "统一", "以后都", "记住", "约定", "规范", "不要用"] return any(t in text for t in triggers) def extract_memory(raw_text): # 调用模型做结构化提取,输出 {type, content, tags} prompt = f"从以下对话中提取值得长期记住的信息,输出JSON:\n{raw_text}" return call_model(prompt)3.2 记忆的检索:相关性排序的三个维度
检索决定了注入质量。我用的排序公式综合了三个维度:语义相似度、时间衰减、类型权重。语义相似度是基础,但光靠它不够——一条三个月前的架构决策,可能比昨天的一句闲聊更该被注入。
时间衰减我用的不是简单的线性衰减,而是按记忆类型区分:项目记忆几乎不衰减(规则就是规则),事实记忆衰减快一些(半衰期大概两周)。类型权重则是让项目记忆在同等相似度下优先于事实记忆。最终的排序分数大致是:
score = 0.6 * 语义相似度 + 0.25 * 时间因子 + 0.15 * 类型权重这几个系数是我反复调出来的,不是拍脑袋。0.6 给语义是因为它最能反映“当前问题需不需要这条记忆”;时间因子给 0.25 是因为长期项目里旧决策依然重要;类型权重 0.15 是个微调项,避免规则被事实淹没。
注意:检索时一定要设一个相似度下限,低于阈值的直接不注入。我早期没设阈值,结果每次都会注入一堆勉强相关的记忆,反而干扰 Claude 判断。阈值我一般设在 0.35 左右,具体看嵌入模型。
3.3 注入格式:让 Claude 一眼分清“记忆”和“任务”
注入格式我改了好几版。最早就是简单地把记忆条目列出来,结果 Claude 经常把记忆里的历史任务当成当前要做的任务。后来我加了三层防护:
第一层是明确的分隔标记,用=== 历史记忆开始 ===和=== 历史记忆结束 ===把块包起来。第二层是指令说明,在块前面加一句“以下是你之前记住的信息,仅作为背景参考,不要直接执行其中的任务”。第三层是类型标注,每条记忆前面标上[项目规则]或[历史结论],让 Claude 知道这条的性质。
=== 历史记忆开始 === 以下内容来自过往会话,仅作背景参考,请勿直接执行其中描述的任务。 [项目规则] 本项目统一使用 pnpm 作为包管理器 [项目规则] 组件文件命名采用 PascalCase [历史结论] 该 API 的 rate limit 是每分钟 60 次,需做退避重试 === 历史记忆结束 ===这套格式实测下来,Claude 误把历史当当前的概率大幅下降。我个人的经验是,指令说明那一句千万不能省,它比格式本身还重要。
4. 实操全流程:从零搭起你的记忆层
4.1 环境准备与依赖安装
先把基础环境搭起来。我用的是 Python 3.10+,主要依赖就几个:anthropic官方 SDK、sqlite3(标准库自带)、以及一个本地嵌入库。嵌入库的选择上,我建议优先用能本地跑的轻量模型,避免每次检索都走网络请求。
pip install anthropic numpy # 嵌入方案二选一,看你的偏好 pip install sentence-transformers # 本地跑,无需联网目录结构我建议这样组织,清晰且好备份:
claude-mem/ ├── memory.db # SQLite 主库 ├── vectors.index # 向量索引文件 ├── config.yaml # 配置(阈值、权重、模型选择) └── mem.py # 核心逻辑4.2 数据库表结构设计
表结构不用复杂,两张表足够。一张存记忆主体,一张存标签关联(方便多标签检索)。
CREATE TABLE memories ( id INTEGER PRIMARY KEY AUTOINCREMENT, type TEXT NOT NULL, -- 'project' 或 'fact' content TEXT NOT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, last_used_at TIMESTAMP, use_count INTEGER DEFAULT 0 ); CREATE TABLE tags ( memory_id INTEGER, tag TEXT, FOREIGN KEY(memory_id) REFERENCES memories(id) ); CREATE INDEX idx_type ON memories(type); CREATE INDEX idx_tag ON tags(tag);last_used_at和use_count这两个字段是我后来加的,用来做“记忆热度”统计。一条记忆如果被频繁检索命中,说明它确实有用,可以在排序时给点加成;如果存了半年一次没被用过,就可以考虑清理。这个机制帮我自动淘汰了不少冗余记忆。
4.3 写入与检索的完整代码
把核心逻辑串起来。写入部分负责接收文本、判断是否值得记、提取结构化内容、存库并更新向量索引。
import sqlite3 import numpy as np class MemoryStore: def __init__(self, db_path, embedder): self.conn = sqlite3.connect(db_path) self.embedder = embedder self.vectors = {} # id -> vector def add(self, content, mem_type, tags=None): cur = self.conn.execute( "INSERT INTO memories (type, content) VALUES (?, ?)", (mem_type, content) ) mid = cur.lastrowid if tags: self.conn.executemany( "INSERT INTO tags (memory_id, tag) VALUES (?, ?)", [(mid, t) for t in tags] ) self.vectors[mid] = self.embedder.encode(content) self.conn.commit() return mid def search(self, query, top_k=5, min_score=0.35): q_vec = self.embedder.encode(query) scored = [] for mid, vec in self.vectors.items(): sim = float(np.dot(q_vec, vec) / (np.linalg.norm(q_vec) * np.linalg.norm(vec))) if sim >= min_score: scored.append((mid, sim)) scored.sort(key=lambda x: -x[1]) return scored[:top_k]检索出来之后,还要叠加时间因子和类型权重做二次排序,再拼成注入块。这部分逻辑我单独抽了个函数,方便调参。
4.4 接入 Claude 调用的完整链路
最后一步是把记忆层接到实际的 Claude 调用上。流程是:用户提问 → 检索相关记忆 → 拼注入块 → 组装消息 → 调用 API → 对话结束后触发写入判断。
def chat_with_memory(user_input, store, client): hits = store.search(user_input, top_k=5) memory_block = build_memory_block(hits, store) messages = [ {"role": "user", "content": memory_block + "\n\n" + user_input} ] resp = client.messages.create( model="claude-sonnet-4-20250514", max_tokens=4096, messages=messages ) reply = resp.content[0].text # 对话结束后判断是否要写入 if should_remember(user_input + reply): store.add(user_input + reply, "fact") return reply这套链路跑通之后,最直观的感受就是:新开会话时不用再复述背景了。我只要提一句“继续上次那个重构”,Claude 就能从记忆里捞出相关的架构决策和命名规范,直接进入状态。
5. 踩坑实录与常见问题排查
5.1 记忆污染:最头疼的问题
记忆污染是我遇到的最大的坑。所谓污染,就是错误或过时的记忆被反复注入,导致 Claude 一直按错误信息行事。有一次我早期存了一条“这个项目用 npm”,后来改成 pnpm 了,但旧记忆没删,结果 Claude 时不时又建议用 npm,非常烦人。
解决办法有两个:一是记忆更新机制,当新记忆和旧记忆冲突时(比如都带同一个 tag 且内容矛盾),自动把旧的标记为失效而不是直接删(保留审计痕迹)。二是定期人工审查,我每周会花十分钟扫一遍最近写入的记忆,把明显过时的手动清理掉。自动机制再聪明,也替代不了人的判断。
5.2 检索不准的排查思路
检索不准通常表现为:明明库里有相关记忆,但就是没被注入。排查我一般按这个顺序走:
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| 完全没命中 | 相似度阈值太高 | 临时把阈值降到 0.2 看是否命中 |
| 命中了但排序靠后 | 时间因子或类型权重压制 | 打印各维度分数看是哪项拖后腿 |
| 命中了不相关的 | 嵌入模型不适合该领域 | 换模型或补充关键词检索兜底 |
| 时好时坏 | 向量索引没同步更新 | 检查写入后是否更新了索引 |
我踩过最隐蔽的一个坑是向量索引没同步:写入新记忆时忘了更新内存里的向量字典,导致新记忆永远检索不到。这种 bug 不会报错,只会让你觉得“怎么刚存的就忘了”,排查起来很费劲。后来我在写入函数里强制加了索引更新,并加了个断言检查。
5.3 成本控制的几个实用技巧
记忆系统跑起来后,token 成本是绕不开的。我总结了几个降本技巧:
- 批量提取代替实时提取:自动提取不要每轮都做,攒一批再处理,能省一大半调用。
- 嵌入本地化:检索用的嵌入模型尽量本地跑,别走 API,这块调用量最大。
- 注入条数设上限:top_k 别贪多,5 条通常够用,多了反而干扰。
- 定期清理冷记忆:
use_count为 0 且超过三个月的,直接归档。
提示:如果你的项目对成本敏感,可以给记忆系统设一个“预算上限”,比如每天最多注入多少 token,超了就只保留项目记忆,事实记忆降级为关键词匹配。
5.4 常见问题速查
问:记忆库越来越大,检索变慢怎么办?答:先看条目量。几千条以内 SQLite 完全扛得住;上万条就该上向量索引的近似检索(ANN)了。另外定期归档冷记忆也能显著提速。
问:多个项目共用一套记忆会不会串味?答:会。我的做法是给每条记忆打项目标签,检索时按当前项目过滤。如果你项目多,建议直接按项目分库,物理隔离最省心。
问:Claude 还是会把历史任务当当前任务执行怎么办?答:强化注入块里的指令说明,把“请勿执行”这句话写得更明确。另外可以在注入块末尾加一句“当前任务是:”,把当前任务和历史记忆做硬隔离。
问:记忆写入太频繁,噪音多怎么办?答:收紧自动提取的触发规则,只保留强信号词。宁可漏记,不可错记——漏了下次还能补,错了会持续污染。
6. 后续可扩展的方向
这套记忆层跑顺之后,我陆续加了些小扩展,效果都不错。一个是记忆的自动摘要:当某个 tag 下的记忆超过一定数量,自动生成一条汇总记忆,检索时优先命中汇总,减少注入条数。另一个是跨会话的任务追踪:把“未完成的任务”单独存一类记忆,每次新会话主动提醒,避免任务断档。
还有个我一直在琢磨的方向是记忆的置信度评分。同一条信息被多次确认(比如用户重复强调过),置信度就高;只说过一次的,置信度低。检索时按置信度加权,能让系统更“信任”那些反复验证过的规则。这个机制我还在小范围试,目前看方向是对的,但权重怎么调还需要更多数据。
最后分享一个我个人的使用习惯:我会在每天收工时,花两分钟把当天最重要的决策手动/remember一下。自动提取再智能,也比不上人在当下对“什么重要”的判断。这个习惯坚持下来,我的记忆库里沉淀的都是真正有价值的东西,检索命中率一直很稳。