1. 从零认识 claude-mem:它到底解决什么问题
第一次看到claude-mem这个名字,很多人会以为它又是一个套壳的对话客户端。其实不是。claude-mem的核心定位是给 Claude 这类大语言模型补上一块“长期记忆”的拼图。用过 Claude 做长期项目的人应该都有体会:每次开新会话,它就像失忆一样,昨天聊过的架构决策、上周定下的命名规范、上个月踩过的坑,统统不记得。你得反复把背景贴进去,token 烧得快,人还累。
claude-mem要干的事情很朴素——把对话里值得留存的信息抽出来,存到一个可检索、可管理的地方,等下次需要的时候再按需喂回给模型。它解决的是“上下文窗口有限”和“跨会话记忆断裂”这两个老大难问题。适合谁来参考?我觉得三类人最该看:一是拿 Claude 做长期开发辅助的工程师,二是想给自己的 AI 工作流加记忆层的独立开发者,三是研究 RAG(检索增强生成)落地细节的技术爱好者。
这里要先说清楚一个前提:claude-mem不是一个官方产品,它更像是一类项目模式的代称——围绕 Claude 构建记忆管理层的开源实践。所以下面我讲的架构、参数、踩坑,都是基于这类项目最常见的实现路径来展开的,具体到某个仓库可能有差异,但底层逻辑是通的。
我个人的判断是,记忆层这件事,未来会像日志系统一样成为 AI 应用的标配。现在不做,等上下文需求爆炸的时候再补,成本会高得多。所以早点把claude-mem这类方案摸透,是有长期价值的。
2. 记忆层的整体设计与思路拆解
2.1 为什么不能只靠加大上下文窗口
很多人第一反应是:上下文窗口不是越来越大吗,等它涨到几百万 token 不就行了?这个想法我试过,结论是行不通。原因有三层。
第一层是成本。上下文窗口再大,你每次请求都要把全部历史塞进去,token 是按量计费的,长期项目的历史动辄几十万 token,每轮对话都重发一遍,账单会非常难看。第二层是注意力衰减。模型对超长上下文中间部分的关注度是下降的,业内叫“lost in the middle”,你把关键决策埋在十万 token 的位置,模型很可能根本没注意到。第三层是噪声。历史里大量寒暄、试错、废弃方案,混在有效信息里,反而干扰模型判断。
所以claude-mem的思路不是“塞更多”,而是“存起来、挑着用”。这跟人脑的工作方式很像:你不会记住今天说过的每一句话,但你会记住结论和教训,需要时再回忆。
2.2 三层记忆架构的设计考量
我参考过的几个claude-mem实现,基本都采用三层结构,这个设计我认为是合理的:
- 短期记忆(会话内):就是当前对话的原始消息,直接放在上下文里,不做压缩。这层负责连贯性,保证当前这轮对话不跑题。
- 中期记忆(会话摘要):一次会话结束后,把整段对话压缩成结构化摘要,比如“讨论了什么、决定了什么、遗留了什么问题”。这层负责跨会话的连续性。
- 长期记忆(知识条目):从摘要里再提炼出可复用的原子事实,比如“项目使用 PostgreSQL 15”“API 鉴权用 JWT 且过期时间 2 小时”。这层负责长期沉淀,可检索、可更新、可删除。
为什么要分三层而不是一层?因为不同信息的生命周期不一样。会话内的细节几小时后就没用了,但“用 JWT”这个决策可能半年都有效。混在一起存,检索时噪声太大;分层存,每层用不同的淘汰策略和检索权重,精度会高很多。
2.3 存储选型:向量库还是关系库
这是绕不开的一个决策。我的经验是:两者都要,各管一段。
向量库(比如 Chroma、Qdrant、pgvector)负责语义检索,解决“我记得聊过类似的东西但想不起原话”这种模糊查询。关系库(SQLite、PostgreSQL)负责精确查询和元数据管理,比如“查一下上周三之后所有标记为 decision 的记忆条目”。
只上向量库的坑我踩过:语义检索会召回一堆“意思相近但事实错误”的条目,比如你问“数据库版本”,它可能把“我们考虑过 MySQL 但放弃了”也召回,因为语义上都是数据库相关。加上关系库做元数据过滤(比如只查type=fact且status=active的条目),精度立刻上来了。
提示:如果项目规模不大,直接用 SQLite + sqlite-vec 扩展就够了,省去维护独立向量服务的麻烦。等条目超过十万再考虑迁移到专用向量库。
3. 核心细节解析与实操要点
3.1 记忆抽取:什么时候抽、抽什么
抽取时机很关键。我的做法是双触发:一是会话空闲超过 N 分钟(比如 10 分钟)自动触发摘要,二是用户手动执行/memorize命令强制触发。只靠自动触发的问题是,用户可能聊到一半去干别的,回来继续聊,中间被截断成两段摘要,逻辑就断了。手动触发给了用户控制权。
抽取内容我建议聚焦四类,这是从实际使用中总结出来的:
| 类型 | 说明 | 示例 |
|---|---|---|
| fact | 客观事实 | 项目用 Python 3.11 |
| decision | 已定决策 | 选 FastAPI 而非 Flask |
| preference | 用户偏好 | 代码注释用中文 |
| todo | 待办事项 | 下周补单元测试 |
为什么要分类?因为检索时权重不同。decision和preference的优先级应该高于普通fact,而todo需要单独的任务视图。不分类型,检索就是一团乱麻。
抽取的 prompt 设计有个技巧:要求模型输出 JSON,并强制包含 confidence 字段。低于 0.6 置信度的条目直接丢弃,宁可漏存也不要存错。我早期没做这个过滤,结果存进去一堆模型“猜测”的内容,后来检索时被误导了好几次。
3.2 去重与冲突处理
这是最容易被忽视、但最影响体验的环节。同一个事实会被反复抽取,比如你每次聊到数据库都会提到“用 PostgreSQL”,存十遍没意义。更麻烦的是冲突:上周说“用 PostgreSQL”,这周改成了“迁移到 MySQL”,两条都在库里,检索时返回哪条?
我的处理策略是基于语义相似度的合并 + 时间戳覆盖:
- 新条目入库前,先用向量检索找相似度 > 0.9 的已有条目。
- 如果找到且内容一致,只更新
last_seen时间戳,不新增。 - 如果找到但内容冲突,把旧条目标记为
superseded,新条目标记active,并记录supersedes指向旧条目。 - 检索时默认只返回
active条目。
这样既保留了历史(可以追溯“什么时候改的”),又不会让旧信息干扰当前判断。superseded这个状态字段是我后来加的,一开始直接删除旧条目,结果有次想回溯决策过程,发现历史没了,很尴尬。
3.3 检索注入:怎么喂回给模型
检索到的记忆怎么放进 prompt,直接影响效果。我试过三种方式:
- 全量拼接:把所有相关记忆拼成一段文本放 system prompt。简单,但记忆多了会挤占上下文。
- 摘要注入:只注入记忆的摘要版本。省 token,但细节丢失。
- 分层注入:高优先级记忆全文注入,低优先级只注入标题,模型需要时再主动查询。
实测下来分层注入效果最好。具体做法是:decision和preference全文注入(通常不多),fact只注入一行摘要,并在 system prompt 里告诉模型“你可以用recall(query)工具查询详细记忆”。这样既保证了关键信息在场,又给了模型主动检索的能力。
注意:注入的记忆一定要带时间戳。模型看到“2024-03 决定用 PostgreSQL”和看到“决定用 PostgreSQL”反应完全不同,前者它会考虑时效性,后者它会当成永恒真理。
4. 实操过程与核心环节实现
4.1 环境准备与依赖安装
假设我们用 Python 实现,核心依赖如下:
pip install anthropic chromadb sqlite-vec sentence-transformers选型理由:anthropic是官方 SDK;chromadb轻量、本地可跑,适合起步;sqlite-vec给 SQLite 加向量能力,省一个服务;sentence-transformers做本地 embedding,避免每次抽取都调远程 API,省钱且快。
embedding 模型我推荐all-MiniLM-L6-v2,384 维,体积小、速度快,对英文和中文混合场景够用。如果项目以中文为主,可以换bge-small-zh-v1.5,效果更好但体积稍大。
4.2 数据库表结构设计
CREATE TABLE memories ( id INTEGER PRIMARY KEY AUTOINCREMENT, content TEXT NOT NULL, type TEXT NOT NULL, -- fact/decision/preference/todo confidence REAL NOT NULL, status TEXT DEFAULT 'active', -- active/superseded supersedes INTEGER, -- 指向被替代的条目 created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, last_seen TIMESTAMP DEFAULT CURRENT_TIMESTAMP, session_id TEXT, embedding BLOB -- sqlite-vec 存储 ); CREATE VIRTUAL TABLE vec_memories USING vec0( memory_id INTEGER PRIMARY KEY, embedding FLOAT[384] );这里有个细节:embedding我单独放在虚拟表里,而不是塞进主表。原因是 sqlite-vec 的虚拟表对向量检索有专门优化,混在主表里查询会慢。主表和虚拟表用memory_id关联,检索时先查虚拟表拿 id,再回主表取内容。
4.3 记忆抽取的核心代码
import json from anthropic import Anthropic EXTRACT_PROMPT = """分析以下对话,抽取值得长期记忆的条目。 只抽取四类:fact(客观事实)、decision(已定决策)、 preference(用户偏好)、todo(待办事项)。 输出 JSON 数组,每条包含 content、type、confidence(0-1)。 confidence 低于 0.6 的不要输出。 对话: {conversation} """ def extract_memories(conversation: str) -> list[dict]: client = Anthropic() resp = client.messages.create( model="claude-sonnet-4-20250514", max_tokens=2000, messages=[{ "role": "user", "content": EXTRACT_PROMPT.format(conversation=conversation) }] ) text = resp.content[0].text # 容错:模型可能包裹 markdown 代码块 text = text.strip().removeprefix("```json").removesuffix("```").strip() try: items = json.loads(text) except json.JSONDecodeError: return [] return [i for i in items if i.get("confidence", 0) >= 0.6]这段代码有两个我踩过坑的地方。一是模型输出经常带 markdown 代码块包裹,直接json.loads会炸,必须先剥壳。二是必须做 confidence 过滤,而且过滤要放在解析之后,因为模型有时会把 confidence 写成字符串"0.8",得先转 float 再比。
4.4 去重合并的实现
def upsert_memory(content: str, mtype: str, confidence: float, session_id: str): emb = embed(content) # 找相似条目 similar = search_similar(emb, threshold=0.9) for old in similar: if is_same_fact(old["content"], content): # 内容一致,只更新时间 update_last_seen(old["id"]) return else: # 冲突,旧条目降级 mark_superseded(old["id"]) # 插入新条目 insert_memory(content, mtype, confidence, session_id, emb)is_same_fact这个判断我建议不要只靠向量相似度,而是再过一次模型做二分类:“这两句话表达的是同一个事实吗?”向量相似度 0.9 只能说明语义接近,但“用 PostgreSQL”和“不用 PostgreSQL”相似度也很高,直接合并就出大事了。多花一次模型调用,换来的是数据准确性,值。
4.5 检索注入的组装逻辑
def build_context(user_query: str, session_id: str) -> str: # 1. 取本会话短期记忆(最近 N 轮) recent = get_recent_messages(session_id, limit=10) # 2. 检索相关长期记忆 relevant = search_memories(user_query, top_k=5) # 3. 分层组装 high_priority = [m for m in relevant if m["type"] in ("decision", "preference")] low_priority = [m for m in relevant if m["type"] not in ("decision", "preference")] parts = [] if high_priority: parts.append("## 关键决策与偏好\n" + "\n".join( f"- [{m['created_at']}] {m['content']}" for m in high_priority)) if low_priority: parts.append("## 相关背景\n" + "\n".join( f"- {m['content'][:50]}..." for m in low_priority)) parts.append("## 最近对话\n" + format_messages(recent)) return "\n\n".join(parts)注意low_priority那里我做了截断,只给前 50 个字符。这是故意的——低优先级记忆只做“提示存在”,模型如果觉得需要细节,会主动调recall工具。这样能省下大量 token。
5. 常见问题与排查技巧实录
5.1 记忆抽取质量差怎么办
最常见的抱怨是“抽出来的东西没营养”。排查顺序我建议这样:
先看对话本身有没有信息量。如果用户就是在闲聊,抽不出东西是正常的,别怪模型。再看prompt 有没有给例子。纯指令的 prompt 效果远不如带 few-shot 示例的。我后来在 prompt 里塞了三个正例一个反例,抽取质量立刻上了一个台阶。最后看模型选型。抽取这种结构化任务,用便宜的小模型往往就够,但如果发现它总把 decision 和 fact 搞混,换大一号的模型试试。
5.2 检索召回不准的排查
| 现象 | 可能原因 | 解决方向 |
|---|---|---|
| 召回无关内容 | embedding 模型不适配领域 | 换领域微调模型或加关键词过滤 |
| 该召回的没召回 | 相似度阈值太高 | 降到 0.7 试,或改混合检索 |
| 召回旧版本信息 | status 过滤没生效 | 检查查询是否带status='active' |
| 中文检索效果差 | 用了英文 embedding | 换 bge-small-zh 等中文模型 |
混合检索是我强烈推荐的一招:向量检索 + 关键词检索(BM25)各取 top 10,合并去重后再排序。纯向量检索对专有名词(比如项目内部代号)很不友好,加上关键词检索能补上这块短板。
5.3 记忆膨胀的处理
用久了记忆库会越来越大,检索变慢、噪声变多。我的处理策略是分级淘汰:
todo类型:完成后 7 天自动归档。fact类型:90 天没被last_seen更新过的,降权处理(检索时排后面)。decision和preference:不自动淘汰,但每季度人工 review 一次。
提示:淘汰不等于删除。我建议加一个
archived状态,归档的条目不参与默认检索,但可以手动查。有次我归档了一条“临时用 SQLite”,半年后想确认当时为什么这么决定,还能翻出来,很庆幸没删。
5.4 几个我踩过的坑
第一个坑是在抽取时把 assistant 的话也当事实存了。模型说“你可以试试 Redis”,这是建议不是决策,存进去就误导后续判断。后来我在 prompt 里明确要求“只抽取用户确认过的信息”,问题解决。
第二个坑是时间戳用了本地时间。跨时区协作时,时间戳对不上,排序全乱。统一用 UTC,展示时再转本地。
第三个坑是embedding 模型换了但没重算历史向量。换模型后新旧向量不在同一空间,检索结果完全不可用。换模型必须全量重算,这个操作要写进文档,不然接手的人会踩。
6. 记忆层的扩展方向与个人体会
claude-mem这类方案跑通之后,能扩展的方向其实不少。我目前在做的是记忆的可视化面板,把记忆条目按类型、时间、状态做成看板,方便人工 review 和修正。模型抽取难免有错,有个面板能快速纠错,比翻数据库强太多。
另一个方向是多项目隔离。不同项目的记忆混在一起会互相干扰,我加了project_id字段,检索时强制过滤。这个改动很小,但效果立竿见影。
还有个想法是记忆的主动遗忘。现在淘汰是被动的(按时间),未来可以做成主动的——模型判断某条记忆已经过时,主动提议归档。不过这需要模型对“时效性”有判断力,目前还在试验。
我个人在实际操作中的体会是:记忆层的价值不在于存了多少,而在于检索时能不能精准命中。我见过太多人把记忆库堆到几万条,结果检索出来全是噪声,还不如不存。宁可少存、存准,也不要贪多。每次调整抽取 prompt 或检索策略后,我都会拿十几个真实查询做回归测试,看命中率有没有下降。这个习惯帮我避免了好几次“优化反而变差”的事故。
最后分享一个小技巧:给记忆条目加一个source_session字段,记录它来自哪次会话。当某条记忆被证明是错的,你能顺着这个字段找到原始对话,搞清楚是抽取错了还是当时就聊错了。这个字段平时用不上,出问题时能救命。