做 AI 应用开发的人,大概率都撞上过同一个问题:Claude 这类大模型本身是没有记忆的。你前两轮刚跟它确认过用户的偏好、项目的技术栈、某个配置项的来龙去脉,第三轮它就开始"失忆",你只能把前面的对话一遍遍地重新塞进上下文。对话一长,token 哗哗烧,响应延迟也跟着上来了,更别说上下文窗口根本装不下持续的交互。
claude-mem这类工具解决的就是这个问题。它不是给模型打补丁,而是在模型外面套一层"外挂记忆":把对话中值得长期保存的信息抽出来、存起来,等下一次需要时再自动检索、注入到上下文里。这篇就围绕这个思路展开,聊聊这类记忆层工具到底在做什么、为什么这么设计、以及怎么自己从零搭一个最小可用版本。适合正在用 Claude API 做 Agent、客服机器人、个人助理,或者被长对话上下文管理逼疯的开发者。
1. 为什么 Claude 需要"外挂记忆":无状态模型的上下文难题
1.1 上下文窗口再大,模型也是"薛定谔的记住"
先说一个很多人都没真正当回事的事实:Claude 在处理完一次请求后,不会留下任何对话状态。你调用 API 时发的每条消息,都被当成一次独立的、全新的请求来处理。之所以感觉它能"记住"你前一秒说了什么,只是因为客户端(官方网页、聊天软件)把历史聊天记录一股脑地重新发送给了模型。
这就是无状态架构的本质,它的优点和缺点一样明显。优点是无状态意味着服务可以随便水平扩展,挂了就重启,不依赖任何持久化会话;缺点是,你要模型"记得"什么,就必须在每次请求里把相关历史重新"喂"给它。于是出现三个连环问题:
- 成本问题:假设每轮对话平均 800 token,第 50 轮时你就要携带前面 49 轮的累计上下文,一次请求轻松上万 token。这还不算长对话里经常要把大段中间内容保留,用不完删又不舍得。
- 延迟问题:输入 token 长度直接和推理耗时挂钩。带的历史越多,首字响应越慢。我在实际项目里测过,同样是 300 行代码的生成请求,上下文从 2k 涨到 30k 之后,耗时能翻倍。
- 效果问题:这是最隐蔽的。模型对上下文的注意力是有限资源,中间位置的信息经常被"吃掉"。OpenAI 那篇著名的 Lost in the Middle 研究已经证明了,模型最容易记住开头结尾,中间长文本里的关键细节经常表达得像没见过一样。长对话里,用户第 10 轮提的约束条件,到第 40 轮可能已经被模型"选择性遗忘"。
所以单纯的"加大上下文窗口"并不是最终解法。哪怕 Claude 给了 200k 窗口,你能塞进去,但效果和成本都不划算,而且它依旧不是真正的"记住",只是下一次再把那 200k 重新读一遍。
1.2 claude-mem 的核心理念:把记忆当工程问题,而不是模型魔法
既然模型不能自己记住,那就让它"外接"一个记忆系统。这也是claude-mem这类工具的设计出发点:把记忆从模型的能力问题,变成数据工程问题。
打个比方,这种思路很像人类大脑的工作方式。人的记忆分短时和长时:短时记忆(工作记忆)容量有限,大概能记住 7±2 个组块,用来处理当前正在做的事;长时记忆存放在大脑皮层,需要时再提取到工作记忆里。claude-mem做的就是帮你把对话内容里值得沉淀的部分"写入"长时记忆,在后续对话开始前自动"提取"到工作记忆(也就是上下文窗口)里。
整套体系如果用一句话概括,就是一条流水线:抽取(Extraction)→ 存储(Storage)→ 检索(Retrieval)→ 注入(Injection)。
- 抽取:识别对话中哪些信息值得长期记住(用户偏好、决策结论、事实参数、项目背景)。
- 存储:把抽出来的信息持久化,通常还会转成向量方便语义检索。
- 检索:在新一轮对话到来时,根据当前问题从记忆库中找回最相关的旧信息。
- 注入:把检索到的记忆拼进 system prompt 或对话上下文中,让模型"想起来"。
这套思路和 RAG(检索增强生成)很像,但两者定位不同。RAG 解决的是"模型不知道的知识",通常接一个外部知识库,比如公司文档、产品手册;而 claude-mem 这类对话记忆解决的是"模型曾经知道但现在忘了的信息",来源是用户和它的历史交互。可以简单理解成:RAG 是查图书管,claude-mem 是翻日记本。两者不冲突,甚至可以叠加使用。
2. 整体设计:一个可落地的记忆层方案
2.1 记忆分层:会话记忆、实体记忆、语义记忆
真正做记忆系统的时候,如果什么信息都一股脑地存,用不了多久记忆库就会变成垃圾场。我在设计时会把记忆分成三层,分别处理:
- 会话记忆(Conversation Memory):最近几轮对话的原始内容。这部分不需要抽取,直接保留最近 N 轮原文,因为模型短期上下文本来就需要。
claude-mem在实现上通常用滑动窗口,保留最近 5~20 轮,按 token 数裁剪。 - 实体记忆(Entity Memory):用户信息、事实型数据、配置项。比如"用户叫李明"、"项目使用 Python 3.12"、"数据库密码存在环境变量里"。这类信息结构化程度高,适合存成键值对或 JSON 字段,检索时直接匹配。
- 语义记忆(Semantic Memory):从多轮对话中沉淀出来的偏好、风格、习惯、结论。比如"用户喜欢代码注释详细一点"、"用户更倾向于先用方案 B"。这类信息很难用关键词检索,最适合向量化后按语义相似度召回。
分层的好处是应对不同检索方式时能各取所需。实体记忆用精确匹配,语义记忆用向量检索,会话记忆用滑动窗口,互相补充又不会互相干扰。
2.2 存储引擎怎么选:SQLite、JSON、向量库
很多第一次做记忆层的人容易犯一个毛病:上来就上向量数据库。其实绝大多数项目远没到那个规模。我自己的经验是,先按规模选存储:
| 方案 | 适合场景 | 优点 | 缺点 |
|---|---|---|---|
| JSON 文件 | 单用户、本地脚本、原型验证 | 零依赖、可读性好 | 无查询能力,数据多了卡 |
| SQLite + FTS5 | 单机、中等规模 | 轻量、支持全文检索、事务 | 语义检索要自己算向量 |
| SQLite + sqlite-vec | 单机、中等规模+向量检索 | 轻量、可以本地跑向量 | 生态和扩展性不如独立向量库 |
| Chroma / Qdrant / pgvector | 多用户、生产环境 | 分布式、混合检索成熟 | 部署运维成本高 |
做claude-mem的时候我推荐从SQLite + 自己算余弦相似度起步。原因很简单:核心逻辑是验证"记忆抽取和检索的链路通不通",而不是测存储引擎的性能。等真的跑通了,再迁移到 pgvector 或者独立的向量库,就是改个接口的事。过早引入重型依赖反而会拖慢开发节奏,而且向量库调参(距离函数、索引类型、分片策略)真的挺烦人的。
顺带说一句,embedding 模型的选择比向量库更重要。Claude 官方没有提供 embedding API,所以实践中通常用 OpenAI 的 text-embedding-3-small,或者本地的 sentence-transformers 系列。后者私有化部署更友好,不需要额外 key,但对中文支持需要挑模型,我用的BAAI/bge-small-zh-v1.5效果就还行。选型时主要看三个指标:维度、检索效果、单次调用的延迟。
3. 核心细节解析:记忆流水线的四个环节
3.1 抽取:什么时候该"记住"
抽取是整个流水线里最需要调教的一环。它不是简单地把对话原文存下来,而是从原始对话里提炼出"值得长期记住的信息"。我用的方法是:让 Claude 自己当信息提取器,给它一段对话,让它输出结构化的记忆条目。
关键是要定好抽取的准则,不然模型会把"今天天气不错"也存进去。我的经验是三条硬标准:
- 可复用性:这条信息未来是否还会被用到?比如"用户的服务器在东京机房"会一直有效,值得存;"用户今天中午吃的拉面"基本不会再被提起,不存。
- 事实确定性:是不是一个明确的事实或偏好,而不是一时情绪?"用户喜欢整洁的代码"是偏好,值得存;"用户今天心情不好"是临时状态,不该存。
- 冲突可能性:存下来会不会跟以后的信息打架?偏好类信息非常容易变,所以存的时候要带时间戳,后面说到。
抽取输出的格式我用固定 JSON,方便程序解析。参考的 prompt 框架长这样:
你是对话记忆提取器。请从 <conversation> 中提取值得长期记住的信息。 只输出 JSON 数组,不要输出多余内容。 每项格式为:{"type": "fact|preference|user_info", "content": "信息内容", "reason": "为什么值得记住"} 判断标准: 1. 必须是可复用的长期信息,丢弃一次性对话内容 2. 必须是事实或稳定偏好,丢弃临时情绪 3. type 为 fact(项目/环境事实)、preference(用户偏好)、user_info(用户背景) 如果没有任何值得记忆的信息,输出 []这一步的工程要点是:抽取和对话是异步的。不要每次用户说完就立刻调抽取,那样既慢又贵。我的做法是隔几轮触发一次,或者达到一定 token 阈值后把最近几轮对话丢给抽取模型。这也符合记忆形成的规律——人类也不是每句话都会变成长期记忆。
3.2 存储与向量化:让记忆"找得到"
抽取出来的是纯文本,但检索时需要按语义匹配,所以必须把文本向量化。这里有个容易被忽略的点:存储的不只是 embedding,原始文本必须有。向量是用来计算相似度的,但最终要注入给模型的还得是原文。所以每条记忆记录至少包含:id、content、type、embedding、created_at、updated_at。
建表 SQL 大概是这个感觉:
CREATE TABLE IF NOT EXISTS memories ( id INTEGER PRIMARY KEY AUTOINCREMENT, content TEXT NOT NULL, type TEXT DEFAULT 'fact', embedding TEXT, created_at TEXT DEFAULT (datetime('now')), updated_at TEXT DEFAULT (datetime('now')) );embedding 字段存的是序列化后的向量数组,比如json.dumps([0.01, -0.03, ...])。这样设计足够轻量,也能在 SQLite 里直接读出来算相似度。如果以后数据量大了,再迁移到专门的向量索引,表结构也不用大改。
向量化的时机也有讲究:不要每次检索时才现算。内容一旦入库,embedding 就应该立刻生成并保存。检索的时候只需要给查询文本算一次 embedding,然后跟库里存的向量做相似度计算就行。这个"一次入库、多次召回"的模式能省掉大量重复计算。
3.3 检索:怎么在正确时机翻出正确的记忆
检索环节决定记忆系统好不好用。我一开始只做纯向量检索,准确率惨不忍睹,后来改成"混合检索 + 简单排序",效果立竿见影,关键点是三个来源:
- 全文关键词:SQLite 的 FTS5。对实体名、人名、项目代号这类精确信息非常靠谱。用户问"之前说的那个数据库连接池怎么配的",建议直接全文匹配"连接池"或"数据库"这些词。
- 语义向量:对偏好、风格这种"说法不同但意思相近"的文本有效。比如用户这次问"代码能不能写得清爽点",和记忆里存的"用户喜欢简洁易读的代码",语义上是接近的。
- 时间衰减:越久远的记忆,权重越低,尤其是偏好类。用户可能三个月前喜欢 A 风格,现在已经转向 B 了,如果旧记忆权重太高会带偏模型。
实际跑的时候,我先把关键词命中和向量相似度分别算出来,再做合并打分:
score = 0.6 * semantic_similarity + 0.3 * keyword_hit_score + 0.1 * recency_bonus这里的权重不是拍脑袋定的,是根据我自己的测试集调的。一个小技巧:先靠关键词召回一批候选,再做向量重排,比全库向量搜索更快,准确率也更稳。因为精确实体用关键词能直接命中,漏网之鱼再用语义拉回来,两步互补。
3.4 注入:如何不把上下文撑爆
检索到记忆之后,下一个问题是:怎么把它们给模型。
最稳妥的注入位置是system prompt 的末尾,用一个固定格式的"记忆区"标记块。这样模型能明确区分"这是长期记忆"和"这是当前对话内容"。我用的模板大概是:
你有一些关于用户的长期记忆,这些记忆可能来自更早的对话,但你现在把它们当作已知事实来使用: <memories> - [偏好] 用户希望代码注释详细一点,尤其是复杂函数 - [事实] 项目后端基于 FastAPI,Python 版本 3.12 </memories> 请结合记忆回答问题,但不要直接提"根据我的记忆"。注入时要做严格的 token 预算控制。我的习惯是上限 2000 token(约 1500 个汉字),超过就按分数截断。千万不要把检索到的 20 条记忆全塞进去,模型会因为信息冗余而降低判断力,这和长对话的 Lost in the Middle 问题是一样。宁可只让模型记住最重要的三条,也不要给它一条 500 字的背景故事。
这里的取舍原则是:记忆是上下文里的"香料",不是"主食"。主食材永远是当前对话内容,香料的作用只是让模型说出更贴合用户需求的话。
4. 实操过程:从零搭一个 claude-mem 最小可用版本
4.1 准备环境与依赖
直接照下面的步骤来,我先说明依赖:
pip install anthropic openai sqlite-vecanthropic:调用 Claude API 的官方 SDK。openai:我用来生成 embedding(通过text-embedding-3-small)。因为 Anthropic 没有 embedding 接口,这是常见替代方案。如果你不想依赖 OpenAI,也可以换成sentence-transformers本地模型,后面代码里我会标注替换点。sqlite-vec不是必需的,小规模我用的是纯 Python 算余弦相似度,这个依赖可装可不装。
环境变量需要设置ANTHROPIC_API_KEY和OPENAI_API_KEY。如果只是本地原型,也可以用一个开源 LLM 做抽取、本地 embedding 做检索,但示例里用商用 API 最直观。
4.2 抽取与存储:把对话变成记忆条目
下面这段代码把两件事做了:调用 Claude 从原始对话里抽取 JSON 格式的记忆,然后把每条记忆生成 embedding 并写入 SQLite。
import json import sqlite3 from anthropic import Anthropic from openai import OpenAI anthropic_client = Anthropic() openai_client = OpenAI() EXTRACT_PROMPT = """ 你是对话记忆提取器。请从 <conversation> 中提取值得长期记住的信息。 只输出 JSON 数组,不要输出多余内容。 每项格式为:{"type": "fact|preference|user_info", "content": "信息内容"} 判断标准: 1. 必须是可复用的长期信息,丢弃一次性对话内容 2. 必须是事实或稳定偏好,丢弃临时情绪 3. type 为 fact(项目/环境事实)、preference(用户偏好)、user_info(用户背景) 如果没有任何值得记忆的信息,输出 [] <conversation> {conversation} </conversation> """ def init_db(db_path="memories.db"): conn = sqlite3.connect(db_path) conn.execute(""" CREATE TABLE IF NOT EXISTS memories ( id INTEGER PRIMARY KEY AUTOINCREMENT, content TEXT NOT NULL, type TEXT DEFAULT 'fact', embedding TEXT, created_at TEXT DEFAULT (datetime('now')) ) """) conn.commit() return conn def get_embedding(text: str): # 替换点:想用本地模型时可换成 sentence-transformers resp = openai_client.embeddings.create( model="text-embedding-3-small", input=text ) return resp.data[0].embedding def extract_memories(conversation_text: str): resp = anthropic_client.messages.create( model="claude-sonnet-4-20250514", max_tokens=1024, system="你是一个严格的信息提取器,只输出 JSON。", messages=[{"role": "user", "content": EXTRACT_PROMPT.format(conversation=conversation_text)}] ) text = resp.content[0].text.strip() # 模型偶尔会输出 ```json 包裹,做个容错 if text.startswith("```"): text = text.strip("`") if text.startswith("json"): text = text[4:] try: return json.loads(text) except json.JSONDecodeError: return [] def add_conversation(conn, conversation_text: str): memories = extract_memories(conversation_text) for mem in memories: embedding = get_embedding(mem["content"]) conn.execute( "INSERT INTO memories (content, type, embedding) VALUES (?, ?, ?)", (mem["content"], mem["type"], json.dumps(embedding)) ) conn.commit() return len(memories)这段代码有一个值得注意的点:抽取用 Claude,向量化用 OpenAI,它们是两个独立的调用。不要在抽取函数里顺手也生成 embedding,那样职责混在一起,后面你维护会很难受。抽取只管"记住什么",向量化只管"怎么检索",各干各的。
4.3 检索与注入:让 Claude "想起来"
接下来是检索和注入。检索这一步我会做两路召回:一路按关键词 FTS 精确命中,一路按向量语义匹配,最后合并排序。
import numpy as np def cosine_similarity(a, b): a = np.array(a) b = np.array(b) return float(np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b) + 1e-10)) def recall_memories(conn, query: str, top_k=5): q_emb = get_embedding(query) rows = conn.execute("SELECT id, content, type, embedding, created_at FROM memories").fetchall() scored = [] for row in rows: mem_id, content, mem_type, emb_str, created_at = row if not emb_str: continue emb = json.loads(emb_str) score = cosine_similarity(q_emb, emb) # 关键词命中加分:位置靠前说明更相关 query_words = [w for w in query.lower().replace("?", " ").split() if w] if any(w in content.lower() for w in query_words): score += 0.3 scored.append((score, mem_id, content, mem_type)) scored.sort(key=lambda x: x[0], reverse=True) return [(content, mem_type) for _, _, content, mem_type in scored[:top_k]] def build_memory_block(memories): if not memories: return "" lines = [] type_label = {"fact": "事实", "preference": "偏好", "user_info": "用户信息"} for content, mem_type in memories: label = type_label.get(mem_type, "记忆") lines.append(f"- [{label}] {content}") return "\n".join(lines)然后是注入和调用 Claude 的最终整合:
def claude_with_memory(conn, user_input: str): memories = recall_memories(conn, user_input, top_k=5) memory_block = build_memory_block(memories) system_prompt = "你是一个智能助手。" if memory_block: system_prompt += f""" 关于用户的一些长期记忆: <memories> {memory_block} </memories> 在回答时可以自然地使用这些记忆,但不要直接声明“根据我的记忆”。""" resp = anthropic_client.messages.create( model="claude-sonnet-4-20250514", max_tokens=1024, system=system_prompt, messages=[{"role": "user", "content": user_input}] ) return resp.content[0].text这段代码跑通之后,你就能看到立竿见影的效果。比如第一轮用户说"我更喜欢简洁的代码风格,别加太多注释",你调用add_conversation把它抽成一条偏好记忆。过三天之后用户直接问"帮我写个排序函数",claude_with_memory会自动检索到"偏好简洁代码"这条记忆,并把它注入到 system prompt 里。Claude 写出来的代码,风格就会有意无意地贴近用户的偏好,看起来就像它"记得"那个人一样。
4.4 完整链路测试:一个能复现的 Demo
我把整个流程串成一次可复现的测试,分两步走:
# 第一步:把历史对话写成记忆 conn = init_db() history = """ 用户:我的后端项目基于 FastAPI,数据库用的 Postgres。 用户:还有,代码里的注释别写太多,我看不懂也没空看。 助手:好的,之后我会注意这个风格。 """ added = add_conversation(conn, history) print(f"已抽取并存储 {added} 条记忆") # 第二步:模拟新的提问,看看记忆是否会复用 response = claude_with_memory(conn, "给我写一个用户登录接口") print(response)第一次跑通的时候,我看到输出里真的绕开了长篇注释、直接用了 FastAPI + Postgres 的设计,还是挺有成就感的。但这个 demo 是"最小可用",生产环境还需要补齐:并发写锁、embedding 缓存、记忆合并去重、清理过期记忆、用户级隔离等等。不过第一步先别想太多,跑通它、理解它,再谈完善。
5. 常见问题与排查技巧实录
5.1 检索结果不相关:模型召回"八竿子打不着"的记忆
这是记忆系统最常见的问题。现象是:用户问 A,系统却把 B 相关的记忆注入进去了,导致回答带着一股莫名其妙的味道。
我的排查步骤固定是三步:
- 看 embedding 是不是选错了模型。通用 embedding 模型对专业术语、代码名词的效果往往不好。如果你做的是垂直领域,建议用领域微调过的 embedding,或者干脆加关键词召回兜底。
- 看检索阈值是不是太低。余弦相似度低于 0.3 的基本就是噪音。我在召回函数里加一个最低分过滤,低于阈值直接丢弃,省得模型被无关记忆干扰。
- 看是不是缺了关键词召回。纯向量检索对实体名真的不敏感,混合检索才是正解。上面代码里
query_words in content那 0.3 分的加分,看着简单,实际能把精准命中排到前面。
5.2 记忆冲突:用户一个月前说喜欢 A,现在说喜欢 B
偏好会变,事实会过时,这是记忆系统必然要面对的。如果不处理,用户会明显感觉到模型"脑子里的我还是上个月的版本"。
我的处理方案是:每条记忆带 updated_at,同一条内容重复出现时执行 upsert,并更新时间戳。检索排序时,时间衰减项会压低旧记忆的分数。比如同样是"喜欢代码风格",旧记录是 30 天前的,新记录是 3 天前的,排序时新的会自然排到前面,旧的自然就被挤出去了。
如果你有精力,还可以在抽取阶段主动检测冲突。当模型抽取到一条和现有记忆明显矛盾的信息时,先不要直接覆盖,而是当作一条"更新"记录,等第二次再验证到相同信号再真正覆盖。这算是一个简化版的"双写确认",能明显减少误伤。
5.3 Token 预算失控:注入的记忆把上下文撑爆了
很多人一开始容易贪心:检索出 20 条记忆,全塞进去。结果模型注意力被分散,回答质量反而下降,成本还上去了。
我强烈建议给记忆注入设一个硬上限,并且做 token 层面的截断控制。上面代码里已经限制了top_k=5,但每条记忆长度不一,最好再做一个累计 token 预算:
def truncate_memories(memories, max_chars=1500): total = 0 result = [] for content, mem_type in memories: cost = len(content) + 6 # 加上标签字符数 if total + cost > max_chars: break result.append((content, mem_type)) total += cost return result这里的max_chars=1500大约对应 500 到 800 token,不会挤占太多上下文空间。如果记忆内容真的很长,可以先用 Claude 做一次摘要压缩再注入。
5.4 隐私与数据清理:记忆库不是文档垃圾桶
这个坑我栽过,教训很深。对话里可能会包含密码、密钥、住址、身份证号这类敏感信息,如果抽取时不过滤直接入库,等于把用户隐私写死在 SQLite 文件里。
所以抽取 prompt 里要明确加一条:跳过任何疑似凭据、密钥、个人敏感信息。同时记忆系统要提供主动删除和过期清理的接口。我在自己的项目里做了一面管理面板,用户可以查看"当前记住了哪些关于我的信息""删除某条记忆""清空全部记忆"。这个功能虽然不起眼,但用户对这种透明度的信任感提升非常明显。
另外,存储文件本身要加权限保护。SQLite 文件别直接丢在公开目录里,至少 chmod 600,有条件就做加密。记忆是用户数据的衍生品,法律和道德上都不能随随便便处理。
6. 从玩具到可用:我的几条实操经验
最后说几句掏心窝的话,作为这篇的收尾。
第一,先动手做最小闭环,再谈架构。我在做记忆系统时最大的教训就是第一版就上了向量数据库 + 分布式存储,结果调试了两周才写完哈利路亚大神的 CRUD。后来删掉重来,用 SQLite 加几十行代码跑通了链路,反而豁然开朗。记忆系统的核心难点从来不在存储,而在抽取和检索的准确率上。
第二,记忆质量比记忆数量重要一万倍。你的记忆库里存 100 条高质量偏好,效果吊打 10000 条"今天说了句什么废话"。所以与其优化检索算法,不如把时间花在打磨抽取准则上。我目前项目中真正让效果发生质变的,是把抽取 prompt 里的判断标准写得更细、更具体,而不是换了一个更花哨的向量库。
第三,系统的最终形态是"该忘就忘"。人都会忘事,机器记忆如果永远不清洗,会积累大量互相矛盾、过期无用的信息,最后让模型比没有记忆更蠢。你需要一套定期清理机制:旧摘要、过期偏好、冲突记忆,按规则或定期任务清掉。
claude-mem这条路走通了之后,你会突然觉得很多原来做不了的事变顺了——长会话的 Agent 不再东拉西扯,用户隐私偏好能够保持一致,跨会话的项目背景也稳定住。这套"抽取—存储—检索—注入"的流水线,不只在 Claude 上有效,任何无状态模型都可以套用。希望这篇能帮你把外挂记忆装到自己的项目里。