如果你已经在用 Claude 的 API 做应用跑一段时间大概率会遇到同一个尴尬模型不记得上一次对话。不是模型不够聪明是 API 本身无状态每次调用都是一次全新的面试。我已经记不清多少次在回填上下文时把用户偏好、项目背景、前几轮结论翻来覆去地重新拼进 prompt浪费 token 不说还容易漏。后来我干脆写了个本地小工具 claude-mem专门替 Claude 保管那些“该记的东西”。它本质上是一个外部记忆层把每次对话的关键内容切片、摘要、向量化落到本地文件里下一次调用之前按相关性捞出来重新拼进 system prompt。我做这个东西的初衷很简单就是不想为“记忆”这个功能去维护一套重型的向量数据库服务。它针对的是正在做聊天机器人、Agent 应用、私有知识库问答并且深受上下文丢失困扰的那拨人。如果你也是这类场景这篇文章应该能帮你省不少弯路。1. 为什么单独搞一层记忆而不是让模型自己记住1.1 API 调用本来就是无状态的先说最底层的逻辑。Claude 这类大模型 API 在设计上就是无状态的服务端不会在两次请求之间保留任何会话状态。你把 system、messages 丢给接口它把这些内容当成“此刻唯一能看到的事实”回答完就结束了。下一次请求它对你上次说过什么一无所知。这个特性从工程角度看是合理的无状态让服务端可以水平扩展随便哪个副本都能处理请求。但从应用开发者的体验来看等于每次都要把所有背景知识重新交代一遍。早期我做对话产品时踩过这个坑第一次用户说“我喜欢简洁的回答风格”第二次对话我没把这个信息拼进去模型立刻恢复成默认口吻用户觉得我做的产品“没有脑子”。我们解决这个问题的方式不是抱怨 API 设计而是把记忆从模型内部挪到模型外部。claude-mem 做的就是这件事你不指望模型自己记住而是由你——应用层——替它把该记的东西存起来下次提问前再塞回给模型。1.2 把历史全部塞回上下文的代价有人可能会说既然 API 不给记忆那我把所有聊天记录原封不动拼到 messages 里不就行了我在项目早期确实这么干过实测下来三个问题很扎心。第一是 token 成本。假设一次对话平均 10 轮每轮来回 500 token100 次对话就是 50 万 token。你不可能每次都把这 50 万 token 重新发给接口光是费用就受不了。更别说上下文窗口总有一天会被撑满撑满之后要么截断前面的历史要么直接报错。第二是信号噪声。模型在被塞进一堆无关闲聊之后回答质量会明显下降。它会把三五天前的某句话当成当前指令来遵循或者被中间一段无关讨论带偏。这跟人一样给一个人看 100 页会议纪要让他回答今天该干什么他也会抓不住重点。第三是延迟。输入 token 越多首 token 延迟越明显。用户不会关心你后端为了“完整记忆”传了多少内容他们只感知到回答变慢了。所以结论很清楚全量历史不可取我们需要一个能“挑重点”的中间层。这正是做 claude-mem 时最重要的设计前提。1.3 记忆层到底该解决什么问题如果把这个问题拆开外部记忆层至少要做四件事写入对话结束后把有价值的内容从原始文本里提取出来做成结构化条目。存储条目落库带时间戳、来源会话 ID、标签最好还能做相似度检索。召回新提问到来时快速找到与当前问题相关的记忆条目。遗忘过期、冲突、不再有用的记忆要能删除或降权否则记忆库会劣化。我经常把 claude-mem 和 RAG 做对比。RAG 解决的是“检索外部知识文本并注入回答”它假设知识源是文档claude-mem 解决的是“检索用户/任务的历史上下文”它处理的是碎片化、主观、经常变化的对话记忆。两者可以共存但关注点不同。把这个定位想清楚之后后面的实现才不会被“做一个万能问答系统”这种目标带跑。2. 核心设计与实现拆解2.1 写入与读取两条链路claude-mem 的整体流程被我拆成了两条链路写入链路和读取链路。写入发生在会话结束之后读取发生在新会话开始之前。写入链路做五件事拿到完整对话记录按会话 ID 分组。把对话切成多个小块每块包含一个相对完整的子主题。对每一块用大模型做摘要抽取出用户目标、结论、下一步动作。摘要文本经过 embedding 模型转成向量。向量和摘要文本一起写入本地数据库并附带关键词标签。读取链路做四件事用户发起新提问。把提问文本转成向量。在本地库里做相似度检索找到 top_k 个相关记忆条目。按时间、重要性、相关度排序拼装成一段“长期记忆”放进 system prompt。两条链路我都设计成异步执行。写入不会阻塞主对话流程用户在聊天的时候后台悄悄把上一轮该记的内容落库读取可以做成同步但缓存了最近 10 分钟的检索结果避免同一用户短时间内反复触发向量查询。这个“先写后读”的分工是我踩过多次性能坑之后才定下来的。2.2 存储选型SQLite 加向量索引就够了做存储选型的时候我比较过三套方案纯 SQLite适合结构化查询但没法做语义相似度计算。独立的向量数据库服务功能强但要额外部署、运维对本地工具来说太笨重。SQLite 扩展 sqlite-vec在 SQLite 文件里直接存向量、做近邻检索不需要单独起服务。我最终选了第三条路SQLite 存全部结构化和文本数据sqlite-vec 做向量索引。理由很实际单文件、零依赖、备份容易。整个记忆库就是一个.db文件复制走就是备份。对我这种个人工具的使用量级——单用户、几十万条记忆——sqlite-vec 的召回性能和准确度完全够用。如果将来要撑多用户、高并发再往独立向量库迁移也不难因为记忆条目本身还是结构化数据向量只是其中一个字段。用独立向量库的时候常见问题是为了检索一个向量把文本、时间戳、标签全塞进向量库里结果每条记录都很臃肿查询不灵活。我的习惯是让数据库管结构、向量库只管向量各干各的活。下面是我实际用的核心表结构精简后大概是这样的CREATE TABLE memories ( id INTEGER PRIMARY KEY, session_id TEXT, content TEXT NOT NULL, summary TEXT, tags TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, last_recalled_at DATETIME, embedding BLOB );embedding字段存的是序列化后的向量检索时交给 sqlite-vec 做近邻计算。tags字段存逗号分隔的标签这个字段在后期维护中比我想象的更有用后面具体说。2.3 记忆不是日志是索引卡片这是 claude-mem 最重要的一个设计理念记忆不是把聊天记录原封不动存下来而是把它改写成“索引卡片”。原始对话里有大量寒暄、重复解释、无关讨论。如果直接存原文检索时很容易被噪声干扰。比如用户在十轮对话里提了三次“我要一个蓝色按钮”原文搜索会召回三条几乎一样的记录但真正有价值的可能只是最终那条“蓝色按钮圆角右上角”。我的做法是先用模型做摘要压缩成一两条结构化内容模板大概是用户目标在页面右上角放置蓝色圆角按钮 结论按钮样式已确认下一步绑定click事件 标签ui, project_x摘要之后还要做显著性过滤。只有那些可能影响后续对话的内容才值得存比如用户偏好、明确决定、待办事项、关键数字。纯粹的闲聊我会直接丢弃否则记忆库会被“今天天气不错”这种话填满。每条记忆的生命周期也值得关注。我在表里加了created_at和updated_at写入时记首次创建时间每次被成功召回时更新last_recalled_at。加这个字段不是为了好看是为了给“遗忘策略”提供依据。长期没被召回的条目在下一次压缩任务里会被降权或者删除。3. 从零到可用实操过程3.1 目录结构与配置文件claude-mem 的默认目录是~/.claude-mem/下面放两份东西记忆库文件mem.db和配置文件config.yaml。我第一次启动时先跑claude-mem init工具会创建目录和数据库文件。配置文件我留了下面这些参数storage: path: ~/.claude-mem/mem.db vector_backend: sqlite_vec retrieval: top_k: 5 min_score: 0.35 max_recall_tokens: 1500 auto_remember: enabled: true session_end_timeout: 60 summary_model: claude-sonnettop_k控制新会话最多召回几条记忆min_score是相似度阈值低于这个值的记忆不会被注入。max_recall_tokens限制拼进 prompt 的记忆总长度防止记忆反客为主挤占正常对话空间。这里有一个参数需要特别提醒min_score不要设太高。我一开始设为 0.6结果大量记忆被过滤掉模型又变成“没有记忆”状态。后来调到 0.35召回数量上来了配合max_recall_tokens做总量控制效果反而更好。阈值高虽然精度高但覆盖率太低对上下文记忆这种场景不划算。3.2 记忆模块的核心接口我实现 claude-mem 的时候对外只暴露了几个特别朴素的接口from claude_mem import remember, recall, forget # 写入一条记忆 remember( content用户要求所有回复使用中文技术名词保留英文, tags[user_preference, language], session_idsession_20250101 ) # 按语义召回相关记忆 memories recall(回复语言偏好, top_k3) for m in memories: print(m.content, m.score) # 删除指定记忆 forget(技术名词保留英文)remember内部会自动做摘要和向量化外部调用者不需要关心 embedding 细节。recall会先把输入文本向量化再做近邻查询最后按时间和相关度综合排序。forget支持按关键字、标签、时间范围删除。这段代码看起来简单但内部有一个要注意的点remember虽然是同步接口但在生产环境里我建议把它放到后台任务队列里执行。因为 embedding 和摘要都需要额外调用模型如果放在请求主链路里用户会明显感觉到“聊完一句话隔了两秒才收到下一条回复”。把它改成异步写入之后体感问题立刻消失。3.3 接入一个最简单的聊天脚本下面是我实际用来验证 claude-mem 的最小实现。它做的事情很直白收到用户问题先召回记忆拼进 system prompt调用 Claude 接口拿到回复后把当前这轮对话异步记录到记忆库。import os import json from claude_mem import recall, remember CLAUDE_API_KEY os.getenv(CLAUDE_API_KEY) CLAUDE_API_URL os.getenv(CLAUDE_API_URL) def call_claude(messages): # 这里用普通的 HTTP 请求调用即可不需要引第三方包 body json.dumps({ model: claude-sonnet, max_tokens: 1024, messages: messages, }).encode(utf-8) # 省略具体网络请求细节 return response_json def handle_user_message(user_input, session_id): # 1. 召回相关记忆 memories recall(user_input, top_k5) memory_block \n.join( f[记忆] {m.content} for m in memories ) # 2. 拼装 system prompt system_prompt f 你是一个有长期记忆的助手。 以下是关于当前用户的历史记忆优先参考这些信息 {memory_block} messages [ {role: system, content: system_prompt}, {role: user, content: user_input}, ] # 3. 调用模型 response call_claude(messages) # 4. 后台记录这轮对话 remember( contentf用户说{user_input}助手回复{response}, tags[conversation, session_id], session_idsession_id ) return response这个脚本跑起来之后我立刻感受到质变新会话里用户不用再重复“我之前说过要简短回答”模型在看到第一条消息时就已经知道这个偏好。不过这里有个小细节remember的content不要直接传完整对话原文。我建议只传提取出的关键信息比如“用户确认按钮颜色为蓝色”。如果硬塞原文会把一堆无意义寒暄也变成向量污染后续检索。3.4 日常维护命令项目跑了一段时间记忆库慢慢变大我开始需要一些维护能力。claude-mem 目前提供这几个子命令足够覆盖日常使用# 初始化目录和数据库 claude-mem init --dir ~/.claude-mem # 手动添加一条记忆 claude-mem remember 用户所在时区是东八区 --tag preference # 手动召回测试 claude-mem recall 时区 # 列出最近 20 条记忆 claude-mem list --limit 20 # 删除指定标签的记忆 claude-mem forget --tag temp # 干跑清理只看会清掉什么不实际删 claude-mem gc --dry-run # 导出全量记忆方便备份 claude-mem export --output backup.jsonl我特别推荐定期跑claude-mem gc。它会清理三种条目超过 180 天没有被召回的、相似度超过 0.92 的重复条目、标记为临时但忘记删除的条目。我最早没有做清理三个月后记忆库涨到 4 万多条检索变慢噪声也开始变多。跑了一次清理之后条目减到 1.2 万召回准确度肉眼可见地回升了。4. 踩坑记录与问题排查4.1 中文召回效果差第一个大坑是中文召回效果差。我最早用的 embedding 模型对中文支持很弱经常出现用户问“回复语气”时召回不到“请用轻松的语气”这条记忆反而召回一堆英文技术文档片段。排查思路是分两步走先看向量相似度发现中文查询和中文记忆的相似度普遍很低再对比其他语言发现同样语义的英文查询能够正确召回。基本可以定位是 embedding 模型对中文特征表达能力不足。解决方案是换一个对中文更友好的 embedding 模型并且做了一层“候选召回”兜底先用 SQLite 的全文搜索FTS5按关键词找出候选集再在候选集上做向量排序。关键词匹配虽然召回粗但不会漏掉明确提到“语气”字眼的记忆向量排序负责语义相似度两者结合中文场景下的 F1 明显提升。4.2 新旧记忆互相打架第二个问题更难处理新旧记忆冲突。典型情况是用户周一告诉我“预算上限 100 元”周五又改成“预算 200 元”。两条记忆同时存在模型在回答时可能同时引用两天前的旧规则和新规则给出自相矛盾的回答。我最初的做法是简单按created_at取最新一条但这样会丢掉旧记忆里的有效背景。后来改成用标签做冲突检测当新记忆写入时如果存在相同实体和相同标签的旧记忆就把旧记忆标记为superseded今天的新记忆成为唯一有效版本。同时在召回排序里加时间衰减权重越新的记忆权重越高。这个策略不能解决所有矛盾但能把大多数“预算到底是多少”这类问题的错误率降下来。4.3 重复记忆堆积第三个问题是重复记忆堆积。只要用户反复聊同一个话题remember就会被反复调用导致库里出现几十条几乎一模一样的记忆。检索时 top_k 被这些重复条目占满真正不同的记忆反而进不来。解决方案是在写入前做一次相似度检查拿新摘要和已有条目算相似度如果超过 0.92就不再新增条目而是更新原条目的last_seen和updated_at同时把新的细节合并到content里。这个操作让记忆库的条目数量增长慢了很多而且同一个主题的相关信息会慢慢收敛成一条高质量记忆而不是散落成几十条碎片。4.4 记忆注入挤占上下文空间第四个问题是记忆注入过多反而挤压了正常对话空间。之前我为了“尽量多给模型一点上下文”开过很大的max_recall_tokens结果 system prompt 被记忆塞得满满当当用户真正的问题被挤到濒临截断的边缘回答质量不升反降。我后来把一个经验值写死记忆注入部分不超过总上下文的 15%剩下空间全部留给当前对话和工具调用结果。召回的 5 条记忆如果超过max_recall_tokens就按相关度从低到高丢弃直到满足长度限制。这样既保住了关键记忆又不影响模型处理当前任务。4.5 问题速查表现象可能原因解决建议中文检索召回不到记忆embedding 模型中文能力弱换中文 embedding叠加 FTS5 候选召回回答前后矛盾新旧记忆冲突增加标签冲突检测新记忆覆盖旧记忆记忆库增长过快重复条目写入写入前做相似度查重相似度高于 0.92 则合并上下文被挤占记忆注入太多收紧max_recall_tokens限制在 15% 上下召回结果不相关min_score阈值过低适当调高阈值同时启用时间衰减权重首次召回慢embedding 在线计算预热缓存提前对常见问题做向量化5. 顺着这个思路还能做什么5.1 用记忆稳定 Agent 的人设claude-mem 不仅能记住用户信息也能用来稳定 Agent 人设。我给每条记忆加了一个role字段写法和普通偏好分开。比如“你是资深产品经理回复先讲结论再给理由”“你负责代码审查优先找安全问题”。这些记忆在每次会话开始时注入效果比把长段人设写死在代码里好得多因为可以在运行时动态调整不用改代码。我试过一个场景让同一个 Agent 上午用产品经理身份回答问题下午切到技术顾问身份。只要在remember时把role字段对应的记忆变更掉新会话的人设立刻切换。对小团队做个多角色 bot 来说这个方案比维护多套 prompt 省事。5.2 多 Agent 共享记忆如果手上有多个 Agent 在跑每个都独立记忆会比较浪费。我在表结构里加了一个agent_id字段不同 Agent 读写同一份记忆库但检索时只查自己的。这样既能各自保留独立的会话上下文又能共享一套公共偏好库。公共库里的记忆标记为scopeshared任何 Agent 都能召回。这个思路在搭建“一个主 Agent 调度多个子 Agent”的系统时特别有用。主 Agent 负责记住用户目标子 Agent 只负责执行具体任务执行完把自己的结论写回共享库。下次主 Agent 处理同类任务时能直接引用上次的执行结果不必重新跑一遍。5.3 隐私和数据安全既然记忆库落在本地数据安全就必须提上日程。我强烈建议不要在记忆里存明文密钥、证件号、密码这类内容即使数据库看起来是本地文件也不行。我在工具里留了一个敏感词过滤层写入之前先扫描命中敏感模式就直接丢弃该条记忆。至于数据库本身可以用 SQLCipher 对整个文件做加密。这样即使有人拿走了mem.db也读不出内容。代价是每次读写多一层加解密开销但对个人工具来说感知不到。还可以给记忆库设置自动过期策略比如“三个月自动清理所有未召回的记忆”减少长期数据堆积带来的隐私风险。5.4 给记忆库加一层知识图谱关系做了一段时间之后我发现纯向量检索有一个天花板它适合“按相似度找”但不适合“按关系找”。比如你问“上次那个按钮方案和用户偏好里的圆角要求是不是同一个项目的”向量相似度很难直接回答这种跨越多个记忆条目、隐含关联的问题。我下一步的计划是从记忆摘要里抽取实体和关系构建一个轻量知识图谱。核心是把标签升级成“实体关系边”例如project_x与blue_button之间建立contains关系检索时不仅能召回直接相关的记忆还能通过关系边做一跳扩展。听起来复杂但 SQLite 存三元组并不难真正难的是抽取关系的准确性。如果你也在做类似的记忆工具我建议先把实体抽取做好再考虑关系推理。我实际用下来的体会是claude-mem 最大的价值不是省了多少 token而是它改变了模型的“存在方式”。过去它像一个每次都失忆的助手你气得跳脚也没用现在它至少记住了你是谁、你在做什么、你上次想清楚了什么。最后再分享一个我在使用时的小技巧每天睡前跑一次claude-mem gc顺便把当天新增的记忆按标签过一遍该合并的合并该删的删。第二天再开新会话时模型拿到的记忆干净、准确、不啰嗦体验比无脑堆历史好太多。