如果你和我一样,每天会打开十几个Claude会话去处理不同的问题,那你大概率也遇到过这种让人抓狂的情况:上礼拜才和Claude敲定的一套技术方案,这周新建一个会话,它连你叫什么、项目用什么语言写的、上次提到过哪些约束条件,统统不记得了。我第一次遇到这事时,第一反应是"我是不是没开某个开关"——后来翻了半天文档,确认了一个残酷的事实:Claude的API本质上是无状态的,每一次对话都是全新开始。社区里专门为这件事做的方案有很多,其中有一个思路被反复提及,就是claude-mem。
claude-mem并不是某个大厂出品的官方插件,而是围绕"给Claude补上长期记忆"这个需求沉淀下来的一套技术方案集合。核心思想很简单,但也很反直觉:对话是即时的,信息却是可以沉淀的。你每次会话中说过的话、做过的决定、表露出的偏好,如果能在会话结束后被自动抽取、分门别类存起来,等到下次会话开始前再重新注入提示词,那就等于给Claude装了一个外置大脑。这篇文章会把我照着这个思路从零搭建一套记忆系统的完整过程写出来,包括存储模型怎么设计、什么时候该提取信息、怎么避免记忆污染、以及一堆我实际跑起来之后才踩到的大坑。
如果你是第一次听说这类方案,不用担心,我会把记忆系统的每个环节尽量拆开讲,涉及代码的部分也给了可以直接复制改用的Python示例。如果你本来就是做Agent或AI应用开发的,那我相信这里面的选型对比和数据流设计能帮你在自己的项目里少走几趟弯路。
1. 先搞清楚为什么Claude记不住事:上下文窗口和记忆系统是两码事
很多人第一反应是把所有历史对话一股脑塞回给Claude,Claude官方也提供了比较大的上下文窗口,看起来"它能记住挺多东西"。但这里有个本质区别:上下文窗口是临时工作台,记忆系统才是长期资料库。
上下文窗口再大,它处理的是"当前这一轮请求里看到的内容"。你确实可以把昨天的完整聊天记录拼到今天的请求里,假装Claude有记忆,但这会带来三个很现实的问题:
- 上下文会持续膨胀。聊天记录越来越多,每轮请求携带的token越来越大,费用肉眼可见地涨,响应速度也会变慢。
- 超出窗口之后只能截断。一旦记录超过模型支持的上下文长度,你得自己决定丢掉哪一段,丢的很可能恰好是最关键的信息。
- 无效信息会干扰判断。老对话里大量寒暄、重复解释、踩坑过程对今天的决策毫无帮助,把这些噪音一起塞回去,反而让模型抓不住重点。
API无状态是模型本身的结构性现状,你发送请求,它返回结果,中间不保存任何东西。即便Claude官方提供了prompt caching之类的优化机制,那也只是帮你省钱、让重复前缀处理更快,它依然不是记忆——它不会因为你上次说"我喜欢简短回复",就在下次会话里主动调整风格。
claude-mem这类方案解决的就是这个错位。它不是在上下文窗口里硬塞,而是先做信息提炼,再把提炼结果持久化存储,等到需要时检索注入。形象一点说:上下文窗口是桌面,记忆系统是档案柜。桌面上只放当前任务需要的文件,其余的全部归档,下次开工时按需调档。
理解了这个前提,后面的所有设计决策就都顺理成章了:我们要做的不只是"存对话",而是"把对话变成可检索、可更新、可淘汰的结构化记忆"。
2. 我给记忆系统设计的存储模型:三类记忆、两级缓存与一个向量索引
从存储层面看,claude-mem类的项目核心要解决三个问题:记什么、怎么找、存多久。不少人一上来就想"用向量数据库Embedding一把梭",把对话切块全存进去,看似简单,实际用起来会发现召回结果又杂又乱。我最后落地的方案是:结构化和向量化并存,按记忆类型分流处理。
2.1 三类记忆的划分:事实、偏好与进行中的目标
我给记忆归纳了三个大类,每类对应不同的写入频率和召回策略:
| 记忆类型 | 含义 | 示例 | 更新频率 |
|---|---|---|---|
| facts(事实) | 稳定的身份、背景、项目环境信息 | "用户是后端工程师,项目用Python 3.11" | 低,几乎不变 |
| preferences(偏好) | 用户的表达方式、习惯、限制条件 | "回复时给出代码示例,风格偏简洁" | 中 |
| progress(进行中目标) | 当前任务的进展、待办、上下文约定 | "正在做用户登录模块,下周联调" | 高,会话结束就要更新 |
为什么这样分?因为不同记忆的时效性完全不一样。facts是长期基石,preferences影响交互风格,progress只在一段时间内有意义。把它们混在一个桶里,召回排序时很难权衡——你可能因为某个"进行中目标"近期常被访问,就把用户的基本信息挤出了上下文,这显然不合理。
SQLite中每张表的核心字段大概是这样的:
CREATE TABLE memories ( id TEXT PRIMARY KEY, content TEXT NOT NULL, memory_type TEXT NOT NULL CHECK(memory_type IN ('fact', 'preference', 'progress')), keywords TEXT, importance REAL DEFAULT 0.5, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, last_accessed TEXT, access_count INTEGER DEFAULT 0 );keywords字段很重要,后面召回阶段,我会先用它做一次粗糙的关键字过滤,再用向量做语义排序,双重保险能明显减少乱七八糟的召回结果。
2.2 两级缓存:热数据进内存,全量数据进SQLite
刚开始我图省事,每次请求都直接查SQLite,测下来发现延迟虽然只有几十毫秒,但在高频场景下磁盘IO还是有压力。后来加了一层内存缓存,把最近常用的top 20条记忆常驻内存,写入时同步更新缓冲,同时维护一个"最近访问列表"用于快速判断哪些该被换出。
这样做还有一个好处:多个会话并发存在时,内存缓存里是同一份快照,不容易出现同一个用户在两处拿到不一致记忆的情况。注意要加锁,防止多线程下写坏数据。
2.3 向量索引的定位:做语义召回的辅助而非唯一依据
我最初试过纯粹用向量库召回,效果很不稳定。Embedding模型擅长捕捉语义相似,但记忆系统中很多查询是确定性的,比如"用户的编程语言偏好"这种结构化事实,根本不需要模糊匹配,直接查SQLite更准、更便宜。
最终方案是"标签查询为主、向量排序为辅"。具体流程是:每次会话开始时,从用户问题里提取一到两个关键词和实体,先用keywords字段做粗筛,得到候选集;再把候选集的内容Embedding化,与当前问题算余弦相似度;最后按"相似度+重要性+时间衰减"综合打分。这样既利用了向量的语义能力,又规避了纯向量召回时"每次结果都不一样、明明存在却搜不到"的尴尬。
3. 写入链路:如何从对话流里"榨取"值得记住的信息
记忆系统好不好用,一半取决于写入质量。很多人一开始只做"存对话切片",结果存进去的全是废话。真正能驱动模型表现的记忆系统,必然自带一套提取与去重的流水线。
3.1 让Claude自己审阅自己:基于元提示词的记忆抽取
这里我要引出一个观点:提取记忆不需要另起炉灶训练模型,直接让Claude自己理解自己的对话,然后按照预设格式输出结构化记忆。我用的提取调用大概长这样:
import anthropic client = anthropic.Anthropic(api_key="your-api-key") EXTRACT_SYSTEM = """ 你是一个记忆提取器。阅读以下对话,提取值得长期记住的信息,分类输出。 规则: 1. fact:用户的身份、工作、项目背景等稳定信息。 2. preference:用户的偏好、禁忌、沟通风格。 3. progress:当前进行中的任务或待办事项。 4. 只输出JSON数组,不要输出任何解释或Markdown。 5. 没有值得记录的信息时,输出空数组。 """ def extract_memories(messages: list[dict]) -> list[dict]: resp = client.messages.create( model="claude-sonnet-4-20250514", max_tokens=1024, system=EXTRACT_SYSTEM, messages=messages ) text = resp.content[0].text # 在实际项目中这里需要做JSON解析和异常兜底 return json.loads(text)注意一个关键点:我不会让Claude对每一条用户消息都跑一次提取。那样token消耗会爆炸。我的做法是,等待一轮任务进入阶段性结束,或者对话轮数达到某个阈值(比如8-10轮)后,再对整个会话的最新部分做一次批量提取。
对话超过一定长度时,也是先做摘要再提取。因为直接给提取器塞几百条消息,它很容易被中间的细节带走,无法提炼出真正稳定的信息。顺序是"消息序列→对话摘要→记忆候选",在提取之前先做一层信息降维。
3.2 置信度门槛与去重合并:拒绝垃圾进档案库
提取出来的候选记忆如果直接写入,系统很快就会变成垃圾场。我养成的习惯是要求每条候选自带一个confidence字段,低于0.7的直接丢弃。什么情况下置信度会低?比如用户随口说"我今天想吃火锅",这绝不构成preference;但如果用户说"我写代码时习惯用制表符而不是空格,别跟我争这个",这就值得记录了。
更麻烦的是去重。同一个信息会在不同会话被反复提取,比如用户第三次强调"回复时别用太多客套话"。如果每次都新增一条,召回时会出现两条几乎重复的preference,浪费token,还可能让模型困惑到底遵循哪条。
我的合并策略是:写入前先用当前提取文本做一次向量检索,如果相似度超过0.92并且类型相同,就认为这是对已有记忆的重复或强化。此时不新增记录,而是更新原记录的updated_at,同时稍微提升importance。如果新内容与旧内容存在细节差异,则以新增内容为准,做一个内容覆盖。这本质上是一个"记忆叠加"操作,让那条记忆越用越准确,而不是越用越臃肿。
3.3 老化与归档:记忆不是永久保险柜
所有记忆都必须有生命周期。我的规则是:
- importance低于0.3的记忆,三个月未被访问就归档。
- progress类型记忆,在任务完成后主动标记完成并移出活跃表。
- 同一记忆被连续性覆盖改写多次后,保留最新版本,历史版本进audit表留作追溯。
这一步经常被忽略,但非常关键。记忆系统不怕"记住不够多",最怕"记住太多过期的"。一旦系统把用户早已不用的旧技术栈当成"事实"反复注入上下文,模型的判断就会越来越偏。老化机制本质上是在跟时间赛跑,让系统始终反映用户当前的真实状态。
4. 读取链路:把回忆变成提示词的一部分,但不能让它喧宾夺主
存储做得再好,召回阶段不对,用户体感依然是"傻"。读取链路的核心矛盾是:注入多少记忆合适?注入得太少,模型还是"无记忆状态";注入得太多,模型可能先回复记忆内容而忽略了当前的实时对话。
4.1 召回时机与综合排序公式
我在两个时间点做召回:
- 会话启动时:检索facts与preferences,建立用户画像基础上下文。
- 每一轮用户消息后:根据当前消息内容,增量检索可能相关的progress与动态信息。
排序不是单纯看相似度。我用的综合得分是:
final_score = 0.6 * semantic_similarity + 0.25 * importance + 0.15 * freshness其中freshness是时间衰减因子,公式为exp(-days_since_accessed / 30)。这样一条三个月前被反复提到、但最近一个月完全没被访问过的记忆,即便当时很重要,也不会总是占据上下文。你回想一下人类记忆的规律——长时间不用的信息,本来就应该沉到后台去。
4.2 注入格式与Token预算
我最终落到提示词里的格式非常朴素,就是一个以[Memory]开头的片段,放在system和用户消息之间:
以下是关于用户的历史记忆,供你参考,但不要直接复述: - [fact] 用户是Python后端工程师,项目主要使用FastAPI。 - [preference] 用户希望回复尽可能精简,优先给结论再给原因。 - [progress] 正在开发用户登录模块,预计下周三联调。注入数量上限控制在10到15条,每条记忆尽量控制在50字以内,整体预算不超过800 token。这个数字不是拍脑袋出来的,是我反复试出来的:超过15条之后,模型开始出现"注意力稀释"现象——会在回答开头花大段篇幅复述记忆,而不是真正解决用户此刻的问题。
4.3 使用embedding做实时语义补充
基础召回依靠结构化查询,我之前在建表时留下keywords字段。但keywords覆盖不了所有场景,比如用户说"我想重构那个老模块的接口设计",如果记忆里存的是"用户关注API兼容性",两者关键词完全不同,语义上却强相关。所以我在粗筛之后还会把候选集合Embedding化,计算语义相似度,把真正相关的记忆捞出来。
在实际项目里,我会用一条批处理Embedding请求同时计算所有候选向量,而不是逐条调用,能省下不少时间。
5. 落地这套系统的完整步骤:从环境配置到跑通第一轮对话
前面讲完了思路,这块直接给完整可复制的落地过程。我会用一个相对精简的实现来演示,你以后可以照着扩展。
5.1 基础环境准备
我用的技术栈是Python 3.11 + SQLite + ChromaDB + Anthropic SDK。ChromaDB负责向量索引,SQLite负责结构化记忆存储。选ChromaDB的原因只有一个:本地就能跑,不需要额外起服务。
mkdir claude-mem-demo cd claude-mem-demo python -m venv venv source venv/bin/activate pip install anthropic chromadb sqlalchemy python-dotenv设置环境变量,在项目根目录创建.env:
ANTHROPIC_API_KEY=sk-ant-xxx5.2 核心代码结构
我会把系统拆成几个模块,但演示时尽量合并到一个文件里,方便你直接跑通。一个最简可运行版本的三个核心函数是:
import sqlite3 import json import os from datetime import datetime, timezone from dotenv import load_dotenv import anthropic from chromadb import PersistentClient load_dotenv() client = anthropic.Anthropic(api_key=os.getenv("ANTHROPIC_API_KEY")) vector_store = PersistentClient(path="./chroma_data") collection = vector_store.get_or_create_collection("claude_memories") DB_PATH = "./memories.db" def init_db(): conn = sqlite3.connect(DB_PATH) conn.execute(""" CREATE TABLE IF NOT EXISTS memories ( id TEXT PRIMARY KEY, content TEXT NOT NULL, memory_type TEXT NOT NULL, keywords TEXT, importance REAL DEFAULT 0.5, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, last_accessed TEXT, access_count INTEGER DEFAULT 0 ) """) conn.commit() conn.close() def write_memory(doc: dict, semantic_text: str): now = datetime.now(timezone.utc).isoformat() mid = doc.get("id", now + str(abs(hash(semantic_text)) % 99999)) conn = sqlite3.connect(DB_PATH) conn.execute( """ INSERT OR REPLACE INTO memories (id, content, memory_type, keywords, importance, created_at, updated_at, last_accessed, access_count) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0) """, (mid, doc["content"], doc["memory_type"], json.dumps(doc.get("keywords", [])), doc.get("importance", 0.5), now, now, now) ) conn.commit() conn.close() collection.add( ids=[mid], documents=[semantic_text], metadatas=[{"type": doc["memory_type"]}] ) def recall_memories(query: str, top_k: int = 10): conn = sqlite3.connect(DB_PATH) rows = conn.execute( "SELECT id, content, memory_type, keywords, importance, updated_at, last_accessed FROM memories" ).fetchall() conn.close() candidates = [] for row in rows: mid, content, mtype, keywords, importance, updated_at, last_accessed = row kw = json.loads(keywords) # 先用关键词粗筛 if any(k in query for k in kw) or not kw: candidates.append({ "id": mid, "content": content, "type": mtype, "importance": importance, "updated_at": updated_at }) if not candidates: return [] # 将候选记忆批量embedding并与query比对,这里用Chroma的query近似 result = collection.query(query_texts=[query], n_results=min(top_k, len(candidates))) return result["documents"][0]这段代码做了一定的简化,但主干完整:写入时同时更新SQLite和向量库,召回时先用关键字候选,再由向量排序。接着是组装提示词:
def build_prompt_with_memory(user_query: str, memory_docs: list[str]) -> str: memory_block = "\n".join(f"- {m}" for m in memory_docs) return ( "以下是关于用户的历史记忆,供参考,不要直接复述:\n" f"{memory_block}\n\n" f"当前用户的问题:{user_query}\n" "请结合记忆和当前问题,给出最合适的回答。" )5.3 验证效果
跑通后,我建议做两轮验证。第一轮,清空记忆库,直接问"我上次提到的登录模块进度如何",模型应该回答不知道。第二轮,手动写入一条记忆:用户是Python后端工程师,正在开发登录模块,下周三联调,然后重新问同一个问题,看模型是否能够引用这条记忆。
这是最粗粒度的验证方式,但足以确认整个链路的数据流没有断掉。之后你就可以把extract_memories接入到实际会话流程,让提取流程自动化,这个项目才真正发挥价值。
6. 我在真实使用中踩过的坑:你大概率也会遇到
方案讲完不代表顺畅,真正让它稳定工作需要克服一堆脏活。这些踩坑经验是我认为这篇文章里最有价值的部分。
6.1 高频提取带来的Token成本比想象中离谱
刚开始我让系统每轮对话都跑一次提取,一天跑下来,光提取消耗就是正常问答消耗的两倍多。后来才缓过神,提取是"低频率高耗能"操作,绝对不能跟着用户消息节奏走。我的调整方案是:
- 对话达到10轮以上才触发一次会话级提取。
- 任务明显完成时,再触发一次"收尾提取",把最后结论固化到progress中。
- 采用后台异步执行,不阻塞用户当前体验。
6.2 模型偶尔"幻觉"出的信息会污染记忆库
有一次Claude在总结对话时,把用户根本没有提过的"用户使用Django"提取成了fact。这个错误进入记忆库后,后续会话里模型反而开始坚定地按Django假设回答问题,形成自我强化的幻觉循环。这个问题很隐蔽,因为它不会报错,只会让答案逐渐偏离事实。
我的对策是双保险:提取置信度低于0.85且类型为fact的记忆,一律先放到pending表人工确认;同时对记忆库做定期逆向抽检,用另一条提示词让Claude判断"当前记忆内容是否和最近对话矛盾"。如果矛盾,直接标为过期。
6.3 SQLite并发写入冲突
多个客户端窗口同时访问同一个SQLite文件时,偶尔遇到database is locked。这其实不是bug,是默认配置下并发处理不到位。解决方案是启用WAL模式,并且把写操作全部交给唯一一个后台进程,其他进程只读。
conn = sqlite3.connect(DB_PATH) conn.execute("PRAGMA journal_mode=WAL") conn.execute("PRAGMA busy_timeout=5000")6.4 有些场景必须主动关闭记忆
听起来可能反直觉,但记忆系统不是任何时候都该开。我在跑纯技术代码测试、复现一个与用户无关的bug、或者做通用知识问答时,都会把记忆注入整个关掉。原因很简单:一旦模型从记忆库中捞到"用户偏向后端技术"之类信息,它可能在回答一个纯算法题时,不自觉地假设用户有很强的工程背景,而忽略了对基础概念的讲解。
记忆应当是"增强上下文相关性"的机制,而不是"让模型自作聪明"的向导。因此在系统里一定要留一个手动开关,区分"有记忆模式"和"无记忆模式"。
6.5 数据隐私与本地优先
最后说一个容易被忽略的坑。记忆库里装的是用户真实身份和项目细节,一旦泄露,问题比较严重。我的偏好是:除非业务需要,否则记忆永远落在本地SQLite,不上云、不共享。如果确实需要云端方案,至少要把敏感实体(人名、公司名、内部代号)脱敏后再存储。
每次做完一个项目的记忆归档,我会用一行命令把记忆库导出备份,同时清空pending表。隐私习惯和性能优化一样,应该是整个系统运维的一部分,而不是事后补上的补丁。
如果让我说一句最实在的体验:Claude自身的推理能力已经很强了,真正拉开差距的恰恰是这些围绕模型底座搭建的外围系统。记忆系统做得好,模型就是越用越懂你的长期伙伴;做得不好,它只是随机初始化的问答引擎。很多人一上来就追逐复杂的编排框架,但记忆这个环节往往才是决定体验上限的那个细节。先把存储模型和写入质量做扎实了,你会明显感受到,同一个模型在加了记忆之后,给出的回答像是换了一个人——像一个真正认识你的人。