
1. 从“聊完就忘”说起claude-mem 到底想解决什么如果你用 Claude 这类对话式 AI 做过稍微长一点的项目大概率遇到过这种尴尬昨天花了两个小时跟它把一套数据清洗逻辑捋得清清楚楚今天开个新会话它一脸无辜地问你“请问你想处理什么数据”。你只能把昨天的上下文重新贴一遍贴到一半发现 token 已经快满了于是又得删删减减。这种“聊完就忘”的体验是所有把 AI 当长期协作工具的人共同的痛点。claude-mem这个名字拆开看就是 “Claude” “memory”直译过来就是给 Claude 加一层记忆。它要解决的核心问题非常明确让 AI 对话拥有跨会话、可检索、可累积的长期记忆能力。不是那种简单的“把历史对话塞进上下文”的伪记忆而是把对话中产生的关键信息抽取出来、结构化存储、在需要的时候精准召回的一套机制。这篇文章适合三类人看第一类是把 Claude 当日常生产力工具、但被上下文窗口折磨过的重度用户第二类是正在做 AI 应用、需要给自己的产品加记忆模块的开发者第三类是对“AI 记忆系统”这个方向好奇、想搞清楚它到底怎么落地的人。我会从记忆系统的设计动机讲起拆到存储结构、召回策略、实操配置再聊我在实际搭建过程中踩过的坑。全文基于 claude-mem 这个项目标题所指向的典型实现思路展开具体细节会结合业界常见的记忆系统实践做合理补全。先说一个反直觉的结论给 AI 加记忆难点从来不在“存”而在“取”和“忘”。存谁都会存往数据库里一扔就完事。但你要在几千条记忆里在用户提问的那一瞬间捞出真正相关的那三五条还不能捞错、不能捞重、不能把过时的信息捞出来误导模型——这才是真正考验设计的地方。claude-mem 这类项目的价值恰恰在于它把“取”和“忘”这两件事工程化了。2. 记忆系统的三层结构原始对话、抽取事实、向量索引要理解 claude-mem 怎么工作得先搞清楚一个成熟的 AI 记忆系统通常分几层。我在实际搭建和拆解这类系统时习惯把它分成三层来看这个分层不是拍脑袋定的而是每一层解决一个独立的问题。2.1 第一层原始对话日志负责“可追溯”最底层是原始对话的完整记录。每次会话结束或者每轮对话结束把 user 和 assistant 的消息原封不动落盘。这一层的作用不是给模型看的而是给人看的——当记忆召回出现问题时你需要能回溯“当时到底说了什么”才能判断是抽取错了还是召回错了。存储格式上我推荐用 JSONL每行一个 JSON 对象而不是一个大 JSON 数组。原因很实际追加写入方便不会因为一条记录损坏导致整个文件读不出来而且流式处理友好。每条记录至少包含这几个字段{ session_id: sess_20240115_001, turn_id: 12, role: user, content: 我们的订单表主键用的是雪花ID不是自增, timestamp: 2024-01-15T10:23:45Z, project_tag: order-system }project_tag这个字段很多人会忽略但它极其重要。如果你同时在做三四个项目没有项目标签记忆召回时就会串台——你在做 A 项目它把 B 项目的技术选型捞出来给你建议那还不如没有记忆。2.2 第二层事实抽取负责“可复用”原始对话是流水账直接拿去做召回效果很差因为里面大量是“嗯”“好的”“那我试试”这种无信息量的内容。第二层的任务是从对话里抽取原子化的事实每条事实是一个独立、自包含、可复用的陈述。比如上面那句“我们的订单表主键用的是雪花ID不是自增”抽取后应该变成一条结构化事实{ fact_id: fact_00087, content: 订单表主键使用雪花ID非自增, category: database_schema, project_tag: order-system, source_turn: sess_20240115_001#12, confidence: 0.95, created_at: 2024-01-15T10:23:50Z }这里有几个设计决策值得展开说。为什么要原子化因为召回的时候你希望命中的是精确的事实而不是一大段包含多个事实的段落。一段话里如果同时讲了主键类型和索引策略召回时可能只想要其中一个混在一起就会引入噪声。为什么要 category因为不同类别的事实召回策略可以不同技术选型类的事实权重可以高一些闲聊类的事实权重低甚至不存。为什么要 confidence因为抽取本身可能出错模型可能把“我考虑用雪花ID”误抽成“我用雪花ID”置信度低的记忆在召回时应该降权或者人工复核。2.3 第三层向量索引负责“可召回”第三层是把抽取出来的事实做向量化存进向量数据库支持语义检索。这一步是让“取”变得高效的关键。用户提问“订单主键怎么设计的”系统把这个问题也向量化然后在向量库里找最相近的事实返回 top-k 条。向量化的模型选择上我实测下来对于中文技术类内容用通用的多语言 embedding 模型就够用没必要上特别大的模型。因为记忆召回的场景对精度要求没有搜索那么极致召回 top-5 里能有 2-3 条相关就已经能显著改善体验了。真正影响效果的是事实抽取的质量和召回后的重排序策略而不是 embedding 模型本身。三层结构的关系可以用一句话概括原始日志保证可追溯事实抽取保证可复用向量索引保证可召回。缺任何一层系统都会瘸腿。只有原始日志召回靠关键词匹配效果差只有事实没有原始日志出错了没法排查只有向量索引没有事实抽取等于把流水账做成了向量噪声极大。3. 事实抽取的提示词设计怎么让模型吐出干净的结构化数据claude-mem 这类系统里事实抽取是整个链路里最影响最终效果的一环。抽取做得好后面召回轻松抽取做得烂后面怎么调都是垃圾进垃圾出。这一节我详细讲讲抽取提示词的设计思路这部分是很多教程不会细说的。3.1 抽取的时机轮次结束还是会话结束第一个决策是抽取时机。有两种选择每轮对话结束就抽或者整个会话结束再批量抽。每轮抽的优点是实时性好用户下一轮提问时新事实已经入库了。缺点是调用次数多成本高而且单轮对话信息量往往不足模型容易抽出一堆低质量事实。会话结束批量抽的优点是信息完整模型能看到整个上下文抽取质量高缺点是如果会话中途崩溃可能丢数据。我实际采用的是混合策略每轮对话结束后先做一个轻量的“候选标记”判断这一轮里有没有值得抽取的内容用一个很小的模型或者规则判断即可会话结束时把所有标记过的轮次连同上下文一起送给模型做正式抽取。这样既保证了实时性候选标记很快又保证了质量正式抽取有完整上下文。3.2 提示词的核心结构抽取提示词我改过十几版最后稳定下来的结构包含四个部分角色设定、抽取规则、输出格式、反例说明。角色设定部分不要写“你是一个有用的助手”这种废话要写具体的“你是一个技术对话事实抽取器专门从工程师与 AI 的对话中提取可复用的技术决策和项目事实。”角色越具体模型的行为越收敛。抽取规则是核心我列几条实际在用的只抽取陈述性事实不抽取疑问、假设、待办。“我打算用 Redis”不算事实“我们用 Redis 做缓存”才算。每条事实必须自包含不能出现“它”“这个”“上面说的”这类指代。指代必须还原成具体名词。一条事实只讲一件事。如果一句话包含两个独立信息拆成两条。忽略寒暄、确认、情绪表达。“好的”“明白了”“这个思路不错”一律不抽。对于不确定的信息保留不确定性标记。“可能用 MySQL”要抽成“考虑使用 MySQL未最终确定”而不是“使用 MySQL”。输出格式强制 JSON并且给出严格的 schema。这里有个技巧在提示词里直接给一个完整的输出示例比只描述 schema 效果好得多。模型是模仿型选手给它看一个正确的例子它照着填的准确率远高于让它自己理解 schema。反例说明是很多人会省掉的部分但我觉得它性价比极高。在提示词里明确写“以下情况不要抽取”并给出 2-3 个具体反例能显著降低误抽率。比如反例1用户说“你觉得用 Kafka 好还是 RabbitMQ 好”——这是疑问不抽。 反例2用户说“刚才那个方案再改改”——含指代且无具体信息不抽。 反例3用户说“谢谢帮大忙了”——情绪表达不抽。3.3 抽取质量的评估与迭代抽取做完不能就这么算了得有个评估机制。我的做法是每周抽 20 条原始对话人工标注应该抽出哪些事实然后跟系统实际抽出的做对比算准确率和召回率。准确率低说明抽了太多不该抽的召回率低说明漏抽了。实测下来第一版提示词的准确率大概在 60% 左右主要问题是把疑问句和假设句也抽了。加上反例说明后准确率能到 85% 以上。召回率的问题通常是模型太保守只抽了最明显的事实漏掉了隐含信息。解决办法是在提示词里加一句“注意抽取对话中隐含的技术约束和前提条件”能明显改善。提示抽取提示词不要一次写完美要留出迭代空间。建议把提示词存在配置文件里而不是硬编码在代码里这样改提示词不用重新部署调起来快很多。4. 召回策略为什么“相似度最高”往往不是最优解记忆存好了接下来是召回。很多人第一反应是“用户提问向量化找余弦相似度最高的几条返回”这是最朴素的做法但实际用下来效果并不好。这一节讲讲召回策略里那些不那么显然的门道。4.1 纯向量召回的三个坑第一个坑是时间衰减。三个月前的一条事实和昨天的一条事实如果向量相似度一样应该优先返回哪条显然是昨天的。因为项目在演进旧事实可能已经过时了。纯向量召回不考虑时间会把过时信息捞出来。解决办法是在相似度分数上乘一个时间衰减因子比如score similarity * exp(-λ * age_days)λ 取 0.01 左右意味着 70 天前的记忆权重衰减到约一半。第二个坑是项目串台。前面提过 project_tag召回时必须先按 project_tag 过滤再做向量检索。这个过滤是硬过滤不是加权。因为跨项目的记忆几乎一定是噪声加权也救不回来。第三个坑是冗余召回。同一个事实可能在多轮对话里被反复提及抽取后变成多条内容高度相似的记忆。如果召回 top-5可能 5 条都是同一个事实的不同表述等于只召回了 1 条有效信息。解决办法是在入库时做去重或者在召回后做 MMR最大边际相关重排保证返回的结果之间有多样性。4.2 混合召回向量 关键词 类别纯向量召回对语义相近但用词不同的情况处理得好但对精确匹配反而弱。比如用户问“雪花ID”向量召回可能返回一堆讲主键设计的事实但不一定精确命中讲雪花ID的那条。这时候关键词召回BM25 之类就能补上。我实际用的混合策略是向量召回取 top-20关键词召回取 top-20两路结果合并去重后用一个简单的加权公式打分final_score 0.6 * vector_score 0.3 * keyword_score 0.1 * category_boost。category_boost 是当召回事实的类别与当前提问意图匹配时给的加成比如用户问的是数据库相关database_schema 类的事实加 0.1。这个权重不是拍脑袋定的是我用一批标注数据调出来的。0.6/0.3/0.1 这组在我的场景下效果最好但你的场景可能不同建议自己也标一批数据调一下。调权重的过程本身就是理解你的数据分布的过程很值得做。4.3 召回后的重排序与截断召回 top-20 之后不能直接全塞给模型那样 token 吃不消而且噪声多。需要重排序后截断到 top-3 到 top-5。重排序我用了两个信号一是事实的新鲜度二是事实被引用的次数。一条事实如果被后续对话多次引用或确认说明它重要且稳定应该提权。这个引用次数可以在每次召回后如果模型在回答里用到了这条事实就给它的引用计数加一。这是一个正反馈机制用得越多的记忆越容易被再次用到。截断的阈值也要动态。如果 top-1 的分数远高于 top-2可能只返回 1 条就够了如果 top-5 分数都差不多可能要多返回几条让模型自己判断。我用的规则是返回分数高于top1_score * 0.7的所有事实但最多不超过 5 条。5. 落地实操从零搭一个最小可用的记忆模块前面讲的是设计思路这一节给一套可以直接抄作业的最小实现。我用 Python 写依赖尽量少方便你快速跑起来验证效果。5.1 环境与依赖核心依赖就三个一个向量库、一个 embedding 模型、一个 LLM 调用。向量库我推荐先用本地的 FAISS不用起服务几行代码就能用。embedding 用 sentence-transformers 加载一个多语言模型。LLM 调用按你实际用的接口来。pip install faiss-cpu sentence-transformers numpyFAISS 用 CPU 版就够记忆系统的数据量通常不大几万条事实用 CPU 检索毫秒级返回没必要上 GPU。5.2 存储层实现存储分两块事实的元数据存 SQLite向量存 FAISS 索引。SQLite 负责结构化查询按 project_tag 过滤、按时间排序FAISS 负责向量检索。两者用 fact_id 关联。import sqlite3 import faiss import numpy as np class MemoryStore: def __init__(self, db_pathmemory.db, dim384): self.conn sqlite3.connect(db_path) self.conn.execute( CREATE TABLE IF NOT EXISTS facts ( fact_id TEXT PRIMARY KEY, content TEXT, category TEXT, project_tag TEXT, confidence REAL, ref_count INTEGER DEFAULT 0, created_at TEXT ) ) self.index faiss.IndexFlatIP(dim) # 内积索引向量需归一化 self.fact_ids [] # 索引位置到 fact_id 的映射 def add_fact(self, fact_id, content, embedding, category, project_tag, confidence, created_at): self.conn.execute( INSERT OR REPLACE INTO facts VALUES (?,?,?,?,?,?,?), (fact_id, content, category, project_tag, confidence, 0, created_at) ) self.conn.commit() vec embedding / np.linalg.norm(embedding) self.index.add(vec.reshape(1, -1).astype(float32)) self.fact_ids.append(fact_id)这里有个细节FAISS 的IndexFlatIP算的是内积所以向量必须先归一化归一化后的内积等价于余弦相似度。这个坑我踩过忘了归一化导致相似度分数全是乱的。5.3 召回层实现召回时先按 project_tag 在 SQLite 里查出候选 fact_id 集合然后在 FAISS 检索时只保留候选集合内的结果。FAISS 本身不支持带过滤的检索所以要么检索后过滤要么用支持过滤的索引类型。数据量小的时候检索后过滤完全够用。def recall(self, query_embedding, project_tag, top_k5): q query_embedding / np.linalg.norm(query_embedding) scores, indices self.index.search(q.reshape(1, -1).astype(float32), top_k * 4) # 查候选集合 cur self.conn.execute(SELECT fact_id FROM facts WHERE project_tag?, (project_tag,)) valid_ids {row[0] for row in cur.fetchall()} results [] for score, idx in zip(scores[0], indices[0]): if idx 0 or idx len(self.fact_ids): continue fid self.fact_ids[idx] if fid not in valid_ids: continue results.append((fid, float(score))) if len(results) top_k: break return results检索时取top_k * 4是为了给过滤留余量因为过滤会砍掉一部分结果。这个倍数根据你的项目数量调整项目越多跨项目噪声越多倍数要越大。5.4 把召回结果注入对话最后一步是把召回的事实拼成一段文本注入到系统提示词里。格式很重要我用的格式是以下是与当前问题相关的历史项目事实供参考 - [database_schema] 订单表主键使用雪花ID非自增 - [tech_stack] 缓存层使用 Redis 6.2集群模式 - [constraint] 订单表数据量预计千万级查询需走索引每条事实前面标类别让模型知道这条信息的性质。结尾加一句“以上事实如与当前对话冲突以当前对话为准”防止旧记忆覆盖新信息。这句话很关键我遇到过模型死抱着旧记忆不放的情况加上这句后明显改善。6. 踩坑实录那些让我熬夜排查的记忆系统问题这一节讲讲我在实际搭建和运行记忆系统时踩过的坑都是文档里不会写、但实际一定会遇到的问题。6.1 记忆污染错误事实一旦入库就很难清除最严重的一次事故是模型在抽取时把用户的一句假设“如果我们用 MongoDB 的话”抽成了事实“项目使用 MongoDB”。这条错误事实入库后在后续十几次对话里被反复召回导致模型一直以为项目用的是 MongoDB给出的建议全是围绕 MongoDB 的。用户过了好几天才发现不对劲。这个坑的本质是记忆系统有放大效应。一条错误记忆被召回后模型基于它生成回答回答又可能被抽成新的事实错误就被固化了。解决办法有两个一是抽取时对假设句、条件句做特殊处理强制标记为“未确认”二是建立记忆的置信度衰减机制长期未被用户确认的记忆自动降权。我现在给每条记忆加了一个confirmed字段只有用户在后续对话中明确确认过的事实才标记为 confirmed召回时 confirmed 的权重是未确认的 2 倍。这个机制上线后记忆污染的影响小了很多。6.2 上下文窗口的隐形消耗记忆注入看起来只是加了几条事实但它对上下文窗口的消耗比想象中大。因为记忆是每轮都注入的如果一次会话有 50 轮每轮注入 200 token 的记忆累计就是 10000 token。加上对话本身的内容很容易就把窗口撑满了。我的优化是按需注入不是每轮都注入记忆而是先用一个轻量判断决定这轮要不要注入。判断规则很简单如果用户的问题里包含“之前”“上次”“我们那个”这类指代词或者问题涉及项目特定的名词就注入如果是纯通用问题“Python 怎么读文件”就不注入。这个规则用关键词匹配就能实现准确率够用。6.3 向量库的更新与删除FAISS 的IndexFlatIP不支持删除只能重建。这意味着如果你要删一条记忆得把整个索引重建一遍。数据量小的时候无所谓数据量大了就很痛苦。我的做法是软删除 定期重建。删除时只在 SQLite 里标记deleted1召回时过滤掉。每周低峰期做一次全量重建把标记删除的从 FAISS 里真正移除。这样兼顾了删除的实时性和索引的效率。注意如果你用的是支持增删改的向量库比如某些托管服务这个问题不存在。但本地 FAISS 一定要考虑删除策略否则索引会越来越臃肿。6.4 多用户场景下的隔离如果你把这个记忆系统做成多人用的服务隔离就是必须考虑的问题。不同用户的记忆绝对不能混。我的做法是在 project_tag 之外再加一个 user_id 维度所有查询都带 user_id 过滤。而且 embedding 索引也要按 user 分片不能共用一个索引否则检索时的过滤成本会很高。这个坑我是被用户投诉才发现的——两个用户的项目都叫“data-pipeline”结果记忆串了。加上 user_id 隔离后再没出现过。7. 记忆的“遗忘”机制什么时候该主动删记忆最后聊一个容易被忽视但很重要的点记忆系统不能只进不出得有遗忘机制。人的记忆会遗忘AI 的记忆也需要。原因有三一是存储成本二是召回噪声三是过时信息误导。7.1 基于时间的遗忘最简单的是时间衰减。超过一定时间未被引用的记忆自动降低权重低到阈值以下就归档不删除但不再参与召回。这个时间阈值取决于你的项目周期短期项目可能 30 天长期项目可能 180 天。我一般设 90 天作为默认值。7.2 基于冲突的遗忘当新记忆与旧记忆冲突时旧记忆应该被标记为“已过时”。比如旧记忆说“用 MySQL”新记忆说“迁移到 PostgreSQL 了”那旧记忆就该退场。实现上可以在抽取时让模型判断新事实是否与已有事实冲突冲突的话把旧的标记为 superseded。这个机制我还在打磨因为冲突判断本身可能出错。目前的策略是冲突时两条都保留但新的权重更高并且在注入时明确标注“以下两条信息存在冲突以时间较新的为准”让模型自己判断。7.3 基于反馈的遗忘如果用户明确说“这个不对”“不是这样的”对应的记忆应该立即降权或删除。这是最直接的反馈信号一定要利用起来。我在系统里加了一个反馈接口用户点“这条不对”时对应记忆的 confidence 直接砍半连续两次被否定就删除。这套遗忘机制上线后记忆系统的长期可用性明显提升。没有遗忘机制的记忆系统用三个月就会变成一个充满过时信息和错误信息的垃圾场召回出来的东西还不如没有。我在实际运行这套系统大半年后最大的体会是记忆系统的价值不在于记得多而在于记得准、忘得对。一个只有 100 条高质量记忆的系统比一个有 10000 条混杂记忆的系统好用得多。所以如果你要动手做建议先把抽取质量和遗忘机制做扎实再考虑扩大记忆规模。规模是最后才需要考虑的事质量才是第一位的。