资讯动态

给Claude API加上外挂记忆:基于向量检索的长期记忆方案实战

发布时间:2026/10/8 21:21:47 来源:尧图企业网站定制
最近在做一个基于 Claude API 的客服机器人最让我难受的并不是模型能力而是它真的没有记忆。用户昨天刚报过的订单号今天换了个会话再问它就跟失忆了一样一切又得从头教。翻遍各种方案之后我看到了一个叫 claude-mem 的项目思路它不修改模型而是在 Claude 外面加一层“外挂记忆”把对话里值得记住的信息抽取、存储下次对话时再自动召回注入。这个思路解决了我一直头疼的长期记忆问题而且不需要微调模型只用 API 就能落地。这篇文章我想把这个项目的核心设计拆开来讲并且带大家手写一个最小可用的版本顺便把我踩过的坑都记录下来希望对同样在折腾 Claude 应用的人有帮助。1. 先聊聊为什么 Claude 需要一块“外挂记忆”1.1 原生对话的“金鱼记忆”尴尬很多人刚开始用 Claude API 时会犯一个错觉它上下文窗口那么大是不是把聊天记录全塞进去就相当于有记忆了实际测下来远不是这么回事。API 本身是无状态的每次请求都需要你把历史消息重新传一遍否则模型面前就是一张白纸。就算你勉强把历史全带上对话一旦跨天、跨会话这些记录就完全断掉了。我做过一个内部知识库问答机器人最典型的情况是用户第一轮问“我们公司报销流程是什么”第二轮问“那差旅住宿的上限呢”。单看第二轮其实它依赖第一轮里的上下文这还没什么。真正崩溃的是用户隔了一周回来说“还是上次那个报销的问题”但系统里完全没有“上次”的概念模型只能继续问“您上次问的是什么”这种体验放在真实场景里根本没法用。另一个问题是 token 成本。假设一段对话有 200 轮每轮平均 500 token光历史就 10 万 token 起步。如果每天都这么堆窗口再大也会爆而且大部分历史内容跟当前问题无关纯粹是浪费。所以我们需要一种更聪明的记忆方式只记住有用的、可以跨会话复用的信息而不是把所有流水账都囤着。1.2 claude-mem 给了一种什么解决思路claude-mem 最核心的思想是把“记忆”从模型会话里拿出来放到一个独立的存储层。它不做模型微调也不依赖 Claude 本身的能力而是作为应用中一个额外的模块存在。你可以把它想象成一个贴身秘书每次你跟 Claude 聊完这个秘书会把聊天里提到的关键信息记在小本子上下次聊天开始前秘书翻出小本子里跟当前话题相关的部分悄悄递给 Claude让它看起来像是一直记得你。具体来说这条链路包括三步抽取、存储、召回。抽取环节用 Claude 自己来做对话摘要把用户偏好、事实信息、待办事项等结构化地提炼出来存储环节把这些结构化内容落到本地数据库同时为每条记忆生成向量索引召回环节则是在用户每次发消息前用当前问题去检索最相关的若干条记忆注入到 system prompt 里。这个思路好在哪里首先是省 token你不需要把全部历史都搬进来只搬最相关的记忆其次是跨会话记忆写进持久化存储用户换个设备、隔个几天再回来照样能召回最后是可控你可以随时查看、编辑甚至删除某条记忆隐私和合规方面也比黑盒式的上下文缓存要踏实。我实际用它重构了客服机器人之后明显感觉到对话的连续性上了一个台阶至少用户不用每次重复报信息了。这才是我理想中的“长期记忆”形态。2. 核心拆解记忆的写入、存储和召回2.1 记忆从哪里来对话摘要与关键信息抽取记忆不是把整段对话往数据库里一扔就完事了。那样既浪费存储空间检索时也会带回大量噪音反而干扰模型。claude-mem 的做法是先用 Claude 对每轮或每组对话做一次摘要抽取要求模型输出一段固定结构的 JSON把对话里的长期有价值信息提炼出来。我在实战中是这样设计抽取 Prompt 的你是一个对话记忆提取器。请阅读以下对话记录抽取需要被长期记住的信息。 只提取客观事实、用户偏好、明确承诺的任务不要提取临时性寒暄。 输出格式必须是 JSON字段如下 { facts: [用户ID是U12345, 用户住在上海], preferences: [用户偏好简洁回答, 用户喜欢用表格], in_progress: [用户正在申请退款需要跟进] } 如果是多轮对话先去重合并相同主体再输出最终结果。关键在于强制模型输出 schema 而非自由文本。自由摘要在存储和检索时很难统一字段而结构化 JSON 则可以直接拆成多条记录每条记忆都有明确的类型和来源。抽取频率上我也做过调整。如果每一轮对话都调用一次 Claude 做抽取成本和延迟都扛不住。比较合理的做法是当对话达到一定轮数比如 5 轮或用户主动结束一个主题时再对这一段历史做一次批量抽取。这样既能覆盖关键信息又不会抽得太碎片化。2.2 记忆存在哪里SQLite 向量索引的混合方案记忆抽取完之后下一步就是存储。这里我用的是“SQLite 向量索引”的混合方案而不是单纯上一个重型向量数据库。原因很简单个人项目或者中小型应用不需要为记忆上整套 Milvus 或者 QdrantSQLite 足够稳维护成本也低。我会建一张memories表核心字段如下字段类型说明idINTEGER PRIMARY KEY记忆 IDuser_idTEXT用户标识用于多用户隔离contentTEXT记忆内容文本memory_typeTEXTfacts / preferences / in_progressembeddingBLOB向量的二进制存储source_session_idTEXT来源会话 IDcreated_atDATETIME创建时间last_accessed_atDATETIME最近召回时间hit_countINTEGER召回命中次数为什么还需要向量因为用户表述往往不是字面精确匹配。比如记忆里存的是“用户养了一只英短”用户下次问“我家猫最近不爱吃东西怎么办”单纯用关键词检索“英短”是搜不到的但用向量相似度就能关联上“猫”和“英短”的语义关系。所以我在每条记忆写入时都会生成一个 embedding存到同一个表里。对于向量计算我建议先用现成的 Embedding API比如 OpenAI 的text-embedding-3-small或者本地的bge-small-zh。如果不想再引入一个外部依赖也可以先用 TF-IDF 或者 BM25 顶一版但效果差距明显。初次实现我强烈建议直接上向量后面你会少走很多弯路。2.3 记忆怎么被想起来相似度检索与主动注入存储只是基础真正决定体验的是召回策略。claude-mem 的召回不是单纯把最新记忆丢给模型而是根据用户当前问题做相关性判断只挑最靠谱的几条。具体流程是用户发来一条新消息我先把它转换成一个 embedding 向量然后在 SQLite 里把所有记忆的 embedding 取出来做余弦相似度计算按得分倒序取 TopK。这个 K 值我一般取 510太少可能漏掉重要记忆太多则容易噪音淹没。召回结果不能直接塞给模型要格式化一下。我的做法是把记忆拼成一个只读的“记忆快照”放到 system prompt 里以下是用户与系统此前交互中提炼出的长期记忆供参考 - [fact] 用户ID是U12345常住上海 - [preference] 用户偏好简洁回答喜欢用表格 - [in_progress] 用户正在申请退款需要跟进 请根据这些记忆回答用户问题。如果记忆与当前问题无关或明显过时请忽略。加最后那句“无关或过时请忽略”非常重要。模型有时候会过度解读记忆把不相关的旧信息硬扯进答案里这句话能有效抑制幻觉式联想。另一个关键点是召回时机。我测试过两种方案一种是只在会话开始时召回一次之后整场对话都固定用那批记忆另一种是每轮用户发消息前都动态召回。实测下来会话开始时召回一次 每轮动态增补增量记忆比较合理。因为用户聊着聊着可能会开启新话题固定记忆会漏掉新出现的相关性而全部动态召回又会让模型读到的记忆不断变化反而可能造成前后不一致。3. 手把手搭一个最小可用的 claude-mem3.1 环境准备与依赖这部分我按最简单的方式来做目的是让你先跑通整个链路再根据实际场景优化。需要 Python 3.10 以上然后安装几个包pip install anthropic openai numpyanthropic用来调用 Claudeopenai库在这里只是用来调用 Embedding API也可以直接用 httpx 手动请求不过用现成库省事numpy用来做向量余弦相似度计算。如果不想用外部 Embedding API也可以换成sentence-transformers跑本地模型但首次运行要下载几百 MB 模型比较慢。我个人的建议先把外部 Embedding API 流程跑通确认记忆召回效果符合预期再考虑换成本地模型做隐私优化。没必要一开始就陷入自托管的坑。3.2 编写记忆管理器记忆管理器是整个 claude-mem 的心脏。我会写一个MemoryManager类包含写入和检索两个核心方法。先看写入逻辑。会话中抽取出结构化记忆文本后调用这个方法import sqlite3 import numpy as np import json class MemoryManager: def __init__(self, db_pathmemories.db, embed_fnNone): self.conn sqlite3.connect(db_path) self.embed_fn embed_fn self._init_db() def _init_db(self): self.conn.execute( CREATE TABLE IF NOT EXISTS memories ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id TEXT, content TEXT, memory_type TEXT, embedding BLOB, source_session_id TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, last_accessed_at DATETIME DEFAULT CURRENT_TIMESTAMP, hit_count INTEGER DEFAULT 0 ) ) self.conn.commit() def add_memory(self, user_id, content, memory_type, source_session_id): embedding self.embed_fn(content) # 返回 list[float] self.conn.execute( INSERT INTO memories (user_id, content, memory_type, embedding, source_session_id) VALUES (?, ?, ?, ?, ?), (user_id, content, memory_type, np.array(embedding, dtypenp.float32).tobytes(), source_session_id), ) self.conn.commit()写入时需要注意embedding 需要通过二进制序列化后再存入 BLOB否则 SQLite 没法直接处理 list。取出时可以反过来np.frombuffer(...)恢复成向量。接下来是检索逻辑。我把当前用户消息转换成向量然后与库里所有该用户的记忆向量做余弦相似度计算返回 TopKdef retrieve(self, user_id, query, top_k5): query_vec np.array(self.embed_fn(query), dtypenp.float32) rows self.conn.execute( SELECT id, content, memory_type, embedding, hit_count, last_accessed_at FROM memories WHERE user_id ?, (user_id,) ).fetchall() scored [] for row in rows: mem_id, content, mem_type, emb_blob, hit_count, last_at row emb np.frombuffer(emb_blob, dtypenp.float32) score float(np.dot(query_vec, emb) / (np.linalg.norm(query_vec) * np.linalg.norm(emb) 1e-8)) scored.append((score, mem_id, content, mem_type, hit_count, last_at)) scored.sort(keylambda x: x[0], reverseTrue) results scored[:top_k] # 更新命中次数和最近访问时间 for _, mem_id, _, _, _, _ in results: self.conn.execute( UPDATE memories SET hit_count hit_count 1, last_accessed_at CURRENT_TIMESTAMP WHERE id ?, (mem_id,) ) self.conn.commit() return [ {content: content, type: mem_type, score: score} for score, _, content, mem_type, _, _ in results ]这个实现非常朴素也没有做分页或真·向量索引但对千万级以下的数据量来说是够用的。如果你后续数据量增长推荐把向量索引迁移到sqlite-vec或者专门的向量数据库。前期别过度设计。3.3 集成到 Claude API 的流畅对话中记忆管理器写完接下来就是把记忆注入到 Claude 的调用链里。我会封装一个带记忆的聊天函数核心思路是先检索记忆再拼 system prompt最后调 Claude。from anthropic import Anthropic client Anthropic(api_keyyour-api-key) mm MemoryManager(db_pathmemories.db, embed_fnembed_query) def chat_with_memory(user_id, user_message, historyNone): # 1. 召回相关记忆 memories mm.retrieve(user_id, user_message, top_k5) memory_text \n.join( f[{item[type]}] {item[content]} for item in memories ) system_prompt f你是一个乐于助人的智能助手。 以下是此前交互中提炼出的用户长期记忆供参考 {memory_text} 请根据记忆回答用户问题。如果记忆与当前问题无关或明显过时请忽略。 # 2. 组装消息 messages [] if history: # history 是 [(role, content), ...] messages.extend( {role: role, content: content} for role, content in history ) messages.append({role: user, content: user_message}) # 3. 调用 Claude resp client.messages.create( modelclaude-3-5-sonnet-20241022, max_tokens1024, systemsystem_prompt, messagesmessages, ) reply resp.content[0].text # 4. 这里可以做一个简化版记忆抽取只抽取本轮明显的用户偏好 # 更完整做法是积累多轮后批量抽取 if len(user_message) 20: facts extract_memory_simple(user_message) for fact in facts: mm.add_memory(user_id, fact, fact, source_session_idcurrent_session_id) return reply需要注意几点第一history不要无限增长。我通常会只保留最近 10 轮对话更早的消息既然已经抽成记忆了就没必要继续占用上下文窗口。第二记忆抽取我上面写的是一个简化占位函数真实用例应该是像 2.1 节里那样用 Claude 批量抽取否则一条用户消息可能会抽出很多无效信息。第三system_prompt里的记忆文本要控制在合理长度内我会在拼装前先做一次 token 估算超过预算就裁剪到 TopK 更小。这一套跑通之后你会发现对话像是“长出”了记忆。用户第一次说“我喜欢简洁的回答”第二次再问技术问题时Claude 会自动给出更精炼的答复。这个体验提升非常直观。4. 踩坑实录与调优心得4.1 记忆污染比没记忆更可怕集成完第一版后我遇到一个很恼火的问题用户随口说了一句“今天天气不错”系统就把它当成长期偏好记下来了。第二天用户提问时模型开始一本正经地结合“天气不错”来回答完全跑偏。这就是典型的记忆污染。不是所有对话内容都值得长期记住抽取时必须做过滤。我后来在抽取 Prompt 里明确加上“不抽取天气寒暄、临时情绪、一次性事件”但效果仍然不够稳。最终解决办法是给每条记忆加一个“置信度”字段置信度低的记忆进入待确认状态如果之后多次在对话中被重复提及或验证才提升为长期记忆。比如用户第一次说喜欢表格你存一个低置信偏好三次对话里他都要求用表格再把它标记为高置信。另一个策略是定期做记忆整理。每周把低命中率、长时间未访问的记忆交给 Claude 做一次合并和清理把冗余内容合并成一条把过时内容删除。这个动作类似人脑的睡眠巩固能有效抑制记忆库膨胀和噪音累积。4.2 上下文窗口和 token 预算控制记忆注入不是免费的。假设每条记忆 50 tokenTopK 取 10 就是 500 token看着不多但如果历史对话还有 20 轮每轮 400 token加起来就接近 8500 token再叠加 system prompt 和工具定义一个小窗口就被塞满了。我的建议是给记忆注入设置硬性预算。比如总窗口是 200K那么记忆最多占用 8K历史最多占用 32K剩下留给生成和动态内容。如果检索出的记忆超过预算按相似度分数从高到低截取而不是硬塞 TopK。简单公式如下记忆预算 tokens min( int(0.04 * total_window_tokens), len(retrieved_memories) * avg_memory_tokens )实测下来记忆占比控制在 4%5% 以内时Claude 的表现最稳定。太少了记忆起不到作用太多了模型容易把记忆当作事实来源甚至出现“编造记忆”的情况。调试的时候我会打印出每次实际注入的记忆内容肉眼检查相关性。4.3 隐私数据与多用户隔离如果你是单机自用用户 ID 隔离可能没那么重要。但只要你的应用会服务多个用户就一定要在存储层做硬隔离。我在第一版就犯过漏加 user_id 过滤的错检索时只查了 TopK 向量相似度结果有次把 A 用户的订单号带给了 B 用户虽然只是测试环境也吓出一身冷汗。安全做法参考我上面的retrieve方法SQL 查询强制WHERE user_id ?从源头保证只检索当前用户的数据。另外记忆文本中如果包含地址、手机号等敏感字段建议先做脱敏再存储。比如把“联系手机 138xxxx”存成“用户手机尾号 1234”能降低泄露风险。还要提供“遗忘”能力。GDPR 这类合规要求先不提单从用户体验出发用户应该有权利说“忘掉我的所有记忆”。我会在管理后台提供一个一键清空按钮调用DELETE FROM memories WHERE user_id ?同时把 embedding 缓存也一并清掉。这个操作虽然简单但能让你后续面对隐私合规审核时更有底气。多用户场景还有一个容易忽视的坑session 过期后用户重启应用旧记忆仍然会被召回。如果用户已经明确登出并切换账号必须先切换 user_id而不是沿用旧会话标识。我在实现时会把 user_id 和会话绑定每次请求都从认证上下文里取而不是从客户端传入的参数里取防止被篡改。4.4 记忆召回时机与多轮对话的协同最后再说一个调参层面的心得记忆召回并不是每轮都必须执行。对于简单问候、寒暄或与长期记忆无关的操作指令比如“帮我打开设置”召回反而可能拖慢响应。我后来加了一个前置判断先判断当前消息里是否包含可检索的实体或意图如果明显是通用交谈就跳过召回直接走正常的无记忆路径。而且多轮对话中历史消息本身已经包含了上下文这时候重复注入同样的记忆会显得冗余。我的策略是会话第一轮必须注入相关记忆之后只有当新消息中出现了新的实体、关键词或话题转移信号时才做增量召回。这么做能明显降低 API 调用次数和 token 消耗同时保持记忆的连续性。我实际跑客服机器人项目时就是这样做的第一轮注入长期记忆之后每一轮把上一轮的对话压缩成短摘要塞给模型而不是无限堆原始消息。整套流程跑了一个月记忆库 2000 多条响应速度和准确率都还在可接受范围内。最后分享一个小技巧我在每条记忆上都留了source_session_id字段本意只是为了追踪来源后来排查问题时发现它太有用了。当模型给出的答案明显依赖某条记忆且那条记忆其实是错的我可以顺着 session 回到原始对话快速定位是抽取错了还是存储错了。这比在黑盒里猜要高效得多。做完这套改造我的一个深切体会是给 Claude 加记忆最难的不是技术而是怎么设计记忆的边界。记忆不是越多越好而是越精准越好。少而精的记忆库配上合理的召回策略反应到用户体验上是质的提升。如果你也在被 AI 的“金鱼记忆”折磨我非常推荐尝试一下这个思路从一个小型记忆管理器开始逐步迭代到完整的 claude-mem 方案你会发现长期记忆这件事真的没那么神秘。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑