每个人用聊天型AI的时候应该都经历过同一个崩溃瞬间上一轮刚聊完的项目背景、技术偏好、称呼习惯新开一个会话之后模型完全忘了你又得从头解释一遍。我搭的项目 claude-mem就是专门解决这个AI失忆症的。简单说它是一层给 Claude 对话补上长期记忆的中间件让模型在跨会话场景下能回忆起你之前聊过的事实、偏好和结论。这篇文章不会讲太多理论我会把整套设计逻辑、存储结构、核心代码和踩过的坑一次性掏出来适合正在用 Claude API 做应用、被上下文窗口折磨得够呛的开发者参考。先交代清楚 claude-mem 到底做了什么它在 Claude 的 API 调用链路里加了一个记忆读写层把原本用完即焚的对话记录按可检索的长期记忆标准重新组织。每次对话结束系统自动从最新一轮消息里提取关键信息写入本地向量库下次对话开始前再把相关的历史记忆检索出来注入到系统提示词里。这样一来即便上下文窗口只有几千 token也能让模型表现出我记得你的感觉。1. 项目起源与核心需求拆解1.1 为什么需要额外加一层记忆先理清问题根源Claude 本身是有上下文能力的但它的记忆范围严格限制在当前会话的 context window 里。一旦会话结束所有消息都不再保留下一次调用是从零开始。这个机制在单轮问答场景下没有问题但一旦进入实际业务场景比如做项目知识库问答、客户支持、多轮写作辅助就会发现体验极其割裂。我最早踩到这个问题是在做一个内部文档助手。用户每天早上会问昨天那个方案改到哪了模型因为完全不记得昨天聊过什么只能给出泛泛而谈的回复。当时有两条路一是无限拉长上下文窗口把所有历史消息全部塞进去二是在外部建一个记忆仓库按需检索。第一条路成本非常高token 消耗量随着对话轮数线性增长到十轮以上基本就撑不住了而且模型对超长上下文中后段内容的注意力会明显衰减。所以 claude-mem 选择了第二条路把完整对话记录和结构化记忆分开处理对话记录只需要保存用户的原始数据供后续检索使用的其实是提炼出来的要点。另外还有一个容易忽略的细节即使你有钱把整个历史都塞进上下文模型对哪些信息重要的判断能力也不是无限的。你喂给它一万条历史消息它可能记住了开头和结尾中间的关键偏好全丢了。外部记忆层做的其实是信息压缩 索引的工作让模型只收到最相关、最凝练的那一小块记忆而不是把原始日志当枕头。1.2 方案选型从内存缓存到持久化记忆一开始我并没有一上来就上向量数据库。第一版 claude-mem 极其简陋就是用一个 JSON 文件存 key-value每个 key 是会话 IDvalue 是整段对话文本。这个方案的缺陷很快暴露没有检索能力你只能整段读出来对话一长就失去了意义而且 JSON 文件在并发写入时非常脆弱一个异常退出就可能损坏数据。第二次迭代换成了 SQLite把对话存成结构化行按会话 ID 和时间戳做索引。这个阶段已经能支持拉取最近 N 条消息的需求但依然没有语义检索能力。直到后来我引入了向量化检索才真正解决了用户问的问题和哪一段记忆相关这个核心难点。选型的最终结果是三层结构SQLite 负责事实型记忆的持久化和查询向量库负责语义相似度召回再加上一个轻量级缓存层承接高频重复请求。选 SQLite 而不是直接上 MySQL 或者独立向量数据库服务主要原因是部署成本。claude-mem 定位是个人开发者或小团队能直接拉起来跑的工具依赖越少越好。而且 SQLite 单文件特性对备份特别友好整个记忆库就是一个 .db 文件拷走就完成了迁移。等数据量真正上来以后再多加一条独立的向量库服务接入选项这个架构并不需要推翻重来。2. 记忆系统的整体架构与存储设计2.1 三层结构会话层、记忆层、检索层整个 claude-mem 的运行流程可以拆成三个层面。会话层本质上是 Claude API 调用的一个薄包装。它仍然负责组织和发送用户消息、接收模型响应只不过在消息发送前插入了一个记忆加载步骤在响应结束之后插入了一个记忆写入步骤。对于调用方来说看到的还是一个普通的 API 封装完全不需要感知记忆逻辑的存在。记忆层是核心它负责回答两个问题什么东西值得被记住为什么是它而不是其他内容要实现这一点不能简单地把每轮对话全部丢进仓库否则大部分噪声信息会污染后续检索。claude-mem 会先在本地做一次文本压缩和提炼把多轮对话浓缩成若干条带属性的记忆片段每条记忆都标注了会话来源、创建时间、相关主题以及一个可信度评分。这个评分会随着用户后续行为自动调整比如用户纠正过某条记忆那么它的权重就会被压低甚至作废。检索层承担的是在正确时间把正确记忆送到模型面前这个任务。当用户发起新一轮提问系统会先把用户问题转成向量在记忆库里做最近邻搜索拿到一批相关度最高的记忆条目。然后再叠一个关键词过滤把明显命中的事实型记忆比如姓名、偏好、项目代号直接注入系统提示避免过度依赖向量召回可能带来的语义偏差。2.2 存储表结构的关键字段设计SQLite 表设计上我踩过几个值得讲的坑。最开始时只设计了两个字段id 和 content。后来发现完全不够用因为检索层不仅需要知道这句话是什么还需要知道这句话在什么场景下成立。最终版记忆主表包含这样几个核心字段memory_id全局唯一标识使用自增整数或 UUID 都行我选 UUID 主要是为了将来做多机同步时避免冲突。session_id来源会话标识用于追溯这条记忆是从哪次对话里提炼出来的。content记忆的正文文本可以直接拼接到提示词里使用。memory_type记忆类型也就是fact、preference、task_state三种。这个字段很关键因为不同类型的记忆在注入提示词时表达方式完全不同。事实型记忆适合直接陈述偏好型记忆适合用用户倾向于的句式任务状态型记忆则要说明当前进展到哪一步了。confidence置信度0 到 1 之间的浮点数。每条记忆刚写入时都有一个默认值后续如果被明确纠正置信度直接下调如果被反复验证则逐渐上调。created_at和last_accessed_at分别记录创建时间和最近一次被模型引用时间。后者是记忆遗忘策略的重要依据长时间未被引用的记忆会被自动归档或清理。related_topic主题标签用于快速过滤和分类比如日志系统重构支付回调排查。除了主表之外还有一张memory_generation表用于记录每条记忆是由哪一轮对话产生的、输入文本的哈希值是什么。这张表主要是为了做幂等如果同一段对话被重复处理不会产生重复的记忆条目。2.3 向量索引与全文索引的配合策略很多人在设计语义检索时有一个误区只上向量库把全文搜索完全丢掉。实际运行下来你会发现向量检索对语义相近但表述完全不同的内容非常强但它对完全字面匹配反而可能不够精确。比如用户问我的服务器 IP 是什么向量检索可能召回一堆和服务器、IP 相关的泛泛内容而全文搜索能直接命中IP1.2.3.4这一条确定记录。claude-mem 的检索策略是双通道并行召回。向量通道用余弦相似度返回 Top 20 候选全文通道用 SQLite 的 FTS5 对关键词做 BM25 排名返回 Top 10然后取并集后做一次重排。重排的逻辑也不复杂两类检索都命中的条目权重最高只被向量命中的次之只被全文命中但关键词非常具体的也能进最终结果。存储上向量不能塞进 SQLite 同一张表里直接查那样性能完全不行。我的做法是用一个独立的向量索引文件保存 memory_id 和 embedding 数组的映射关系。每次启动时把向量全部加载进内存检索在内存里完成返回 memory_id 列表后再去 SQLite 里捞明细。这个方案在个人项目规模下完全没有性能压力几千条记忆的检索耗时在十毫秒以内。3. 核心链路从对话写入到记忆召回3.1 写入管线的三个处理阶段记忆写入是整个系统里最容易出错的部分。如果原始对话里有错误信息被提炼成记忆之后就会一直污染后续回答。所以 claude-mem 的写入管线没有采用全量吸收策略而是分成三个阶段。首先是消息过滤阶段。系统会把每一轮对话中的用户消息和模型响应拆成独立单元先做一轮简单规则过滤把纯寒暄类的消息、无关闲聊、重复语气词直接丢弃。比如好的明白了谢谢这类反馈消息本身不携带任何需要长期记忆的信息全部进入写入链路只会浪费 token。其次是信息提炼阶段。这里调用一次轻量模型任务把过滤后的对话片段转换成一到三条 JSON 格式的记忆候选。提炼的 prompt 要写得非常具体我试过好几版如果只写总结重要信息模型会输出一堆空泛的概括比如用户讨论了项目方案。如果写清楚提取用户提供的具体事实、表达出的偏好、当前任务的进行状态并区分类型输出质量会明显提升并有稳定的 JSON 结构。然后是确认阶段也是容易被人忽略的阶段。初步提炼出的记忆候选需要和历史记忆库做一次冲突检测。怎么检测把候选记忆和历史记忆分别向量化计算相似度如果相似度高于某个阈值就认为可能存在重复或矛盾此时会触发人工确认或标注为待定而不是直接写入。这一步能有效减少记忆打架的问题。提示信息提炼阶段的模型调用成本不用太担心。我做过统计每 100 轮对话大约产生 120 到 200 条候选记忆提炼过程的 token 消耗只占总调用量的 3% 左右换取的是后续所有对话的上下文精简效果长期看是赚的。3.2 检索召回与提示词注入时机记忆写入解决了存什么检索注入解决的是什么时候用、用哪些。这一环的优化空间最大也最容易失控。最简单的实现方式是在每次请求前无条件检索 Top 5 条记忆拼进系统提示。这个方案我第一时间放弃了因为不同轮次的对话需要的记忆类型很可能完全不同。比如早上聊工作需求的时候记忆库里可能有几百条事实型记忆如果无差别注入系统提示会变得又臭又长而且引入大量与当前任务无关的信息反而干扰模型判断。claude-mem 的召回策略是这样设计的。第一步解析当前用户消息提取粗粒度的领域意图。如果消息里含项目任务进展优先加载task_state类型记忆如果含我喜欢偏好优先加载preference类型记忆如果询问的是具体数值、名称、地址那么优先走全文搜索通道。第二步根据解析结果构造查询向量执行上面说的双通道召回。第三步把召回结果按照当时对话的时效性 置信度 与当前问题的语义相似度加权排序取前 3 到 6 条注入提示词。注入格式也很有讲究。我最初的写法是以下是与用户相关的历史信息 - 用户是前端开发者 - 用户在做一个项目X后来发现这样的裸信息注入经常导致模型直接引用记忆中的内容回答而不是优先回应当前问题。更好的做法是为模型提供足够的使用空间把记忆信息嵌进清晰的指令中同时加入一个明确的提醒逻辑要求模型在信息相关时才使用并在回答时避免逐字复述。这里可以直接用 prompt 模板控制。3.3 置信度、遗忘窗口与参数调节几乎每一个做记忆系统的人都会忽略参数调节这块但实际体验的天差地别全靠这几个参数调出来。置信度衰减规则我最终定为新记忆默认 0.65每次被成功引用后加 0.05上限 0.95被用户明确否定一次直接降到 0.2被否定两次进入待删除队列不再参与检索。阈值不是拍脑袋定的我做过一次小规模统计实验固定问题集跑 30 轮对话对比不同衰减步长下模型回答的准确率0.05 步长在召回率和信噪比之间平衡得最好。遗忘窗口的设计更直接相关。有些记忆是有时效性的比如用户明天出差项目下周二上线这些信息如果在三天后依然被高权重注入就是在制造噪声。claude-mem 在memory_type task_state的条目上额外挂了一个expires_at字段超过轮次的记录自动降级为只存不查。事实型记忆则默认不过期靠置信度机制自动调节。上下文体量控制方面最终采用的方案是把注入记忆的 token 上限固定在系统提示词的 25% 以内。假设系统提示词本身有 800 token那么注入记忆不超过 200 token折合大约 3 到 5 条记忆条目。超过这个比例模型开始出现记忆复读症状回答越长车轱辘话也越多。4. 实操落地构建一个最小可用版本4.1 项目结构选型到这里理论部分讲得差不多了接下来直接进入落地环节。下面是一个最小可用的 claude-mem 结构整个项目只有五个文件claude-mem/ ├── mem_db.py # SQLite 连接与增删改查操作 ├── mem_embed.py # 向量化与内存索引 ├── mem_extract.py # 记忆提炼调用模型接口 ├── mem_inject.py # 检索与提示词注入 └── claude_mem.py # 主流程对上层暴露统一 API这个结构刻意保持了简单没有上框架、没有消息队列、没有独立配置中心。对于个人项目和日均请求量在百级别的小应用过度设计是最常见的失败原因。4.2 核心模块实现细节记忆仓库模块的逻辑相对固定比较需要注意的地方是 SQLite 的写入。下面是我用过的比较稳的写法import sqlite3 import uuid from datetime import datetime, timezone class MemDB: def __init__(self, pathclaude_mem.db): self.conn sqlite3.connect(path) self.conn.row_factory sqlite3.Row self._init_schema() def _init_schema(self): with self.conn: self.conn.execute( CREATE TABLE IF NOT EXISTS memories ( memory_id TEXT PRIMARY KEY, session_id TEXT, content TEXT, memory_type TEXT, confidence REAL DEFAULT 0.65, created_at TEXT, last_accessed_at TEXT, related_topic TEXT, expires_at TEXT ) ) self.conn.execute( CREATE TABLE IF NOT EXISTS memory_generations ( id INTEGER PRIMARY KEY AUTOINCREMENT, source_text_hash TEXT, generated JSON, created_at TEXT ) ) def insert_memory(self, session_id, content, memory_type, related_topic, confidence0.65, expires_atNone): memory_id str(uuid.uuid4()) now datetime.now(timezone.utc).isoformat() with self.conn: self.conn.execute( INSERT INTO memories (memory_id, session_id, content, memory_type, confidence, created_at, last_accessed_at, related_topic, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?), (memory_id, session_id, content, memory_type, confidence, now, now, related_topic, expires_at) ) return memory_id一个很实用的经验写入时把created_at和last_accessed_at都设为当前时间不要图省事只在后续更新时补。因为检索排序经常会用到这两个时间default 值缺了会导致排序结果不可预期。向量化部分我直接使用了轻量的编码模型。这个选择的核心考虑是本地化运行尽量避免把文本送出外部服务。import numpy as np from sentence_transformers import SentenceTransformer class MemEmbed: def __init__(self, model_nameBAAI/bge-small-zh-v1.5): self.model SentenceTransformer(model_name) self.index {} self.rev_index {} def build_index(self, rows): self.index.clear() self.rev_index.clear() for row in rows: if not row[content]: continue vec self.model.encode(row[content], normalize_embeddingsTrue) mid row[memory_id] self.index[mid] vec self.rev_index[mid] row def search(self, query, top_k5): if not self.index: return [] q self.model.encode(query, normalize_embeddingsTrue) scored [] for mid, vec in self.index.items(): score float(q vec) if score 0.45: scored.append((score, mid)) scored.sort(reverseTrue) return [self.rev_index[mid] for _, mid in scored[:top_k]]向量模型选了中文小尺寸版本单条编码耗时约 10 毫秒索引几千条记忆时每次检索最坏情况也就是扫描一遍内存数组耗时可以被忽略。如果将来记忆量超过十万条再考虑换成独立向量库但架构上不用改动。记忆提炼模块是整个系统里最软的部分建议用一个硬编码的提示词模板调用模型服务。def extract_memories(messages): user_text \n.join(m[content] for m in messages if m[role] user) assistant_text \n.join(m[content] for m in messages if m[role] assistant) prompt f请阅读下面这段对话提炼出需要长期记住的信息。 用户消息 {user_text} 助手消息 {assistant_text} 要求 1. 不要概括对话过程只提取具体事实、偏好、状态。 2. 输出 JSON 数组每个元素包含 content、memory_type、related_topic。 3. memory_type 只能是 fact、preference、task_state。 4. 没有值得记住的信息时输出 []。 只输出 JSON不要输出额外文字。 # 调用模型接口假设已有 call_model 函数 resp call_model(prompt, max_tokens500) return json.loads(resp)这里想特别提一点提炼 prompt 里的不要概括对话过程这几个字是我试了很多版之后最有价值的一句。不加这句模型会疯狂输出用户询问了关于…的问题助手回复了…这类流水账加了之后才真正开始输出可用的记忆条目。4.3 对接 Claude 调用的完整流程主流程模块把上面几个组件串起来对上层暴露一个最简单的方法。我这里演示一个ask函数内部自动完成记忆加载、对话追加和对话后记忆写入。import json class ClaudeMem: def __init__(self): self.db MemDB() self.embed MemEmbed() self._load_index() def ask(self, user_message, session_iddefault): # 1. 检索历史记忆 relevant self.embed.search(user_message, top_k5) memory_block self._format_memories(relevant) # 2. 构造带记忆的请求 system_prompt ( 你是用户的长期助手。请结合背景信息回答当前问题。\n\n f背景信息\n{memory_block} ) # 调用 Claude API messages [ {role: user, content: user_message} ] response call_model( system_promptsystem_prompt, messagesmessages ) # 3. 对话结束后异步提炼记忆 extracted extract_memories(messages [ {role: assistant, content: response} ]) for item in extracted: self.db.insert_memory( session_idsession_id, contentitem[content], memory_typeitem[memory_type], related_topicitem.get(related_topic, general) ) # 4. 重建向量索引 if extracted: self._load_index() return response异步写入是一个很关键的设计选择。如果在用户等待响应的同步链路里做记忆提炼每次对话都会多出 200 到 500 毫秒的延迟而且提炼失败还会影响主流程。实际部署中应该把这个步骤丢到后台线程或者独立 worker 里用户不必感知。4.4 数据备份与迁移经验claude-mem 的数据备份比大多数服务都简单因为整个记忆都收敛在一个 SQLite 文件和一份向量索引里。SQLite 支持在线备份不需要停服。我日常会写一个 cron 任务每天凌晨把 .db 文件复制一份出来保留最近七天。迁移到另一台机器就更简单了直接把 .db 文件拷过去启动时自动重建向量索引即可。唯一需要注意的坑是向量模型的版本要一致。如果你在 A 机器用 1.0 版本的模型建了索引迁移到 B 机器时换成了 2.0 版本向量维度一样、语义空间却变了会导致相似度计算完全乱套。所以我每次迁移都会先检查model_name是否一致不一致就直接删库重建索引绝不节省这一步。5. 踩坑记录与问题排查5.1 上下文膨胀问题上下文膨胀是上线 claude-mem 后第一个遇到的严重问题。现象是系统提示词越来越长即使召回上限设了 5 条每条记忆也有 100 到 200 token还没算上格式化开销。跑了 200 轮对话后提示词里的记忆部分超过了 1000 token模型经常性偏题。最终排查出来的原因有两个一是没有对召回内容做去重同一个事实在不同对话里被提炼出了两条相似表述权重排序后同时进入候选二是last_accessed_at更新时机写在了检索完成之后但没考虑因相似而触发的条目导致某些内容被反复激活。解法是在写入提炼阶段做一个相似度抑制新记忆向量和历史记忆的相似度超过 0.9 时不新建条目而是递增原条目置信度并追加一个别名文本到related_topic。这样既保留了语义信息又控制了总量。5.2 记忆错乱与互相冲突第二种常见问题是记忆冲突具体表现为用户今天说我喜欢使用 Python三天后说我以后主要用 Go结果第三条回答里模型同时引用了两条矛盾记忆输出自相矛盾。这套机制的处理方式是置信度斗殴。当新记忆写入时冲突检测发现高相似度记录分两种情况处理如果memory_type一致允许保留两条但新记录置信度 0.1旧记录 -0.1如果memory_type还不一致直接把旧记录标记为superseded不再参与检索。这样做的前提是冲突检测的阈值不能太低我用了 0.82宁可偶尔漏检也不频繁误杀。5.3 隐私、误记与调试技巧隐私问题在记忆系统里比在普通应用里更值得警惕。因为记忆库本质上是一个长期留存用户信息的仓库一旦泄露就是系统性风险。claude-mem 的做法有三条线第一默认不记录原始对话正文只记录提炼后的记忆条目第二提供白名单关键词凡是包含身份证号、银行卡等敏感模式的原文直接跳过提炼第三提供遗忘接口用户可主动删除某条记忆或整个 session 的记忆。最后再分享一个调试技巧线上查为什么模型没引用记忆中已有的信息时别急着改代码先打开调试日志看检索结果里有没有那条记忆相关度排第几。我最初调试时一大半时间花在纠结模型行为上后来发现大部分情况是检索阶段就把目标记忆排到了 5 名之外根本不是模型选择问题。给检索层加一个手动的highlight_memory_id参数临时强制指定某条记忆拼到提示词最前面这个参数排查问题时非常管用。6. 一点个人体会claude-mem 做到现在这个形态我最大的感受是给 LLM 加记忆难点从来不在存储而在取舍。存储方案遍地都是SQLite、Redis、向量库都行真正的门槛是你怎么定义哪些信息值得记、哪些不值得以及怎么让记忆在正确的时间以正确的方式出现。这个问题的答案没有标准解只能靠在实际业务场景里反复调参、反复观察模型输出一点点打磨出来。如果你准备自己也搭一套记忆层我的建议是从最小链路起步先上单一记忆表 关键词过滤跑一跑让用户骂几天收集一批真实的问题案例再逐步加入向量召回、置信度、遗忘机制。不要一上来就铺一个大而全的框架那只会让你花大量时间调试一个其实用不上的复杂系统。踏踏实实把一条写入、一条召回跑通比什么都强。
阅读完成 · 觉得有帮助?