claude-mem:为Claude等对话模型实现长期记忆的工程实践 做了个叫 claude-mem 的小项目目的是给 Claude 这类对话模型补上长期记忆。起因很实在我日常工作里大量时间花在同一个项目上今天聊完的方案明天新开一个会话就得重新讲一遍约束、背景和中间结论一旦对话拉长模型还会把开头的信息忘得差不多。claude-mem 做的事情很简单——把每次对话里值得留的内容抽取出来存成结构化记忆下次开新会话之前把相关记忆自动检索出来、注入到上下文里让 AI 从一开始就记得我们之前聊过什么。这个项目面向的是长期依赖对话模型做开发的开发者、用 AI 做研究和写作的人以及所有被每次都要重新交代背景折磨过的用户。它不追求大而全核心逻辑只有闭环的三步写入、检索、注入。这篇文章从需求拆解、技术选型到核心代码、部署调优和踩坑记录完整梳理一遍给你一个可以直接照着实现的参考方案。1. 需求拆解模型为什么会失忆记忆系统到底在解决什么问题1.1 上下文窗口是容量问题不是记忆问题模型对上下文的处理方式本质上是一个有限容量的工作台。它有窗口上限输入越长能放下的内容越多但有两个现实限制一是窗口本身有 token 上限总长度不可能无限增长二是即便长度没到上限模型对窗口中间偏早位置的信息关注度和保持度也会明显下降这就是常说的长上下文注意力退化。实测下来一个几万 token 的对话里最早那部分关键需求经常被后续内容稀释。所以单纯把历史记录全部塞进窗口既不经济也不可靠。这就好比一个程序员桌上只能放得下几本书你要让他同时维护几十个模块的细节最合理的做法不是把四十本书全摊在桌上而是把他现在需要的那几本挑出来。挑的过程就是一个检索过程。1.2 记忆不等于上下文要分清工作记忆和长期记忆我做 claude-mem 之前反复想过一个问题模型本身有没有记忆严格说对话上下文是它的工作记忆会话一关就没了。而人和人协作时真正起作用的是长期记忆——项目背景、技术偏好、之前踩过的坑、已经拍板的结论这些是可以跨天跨周被调用的。长期记忆系统要做的不是保存所有聊天原文而是把原文里重要且可复用的部分提炼成条目存进外部存储在新会话开始前再按当前任务把相关条目拿出来放进上下文。这样模型的窗口永远是干净的但又是知情的。1.3 我给自己列出的核心使用场景跨天开发同一个仓库今天定下的架构约定、命名规范、已知依赖冲突第二天新会话直接带入。技术调研与决策记录为什么选 A 方案而没有选 B当时对比了哪些维度避免下次重复调研。文档与论文阅读笔记关键定义、结论、示例代码片段需要时直接检索引用。写作与内容项目设定好的语气风格、人物设定、章节走向不用每次重新润色一遍。这些场景的共性是信息有价值、会复用它、而且很难一句话说清全部背景。2. 整体设计思路先定存储再定流程最后定接入方式2.1 存储选型默认 SQLite不上重型数据库记忆系统需要存两类内容一条条结构化的记忆条目和对应的原始对话片段。数据量在个人使用场景下一年撑死几十万条SQLite 完全够用而且它有三大优势零部署、单文件、原生支持全文检索FTS5。语义检索方面我没有一开始就引入专门的向量数据库。原因是个人项目的记忆量级用本地 Embedding 模型把每条记忆转成向量再在 Python 里用 NumPy 算余弦相似度性能完全够。记忆条数在 10 万以内时全量暴力计算只有几十毫秒到几百毫秒完全在可接受范围。真要遇到更大量级再换成专门的向量索引也不迟。提示一套系统能跑起来的核心是简单。能用一个文件解决的就别上服务能本地算的就别远程调用。后续换组件时只要接口抽象得当迁移成本很低。2.2 记忆生命周期写入、检索、更新、遗忘任何记忆系统都需要管理四条链路写入从对话消息中抽取要点生成记忆条目写入存储。检索根据新会话的当前任务召回相关记忆。更新当新信息与旧条目冲突或补充时做合并和修订。遗忘低价值、长期未访问的记忆逐步降权并清理。claude-mem 的设计重点放在前两个环节因为那是日常使用频率最高的部分。更新机制用同主题覆盖 版本时间戳处理遗忘机制用重要性评分 时间衰减处理。2.3 接入方式为什么我选了脚本工具 上下文注入给 Claude 加记忆有三种常见接法MCP 协议服务把记忆能力封装成工具模型可以在对话中主动调用查记忆存记忆。灵活度高但依赖运行环境支持配置成本高。命令行工具提供init、ingest、ask等指令用户手动或通过脚本调用。简单直接适合快速验证。上下文注入封装在发起新对话前先调用检索接口把命中的记忆拼进 system prompt。对上层完全透明我的主方案是这种。我实际采用的是命令行工具负责写和管上下文注入负责读。也就是说claude-mem 提供一套检索接口用户在自己的启动脚本里先拿到检索结果再带进提示词。这样不依赖特定前端任何能自定义提示词的界面都能用。之后如果想要更深的集成再加一层 MCP 包装就行底层逻辑完全不用改。3. 核心实现记忆是怎么写进去的3.1 先教会模型识别什么值得记记忆抽取不是把聊天记录截断存下来而是把连续对话变成一组离散条目。我设计了一个专门的抽取提示词让模型在对话到达一个节点时提取以下内容项目实体和术语比如支付模块回调超时。明确的决策与结论比如超时上限设定为 3 秒不重试。约束与偏好比如后端接口一律返回 snake_case 字段。待办与下一步比如明天需要验证消息队列的消费幂等。关键资源位置比如配置文件在 config/prod.yaml。每个条目还要附带两个元信息重要性1 到 5和标签用于过滤。抽取过程通过调用一次独立的文本生成请求完成输入是最近一轮对话的消息列表输出是 JSON 数组。3.2 存储结构两张表搞定数据库里有两张核心表。第一张memories存提炼好的条目第二张episodes存原始对话片段便于追溯来源。建表语句大致这样CREATE TABLE IF NOT EXISTS memories ( id INTEGER PRIMARY KEY AUTOINCREMENT, content TEXT NOT NULL, importance REAL NOT NULL DEFAULT 1.0, tags TEXT DEFAULT [], source_episode_id INTEGER, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, access_count INTEGER DEFAULT 0, last_accessed_at TEXT, content_hash TEXT UNIQUE ); CREATE TABLE IF NOT EXISTS episodes ( id INTEGER PRIMARY KEY AUTOINCREMENT, project TEXT NOT NULL, raw_text TEXT NOT NULL, model_summary TEXT, created_at TEXT NOT NULL );content_hash的唯一索引是去重的基础。如果新提炼的内容和已有记忆完全相同就直接更新updated_at不会重复插入。每条记忆入库之前还会过一次重要性过滤器。重要性低于 1.5 的条目直接丢弃这类通常是对话里的寒暄、临时口头禅、无信息量的确认语句。这样能有效避免记忆库被噪音淹没。3.3 同主题合并如何处理翻来覆去聊一件事现实对话里同一件事不会只出现一次。今天说超时上限定为 3 秒下周可能变成压测发现 3 秒不够改成 5 秒。如果两个条目同时存在检索出来就会打架。我做了一个简单的合并策略入库时先用标签和关键词比如提取实体词找候选旧条目计算文本相似度高于阈值的就视为对同一件事的新描述直接覆盖旧内容并更新时间戳和来源而不是新增一条。import sqlite3, json, hashlib def insert_memory(conn, content, importance, tags, source_episode_id): digest hashlib.sha256(content.encode(utf-8)).hexdigest() row conn.execute( SELECT id, content FROM memories WHERE content_hash ?, (digest,) ).fetchone() if row: conn.execute( UPDATE memories SET updated_at datetime(now), source_episode_id ? WHERE id ?, (source_episode_id, row[0]), ) return row[0], updated conn.execute( INSERT INTO memories (content, importance, tags, source_episode_id, created_at, updated_at, content_hash) VALUES (?, ?, ?, ?, datetime(now), datetime(now), ?), (content, importance, json.dumps(tags), source_episode_id, digest), ) return conn.execute(SELECT last_insert_rowid()).fetchone()[0], created这套逻辑在单个工具函数里完成后续想加向量相似度查重也只是替换候选筛选这一步。4. 核心实现记忆怎么被准确想起来4.1 检索链路先硬过滤再语义召回最后融合排序一个记忆系统能不能用检索质量比写入质量更关键。写多了顶多是噪音多检不准就直接废了。我的检索链路分三段第一段硬过滤。根据当前任务的显式关键词可以从任务描述里提取也可以直接让用户指定项目名过滤候选记忆。比如检索条件限定 project 范围或者要求标签包含后端和支付。这一步能砍掉大量明显无关的条目。第二段语义召回。把当前任务描述向量化跟记忆库里的每条向量做余弦相似度计算召回 TopN。第三段融合排序。把关键词匹配得分和语义相似度得分用 RRFReciprocal Rank Fusion合并得到一个综合排名取前 K 条注入上下文。RRF 的好处是不需要精细调权重对两种检索结果里的排序位置比较敏感但对绝对分数不敏感鲁棒性好很多。4.2 向量化与相似度计算Embedding 我用的是本地模型主要考虑是隐私和零额外费用。向量维度通常在 384 到 768 之间对 10 万条记忆做一次全量余弦计算实测在普通笔记本上约 0.2 到 0.5 秒可接受。如果以后规模增长我会在memories表上再加一个向量缓存表用近似最近邻索引替换暴力计算接口层面不用动。import numpy as np def cosine_similarity(a, b): a np.asarray(a, dtypenp.float32) b np.asarray(b, dtypenp.float32) return float(np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b) 1e-9))对每条候选记忆向量在写入时就算好并缓存检索时只需要对当前 query 算一次向量然后和缓存做批量点积。4.3 融合排序的具体实现RRF 的分数公式非常简单def rrf_score(rank, k60): return 1.0 / (k rank)关键词检索结果和语义检索结果各自有一个排序列表。每个记忆条目在两个列表里都可能出现最终得分就是它在两个列表里 RRF 分数的累计值。排序靠前、同时被两种方式都命中的条目分数天然高。这比语义 0.7 * 关键词 0.3这种线性加权稳得多因为线性权重要调而 RRF 不用调。def fuse_results(lexical_hits, semantic_hits, top_k5): scores {} for rank, item in enumerate(lexical_hits): scores[item[id]] scores.get(item[id], 0.0) rrf_score(rank) for rank, item in enumerate(semantic_hits): scores[item[id]] scores.get(item[id], 0.0) rrf_score(rank) ranked sorted(scores.items(), keylambda x: x[1], reverseTrue) return [item_id for item_id, _ in ranked[:top_k]]4.4 注入模板控制 token别让记忆反客为主检索到记忆后要组装成一段提示词注入上下文。我的模板是这样的以下是此前项目中沉淀下来的相关记忆供你参考 - [记忆] 支付模块回调超时上限已从 3 秒调整为 5 秒且失败不重试。 重要性4/5最近访问2 天前 - [记忆] 后端接口字段统一使用 snake_case。 重要性3/5最近访问5 天前 请结合以上已知信息回答我的新问题。这里有个硬指标注入的记忆总 token 数不能超过模型上下文的 5% 到 10%。我一般控制 TopK 在 3 到 8 条之间每条记忆在 50 到 120 字以内。超长记忆在写入时就会做截断和摘要宁可少给不能塞爆。5. 实操部署与参数调优记录5.1 一套可直接上手的目录结构和部署流程整个项目我保持了极简结构claude-mem/ ├── claude_mem/ │ ├── __init__.py │ ├── store.py # SQLite 存储层 │ ├── embed.py # embedding 封装 │ ├── extract.py # 记忆抽取 │ ├── retrieve.py # 检索与融合排序 │ └── cli.py # 命令行入口 ├── config.toml ├── requirements.txt └── data/ └── memory.db # SQLite 数据库文件部署流程分四步准备 Python 3.10 环境执行pip install -r requirements.txt。初始化数据库claude-mem init --project my_project会自动建表。配置 Embedding 模型和摘要模型的接入信息写在config.toml。写一个启动脚本在发起新对话之前调用claude-mem retrieve --project my_project --query $TASK把返回的记忆拼进提示词。抽取代执行claude-mem ingest --project my_project --file conversation.log也可以做成每轮对话结束自动触发的钩子。个人使用的话手动执行完全够。5.2 关键参数到底该怎么调调参不是玄学每一项都有明确的观察指标TopK注入条数先设 5。如果 AI 回答时表现得不知道一些关键背景往上加到 8如果提示词太长、模型开始忽略记忆往下减到 3。重要性阈值默认 1.5。设置的目的是过滤寒暄类内容。如果你发现记忆库里全是好的明白这种就往 2.0 调。衰减半衰期默认 30 天。一个记忆如果 30 天没被访问重要性折半。做短期项目可以把半衰期改成 7 天让旧信息更快淘汰。最大记忆长度默认 100 字。超过这个长度的内容在写入时强制压缩防止注入模板被撑爆。重点提醒调参要一次只改一个变量并且用固定的测试问句集来验证效果。我自己留了三组测试问句每次改完参数都跑一遍用回答是否提到关键记忆来判断好坏。不这么做的话很容易凭感觉乱调最后连哪个改动起作用了都不知道。5.3 性能、成本与隐私的取舍我在这套设计里刻意做了三个取舍本地优先。所有记忆数据保存在本地 SQLite 文件里Embedding 计算也在本地做不上传任何对话内容。唯一可能出网的环节是记忆抽取用的摘要模型调用这个可以根据自己隐私要求选择本地模型或者关闭自动抽取。批量写入 实时写入。每轮对话结束都调一次抽取接口成本和延迟都受不了。我的做法是攒到一段时间或一定对话量后统一批量抽取一次实测成本下降一大截。不用重型组件。不引入独立的向量数据库、不部署专门的记忆服务整套东西跑在一台笔记本上迁移时拷一个目录就能走。6. 踩坑实录与问题排查6.1 检索结果看起来相关但实际没用这是记忆系统最典型的问题。语义相似度高不代表信息真的对当前任务有用。比如任务问订单超时怎么处理语义召回可能把支付回调超时上限 5 秒捞出来但真正需要的是订单状态自动取消策略这条记忆。我最后用三层办法缓解第一关键词硬过滤先砍掉领域不匹配的条目第二融合排序时给关键词命中者额外加一个小权重第三重要性和最近访问时间也纳入最终排序参考重要性高的默认排前面。这样做的效果是语义负责扩展关键词负责锁定重要性负责兜底。6.2 同一项目里记忆之间互相打架第一次合并策略做得不够狠时出现过一条说超时 3 秒、另一条说超时 5 秒的情况模型被两条记忆带偏。后来我在合并策略上加了同标签强覆盖规则只要标签和实体词一致新记忆直接替换旧记忆不搞并列展示。争吵类条目少了很多。6.3 Token 消耗突然变大排查后发现每次对话结束后的批量抽取里我把整个长对话全部发给了摘要模型单次输入 token 就上万。后来改成先压缩再抽取只发送对话里的小结和多轮关键片段而不是全文。抽取时也限制输出长度为每 10 轮对话输出不超过 200 字摘要。这一项优化直接把每轮对话的抽取成本降到了原来的三分之一。6.4 隐私敏感不敢存对话内容有朋友试用时很犹豫怕记忆文件里躺着整段整段的聊天原文。我的处理方式是做成可配置原文留存默认只存提炼后的记忆条目原始对话默认不落盘除非显式开启--keep-episodes。这样即使记忆库被翻看泄露的也只是结构化结论不是完整聊天记录。对隐私要求高的场景再把摘要模型替换为本地模型整套链路完全离线。最后分享一点使用体会这套 claude-mem 我实际用了两个多月最大的感受不是AI 变聪明了而是它终于像一个连续工作的协作者了。以前的模型每次都是新来的实习生现在的它记得上周拍板的方案、知道你写过哪些工具函数、也清楚你习惯什么样的回答风格。踩过几次坑之后我还有个额外的小技巧每周花两分钟执行一次记忆库的导出直接输出成 Markdown塞进项目的docs/目录。这样即使哪天数据库文件丢了这些记忆也已经固化成文档不依赖任何系统。如果你想做一个类似的记忆增强项目我的建议是从最窄的场景开始比如只给你的一个固定项目加记忆跑通写入 - 检索 - 注入闭环再去扩展功能和接入方式。把检索质量打磨到每次都能带出对的东西比做一堆华丽的插件接口重要得多。