ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

给 Claude 打造外部记忆层:告别无状态,让 AI 拥有第二大脑

给 Claude 打造外部记忆层:告别无状态,让 AI 拥有第二大脑 claude-mem 这个名字是我某天夜里拍脑袋起的直译过来就是“Claude 的记忆”。起因很简单我用 Claude 干活时每次新开一个会话它都像得了失忆症完全不记得我们上一个小时聊过什么。项目背景要重新贴一遍偏好设置要重新解释一遍甚至昨天刚定下来的技术方案今天它又能给出完全相反的建议。这种体验对于重度使用者来说真的很耗耐心。claude-mem 就是冲着这个问题去的。它是一个给 Claude 用的轻量级外部持久记忆层。核心职责是把每次会话里值得保留的客观事实、用户偏好、项目背景抽出来存进本地数据库下次对话开始时再把与当前主题相关的记忆注入到提示词里让模型能够像“我还记得我们之前聊过什么”那样继续工作。它不是微调不涉及训练数据就是一个夹在 Claude API 和你的业务逻辑之间的中间层。这篇文章会把我设计、实现和踩坑的全过程拆开讲清楚。包括为什么选择外部记忆而不是微调、存储层怎么选、记忆提取和注入的提示词怎么写、用什么方式把记忆“塞回”对话流而不打断模型节奏以及我实际使用中遇到的一堆翻车现场和对应解法。适合那些长期使用 Claude 等大模型、对“无状态”感到痛苦、并且有动手能力想自己搭一套记忆层的开发者。1. 核心设计思路——为什么 Claude 需要一个“第二大脑”1.1 会话无状态是最大的日常痛点一切大模型产品在底层都源于“无状态”的推理服务。所谓无状态就是你发上去的是一段独立文本模型只根据这段文本输出回答。它不会记得昨天你问过什么也不会记得你在另一个会话里上传过的代码。这个特性在闲聊场景没什么问题但一旦进入生产级使用就很要命。我维护一个开源项目的时候每天会和 Claude 讨论架构演进、API 设计、依赖选型。最开始我每个会话都要从“我的项目是干什么的”开始讲起。那段时间我的复制粘贴频率高得离谱甚至写了个脚本专门存我的项目背景文案每次新开对话就自动复制进去。后来发现背景文字越来越长占掉一大半上下文窗口留给有效思考的空间越来越小。Claude-mem 想解决的就是这个把高价值的、跨会话的信息单独存起来外面建一个持久层。模型平时不需要维护这些信息但每次开始新会话前主动把相关的记忆塞回去。相当于给它配了一个“第二大脑”。1.2 为什么不是微调或超长上下文很多朋友听说这个需求第一反应是“你直接微调模型不就完了”或者“Claude 现在上下文窗口那么大全丢进去不就行了”。这两个方向我认真评估过也都分别验证了走不通。先说微调。微调的本质是修改模型权重让它固化某些知识模式。但它有一个根本问题你今天的偏好和明天的不一样你的项目昨天可能还叫 A今天就改名成 B。微调一次成本高昂而个人或小团队的偏好信息往往是动态的、短周期的。你不可能为了记住一个“用户喜欢用 ruff 而不是 black 格式化 Python 代码”这种偏好去微调模型。收益极低成本极高而且更新起来特别痛苦。微调更适合在特定领域大规模固化一套稳定的知识体系比如把某家公司的客服话术和产品手册整体揉进模型里而不是处理对话级的短期记忆。再说超长上下文。Claude 确实有非常大的窗口能一次容纳很长文本。但两个问题第一每次把历史记录全量打包发送token 费用会指数级膨胀第二模型对长上下文的注意力并不是均匀分布的它会倾向于关注最靠近开头和结尾的内容中间的历史记录很容易被“淹没”。这是我实测下来很明显的现象——之前把几万字对话历史硬塞进去模型反而会忽略关键约束条件输出一些前后矛盾的方案。相比之下把记忆做一次提炼、只塞可能相关的几百 token效果反而稳定得多。1.3 claude-mem 需要解决的核心问题在设计 claude-mem 时我给自己列了一份需求清单。这份清单也是后面所有技术选型的基础记忆的写入如何从一段对话里自动识别出“值得记住的东西”而不是把每句话都存进去。记忆的存储用什么样的存储结构能兼顾快速读写、语义检索和本地隐私。记忆的读取如何在一次新对话开始时从记忆库里筛选出最相关的一条或几条而不是把所有记忆都灌回去。记忆的更新当同一件事出现了新的说法如何判断是覆盖旧记忆还是新增一条。记忆的遗忘有些记忆会过时甚至会产生误导需要机制来淘汰它们。这五件事单独拎出来都不算难难点是把它们串成一个闭环。写入、存储、读取、更新、遗忘每一步都会互相影响。比如你提取出的记忆质量差后面存得再多也是垃圾进垃圾出你检索算法选得不行读出来的全是无关信息还不如没有记忆。所以我在文档里反复强调一个观点claude-mem 的表面功能是存储和读取但真正的难点在提取和筛选。2. 架构与核心技术拆解2.1 整体组件结构我先画一幅纯文字架构图帮你理解 claude-mem 的形态。它并不是一个复杂的服务而是由三个核心模块加一个存储层组成你的应用 / CLI │ ▼ claude-mem 代理层 ├─ 1 记忆提取器Memory Extractor ├─ 2 记忆检索器Memory Retriever ├─ 3 上下文注入器Context Injector └─ 存储层SQLite 数据库 向量索引 │ ▼ Claude API你的应用发出请求时经过 claude-mem 代理层。这个代理层做的事情按顺序是把当前用户问题拆成两部分一部分是本次要回答的内容另一部分是需要记住的信息。从存储层里检索所有与当前问题相关的历史记忆。把检索到的记忆拼成一个格式化的“记忆卡片”注入到发给 Claude 的 system prompt 中。Claude 返回回答后再把新一轮对话交给记忆提取器看看有没有新的值得入库的信息。这个代理层既可以做成 HTTP 服务也可以做成一个 Python 包直接在你的代码里调用。我一开始做的是本地 CLI 工具后来为了配合我自己写的企业微信机器人又加了一个 FastAPI 封装。核心逻辑完全复用。2.2 存储层选型SQLite 向量索引而不是专用向量数据库存储层是 claude-mem 最早定下来的地方。我用了 SQLite再加上一个轻量级的向量索引扩展。很多朋友第一反应是“记忆搜索应该上专门的向量数据库比如 Milvus 或者 Qdrant”但实际跑下来SQLite 在单机环境下的性价比远高于这些重型组件。原因有几个。第一个人项目的记忆数据量通常很小。我满负荷使用一个月沉淀的长期记忆条目也不过几千条。几千条数据对任何数据库来说都是小量级SQLite 完全够用根本不需要为它部署一个独立服务。第二向量数据库的部署、运维、备份都是成本除非你的记忆规模到了几十万条以上否则这些成本完全没有必要。第三SQLite 有官方的sqlite-vec扩展支持在 SQLite 表里直接做向量相似度检索。这意味着我可以在一个数据库文件里同时存结构化字段比如记忆类型、时间戳和向量字段用一条 SQL 完成“按条件过滤 按相似度排序”的操作不用在中间做内存拼接。存储结构我用了三张核心表memories表存储记忆正文和元信息包括记忆类型、来源会话 ID、创建时间、更新时间、访问次数。memory_vectors表存储每条记忆对应的 embedding 向量。memory_prompts表存储每次记忆提取时使用的提示词模板版本方便后续追踪升级效果。每写入一条记忆就会先调用本地 embedding 模型生成向量插入两张表。查询时用sqlite-vec的vec_distance_l2函数计算余弦距离并按照相似度分数从高到低排序。2.3 记忆提取用一次调用让模型“自述要点”记忆提取是整个 claude-mem 里最关键的一步。我踩了很多坑才找到稳定可用的提示词模板。核心思路不是让模型去总结整段对话而是让它把自己当成一个“会议记录员”只提取那些以后还会用得上的信息。我使用的提示词大致长这样你是一套长期记忆系统的信息提取器。我会给你一段用户与 AI 的对话记录请你从中提取出符合以下条件的“持久化事实” 1. 用户的身份信息、职业角色、个人偏好、长期目标。 2. 用户正在进行的项目的名称、技术栈、关键决策、当前进度。 3. 用户表达过的规则性指令例如“以后都用 ruff 格式化”。 4. 重要的人物关系和客观事实不包含情绪化表达。 5. 不要提取一次性信息比如“今天天气很好”“帮我写一个临时脚本”这类只对当次对话有意义的内容。 输出格式为 JSON 数组每个元素包含 { type: identity|project|preference|rule|fact, content: 一句话描述, scope: global|project:项目名|user:用户名, importance: 1-10 } 只输出 JSON不要输出解释。这套提示词有几个设计要点值得展开。第一我限定了记忆类型。用类型划分的作用不只是分类更重要的是影响后续检索权重。我在检索时会给不同类型设不同的系数比如“rule规则”类记忆权重最高因为违反规则会直接导致用户不满“preference偏好”次之“fact事实”最低。这样能避免某些闲聊产生的“事实”在关键时刻抢走规则类记忆的上下文权重。第二我要求输出 importance 打分。这个分数用来做初筛。重要性低于 5 的条目默认不写入长期记忆只留在临时上下文里。这条规则帮我从每天几十条候选记忆中筛掉大量噪音在源头上控制了存储质量。第三我强调“只输出 JSON”。这一点看起来简单但实际操作中极其重要。如果你不强调模型偶尔会输出一段带散文的 Markdown导致后续 JSON 解析失败的频率飙升你不得不写一堆容错代码。后来我在提示词里加上“不要解释不要 Markdown”解析成功率才稳定到 99% 以上。至于 embedding 模型我在本地跑的是一个轻量级的中英文双语向量模型比如bge-small-zh-v1.5或者text-embedding-3-small通过 API 调用。本地模型完全离线隐私友好但维度只有 512检索精度有时会差一点。API 模型精度高但每次写入都要网络请求成本略高。这个取舍看个人需求。2.4 上下文注入记忆如何回到对话流里存进去的记忆如果注入方式不对效果会适得其反。我一开始是简单地把所有检索到的记忆拼在 system prompt 的末尾结果发现模型经常把记忆当成当前用户输入来理解甚至出现“我的记忆里说你想做 A 项目你现在却在问 B 项目这是怎么回事”这种混乱回答。后来我加入了两个处理结构化前缀和优先级裁剪。结构化前缀的做法是在 system prompt 之前插入一个明确的记忆区【记忆上下文来源claude-mem】 以下信息是你在过去与用户的对话中确认过的内容。仅作为背景知识参考不要主动复述除非与当前话题直接相关。 - [rule][重要性 9] 用户要求所有 Python 代码必须用 ruff 格式化行长度限制为 88。 - [preference][重要性 7] 用户偏好使用 FastAPI 而非 Flask 开发新服务。 - [project][重要性 8] 用户正在开发开源项目 claude-mem当前阶段是实现记忆更新机制。这样模型就能明确区分“背景知识”与“当前对话”不会把记忆当成新的用户指令。优先级裁剪的逻辑则是按重要性排序只保留预算内的记忆。假设上下文窗口预算为 1500 token那么我把每条记忆按“importance 分数 检索相似度分数”加权排序从上往下取直到填满预算为止。这个策略避免了一个常见问题相似度高的记忆不必然重要重要的记忆不必然和当前问题相似。两个分数加权才是比较稳健的做法。3. 实操过程全记录搭建一套能用的 claude-mem3.1 环境与依赖准备我建议你从一个最小的 Python 项目开始。先把基础环境建好再逐步加功能。下面是我跑通的最小依赖清单# Python 3.10 pip install anthropic fastapi sqlite-vec sentence-transformers这里解释一下为什么选这些库。anthropic是官方 SDK用来调 Claude API没得选。fastapi用来做 HTTP 服务封装方便以后接到脚本里。真的不想引这个依赖的话CLI 模式完全可以不用它但写到最后你会发现有个 HTTP 接口在调试时省事很多。sqlite-vec是向量检索扩展。sentence-transformers是本地 embedding 模型加载器。接下来初始化数据库import sqlite3 import sqlite_vec def init_db(db_path: str): conn sqlite3.connect(db_path) conn.enable_load_extension(True) sqlite_vec.load(conn) conn.execute( CREATE TABLE IF NOT EXISTS memories ( id INTEGER PRIMARY KEY AUTOINCREMENT, type TEXT NOT NULL, content TEXT NOT NULL, scope TEXT DEFAULT global, importance INTEGER NOT NULL, metadata TEXT DEFAULT {}, created_at TEXT DEFAULT CURRENT_TIMESTAMP, updated_at TEXT DEFAULT CURRENT_TIMESTAMP, access_count INTEGER DEFAULT 0 ) ) conn.execute( CREATE TABLE IF NOT EXISTS memory_vectors ( memory_id INTEGER PRIMARY KEY, vector BLOB NOT NULL, FOREIGN KEY(memory_id) REFERENCES memories(id) ON DELETE CASCADE ) ) conn.commit() return conn注意conn.enable_load_extension(True)这一行不能漏。sqlite-vec作为一个可加载扩展必须显式启用扩展开关才能加载。我第一次写的时候就是因为漏了这行运行时报no such module: vec0排查了半天。3.2 实现记忆写入接口记忆写入接口承担两项任务调用 Claude 提取结构化记忆再存储到数据库。这个接口的输入是对话记录输出是写入成功的记忆条数同时给调用方返回一批需要更新上下文的信号。我把核心代码贴出来逐块解释from anthropic import Anthropic import json EXTRACT_SYSTEM_PROMPT 你是一套长期记忆系统的信息提取器。... # 上面那段 def extract_memories(client, conversation_turns: list[dict], modelclaude-sonnet-4-20250514): 从对话轮次中提取结构化记忆。 conversation_turns: [{role: user, content: ...}, {role: assistant, content: ...}] transcript \n.join(f{turn[role]}: {turn[content]} for turn in conversation_turns) resp client.messages.create( modelmodel, max_tokens1000, systemEXTRACT_SYSTEM_PROMPT, messages[{role: user, content: transcript}], ) raw resp.content[0].text.strip() # 容错去掉可能的 code fence if raw.startswith(): raw raw.strip() if raw.startswith(json): raw raw[4:] return json.loads(raw)这段代码有一个很重要的容错点Claude 偶尔会把 JSON 塞进 Markdown 代码块里所以我在解析前做了 code fence 清理。先检查是否以三个反引号开头如果是就去掉反引号和“json”标记。这个容错写进去之后解析失败率从 5% 左右降到了几乎为零。写入数据库时我会同时计算向量并插入向量表def save_memory(conn, model, memory: dict, vector_model): cur conn.execute( INSERT INTO memories (type, content, scope, importance, metadata) VALUES (?, ?, ?, ?, ?), (memory[type], memory[content], memory.get(scope, global), memory[importance], json.dumps(memory.get(metadata, {}))) ) mem_id cur.lastrowid vector vector_model.encode(memory[content]).tobytes() conn.execute(INSERT INTO memory_vectors (memory_id, vector) VALUES (?, ?), (mem_id, vector)) conn.commit()真实场景里我不会每条记忆都单独 commit。更好的做法是把一批记忆放到一个事务里批量提交否则高频使用时会明显感觉到卡顿。我自己的上限是每次提交 50 条左右。3.3 实现记忆检索与注入检索这条链路的核心是“向量相似度优先结构化过滤兜底”。我首先根据当前用户问题生成查询向量然后用sqlite-vec寻找最相似的 N 条记忆再在其中按记忆类型和重要性重排。def retrieve_memories(conn, query_text: str, vector_model, top_k: int 10): # 查询向量 query_vec vector_model.encode(query_text).tobytes() # 最近邻检索 rows conn.execute( SELECT m.id, m.type, m.content, m.scope, m.importance, vec_distance_l2(v.vector, ?) AS distance FROM memory_vectors v JOIN memories m ON v.memory_id m.id ORDER BY distance ASC LIMIT ? , (query_vec, top_k * 3)).fetchall() # 重排结合相似度与重要性 scored [] for row in rows: sim_score 1.0 / (1.0 row[5]) # 把 L2 距离转成相似度 importance_score row[4] / 10.0 final_score 0.5 * sim_score 0.5 * importance_score scored.append((*row, final_score)) scored.sort(keylambda x: -x[6]) return scored[:top_k]这里我故意先取top_k * 3个候选再重排。原因是向量相似度检索到的结果往往会有“局部扎堆”现象比如最近的 10 条里 8 条都聊的是同一个项目的一次具体调整这并不利于全局记忆的利用。先多取一些候选再混合重要性重排能减少这种扎堆问题。注入阶段的代码相对简单。你要负责把变量稳稳地放进提示词里不要出格式错误def build_system_prompt(memories: list[tuple]) - str: if not memories: return 【记忆上下文来源claude-mem】\n暂无可用记忆。 memory_block \n.join( f- [{m[1]}][重要性 {m[4]}] {m[2]} for m in memories ) return f【记忆上下文来源claude-mem】 以下信息是你在过去与用户的对话中确认过的内容。仅作为背景知识参考不要主动复述除非与当前话题直接相关。 {memory_block} 这样生成的 system prompt 配合任意 Claude API 请求就能让模型带着历史记忆回答新问题。3.4 打通 Claude API 调用链基本的调用流程是先把用户消息和检索到的记忆一起发送给模型等模型返回后再把对话历史追加到临时缓冲区异步触发记忆提取。def chat_with_memory(client, user_message: str, conn, vector_model, memory_buffer): memories retrieve_memories(conn, user_message, vector_model) system_prompt build_system_prompt(memories) messages [{role: user, content: user_message}] resp client.messages.create( modelclaude-sonnet-4-20250514, max_tokens2000, systemsystem_prompt, messagesmessages, ) assistant_reply resp.content[0].text # 更新记忆提取缓冲 memory_buffer.append({role: user, content: user_message}) memory_buffer.append({role: assistant, content: assistant_reply}) if len(memory_buffer) 4: # 每两轮对话提取一次 extracted extract_memories(client, memory_buffer) for mem in extracted: if mem[importance] 5: save_memory(conn, mem, vector_model) memory_buffer.clear() return assistant_reply注意缓冲区的长度阈值。一开始我设成每轮都提取结果发现成本翻了三倍而且很多内容本来就是重复的。后来改成至少攒够两轮对话再提取一次效果没有变差成本反而降下来了。这是 Claude-mem 第一个值得记住的优化点提取频率和成本直接挂钩没必要每条消息都触发。3.5 命令行小工具一键回顾对话最后我加了个 CLI 功能方便快速回顾某条记忆在历史里的来龙去脉。这个阶段主要面向调试也适合作为日常使用入口。claude-mem query 帮我查一下我之前定的代码规范 claude-mem recent --limit 20 claude-mem stats核心实现就是用一个argparse包一层。其中query命令走上面写的retrieve_memoriesrecent命令直接按时间倒序查数据库stats命令统计各类型记忆数量和平均重要性。这个 CLI 并不复杂但有了它之后我就能在不开任何聊天界面的情况下快速检查记忆库的健康度。如果你发现自己记忆库里全是重要性 5 的条目说明提取提示词可能需要调一调了。4. 常见问题、翻车实录与排查方子4.1 提取出来的记忆垃圾太多这是 claude-mem 上线后第一个让我头疼的问题。头几天数据库里迅速长出来一堆“用户使用 Windows 操作系统”“用户今天在写 SQL”这种毫无长期价值的条目。每条都占存储空间检索时还容易干扰真正重要的记忆。排查后发现问题出在提取提示词里对“事实”的定义太宽泛。我后来加了两条硬规则第一内容必须能在 3 个月后仍然成立否则不入库。第二如果一条记忆可以用“用户当时在做什么”概括就不符合入库标准。这两条规则直接写进 system prompt变成“不要提取一次性信息”和“考虑信息在三个月后是否仍然有意义”。做了之后无效记忆的比例明显下降数据库增长速度慢了一大截。另外importance 分数也确实需要校准。默认情况下模型给 importance 7 分以上的条目通常是有价值的但 5-6 分这个区间鱼龙混杂。我建议在写入时加一个阈值开关设置为 6 比较保守也可以后续根据记忆命中率动态调节。4.2 注入的记忆把对话带偏了有一次我和 Claude 聊一个新项目的技术选型检索出来的记忆里有一条“用户统一使用 FastAPI 开发 API 服务”。这本是正确记忆但问题是当前项目其实是一个嵌入式工具根本不需要 Web 框架。模型看到这条记忆后反复提议用 FastAPI 做本地管理界面把我气得不轻。问题本质是记忆缺少了“适用范围”约束。我后来在记忆提取时强制要求scope字段并在注入时做匹配。如果新对话的主题明显属于某个项目那么只注入该项目的记忆只有没有明确项目归属时才回退到全局记忆。对于scope匹配我用的是关键词规则在用户问题里检测项目名能匹配上就只查该项目作用域否则查全局。这个规则比我想象的粗但实际效果已经很不错。4.3 上下文窗口被记忆吃掉了高级用户使用场景下检索出来的记忆有时候能有一大堆毕竟每条都长得差不多。如果不限制总量最终会吃掉大量上下文窗口压缩模型真正处理当前问题的空间。我实现的解决方式是动态预算机制。先按记忆的重要性 相似度综合排序再按从高到低的顺序累加每条记忆估算的 token 数直到达到一个预设的预算上限。预算上限用字数和 token 数双重约束。比如默认是 1,500 token也就是大约 1,000 个汉字。超过预算的部分直接丢弃不加进提示词。这个方法保证了系统在任何情况下都不会因为记忆过载而失去平衡。当然预算也不是越多越好。如果记忆内容确实有用可以适当调高到 3,000 token但不要超过 Claude 窗口的很大比例。要记住记忆是背景不是正戏。4.4 性能开销和隐私顾虑最后一个常见问题来自 embedding。我之前一直用本地sentence-transformers每次写入和查询都要做一次编码。在 CPU 机器上编码一次大概要 100-300 毫秒累积下来还是有明显体感的。后来我把 embedding 模型换成了轻量级版本并做了缓存。同一句话查询两次时直接命中缓存省掉了重复编码的时间。隐私方面记忆库里存的是用户真实对话中提取出来的信息所以我把 claude-mem 设计为默认全本地运行。SQLite 文件放在本地目录embedding 也在本地做。唯一出网的只有 Claude API 的请求本身。如果你要部署成多人使用务必做好数据库访问控制和审计日志。记忆数据有时候比原始对话更敏感因为它属于高度浓缩的个人信息。5. 后续还可以怎么扩展这套 claude-mem 的设计虽然现在只服务于我个人的工作流但它的骨架其实挺通用的。如果你也对这类外部记忆层感兴趣有几个方向很值得继续折腾。第一是多 Agent 共享记忆。我在实际使用中发现一旦记忆库积累起来完全可以把它开放给多个不同的 Agent 使用。比如让代码审查 Agent、文档撰写 Agent 和日常问答 Agent 共享同一个记忆库。这样 A Agent 刚刚获知的项目决策B Agent 下一次响应时就能感知到。数据一致性会成为一个新的挑战需要引入记忆版本号和更新时间戳。第二是自动遗忘机制。现在记忆库里大部分记忆都是永久保留的但很多信息其实有时效性。比如某次升级后的临时约束过了几天就不适用了。我计划加一个定期清理任务对“三个月未被检索且 importance 低于 4”的记忆做归档既控制数据库体积也减少检索噪音。第三是接入个人知识库。如果你已经有 Obsidian 或 Notion 这样的笔记体系可以把 claude-mem 的记忆导出成 Markdown 文件或反过来把笔记库里的内容作为初始记忆导入。这个方向能大大提升记忆库的初始质量免去前一个月纯人工对话积累的冷启动周期。我在实际折腾 claude-mem 的过程中最大的体会是给大模型做记忆层真正难的不是存储不是检索而是“判断什么值得记”。这一层的判断质量直接决定了整个系统的上限。如果你打算复刻一个类似的工具我建议你把最多精力花在打磨提取提示词上而不是急着上什么复杂组件。先跑起来再慢慢调这条路我是走通了。
返回列表