1. 为什么要给对话模型加一层长期记忆
给人用过的对话模型写需求,多写几次就能撞上同一个尴尬:上午刚跟它敲定的技术方案,下午换个话题再回来,它连方案名字都要你重新讲一遍。不是模型变笨了,而是默认的对话机制里根本不带“跨会话记忆”这一层。claude-mem 就是在这个背景下做的项目:在对话服务外面挂一个长期记忆中间层,把每次会话里值得保留的信息抽取出来、存下来,下次再遇到相关话题时自动找回来,注入到提示词里。
这篇文章我会从架构设计、核心实现、实操落地到问题排查,完整过一遍我踩过的坑和最终稳定运行的方案。内容主要面向准备自己动手给模型加记忆层的开发者,也适合刚开始接触记忆增强应用,想知道“这东西到底怎么落地”的读者。别急着上向量数据库,也别一上来就设计复杂的分布式架构,先把一条能跑通的链路做出来,比什么都重要。
1.1 上下文窗口不是记忆,只是临时草稿纸
要理解 claude-mem 做了什么,先分清楚上下文窗口和长期记忆的区别。上下文窗口可以理解成一张临时草稿纸:它能让你在这次对话里翻看前面写过的内容,但草稿纸一旦翻页,上面的字就没了。你向模型传入的对话历史、系统提示词,全部属于“一次性上下文”。这些内容只对当前请求有效,请求结束就归零。
长期记忆则是一本工作笔记。它存在于会话之外,能在你开启新对话时把之前记录的结论、偏好、决策重新带回来。很多人刚接触这个概念时,会试图通过不断加长系统提示词来“教会模型记住我”。这不是不行,但效果很差,因为静态提示词是面向所有人的,不能替你保存个性化的历史。你告诉它“我叫李四”,和你告诉它“上周讨论过用 Go 重写网关,结论是保留 Python 但引入 asyncio”,前者只是身份信息,后者才是真正影响后续判断的项目上下文。
我决定动手做 claude-mem 的直接原因,是自己在同一个助手场景里反复重新解释需求。用 AI 辅助做数据分析和项目复盘时,每次开新会话都要重新交代输出格式、关键指标、不接受的方案。这种复述既浪费 token,又让模型风格飘忽不定。上一轮记住了,下一轮又变回默认状态,用起来永远像是第一次见面。
1.2 claude-mem 想解决什么,不解决什么
claude-mem 的本质是一个挂在对话服务外的记忆中间层。它由三个动作组成:从对话里抽取值得长期保存的信息,把信息按结构化方式存储,在下次对话前检索相关记忆并注入提示词。它解决的是跨会话的个性化记忆问题,包括用户偏好、项目背景、关键决策、曾经明确拒绝过的事情。
这里有个重要边界:一定不要顺手给 claude-mem 加上“知识库”功能。很多类似的尝试最后都滑进一个误区,什么聊天记录都存、什么信息都想让它记住,最后变成四不像的检索增强应用。claude-mem 的定位很窄,只记录跟特定用户、特定项目有关的、稳定的、不容易变化的事实和偏好。通用知识问答不该靠聊天记忆,物理公式、行业法规这类东西,应该交给正经文档库处理。边界划清楚了,系统才会简单可靠。
2. 整体架构与设计思路
动手写代码之前,我先把整个系统拆成三个模块:提取模块、存储模块、召回模块。这个拆分不是拍脑袋想的,而是来自一次失败的第一版开发。当时把所有逻辑揉在一个文件里,结果提取和召回互相纠缠,改一处崩一片。后来老老实实做了模块化,整个流程才顺起来。
2.1 三个核心模块:提取、存储、召回
提取模块负责把一段对话历史“读”成结构化记忆。我设置的触发条件是对话累积到 10 条消息或大约 5000 token 时跑一次。提取不是把聊天记录原样复制,而是提炼成类似“用户偏好使用 Python 处理数据任务”“用户明确不希望在日报里包含低优先级告警”这样的短句。这个模块通常由一次模型调用完成,输入是一段对话历史,输出是结构化 JSON。
存储模块维护记忆数据库,按用户、重要性、类别把记忆存放好。我选用了 SQLite 作为存储引擎,原因后面细说。存储模块除了写入,还要负责去重、更新、降级和删除。很多人以为存储就是 insert 语句,实际上记忆数据的生命周期管理才是存储模块的核心工作量。
召回模块在最容易被忽略的位置,但恰恰最影响体验。每次新会话开始时,召回模块基于当前会话标题或前几句内容,从记忆库里捞出相关条目,拼成一段上下文注入系统提示词。召回必须是有选择性的,且不能只看关键词命中,还要考虑重要性和时间衰减。记住一个原则:记忆系统的价值不由存储量决定,而由有效召回决定。
2.2 为什么用 SQLite + 全文检索,而不是向量数据库
最开始我也考虑过向量数据库,毕竟语义记忆这个概念在圈内很流行。但仔细测算后,我把方案改成了 SQLite 自带全文检索,目前运行得很稳定。原因有三点:数据量级、维护成本、检索质量。
第一,个人场景的记忆条数通常只有几千到几万条,这个量级用 SQLite 全文搜索毫秒级就能返回结果,完全不需要单独部署向量服务。我在本地压过 8000 条记忆数据,关键词检索加权重排序耗时基本不超过 50ms,远达不到影响接口响应的程度。第二,SQLite 是内置能力,零额外依赖,不需要维护嵌入模型服务,也不会产生 embedding 的 token 成本。第三,对话记忆大多包含明确的人名、项目名、技术名词等实体,关键词检索对这些实体非常友好,反而比“语义向量相似”更容易解释。出了问题,你也能说清楚它为什么找到这条记忆。
向量数据库当然有它的优势,但那是面对百万级语料、需要强语义关联时的事。claude-mem 的目标是记住“你说过什么”,语义相近但表达不同的情况通常来自同一个人的表达习惯,关键词检索已经足够覆盖。等规模真上去了,再在 SQLite 外层挂一个可选向量扩展也不迟。
2.3 记忆分级与遗忘机制
第一版方案里,我把所有记忆往一个池子里倒,很快发现严重问题:召回出来的内容同质化严重,全是“用户喜欢喝美式咖啡”这类细节,而真正重要的项目决策反而被淹没。后来引入了分级机制,每条记忆带一个 importance 字段,范围 0 到 1。
- 0.9 以上:用户明确表达的身份信息、长期偏好、不可逆决定,例如“以后所有报告都用中文写”“不要再建议我使用某个框架”。
- 0.6 到 0.9:项目进度、技术选型、阶段性结论,例如“网关重构定为 Python asyncio 方案”。
- 0.3 到 0.6:一般性事实,例如“本周在调研离线缓存方案”。
- 0.3 以下:临时状态,例如“今天网络不好,晚点再测”。
光有分级还不够,还需要遗忘机制。临时信息会随着时间推移失去价值,三天前记下的“今天网络不好”已经没有任何意义。我实现了一套衰减与淘汰机制:importance 低于 0.3 且超过 7 天未访问的记忆自动降级为候选状态;超过 90 天未被召回的 P2 级别记忆降为 0.1;超过 180 天未被访问的临时记忆直接删除。这个机制不为了省存储空间,而是为了减少召回时的噪音。实测加入遗忘机制后,召回准确率有明显提升,模型不再被过期信息误导。
3. 核心实现:记忆提取、注入与动态更新
架构定了之后,真正决定体验的是实现细节。claude-mem 的核心代码量不大,但每段逻辑背后都有实际踩过的坑。这一节拆开讲提取、注入、更新三个环节,这些都是可以直接抄作业的部分。
3.1 提取提示词:让模型输出结构化记忆
提取模块的本质是让对话模型做一次结构化总结。我最初直接给模型一句“请提取重要信息”,结果它什么都想存,连“用户今天中午吃了外卖”都提炼成长期记忆。后来把提示词写明确,加上输入输出约束,才稳定下来。
提取提示词必须明确定义“什么是值得长期记住的信息”,而不是笼统地说“重要信息”。我现在的提示词大致是这样的:
请从以下对话中提取需要长期记住的信息。值得记住的信息包括: - 用户明确表达的身份信息、工作背景、长期偏好 - 用户明确表达的项目目标、技术选型、不可撤销的决策 - 用户明确要求遵守的规则或禁忌 不要提取: - 临时的情绪表达、随口说的客套话 - 本次对话中出现但与用户长期情况无关的具体事件 - 对话模型自己输出的建议或推理内容 输出 JSON 格式,包含 candidate_memories 和 candidate_rejected 两个数组。对应输出示例:
{ "candidate_memories": [ { "content": "用户偏好使用 Python 处理数据分析任务", "category": "user_preference", "importance": 0.9 }, { "content": "用户已决定网关重构采用 asyncio 方案,不使用 Go", "category": "project_decision", "importance": 0.8 } ], "candidate_rejected": [ { "content": "用户今天提到网络不稳定", "reason": "临时环境状态,不具备长期记忆价值" } ] }注意最后要求模型显式输出 candidate_rejected。这个设计非常有用,它能让你在调试阶段看到模型否决了哪些信息、为什么否决,帮你判断是提示词约束不够,还是模型理解偏了。很多提取类应用只关注保存了什么,不关注丢掉了什么,导致问题出现时无从排查。有了 rejected 日志,你才能持续迭代提取质量。
3.2 记忆注入:不是越多越好
召回出的记忆如何进入对话上下文,直接影响模型行为。我的做法是把召回的记忆转成 Markdown 列表,插在系统提示词最前面,并加一句“以下是关于用户的长期记忆,请根据这些信息回答问题”,让模型知道这不是普通对话内容。
注入时严格控制体量。我给 claude-mem 设的参数是:单次召回最多 10 条,每条记忆截断到 128 字符,整体注入预算控制在 1200 token 以内。这么严格是有原因的:记忆只是辅助信息,不是主角。注入太多记忆会让模型产生“回答背调”的错觉,倾向于在回复里大量复述已知信息,而不是解决用户当下的问题。早期版本我把 top_k 调到 20,结果模型每个回答都像在做汇报,先把我记忆里的事情说一遍,再谈正事,非常影响体验。
每条记忆还应该带上时间和来源说明,格式类似于“记忆创建于 2025-03-10,来自消息 42”。这对模型判断记忆时效性非常有帮助。如果不带时间,一条三个月前的“用户当前在使用 MySQL”会和新近更新的“用户已迁移到 PostgreSQL”出现在同一列表里,模型很难区分哪个才是当前状态。加了时间戳之后,模型在回答时会更自然地采用最近更新的记忆。
3.3 对话中的实时记忆更新:处理纠错场景
记忆不是只在会话开始时写一次。用户在对话过程中经常会对之前的说法进行修正,比如“我说的是本地环境,不是生产环境”“刚才那句删掉,换成另一个方案”。如果只做持久化累积不做更新,旧信息会一直残留,不断产生矛盾,最终让模型给出错误判断。
为此我实现了记忆的 upsert 机制,并在提取提示词里增加特殊指令:当对话中出现对已有说法的否定或修正时,提取结果里要包含 replace_memory_id 字段。实现路径是:先根据用户消息里的纠错意图,在现有记忆库里找到相似度最高的旧条目,再将该条目的 content 替换为新内容,并保留原来源 message id。这比盲目新增一条干净得多,也省了很多后期清理成本。
这个场景还有一个小坑:模型容易把用户的玩笑式修正也当成纠错。比如用户说“你要是再推荐这个框架我就跑路了”,这句话不应触发替换。解决方法是提取模块做二次判断,只有包含明确对象的状态变化时才允许 replace,纯情绪表达仍归入 candidate_rejected。
4. 实操落地:从零搭一套 claude-mem
这一节给出一套可以直接复制的轻量实现方案。使用 Python 和标准库,依赖很少,方便按自己的需求改造。完整流程走通之后,你随时可以替换存储层或召回算法,不会影响整体结构。
4.1 目录结构与依赖选择
项目结构非常简洁,四个文件承担全部逻辑:
claude-mem/ ├── main.py # 入口,接收对话文本并调度 ├── extractor.py # 提取模块,负责发提示词收 JSON ├── storage.py # 存储模块,负责 SQLite 读写 └── recaller.py # 召回模块,负责检索和注入依赖方面,Python 3.10 以上自带 sqlite3,外部只需要一个 HTTP 客户端和一个 JSON 解析器。我在实际开发里用 httpx 发请求,用标准 json 解析输出。没有引入 ORM,也没引入异步框架,因为单用户的记忆读写量很小,越简单越不容易出错。如果后续要支持多用户或更高并发,再考虑引入队列和连接池也不迟。
4.2 数据模型与建表语句
存储层我设计成一张主表保存长期记忆,一张辅助表保存提取历史。主表的字段设计考虑到三个需求:幂等去重、重要性排序、更新溯源。
CREATE TABLE IF NOT EXISTS long_term_memories ( id INTEGER PRIMARY KEY, user_id TEXT NOT NULL, content TEXT NOT NULL, category TEXT NOT NULL DEFAULT 'general', importance REAL NOT NULL DEFAULT 0.5, source_message_id TEXT, created_at INTEGER DEFAULT (unixepoch()), updated_at INTEGER DEFAULT (unixepoch()), last_accessed_at INTEGER, access_count INTEGER DEFAULT 0, conflict_group TEXT, content_hash TEXT NOT NULL, UNIQUE(user_id, content_hash) ); CREATE TABLE IF NOT EXISTS extraction_logs ( id INTEGER PRIMARY KEY, user_id TEXT NOT NULL, raw_length INTEGER NOT NULL, extracted_count INTEGER NOT NULL, rejected_count INTEGER NOT NULL, created_at INTEGER DEFAULT (unixepoch()) );content_hash 是核心去重字段。提取出的每条记忆先做哈希,如果同一条记忆已经存在,就只更新 importance 和 updated_at,而不是插入新行。importance 和 last_accessed_at 是召回排序的主要依据。conflict_group 用于把互相矛盾的记忆关联起来,方便后续人工清理。source_message_id 保留信息来源,出了问题能回溯到具体消息。
4.3 核心流程与关键代码
主流程按三个阶段写,核心逻辑大约一百行。这里贴出关键部分的代码,并说明每段背后的意图。
提取阶段,调用模型并解析 JSON:
import json from pathlib import Path EXTRACT_PROMPT = Path("extract_prompt.txt").read_text(encoding="utf-8") def extract_memories(history: list[dict]): user_content = format_history(history) resp = llm_chat([ {"role": "system", "content": EXTRACT_PROMPT}, {"role": "user", "content": user_content}, ]) data = json.loads(resp) return data["candidate_memories"], data["candidate_rejected"]这里有一个值得注意的细节:提取提示词单独放在文件里,不要硬编码在代码里。因为提示词会频繁迭代,抽成独立文件可以快速调整,也可以给每条提示词做版本管理。
存储阶段,做幂等 upsert:
def upsert_memories(user_id, memories): for m in memories: h = sha256(user_id + m["content"]).hexdigest() row = db.execute( "SELECT id, content FROM long_term_memories WHERE user_id=? AND content_hash=?", (user_id, h), ).fetchone() if row: db.execute( "UPDATE long_term_memories SET importance=?, updated_at=unixepoch() WHERE id=?", (m["importance"], row["id"]), ) else: db.execute( "INSERT INTO long_term_memories " "(user_id, content, category, importance, source_message_id, content_hash) " "VALUES (?,?,?,?,?,?)", (user_id, m["content"], m["category"], m["importance"], m.get("source_message_id"), h), )这段代码的关键在于 content_hash 的粒度。如果哈希粒度太粗,比如只对用户 ID 做哈希,会误伤所有不同记忆;如果太细,包括时间戳在内,又永远无法去重。我最终选择“用户 ID + 内容全文”做哈希,同一用户说同一句话只会存一条,这是最符合记忆场景的粒度。
召回阶段,用 FTS5 检索并拼接上下文:
def build_recall_context(user_id, query, top_k=10): sql = """ SELECT content, importance, last_accessed_at, updated_at FROM memory_fts WHERE memory_fts MATCH ? ORDER BY bm25(memory_fts) ASC LIMIT ? """ rows = db.execute(sql, (f'user:{user_id} AND {query}', top_k)).fetchall() lines = [] for r in rows: lines.append(f"- [{r['updated_at']}] {r['content']}(来源记忆ID {r['id']})") return "\n".join(lines)我没有在 SQL 里直接完成重要性排序,而是在拿到检索结果后做一次加权排序:得分 = 0.5 乘 BM25 分数加 0.3 乘 importance 加 0.2 乘最近访问时间。这样关键词命中的记忆不一定排在最前,重要且常被验证的记忆反而能稳定出现在注入列表里。BM25 分数需要归一化处理,否则不同长度文档的分数差异会直接影响排序公平性。
4.4 参数调优经验
调参是最花时间的部分,我把 claude-mem 里几个关键参数和背后的判断逻辑列成表格,方便直接参考:
| 参数 | 推荐值 | 说明 |
|---|---|---|
| 提取触发间隔 | 10 条消息或 5000 token | 太低浪费调用成本,太高会漏掉会话尾段信息 |
| 召回数量 top_k | 5 到 10 | 超过 10 条,模型会开始复述记忆而不是回答问题 |
| 单条记忆最大长度 | 500 字 | 超过这个长度,说明抽取没做好,应再拆分 |
| 注入总预算 | 1200 token | 这是留给记忆的独立预算,与模型最大上下文无关 |
| 相似度替换阈值 | 0.8 | 低于该值不要做替换,容易误伤不同主题 |
提取触发间隔最容易被低估。一开始我设置成每 20 条消息才提取一次,结果长会话后半段的重要决定没有入库。改成 10 条之后,信息覆盖率上来了,但 token 开销也翻倍。如果对成本敏感,可以把触发条件改成“用户连续两条消息都包含项目或方案相关词,且距离上次提取超过 15 分钟”,先用规则过滤可能重要的片段,再丢给提取模块,成本能降一半。
5. 常见问题与排查实录
这部分是我实际跑了多轮之后整理的问题清单,每个都对应过真实的事故或效果退化。如果遇到类似问题,可以直接对照排查。
5.1 记忆越积越多,召回准确率不升反降
典型症状是:明明存了几百条记忆,新会话却经常召回一些无关内容。原因通常是排序只考虑了关键词命中,没有考虑时间衰减。比如“用户喜欢 Python”这条记忆被反复命中,重要性权重一直在涨,其他新写入的重要决策反而排不到前面。解决办法是把时间衰减纳入排序公式,并对长期未被访问的记忆执行降级。前面提过的 90 天规则,实测下来加了之后召回准确率提升明显,主要改善是“重要但不常提”的记忆终于能冒头了。
5.2 模型把临时信息当成长期记忆保存
这种问题要靠提取提示词和 candidate_rejected 日志双管齐下解决。先看 rejected 日志,如果模型经常把“这周在调研 X”这类临时状态放进候选记忆,说明提示词里的“不要提临时性事件”约束不够显眼。我后来把这条约束提到了提示词第二行,并增加了“该信息在一个月后还可能影响对话吗”这个自问步骤,效果立竿见影。如果模型还是分不清,就在提取前加一道硬筛选:凡包含“今天”“这次”“本周”等时间词的候选,importance 上限锁定在 0.4 以下。
5.3 旧记忆和新记忆互相矛盾
矛盾记忆几乎不可避免,尤其当用户改变主意时。处理方式是用 conflict_group 做标记。每次 upsert 时,如果新记忆和旧记忆的编辑距离很低但内容相反,就给两条记录打同一个 conflict_group 值。召回时,同一个 conflict_group 只召回最新 updated_at 的那一条,旧的下次再通过人工或定期任务合并。这个方案不算完美,但它能防止矛盾记忆同时出现在模型视野里,是成本最低的短期解法。
5.4 记忆泄漏风险与数据保留策略
记忆系统存的是用户的个性化信息,隐私问题要在架构层面预先考虑。我的原则是:只保存结构化提取结果,不保存原始对话。原始对话在提取完成后直接丢弃,数据库里的 content 字段也做脱敏处理,手机号、邮箱、住址这类高敏感信息在提取阶段就要求模型不要写入记忆,用占位符代替。如果部署在服务器上,建议对 SQLite 文件做整库加密,避免磁盘泄露。
我还做过一次“灾难测试”:导出数据库后检查是否存在能直接对应到某个人的连续文本片段。因为只存短句记忆,很难还原完整对话,这也是我坚持不存原文的重要原因。在 AI 工具里长期使用,用户的隐私安全感比短期推荐精度更重要。
6. 我在实际使用中的体会
把 claude-mem 接到自己的日常流程里用了两三个星期后,最直观的感受不是“模型变聪明了”,而是“它终于像一个合作过的同事了”。以前每次开始一个话题,都要重新铺垫我的偏好、项目背景和之前达成的结论,现在打开新会话,模型会主动引用我之前存档的关键决策,并基于此做出一致的判断。这种一致性,比任何单次回答的智商提升都重要。
我也逐渐想明白,AI 记忆系统的难点从来不在“能存多少”,而在“该忘多少”和“何时想起”。模型没有人类的遗忘曲线,如果你不主动管理,它就会变成一个越来越吵的曲库,什么歌都在响,反而听不清主旋律。给记忆分级、设置淘汰规则、在注入前做加权排序,这些听起来都是小事,但正是这些小事的组合,决定了系统是越用越顺,还是越用越乱。
最后分享一个小技巧:部署阶段不要一上来就追求全自动。先把提取结果导出成 Markdown,每周人工看一眼,确认模型保存的记忆都是对的。等它连续两周没有出现明显错误,再放权让系统自动写入。这个习惯能帮你避开不少记忆污染的坑,也能更快判断自己的提示词约束是否到位。