提到 Claude API,很多人的第一反应是“对话质量高”,但紧接着就会遇到一个尴尬问题:它完全不记得你上一轮说了什么。我最初在做智能客服时就被这个特性坑过,用户刚报完订单号,下一轮它就问“请问您的订单号是?”。后来我做了 claude-mem 这个项目——一个给 Claude 补齐长期记忆的外部记忆层,才把对话体验拉回正常水准。这个工具适合正在用 Claude API 搭应用、做 agent、搞角色扮演的开发者,它可以解决跨会话记忆、上下文超限、检索注入等一堆实际问题。这篇文章我把 claude-mem 的设计思路、核心代码和排查经验完整拆开讲。
1. 先搞清楚一件事:Claude 为什么“记不住”
1.1 无状态 API 的真相
所有大模型 API 本质上都是无状态的函数调用:你发一个 prompt,它返回一个 completion。Claude API 不会主动保存任何聊天记录,所谓“多轮对话”,不过是每次请求时把之前的消息全部重新发给模型。这个设计不是缺陷,而是为了保证 API 的简单性和可扩展性——服务端不需要为每个用户维护状态,所有状态都靠客户端自己管理。
知道这一点,你就明白 claude-mem 解决的不是“模型记忆力”问题,而是“应用层上下文管理”问题。模型本身是聪明的,但它能看到的只有你塞进窗口里的文字。你给它的上下文越完整、越有条理,它表现得就越像“记住了一切”。换句话说,记忆是一种 prompt 工程。
我见过很多人一上来就抱怨“Claude 怎么这么健忘”,其实大多数情况不是模型问题,而是我们根本没把历史交给它,或者把历史一股脑全塞进去导致关键信息淹没在噪声里。claude-mem 做的就是把“交历史”这件事系统化、自动化。
1.2 claude-mem 到底是什么
claude-mem 的核心思路可以概括成一句话:把记忆从会话上下文里抽离出来,放进外部存储。每次调用 API 之前,先从记忆库中检索出和当前问题相关的片段,再拼接到系统提示里。这样 Claude 每次看起来都像“记得一切”,其实是我们在请求前帮它“翻档案”。
具体来说,claude-mem 维护三层记忆:
- 短期对话记忆:最近几轮 raw 消息,保证对话连贯性。
- 摘要记忆:把已经滚出窗口的旧对话压缩成结构化摘要,保留关键事件和结论。
- 长期事实记忆:用户偏好、项目背景、重要数据点,以键值或向量形式长期保存,跨会话复用。
这三层对应不同时效性和抽象程度。短期记忆直接、具体,但会占用大量 token;摘要记忆是折中,保留 vấn đề核心又能控制体积;长期记忆影响最持久,但必须经过筛选,否则什么乱七八糟的都存进去,最后检索出来的全是噪音。
1.3 适用场景和边界
从我的实践看,claude-mem 的价值观在以下场景最明显:
- 客服机器人:用户多次咨询,不用每次重新自我介绍、重复订单信息。
- 个人知识助手:你昨天和它讨论过某项目,今天继续问,它能直接接着话题讲。
- 角色扮演/陪伴类应用:角色的人设和关系进展需要跨天保持。
- 长文档处理 agent:分多次读取内容后,还能记住前文的关键结论。
但它不是万能的。如果业务要求严格的事实一致性,比如金融对账、法律文书,那么靠检索注入记忆可能不够可靠。记忆毕竟是“压缩过的主观重构”,不是原始记录。这类场景需要额外设计持久化的事实存储,比如数据库里的订单状态,而不是把所有东西都塞给 Claude。
2. 拆解 claude-mem 的四个核心模块
2.1 摘要压缩:让旧对话“瘦身但不失魂”
摘要压缩是整个记忆系统里性价比最高的一环。它的作用很简单:当对话超过预设轮数或 token 阈值时,把这一段对话用 Claude 自己总结成若干条要点,然后丢弃原始消息。
我常用的做法是维护一个“摘要链”。每次摘要不是从零开始,而是把上一次的摘要加上新一批对话,一起交给模型,让它输出更新后的摘要。这样能避免每次摘要都丢失前面信息的问题,也符合人类做笔记的习惯——在旧笔记上增补,而不是重写。
触发摘要的时机需要根据上下文窗口动态调整。我见过不少人固定“每 10 轮摘要一次”,但在实际使用中,对话长度差异很大,有的用户一句话能顶 500 token,有的十句都很短。所以我更建议按 token 数触发:累计对话超过窗口的 40% 时,启动一次摘要。这样无论对话长短,都能在接近极限前主动瘦身。
摘要的格式也很关键。如果只是让模型自由发挥,它会输出“用户今天聊了买电脑的事”这种不可用的废话。我在 claude-mem 里固定了一个摘要模板,要求输出三类信息:
- 已确认的事实(用户提到了哪些具体信息)
- 未完成的待办或问题
- 用户情绪/偏好等软性特征
这样摘要才是结构化的,后续才能被检索和注入。
2.2 向量检索:从“大海捞针”到“精准定位”
只有摘要还不够。用户经常会问“我之前说过的某某事”,这时候需要的是能快速定位长期记忆里相关片段。claude-mem 在长期记忆层采用向量数据库,把每条记忆片段通过 embedding 模型转成向量,查询时把用户当前问题也转成向量,然后做相似度检索。
这里的核心不是向量数据库本身,而是“切片”策略。长期记忆不是一整块,而是要切成有独立含义的小片段。我踩过的坑是直接把一段长对话丢进向量库,结果检索出来的片段语义不完整,上半句讲 A 事,下半句跳到 B 事。正确做法是先把文本切成若干个语义完整的 chunk,每个 chunk 尽量控制在 500 字符以内,并且确保每个 chunk 有一个独立的主题。
切片之后还要做一步“记忆去重”。因为会话中用户可能反复提及同一件事,直接存储会让检索结果出现大量重复。我会在写入前先用 hash 或 embedding 相似度检查,如果和已存储的记忆相似度超过 0.9,就只更新时间戳,不新增记录。这样记忆库长期保持精简。
检索时的 top-k 值也会影响效果。k 太大,噪声多;k 太小,可能漏掉关键信息。我一般设置 k=5,同时设定一个相似度阈值,低于 0.7 的检索结果直接丢弃,不要硬塞给模型。宁可让 Claude 说“我不记得”,也比给它错误记忆强。
2.3 写入策略:什么该记住,什么该忘
很多人做记忆系统时只想着“怎么存”,却忽略了“存什么”。Claude 对话里 90% 的内容都是寒暄、过程性描述和无关细节,如果全部写入长期记忆,检索质量会急剧下降。
claude-mem 在写入前增加了一个“记忆提炼”步骤:把最近一轮对话交给模型,让它提取出值得长期记忆的事实、偏好和承诺。这一步可以用一次轻量 prompt 完成,成本不高,但能把存储质量提升一个量级。
举个例子,用户说“我下周要去上海出差,顺便帮我把去年那个项目的报告带过去”。这里值得记的是“用户下周去上海出差”“需要带去年项目报告”,而不是“用户说了一句很长的话”。模型提炼后,存储的就是这两条结构化记忆。
有写入就必须有遗忘。我设计了三种遗忘机制:
- 时间衰减:超过 90 天未被访问的记忆自动降权,检索排名靠后。
- 软删除:用户在对话中明确表示“之前说的那个不要了”,系统会标记对应记忆失效。
- 容量上限:每个用户长期记忆不超过 200 条,超出后按重要性评分淘汰最旧的。
这套策略看起来简单,但解决了记忆系统最常见的两大问题:越用越臃肿,以及检索结果发散。
2.4 注入顺序:记忆拼接到 prompt 的黄金位置
记忆检索和存储做得再好,如果注入顺序不对,效果也会大打折扣。Claude 对 prompt 不同位置的注意力权重是不一样的。根据我的实测,距离末尾越近的内容对生成结果影响越大,这也符合模型架构的注意力机制特点。
所以在 claude-mem 里,我按照以下顺序拼接最终发送给模型的上下文:
- 系统指令:角色设定和任务规则,固定不变。
- 长期记忆:检索到的相关事实和偏好,放在前面作为背景知识。
- 摘要记忆:旧对话的压缩结论,帮助模型理解当前话题脉络。
- 最近对话:最近的 5-8 轮 raw 消息,放在最尾部,确保模型对当前语境有最直接的感知。
这个顺序不是拍脑袋定的。长期记忆和摘要更像“参考资料”,应当在开头就建立心智模型;而最近的对话是“当下正在发生的事”,必须靠近 prompt 末尾才能产生最强的引导效果。如果你发现 Claude 经常忽略你注入的记忆,先检查一下是不是把这些内容放在了中间偏前的位置,被其他信息淹没了。
3. 手把手实现一个最小可用的 claude-mem
3.1 技术选型:本地优先,还是上向量库
在动手写代码之前,先决定记忆存储方案。我最初想直接上 Redis + 向量数据库,后来发现很多项目根本用不到那么重的依赖。实际选型要看你自己的场景规模。
| 方案 | 优势 | 劣势 | 适合场景 |
|---|---|---|---|
| JSON 文件 + 手动循环检索 | 零依赖,调试直观 | 数据量大后检索慢,语义匹配差 | 原型验证,个人小工具 |
| SQLite + 关键词/正则匹配 | 结构化查询,轻量 | 无法处理语义相近但关键词不同的记忆 | 中小型应用,百级记忆 |
| SQLite + 本地 embedding 模型(如 sentence-transformers) | 本地语义检索,隐私好,不用额外付费 | 需要一定内存,首次加载模型慢 | 对隐私要求高的本地应用 |
| 托管向量库(如 pgvector / 云服务) | 并发强,扩展容易,支持分片 | 引入外部依赖,成本高 | 正式产品,多用户高并发 |
从 claude-mem 的定位出发,我推荐后两种。如果你只是个人项目,用 SQLite + 本地 embedding 就完全够了。不要一上来就搞微服务,分布式向量库,那会让你的开发周期被基础设施拖垮。
3.2 最小核心代码:存储、检索、组装
下面我给出一版极简的 Python 实现,重点关注三个函数:写入记忆、检索记忆、拼装上下文。这个版本不包含摘要压缩,但已经能体现 claude-mem 的核心。
import sqlite3 import numpy as np from sentence_transformers import SentenceTransformer # 这里用本地 embedding 模型,避免额外 API 调用 model = SentenceTransformer("paraphrase-multilingual-MiniLM-L12-v2") class ClaudeMem: def __init__(self, db_path="claude_mem.db"): self.conn = sqlite3.connect(db_path) self._init_db() def _init_db(self): self.conn.execute(""" CREATE TABLE IF NOT EXISTS memories ( id INTEGER PRIMARY KEY AUTOINCREMENT, content TEXT, embedding BLOB, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, last_access TIMESTAMP DEFAULT CURRENT_TIMESTAMP, access_count INTEGER DEFAULT 0 ) """) self.conn.commit() def _embed(self, text: str) -> np.ndarray: return model.encode(text) def add_memory(self, content: str): # 简单去重:如果已有语义非常接近的记忆,只更新时间戳 existing = self.search(content, top_k=1) if existing and existing[0][1] > 0.95: return vec = self._embed(content) self.conn.execute( "INSERT INTO memories (content, embedding) VALUES (?, ?)", (content, vec.tobytes()) ) self.conn.commit() print(f"[claude-mem] 已记忆: {content}") def search(self, query: str, top_k: int = 5, threshold: float = 0.7): query_vec = self._embed(query) rows = self.conn.execute("SELECT id, content, embedding FROM memories").fetchall() scored = [] for row in rows: mem_vec = np.frombuffer(row[2], dtype=np.float32) score = np.dot(query_vec, mem_vec) / (np.linalg.norm(query_vec) * np.linalg.norm(mem_vec)) if score >= threshold: scored.append((row[1], score)) scored.sort(key=lambda x: x[1], reverse=True) # 更新访问记录 return scored[:top_k] def build_context(self, user_query: str, recent_messages: list, system_prompt: str) -> str: memories = self.search(user_query, top_k=5) memory_block = "\n".join([f"- [长期记忆] {m[0]}" for m in memories]) recent_block = "\n".join([f"{msg['role']}: {msg['content']}" for msg in recent_messages]) return f"{system_prompt}\n\n以下是用户相关的长期记忆:\n{memory_block}\n\n最近对话:\n{recent_block}" # 使用示例 mem = ClaudeMem() mem.add_memory("用户上个月提到希望生成文案风格偏正式、专业") mem.add_memory("用户项目代号:蓝鲸,截止日期是下周五") context = mem.build_context( "我那个蓝鲸项目的文案风格你们定了吗?", [{"role": "user", "content": "我那个蓝鲸项目的文案风格你们定了吗?"}], "你是一个文案助手。" ) print(context)这段代码已经能完成基本的记忆写入、语义检索和上下文组装。需要注意几个细节:
- embedding 模型选型:上面用的
paraphrase-multilingual-MiniLM-L12-v2是一个轻量多语言模型,中文效果尚可,适合本地跑。如果你的内容以英文为主,可以换all-MiniLM-L6-v2。 - 阈值设置:我默认是 0.7,但实际要按模型调。本地多语言模型的分数普遍偏低,有时候 0.7 会过滤掉太多结果,可以降到 0.6 再观察。
- 去重条件 0.95:这是近似判断,如果你的模型分数整体偏高,需要相应调高。
3.3 token 预算:怎么算才不被截断
Claude API 的上下文窗口是有限的,你不可能把全部记忆都塞进去。这里需要做一次严格的 token 预算规划。
我给 claude-mem 设了一份默认预算表,假设窗口是4000 token(你也可以按实际窗口调整):
| 区域 | 预算 token | 说明 |
|---|---|---|
| 系统指令 | 500 | 固定角色设定,一般不会变 |
| 长期记忆(检索结果) | 800 | 最多 5 条,每条约 160 token |
| 摘要记忆 | 800 | 旧对话的浓缩版,只有一条 |
| 最近对话 | 1800 | 最近 6-8 轮消息 |
| 预留缓冲 | 100 | 给模型生成留余地,防止超限 |
为什么不说“最大化利用”?因为一旦请求超出窗口,API 会直接报错,你的应用就断了。预留 100 token 缓冲区看似浪费,但能避免边缘情况下的灾难性失败。
计算 token 时不要靠猜。Claude 的 tokenizer 和普通字符数不是一个概念,中文大概是每 1.5 个汉字占 1 个 token。你用claude-tokenizer或官方 SDK 自带的方法实际计算更稳妥。我在项目里做了一个简单的检查函数,在发送前统计各区块 token 数,如果超预算,先从“最近对话”里丢弃最旧的轮次,而不是丢掉长期记忆。因为长期记忆是精心检索出来的,价值密度通常高于随手记录的寒暄。
3.4 对接 Claude API 的完整请求链
记忆模块本身不调 Claude,它是为最终请求服务的。下面展示一个完整调用链,包括请求前构建上下文、调用 API、根据响应更新记忆。
import anthropic client = anthropic.Anthropic(api_key="YOUR_KEY") mem = ClaudeMem("claude_mem.db") def chat_with_memory(user_msg: str, history: list): # 1. 检索并构建完整上下文 context = mem.build_context(user_msg, history, "你是一个有记忆力的助手。") # 2. 调用 Claude API response = client.messages.create( model="claude-3-5-sonnet-20241022", max_tokens=1024, messages=[{"role": "user", "content": context}] ) reply = response.content[0].text # 3. 根据本次对话,提炼值得记忆的新信息 mem.extract_and_memorize(user_msg, reply) return reply这里的extract_and_memorize可以用 Claude 自己完成一次轻量摘要,也可以用启发式规则。我在生产里用了一种低成本做法:把这一轮用户消息和助手回复拼接成一个短 prompt,让 Claude 判断“有没有值得长期记住的内容”,有则输出,没有就返回空。这一步会额外消耗一次请求,但能把记忆质量提高很多。如果你的调用成本敏感,也可以跳过自动提炼,只在特定关键词出现时才写入,比如用户说“记住”“以后”“我习惯”等。
4. 实测中的问题与排查技巧
4.1 记忆污染:检索结果和当前话题根本无关
这是 claude-mem 上线后最先遇到的高频问题。明明用户聊的是电脑配置,检索出来的却是三个月前讨论旅游的记忆。原因有两个方向:一是切片太粗,一个记忆片段里混了多个主题;二是 embedding 模型对中文口语理解不够,语义距离区分度太低。
排查方法:先把检索结果直接打出来看,别急着调模型。我通常会在日志里记录每条检索记忆和相似度分数,然后人工判断“这条该不该出现”。如果大部分检索结果表面相关但逻辑不对,说明切片有问题;如果检索结果完全放飞,说明阈值太低或者 embedding 模型不适合当前语料。
解决手段有三个:提高相似度阈值;对记忆内容做主题标签,在检索后按当前对话主题过滤;或者引入重排序模型(reranker)对粗选结果二次打分。其中主题标签最便宜,效果也最稳定。我在每条记忆入库时让模型顺手生成一个topic字段,检索时先按主题粗筛再按向量精排。
4.2 上下文超限:截断顺序错了,记忆就白做了
很多人在超限时直接“从最早的消息开始丢弃”,这是最省事也最错误的方式。最早的消息虽然旧,但里面往往包含用户的初始需求和个人偏好,价值并不低。真正该优先丢弃的是“那些已经被摘要覆盖过、且和当前问题无关的中间轮次”。
我的经验是:当单次请求超限,先尝试压缩最近对话,把相邻的 3-4 轮合并成一条摘要再重新构造请求。如果仍超限,再从“最近对话”末尾丢弃最旧的一小段。绝对不要动系统指令和长期记忆,除非你真的确定某条记忆和本轮任务无关。
可以参考下面的优先级表:
| 处理顺序 | 操作 | 影响 |
|---|---|---|
| 1 | 压缩最近对话中的冗余轮次 | 几乎无损 |
| 2 | 丢弃检索记忆中相似度最低的一条 | 轻微 |
| 3 | 丢弃最近对话最旧的一轮 | 中等 |
| 4 | 截断摘要记忆 | 可能丢失前文线索 |
| 5 | 压缩系统指令 | 不建议,会影响角色行为 |
4.3 隐私泄漏:记忆是双刃剑
把记忆长期存储在本地或数据库里,本质上是给模型装了一个“长期追踪器”。这确实能带来体验提升,但也意味着用户的所有敏感信息都会留存在你的系统里。这个风险不能忽视。
我在 claude-mem 里做了三层防护:第一,所有记忆入库前先过一遍脱敏规则,手机号、身份证、银行卡用正则替换成占位符;第二,记忆库文件权限设置为仅当前用户可读写,不通过任何网络服务暴露;第三,增加“遗忘指令”,用户说“忘掉我的所有信息”时,直接清空该用户全部记忆。
实测中,第三点特别重要。很多用户会在测试时故意说“忘记我刚才说的”,如果系统做不到,信任感会迅速崩塌。技术实现不复杂,关键是有没有把这条指令纳入处理流程。备份时也要注意,不要连着记忆库一起传到公开仓库,这个坑我踩过两次,后来干脆把记忆库文件加了.gitignore。
4.4 调试记忆系统的“三把尺子”
记忆系统不像普通 API 能通过返回结果直接判断好坏,它的问题往往在几个请求之后才暴露。所以调试方法需要特别设计。
我在 claude-mem 里加了一个调试工具模块,每次请求都输出三份东西:
- 记忆快照:当前用户存储了什么内容,哪些条目最近被检索过。
- 检索回放:显示了哪些记忆被注入 prompt,相似度是多少。
- 对照测试:关闭记忆和开启记忆各跑一遍相同问题,比较回答差异。
这套工具最大的价值是能让你快速判定“是记忆没存上,还是存上了没检索到,还是检索到了但 prompt 注入顺序不对”。我遇到过一个非常隐蔽的问题:记忆明明检索到了,但因为排在很靠前的位置,被后面一大段最近对话给挤出了模型的重点注意力范围。调整注入顺序之后,回答质量立刻提升。
调试完后,这三把尺子我并没有删掉,而是做成了生产环境的一个隐藏开关。在测试阶段打开完整日志,跑几天后可以总结出一套适合自己场景的阈值和策略。
最后再分享一点心得
做了 claude-mem 之后,我对“记忆”这个词的理解变了。它不是一个数据库字段,也不是一个魔法参数,而是一套需要在“存储—检索—注入—遗忘”四个环节都做对的系统工程。哪怕只有一环粗糙,整体体验就会塌下去。
个人建议:如果你也要做类似的项目,千万不要一上来就搞完整方案。先用最简单的方式把摘要+单文件存储跑通,观察一周真实对话,再去补向量检索和遗忘策略。很多同学一开始就铺开摊子,结果连“用户到底需要什么记忆”都没搞清楚,花在调 embedding 上的时间全浪费了。
claude-mem 目前已经被我用在三个项目里,表现最稳定的反而是代码最短的那个版本。后面我打算给它加上自动评估记忆质量的模块,让每个记忆条目都能根据实际回答反馈做动态加权。如果你也在做 Claude 的长期记忆,或者被上下文管理折磨过,欢迎一起聊聊踩坑心得。