给Claude API装上跨会话记忆:claude-mem架构与实现解析
上周我搭了个本地数据流水线项目,第一轮让Claude帮忙把脚本框架、目录约定和命名规范都定了下来。隔了几个小时,我开了个新会话让它继续优化,结果它上来第一句话是:“请问你之前的项目结构是怎样的?”——那一刻我整个人是崩溃的。跟大模型聊得再久,它对你依旧是个陌生人。这就是 claude-mem 这类记忆层工具存在的理由:给基于Claude API的应用和Agent补上一套跨会话的持久记忆,让模型不再“三秒失忆”。简单说,claude-mem 就是在Claude的API调用外面套一层“记忆系统”,记住用户偏好、项目事实、历史决策和反复出现的术语,在每次对话开始前把相关记忆塞回上下文里。这篇文章我会从设计思路、架构拆解、代码实现到实测翻车现场,完整讲一遍我是怎么把claude-mem跑起来的。
如果你也在用Claude API做自动化脚本、Agent、个人知识库,或者正在维护一个跨多轮次、多会话的长期项目,这篇内容应该能帮你省掉不少重复解释背景的力气。下面直接进入正题。
1. 大模型的“三秒记忆”与claude-mem的切入点
1.1 无状态推理的本质
先说一个所有LLM开发者都必须接受的事实:Claude本身不记任何事。每次调用messages.create接口,本质上是发一个独立的HTTP请求,模型收到一段文本,预测下一段文本,然后返回。上一轮请求的状态不会带到下一轮,几十K的上下文窗口用完即焚,就像一张草稿纸——你在上面写了再多的推导过程,下一节新开一页,全部归零。
很多人第一反应是“上下文窗口不是可以放很多东西吗”,但这里要分清楚:上下文窗口是短期工作记忆,不是长期存储。它解决的是“这一个请求内能不能容纳足够信息”的问题,解决不了“用户昨天定下的约束今天是否还记得”的问题。想要跨会话记忆,只有两条路:要么每次把所有历史都重新塞进上下文,要么在外面另建一个存储系统,按需把相关记忆捞回来。claude-mem走的是第二条路。
1.2 硬塞历史的三种“伪记忆”方案
在真正动手做记忆层之前,我试过三种看起来很合理、实际很坑的方案:
第一种是“把所有历史粘贴进system prompt”。这大概是最多人踩的坑。开始时我天真地以为,既然上下文有200K,那我把之前所有对话全文都放进prompt不就行了?结果是:token费用暴涨,模型回复质量反而下降。当上下文里塞满了几万token的历史记录,模型在处理当前问题时会被大量弱相关的历史信息干扰,表现为“注意力被稀释”。而且这还不是记忆,只是把同一张草稿纸换成了更长的草稿纸。
第二种是“每次手动复制上一轮结论”。这个方案在小项目里能跑通,但完全不可扩展。会话一多,谁记得上次结论存在哪个文件里?更别提在Agent或自动化脚本场景下根本没人去手动维护。我试了几天就放弃了,这不是工程方案,是人力外包。
第三种是“把整段历史压缩成一段摘要,塞进system prompt”。这比前两种好不少,但问题在于摘要是一团压缩后的连续文本,没有结构、没有索引、没有定位能力。它把“昨天的用户偏好”和“三个星期前的技术决策”混在一起,模型只能整体被动接收,无法只提取当前任务真正需要的部分。说白了,摘要的作用是“泛泛地不忘记”,而不是“精准地想起来”。
这三种方案都验证了一个核心判断:记忆不能靠上下文硬扛,必须外置,必须具备检索能力。
1.3 判断记忆系统好坏的四个维度
折腾了一圈之后,我给记忆系统定下了四个硬性标准,claude-mem的设计就是围绕这四个维度展开的:
- 写入成本:记忆提取不能太贵、太慢,不能每次对话都额外花一轮完整的模型调用。
- 检索精度:能不能在正确的时间把正确的记忆捞出来,而不是把一堆无关记忆灌进去。
- 注入开销:捞出来的记忆需要用多少token?会不会挤占当前对话的有效上下文空间?
- 遗忘机制:没有遗忘的记忆库会无限膨胀,最终噪音淹没信号,必须有一套老化或淘汰策略。
后面所有架构决策,基本都是用这四个标准来权衡的。
2. claude-mem架构:记忆从写入到回放的完整链路
2.1 四阶段流水线:提取、存储、检索、注入
claude-mem 的核心是一条四阶段流水线:Extract → Store → Retrieve → Inject。听起来有点术语化,但拆开看其实非常直白。
- 提取(Extract):在一轮对话结束后,从对话内容中识别值得长期记住的信息,比如用户偏好、项目约束、技术决策、流程规范。
- 存储(Store):把提取出的记忆条目转成向量,连同元数据一起写入数据库。
- 检索(Retrieve):用户发起新请求时,先把用户的输入做向量化,在记忆库里做相似度搜索,找出最相关的若干条记忆。
- 注入(Inject):把检索到的记忆压缩成一段结构化的“记忆上下文”,插入到system prompt中,让Claude在回答前已经“想起”相关内容。
整个流程里,提取和存储是异步的,检索和注入是同步的。同步链路必须短、快、稳,否则会影响主对话的延迟;异步链路可以稍微慢一点,但不能丢数据,所以需要一个可靠的消息队列或者事件表。
2.2 记忆条目的数据模型与存储选型
一开始我用的存储非常简单:单机SQLite。理由很朴素——零运维,一个文件搞定,单人开发阶段根本不需要上分布式数据库。表结构我也尽量精简,核心字段如下:
CREATE TABLE memories ( id INTEGER PRIMARY KEY AUTOINCREMENT, scope TEXT NOT NULL, -- 命名空间:项目A / 项目B / global type TEXT NOT NULL, -- preference / fact / decision / procedure content TEXT NOT NULL, -- 记忆内容,要求自包含且简洁 embedding BLOB NOT NULL, -- 向量化结果 importance REAL NOT NULL DEFAULT 0.5, -- 重要度 0~1 hit_count INTEGER DEFAULT 0, -- 命中次数,用于热度加权 last_accessed_at DATETIME, -- 最近访问时间,用于老化 source_turn_id TEXT, -- 来源轮次ID,方便回溯 created_at DATETIME DEFAULT CURRENT_TIMESTAMP );这里有个细节值得单独说:content字段我要求“自包含”。什么叫自包含?就是在不依赖上下文的情况下,单独一句话也能看懂。比如“用户的数据库是PostgreSQL 15,部署在私有服务器上”就是自包含的;而“它用了PG”就不是。这个要求直接决定了注入阶段记忆的可读性,能省掉很多事。
embedding向量我选择存储在SQLite里,直接放BLOB列,配合余弦相似度做暴力遍历。单机几千条记忆毫无压力,秒级返回。但如果你要跑几十万条记忆,就必须换Postgres + pgvector或者专门的向量数据库,这个我放在第四章说。
2.3 为什么选语义检索而不是Key-Value
有人可能会问:记忆嘛,用Redis之类存一个Key-Value表不就行了?用户ID做Key,历史记录做Value,查起来多快。
这个思路在“存档式记忆”里成立,但claude-mem要解决的是“联想式记忆”问题,两者有本质区别。打个比方:Key-Value方式很像图书馆里的固定座位,你知道书在哪个编号就直奔而去;语义检索则像你跟图书管理员描述“上次看到一本讲数据库优化的书,好像是讲索引的”,管理员帮你翻遍书库找出最像的那几本。真实场景里,用户不会说“请调取我昨天第3轮对话的第5句话”,而是会说“我之前记过关于数据库优化的偏好”——两者的表述几乎不可能完全匹配,只有语义检索才能建立这种桥梁。
另一个原因:记忆往往不是精确命中的。你存的是“用户喜欢简短回复”,新会话用户问的是“帮我看看这个报错”,两句话词面上完全没交集,但意思上是相关的。语义向量能捕捉到这种关联。这就是为什么我把检索放在核心位置——记忆系统的价值不在“存了多少”,而在“能想起来多少”。
3. 代码级拆解:记忆提取器与注入器的实现
3.1 用结构化输出让模型吐记忆条目
提取这一环,我实测下来最好用的方式不是让模型自由发挥,而是给它一个强约束的提取Prompt,要求只输出JSON数组。核心提示词是这样的:
你是记忆提取器。下面是一段AI与用户的对话记录。请提取其中值得长期保留的信息。 只输出JSON数组,每个元素包含: - type: "preference" | "fact" | "decision" | "procedure" - content: 简洁、自包含的记忆内容,不要夹带对话上下文 - importance: 0.0到1.0的浮点数 - scope: 该记忆适用的场景,如项目名或空字符串(空字符串表示全局通用) 如果没有任何值得记忆的内容,输出[]。客户端代码也不需要太多花活:
import json from anthropic import Anthropic client = Anthropic() def extract_memories(conversation_text: str) -> list[dict]: resp = client.messages.create( model="claude-sonnet-4-0", max_tokens=2000, system=EXTRACT_PROMPT, messages=[{"role": "user", "content": conversation_text}] ) return json.loads(resp.content[0].text)有个参数必须吐槽一下:max_tokens只给2000。因为提取器的输出是纯结构化的,不需要长篇大论,太大反而容易让模型“发挥”,输出一堆废话。另外我在代码里加了一条硬限制:单轮对话最多提取5条记忆。防止模型把一次普通闲聊拆成十条“偏好”,那样存储会快速膨胀。
这里为什么不让主对话模型自己顺带输出记忆标记?我一开始确实这么试过,在主模型的回复里夹带<memory>标签,让返回文本携带记忆指令。结果很糟糕:模型偶尔会忘记输出标签,偶尔会在正常回复里生成假的记忆,更麻烦的是标签指令会干扰它的回答风格。把提取拆成一个独立请求之后,主模型的Prompt完全不用污染,提取质量也稳定很多。代价是多一次请求,但这个请求可以用max_tokens=2000的轻量配置,成本可控。
3.2 检索阶段的加权排序
记忆入库之后,每次用户发新请求,第一件事就是根据用户输入去记忆库里捞相关记忆。我的实现分两步:先算相似度,再做加权排序。
def cosine_sim(a: list[float], b: list[float]) -> float: dot = sum(x * y for x, y in zip(a, b)) norm_a = sum(x * x for x in a) ** 0.5 norm_b = sum(x * x for x in b) ** 0.5 return dot / (norm_a * norm_b + 1e-12) def retrieve(query: str, memories: list[dict], top_k: int = 6) -> list[dict]: q_vec = embed(query) scored = [] for m in memories: sim = cosine_sim(q_vec, m["embedding"]) recency = 0.98 ** m["days_since_last_access"] importance_weight = 0.5 + m["importance"] combined = sim * recency * importance_weight scored.append((combined, m)) scored.sort(key=lambda x: -x[0]) return [m for _, m in scored[:top_k]]底层的排序逻辑,我拆成三部分相乘:
- 相似度(sim):语义相关性,这是主指标。
- 时间衰减(recency):三天前和昨天访问的记忆,权重应该不同。每天乘0.98,一周大约衰退12%,避免旧记忆一直霸占前排。
- 重要度加权(importance_weight):一条记忆的重要度越高,相同相似度下排序越靠前。这个权重我调过很多次,最终用了0.5+importance而不是直接乘importance,因为完全乘的话,一条importance=1的记忆即使相关性一般也会被排到很前面,导致当前话题被不相关的重要事项干扰。
3.3 token预算管理:给记忆装上“流量控制”
记忆注入最大的敌人是token失控。检索出来的记忆条条都相关,但你全塞进去,上下文瞬间被吃掉一大块。我的经验是:记忆不是越多越好,而是越准越好。
在注入之前,我会做一个严格的token预算控制。预算上限我给的是3000 token,也就是通常CLAUDE模型上下文窗口的1.5%到3%之间。记忆块占太多会挤占正常对话空间,太少又往往不够用。这个值是我跑了很久测试之后确定的:低于2000时,多轮关键约束经常漏掉;高于4000时,模型开始在记忆之间“过度联想”,回答显得东拉西扯。
import tiktoken # 近似估算,实际项目中建议用与你embedding模型对应的tokenizer encoder = tiktoken.encoding_for_model("gpt-4") MAX_MEMORY_TOKENS = 3000 def pack_memory(candidates: list[dict]) -> str: budget = MAX_MEMORY_TOKENS blocks = [] for m in candidates: block = f"- [{m['scope']}] {m['content']}" cost = len(encoder.encode(block)) if cost > budget: continue blocks.append(block) budget -= cost return "\n".join(blocks)这段代码强制保证记忆块总量不超预算。如果检索结果太多,就按排序分数从高到低逐条往里放,放不下的自动放弃。注意顺序很关键——分数最高的必须先放进去,哪怕它占了一半预算,因为语义最相关的记忆对当前问题的帮助最大。
4. 从单机到服务化:claude-mem的工程改造细节
4.1 接入点一:请求前的记忆检索与注入
把claude-mem接入现有API调用,不需要对Claude SDK做任何魔改,只需要在调用前后各包一层。请求前的逻辑我封装成一个装饰器:
def with_memory(func): @wraps(func) def wrapper(user_message, **kwargs): memory_block = retrieve_and_pack(user_message) original_system = kwargs.get("system", "") kwargs["system"] = f"{original_system}\n\n<memory_context>\n{memory_block}\n</memory_context>" return func(user_message, **kwargs) return wrapper这里有个特别重要的细节:注入的记忆块我放在system prompt的末尾,而不是开头。实测下来,放在末尾时模型对记忆的利用效果明显更好,可能是因为越靠近当前用户消息的信息,越容易被模型当作“临场需要关注的重点”。这个细节我从很多Prompt工程的资料里也看到过类似的结论,算是一个普遍规律。
4.2 接入点二:请求后的记忆提取与落库
请求结束后,把用户消息和模型回复拼在一起,丢给提取器,然后拿结果去更新数据库。这里要注意反序列化解析时做好容错——模型偶尔会输出JSON之外的废话,或者返回一个空数组。
def after_response(conversation_text: str) -> None: raw = extract_memories(conversation_text) if not isinstance(raw, list): raw = [] upsert_memories(raw)实际工程里我给它套了一层重试和幂等:同一条记忆不要因为提取请求重试而重复入库。我给记忆条目加了source_turn_id字段,每次提取前先查一下这个轮次是否已经提取过,提取过就直接跳过。这个设计是为了配合异步任务队列——提取过程对主链路透明,主API响应已经返回给用户了,后台慢慢消化记忆就行。
4.3 配置项与参数推荐
claude-mem 的配置我用YAML管理,核心参数如下:
storage: engine: sqlite path: ./claude_mem.db embedding: model: text-embedding-3-small dimensions: 1536 retrieval: top_k: 6 min_score: 0.25 recency_decay: 0.98 memory: max_system_prompt_tokens: 3000 max_extract_per_turn: 5 ttl_days: 90这些参数的初始值我建议直接照抄,跑一段时间再根据你的业务调整。top_k=6是一个平衡点:太少记忆覆盖度不够,太多就开始混入噪音。min_score=0.25是个过滤阈值,和余弦相似度强相关,如果调低了会捞出大量弱关联记忆。ttl_days=90的意思是超过90天没有被命中的记忆自动标记为过期,不再参与检索——这就是我前面说的“遗忘机制”。
4.4 多端共享、命名空间隔离与隐私
用了大概两周之后,我开始觉得单机SQLite不够用了。不是量的问题,而是我有两台电脑,本地跑的项目希望另一台也能访问同一份记忆,SQLite文件同步太痛苦。我把存储换成了Postgres + pgvector,表结构几乎不用变,只是把embedding列从BLOB换成向量类型,检索SQL里直接做ORDER BY embedding <=> $query_vec。这个迁移过程最大的坑是历史数据的embedding维度不一致,我换模型后重新全量生成过一次向量,花了一晚上跑批任务。
这里必须强调一个设计原则:记忆必须按scope隔离。我同时跑好几个项目,每个项目有自己的命名空间。绝不能让A项目的记忆污染B项目。加上数据库中多业务的数据隔离也很重要,不同用户、不同项目的记忆,应该在物理层面就不相互可见,而不是靠检索时的prompt去“祈祷模型别搞混”。否则就会出现特别尴尬的场景:你做前端项目的会话里,模型突然冒出一句“根据你Python项目的经验,我觉得应该……”。scope字段加上之后,检索时强制加一层过滤条件:
SELECT * FROM memories WHERE scope IN ('project_b', 'global') ORDER BY embedding <=> :query_vec LIMIT 6;隐私方面,如果记忆里可能包含敏感信息,建议对content字段做加密存储,至少也要做好数据库层面的访问控制。记忆是长期积累的数据资产,泄露一次比对话泄露严重得多。
5. 实测:跑了三周,哪些场景真香,哪些场景翻车
5.1 搭建一个20条问题的记忆测试集
空谈架构没有意义,我给自己定了一套可量化的验证方法。我选了一个真实项目,连续三周每天用Claude API做代码重构,同时开启claude-mem。测试过程是准备了20个问题,覆盖三个维度:
| 测试维度 | 问题数量 | 无记忆基线(答对) | claude-mem(答对) |
|---|---|---|---|
| 短期事实:上一轮对话里明确提到的信息 | 20 | 5 | 19 |
| 跨会话偏好:早先会话中表达过的编码风格/命名习惯 | 20 | 2 | 17 |
| 长程约束:3天前定下的技术决策和边界条件 | 20 | 1 | 14 |
数字挺说明问题。无记忆基线在这些问题上几乎全军覆没,因为它根本没有信息来源,只能靠猜;加了claude-mem之后,凡是记忆库里明确存过的信息,都能被准确唤起。而我丢分的那些题目,大多不是检索没查出来,而是记忆提取的时候压根没往库里存——属于“源头丢失”。
这个测试集也值得推荐给所有想验证自己记忆系统的朋友:不要问“我感觉它记住了吗”,而是设计一批事实性问题跑分,把主观感受变成数据。
5.2 翻车现场:上下文污染与scope过滤
跑测试过程中翻车最狠的一次,是跨项目污染。有段时间我让claude-mem同时服务于A项目和B项目,但我的scope过滤写得太粗——两个项目都归到了global。结果A项目里用户明确说过“不要用Python 3.12的新语法”,B项目对话时模型却用Python 3.12的语法写了段示例代码,因为它“记得”用户偏好。用户当场无了个大语。
这个教训让我把scope策略改成强制隔离:所有检索必须附带当前会话的scope列表,不在列表内的记忆一律不参与检索。特殊情况下才允许显式声明“临时共享”某条记忆。别指望语言模型自己判断哪条记忆适用于哪个场景——记忆层就必须把边界条件执行掉,而不是让模型去猜。
另一个常见问题是记忆块里混入了“描述性废话”。比如某条记忆是“用户在之前的对话中提到过自己的项目结构比较复杂”,这种自我指涉的信息对当前回答毫无帮助,反而让模型变得啰嗦。后来我在提取Prompt里加了一句硬约束:“不要输出对对话本身的总结,只输出可复用的持久化信息。”提取质量提升了一大截。
5.3 记忆膨胀、碎片化与老化策略
第三个坑来自记忆的放任生长。跑了大约一个星期,我的库里攒了两千多条记忆,很多是同一个偏好的不同表述:今天存一条“用户偏好简洁回复”,明天又存一条“用户要求回复不要太长”。检索时两条都命中,注入块里出现两条意思相近又表述不一的记忆,模型开始困惑,回答风格左右摇摆。
我用了两个办法解决。第一是碎片合并:每当要写入一条新记忆,先跟已有的同scope记忆算相似度,如果嵌入向量相似度超过0.85,就认为它们是同一件事,不再新增,只更新原文和重要度。这招有效抑制了记忆体的无意义膨胀。第二是老化淘汰:定期跑一个清理任务,把last_accessed_at超过90天且importance < 0.5的记录直接归档,不再参与检索,给新记忆腾出空间。
老化这件事要特意提醒:如果完全没有遗忘机制,记忆库最终会被历史噪音淹没,检索精度呈断崖式下降。好的记忆系统不是一味的“记得”,而是“该记的记、该忘的忘”。
最后说点实在的
跑了一个多月之后,我最大的感受是:claude-mem真正带来的不是“模型什么都知道了”这种夸张效果,而是少废话了。以前每次开新会话,我要花五六轮把背景、约束、偏好重新说一遍;现在直接一句话切入,模型就能接着上次的脉络继续干活。迭代效率的提升非常明显。如果你也要搭自己的记忆层,我只有一个建议:先从SQLite起步,把提取、检索、注入这三个环跑通,再去追求分布式和高性能。记忆系统的核心价值不在存储引擎有多高级,而在你对“什么该记、什么该忘、什么时候该想起”的定义有多准确。