
上周我搭了个本地数据流水线项目第一轮让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( modelclaude-sonnet-4-0, max_tokens2000, systemEXTRACT_PROMPT, messages[{role: user, content: conversation_text}] ) return json.loads(resp.content[0].text)有个参数必须吐槽一下max_tokens只给2000。因为提取器的输出是纯结构化的不需要长篇大论太大反而容易让模型“发挥”输出一堆废话。另外我在代码里加了一条硬限制单轮对话最多提取5条记忆。防止模型把一次普通闲聊拆成十条“偏好”那样存储会快速膨胀。这里为什么不让主对话模型自己顺带输出记忆标记我一开始确实这么试过在主模型的回复里夹带memory标签让返回文本携带记忆指令。结果很糟糕模型偶尔会忘记输出标签偶尔会在正常回复里生成假的记忆更麻烦的是标签指令会干扰它的回答风格。把提取拆成一个独立请求之后主模型的Prompt完全不用污染提取质量也稳定很多。代价是多一次请求但这个请求可以用max_tokens2000的轻量配置成本可控。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(keylambda x: -x[0]) return [m for _, m in scored[:top_k]]底层的排序逻辑我拆成三部分相乘相似度sim语义相关性这是主指标。时间衰减recency三天前和昨天访问的记忆权重应该不同。每天乘0.98一周大约衰退12%避免旧记忆一直霸占前排。重要度加权importance_weight一条记忆的重要度越高相同相似度下排序越靠前。这个权重我调过很多次最终用了0.5importance而不是直接乘importance因为完全乘的话一条importance1的记忆即使相关性一般也会被排到很前面导致当前话题被不相关的重要事项干扰。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\nmemory_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_k6是一个平衡点太少记忆覆盖度不够太多就开始混入噪音。min_score0.25是个过滤阈值和余弦相似度强相关如果调低了会捞出大量弱关联记忆。ttl_days90的意思是超过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答对短期事实上一轮对话里明确提到的信息20519跨会话偏好早先会话中表达过的编码风格/命名习惯20217长程约束3天前定下的技术决策和边界条件20114数字挺说明问题。无记忆基线在这些问题上几乎全军覆没因为它根本没有信息来源只能靠猜加了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起步把提取、检索、注入这三个环跑通再去追求分布式和高性能。记忆系统的核心价值不在存储引擎有多高级而在你对“什么该记、什么该忘、什么时候该想起”的定义有多准确。