去年年底我一直在折腾一个头疼的问题:Claude 这类对话模型什么都好,就是“聊完就忘”。今天你跟它说过项目背景、技术偏好、踩过的坑,明天新开一个对话,它又是一个毫无记忆的陌生人。这种体验对于偶尔聊天的人来说无所谓,但对像我这种把 AI 当长期写作搭子、代码审查员、甚至半个人生管理助手来用的人,简直是致命伤。所以我动手写了一个自用工具,名字就叫 claude-mem,用了一个多月,效果超出预期。这篇博文就把整个设计和实现过程完整拆开讲一遍,包括踩过的坑和最终沉淀下来的方案。
claude-mem 的核心定位非常明确:给 Claude 补齐一个“长期记忆层”。它不修改模型本身,也不做任何训练,而是通过提取对话中的重要信息、持久化存储、以及在合适的时机把相关记忆重新注入到上下文中,让 Claude 在跨会话、跨时间尺度上表现得像是“记得你”。这件事听起来简单,但真正落地时涉及对话信息抽取、记忆组织、检索召回、上下文占用控制、注入位置选择等多个环节。我这里把每一个环节的取舍逻辑和实操代码都记录下来,给同样被 AI 失忆症困扰的人一个可以直接抄作业的参考。
1. 为什么需要外部记忆层:模型上下文的天花板
1.1 上下文窗口的本质限制
先明确一个基本认知:Claude 为代表的对话模型,工作方式是基于一个有限长度的上下文窗口。这个窗口里放着你当前的对话、系统提示词,以及中间过程的推理内容,用完之后最老的内容会被丢弃或者被截断。你用官方对话界面的时候,长对话后期它“忘记”了你开头提到的需求,本质上不是模型智商的问题,而是上下文长度被撑爆了。
打个生活化的比方:你临时雇了一个记忆力超强但脑子只能装一张纸的助理,这张纸上写满之后,再输入的新信息就得把旧信息擦掉,这个助理每次表现好不好,完全取决于纸上的内容是不是恰好覆盖了你现在最关心的那部分。我们做的 claude-mem,就是给这个助理配一个外置的笔记本,随时把他该记住的重要信息抄到本子上,下次接待你的时候提前翻好放到桌上。
技术层面,当前主流模型的上下文窗口虽然越来越大,但依然满足不了真实的使用场景。日常使用中,单纯把历史对话一股脑往前带就是灾难,几万 token 很快就会放满,先不说成本,模型对超长上下文的注意力分配天然会衰减,中间部分的信息很容易被忽略。这就引出了外部记忆层的两条路:一是做压缩摘要,把天量的历史对话浓缩成几条关键记忆;二是做向量化检索,把对话编码成语义向量存进数据库,需要时按相关性召回。claude-mem 两条路都走了,后面细说。
1.2 我设定 claude-mem 要满足的四个硬指标
写这样一个小工具之前,我先给自己定了四条规定,整个开发过程都围绕这四条来:
第一是对用户零干预。也就是我不想在每次对话之后手动告诉它“记下这句话”,这不现实,也是传统对话工具一直没能做大的核心原因。信息提取必须全自动完成,我需要做的只是定期打开看一眼摘要是否正确。
第二是跨会话可用。记忆存储之后,新的对话要能自动感知到相关的旧记忆。今天聊到 Python 项目部署,它应该自动想起上次我们讨论过的服务器选型和踩坑记录。这就要求实现语义检索,而不是简单的关键词匹配。
第三是上下文开销可控。注入记忆会占用模型上下文,注入太多反而伤害模型对当下对话的理解,所以必须有优先级机制,每条记忆要有一个重要性分数,只把真正需要的高分记忆注入进去。
第四是数据本地可导出。所有记忆都保存在本地 SQLite 和向量数据库里,随时可以导出、删除、备份,不绑定任何云端服务,这是隐私底线。
事实证明,这四个指标在开发过程中帮了大忙。每当我面临技术选型的纠结——比如用复杂的历史摘要算法还是简单的时间衰减评分——都会拿它们当标尺来卡,有时候直接一刀切掉那些华而不实的选项。
2. 整体架构拆解:从对话流到持久记忆的流水线
2.1 三个核心模块的职责划分
claude-mem 的整体架构可以切成三个完全独立的层。第一层是摄取层,负责监控对话过程、抽取和结构化对话中的关键信息;第二层是存储层,负责把结构化的记忆写进本地数据库,同时生成向量索引;第三层是召回层,在新对话启动时查询相关的旧记忆并拼进系统提示词里。
第一层的实现在官方 API 上很直接:通过流式接口监听对话完成的消息,一旦助手生成了一条完整回复,就把刚刚这一轮完整的用户消息和助手回复拼起来,丢给专门的信息抽取器去处理。信息抽取器的做法是单独开一个小 model call,让 Claude 用一套固定的 JSON Schema 返回结构化抽取结果。
第二层做“本地优先”存储。结构化信息落地到 SQLite,这样我可以随时用 SQL 查询操作记忆;同时把记忆文本丢给嵌入模型生成向量,存入一个轻量的向量索引目录,用于语义相似度检索。选 SQLite 而不是 MySQL 这类服务端数据库,是为了让工具保持零配置、单文件启动的轻量状态。
第三层是用户真正能感知到的部分:新对话开始时,先把用户当前这句话做向量化,拿到记忆库里检索最相关的记忆片段,按相关性阈值过滤,再拼装成一段“记忆提示块”,附加在系统提示词后面。
听起来很顺滑是不是?但这个架构第一个版本跑起来后,我很快就发现了一个尴尬的事实:抽取层生成的记忆质量太拉胯,经常把一些毫无意义的内容当成重要信息存下来,比如“用户今天喝了一杯咖啡”这种,更别提后续的向量召回环节会因此被大量噪声污染。这就是我接下来要重点讲的内容,信息抽取环节,是整个流水线里最需要精细调教的部分。
2.2 为什么选择“双层存储”而不是单个方案
可能有人会问,既然要检索旧记忆,直接全丢进向量数据库不就行了吗,何必还需要 SQLite 结构化存储?这里有一个核心产品逻辑上的考量:向量检索擅长召回“语义相似”的内容,但它吐回来的结果是不确定的、排名式的,我无法精确判断它遗漏了哪些内容,也无法层次分明地管理记忆。而结构化存储的价值在于提供了确定性的索引和审计能力。
举个例子。我可以用一条简单的 SQL:SELECT * FROM memories WHERE importance >= 7 AND last_accessed_at < datetime('now', '-7 days')把过去一周都没访问过的次要记忆找出来,自动做一次降权或者存档。这种操作在纯向量库里实现起来非常别扭,因为向量库没办法直接对“上次访问时间”这种字段做条件查询。
而且双层存储还让调试变得可行。当某次对话里模型没有记住应该记住的信息,我可以去 SQLite 里验证这条记忆是否真的被写入了,它的重要度打分是多少,距离上一次被召回是什么时候。排查起来就像是“查看后端日志”,问题出在抽取阶段、存储阶段还是注入阶段,一目了然。如果只有向量库一个存储体,排查这种问题时往往只能瞎猜。
两个存储体之间的数据流是这样走的:抽取器输出的结构化记忆首先写入 SQLite 生成一个全局唯一 ID,然后拿同一段文本算 embedding 向量,连带这个 ID 一起写进向量索引。这样在任何时候,向量检索命中的结果都可以通过 ID 回查到 SQLite 里的完整记录,包括置信度、来源对话 ID、时间戳等元数据。
2.3 记忆类型体系:怎么让记忆变得“可管理”
光有存储还不够,裸数据堆在那里等于没有管理。我在记忆条目上设计了类型标签体系,把对话中抽出来的信息归为六个大类:
人设偏好类记录你的身份背景、职业、语言风格偏好等长期稳定信息,适合永久保留;项目状态类记录当前手头在推进的事,比如某个模拟项目的技术选型、进度节点、卡点,时效性中等;事实知识类记录对话中出现过的具体事实,比如某个库的版本号、某个 API 的调用方式,这类信息要尽量精确,不能做模糊转述;情感关联类记录你提到过的个人情绪、担忧、期待,这类信息在后续激励和沟通里用得上;临时待办类记录短期的 action items,比如“下周要提交那个报表”,过了时间就可以降权;事件回顾类记录发生过的事和结论,比如上一次评审总结的几条决定。
这个分类不是拍脑袋定的,完全是基于我平时使用的对话场景反推出来的。分类直接决定了后续每条记忆的生命周期策略,比如临时待办类的记忆重要性会随着时间快速衰减,而人设偏好类的除非用户主动推翻,否则一直保持高权重。数据库表设计上很简单,一个 memory_type 字段就解决了,但它是整个记忆管理逻辑的抓手。
3. 核心实现细节:三个最关键的代码块
3.1 信息抽取器:用严格 JSON Schema 约束输出
信息抽取器是 claude-mem 的灵魂模块。我的做法是给 Claude 一段单独的抽取系统提示词,附带上完整的对话文本,让它按照预定义的 JSON Schema 输出一段 JSON,里面包含记忆条目数组。
这里最容易翻车的是模型输出不符合 JSON 规范的随机问题。我的解决方案是让模型严格按照一个枚举类型的 JSON Schema 输出,并在解析侧做了多重容错:先用正则把包裹 JSON 的注释性文本去掉,再用 json.loads 直接解析;如果失败就进入第二级容错,单独调用一次修复合规接口,把模型原输出和 Schema 定义都扔给它,让它重新输出一串合法的 JSON。
import json import re def parse_extraction_response(raw_response: str) -> list[dict]: # 第一步:剥离可能的 markdown 代码块包裹 cleaned = re.sub(r"^```(?:json)?", "", raw_response.strip()).strip() cleaned = re.sub(r"```$", "", cleaned).strip() try: data = json.loads(cleaned) except json.JSONDecodeError: # 第二步:容错修复——把原始输出交给一个专门修正 json 的调用 data = repair_json_output(cleaned) return data.get("memories", [])稳定性上来之后,下一步是调教抽取质量。我敢说,运行起来之后最能拉开工具差距的就是提示词里“抽取什么”的定义部分。我最终沉淀下来的版本总共三百多词,核心指令讲了这么几个要点:区分“临时对话信息”和“值得长期保存的信息”;不要抽取礼貌性对白、无关寒暄、明显一次性的事件;记忆条目必须具体到可以在未来独立使用,禁止出现“用户对部署方案有兴趣”这种语义残缺条目;每一条记忆必须附带 importance 分数、confidence 分数和推测出的记忆类型。
过了一个月的使用,我发现抽取器产出的记忆条目质量已经稳定在一个很高的水平,偶尔出现几条“用户提到了某个技术”之类的泛化条目时,多数也是因为源对话本身就含糊。这里有个很反直觉的经验:不要试图让抽取器把每条对话都榨出记忆来,它越克制,保留下来记忆的有效率越高。宁缺毋滥,是记忆抽取的第一原则。
3.2 向量化与相似检索:轻量方案也能好用
向量检索我最终选了轻量级的方案:固定维度嵌入模型加内存向量索引,直接嵌入到主进程内。索引方式用暴力余弦距离扫描,没有上 HNSW 这类图索引。原因很简单,我的记忆库规模在个人使用场景下最多几万条,暴力扫描一个几万规模的向量集合,在 CPU 上每次检索也就几十毫秒的量级,完全够用,但省去了维护索引构建参数和持久化策略的负担。
import numpy as np class MemoryVectorIndex: def __init__(self): self.vectors: list[np.ndarray] = [] self.memory_ids: list[int] = [] def add(self, memory_id: int, vector: np.ndarray) -> None: self.vectors.append(vector) self.memory_ids.append(memory_id) def search(self, query_vector: np.ndarray, top_k: int = 8) -> list[tuple[int, float]]: if not self.vectors: return [] matrix = np.vstack(self.vectors) scores = matrix @ query_vector top_indices = np.argsort(scores)[::-1][:top_k] return [(self.memory_ids[i], float(scores[i])) for i in top_indices]向量维度选的是 768 维,模型用的一个通用中文 embedding 方案,覆盖中英文效果都不错。每次新对话开始时做一次查询向量生成,然后在记忆库里召回 top_k 条结果,再用相关性分数和记忆条目里的 importance 分数做一个和值排序,筛出最终进入上下文的记忆。
有个细节值得分享:向量召回的 top_k 不要永远固定一个值。我是根据用户当前输入消息的长度动态调整的,用户的输入信息量少的时候 top_k 取小一点,避免背景记忆喧宾夺主;输入包含明确技术名词或者任务指令时,top_k 加大,让模型更多参考历史记忆。这个动态策略实测效果非常好,模型回答的上下文相关性提升很明显。
3.3 注入机制:用“记忆提示块”拼进系统提示词
召回出来的记忆不能直接裸放在对话里,那样格式混乱而且容易被模型忽略。我封装了一个标准化的记忆提示块,在每次开启新对话时作为系统提示词的一部分拼进去,让模型明确知道这些是外置记忆,需要参考但不要当成当下对话的输入。
def build_memory_prompt(memories: list[dict]) -> str: if not memories: return "" lines = ["【长期记忆档案】以下是从历史对话中提取的相关记忆,\n" "可据此了解用户的习惯和项目脉络,但不要直接复述或过度依赖。"] for i, mem in enumerate(memories, start=1): lines.append(f"记忆{i}: [{mem['type']}] {mem['content']}") return "\n".join(lines)注入位置放在系统提示词里,紧跟在角色定义之后、正式对话内容之前。注入量严格控制在总上下文的 8% 以内,超过这条线宁可不注入也不强塞,这是实测下来的甜点区。一开始我堆了很多记忆进去,12% 左右的占比,结果模型经常在回答问题前先给记忆里的内容做个意识流回顾,废话明显增加,后来降到 8% 以内才恢复正常。
另外要强调的是,注入记忆时我在提示词里明确要求了“不要直接复述这些记忆”。因为刚开始不加这个约束的时候,模型经常把记忆里的内容当成当前问题的直接答案,问一个开箱即用的工具推荐,它上来就复述上次对话提到的那个工具名,显得非常僵硬。加了“仅供参考”的说明之后,模型的回答明显更自然,记忆变成了背景知识而不是模板句。
4. 实操过程:从零配置到跑通全流程
4.1 初始化配置:存储目录与模型端点
整个 claude-mem 不需要复杂的服务编排,初始化过程是零配置的。数据全部落在单个数据目录下,结构包含三个部分:SQLite 主数据库文件、向量索引目录、日志目录。
mkdir -p ~/.claude_mem/{db,vectors,logs}第一次启动时,工具会检查数据目录是否存在,不存在就自动初始化数据库表结构。数据库里核心就两张表:一张存记忆条目本体,包含内容、类型、重要度、置信度、来源对话 ID、创建和最后访问时间;另一张存所有历史对话的元数据,包含会话 ID、起止时间、对话摘要。向量索引目录和数据文件分离,方便备份的时候把数据库文件直接拷走,向量做了持久化序列化存储。
配置项上我最关注的是两个端点设置:主对话模型端点和信息抽取模型端点。日常对话用一个快速模型,信息抽取我用同一个模型,但 prompt 单独走了完整长版本。实际使用下来,抽取的质量和推理模型的选择关系不大,关键在于提示词的设计,用常规模型跑抽取反而速度快、成本低。
4.2 接入对话流程:三种接入方式对比
跑通 claude-mem 的方式按你的使用习惯有几种,我实测下来最推荐的是命令行包装器模式。所谓包装器就是一个简易的交互式脚本,在这个脚本里你先输入一条消息,程序内部把消息发给 Claude API,拿到回复之后,自动把这一轮的 message pair 送进抽取器,抽取器返回的 JSON 记忆立即持久化。整个流程对用户只有一句话的感知差异,就是每次对话结束后终端会闪出一行小字:“已新增记忆 3 条”。
第二种方式是 API Proxy 模式,适合你平时已经通过脚本或者网页客户端调用模型、不想迁移既有工作流的情况。做法是把 Claude API 的 base_url 指到本地一个代理服务上,代理服务在转发请求前先查询记忆库,把相关记忆注入系统提示词,收到回复后再异步执行记忆抽取和写入。这个方案的优点是接入面广,缺点是要多维护一个常驻进程。
第三种是我最不推荐的:手动导出对话历史,然后离线批量抽取。适合你有大量历史对话想一次性导入记忆库的场景,但不适合日常使用,因为延迟和割裂感太强。我试过把过去三个月的几百条历史对话文本一次性扔进去批量抽取,效果只能说勉强能用,因为信息抽取器对脱离对话语境的长文本理解力有限,很多关键记忆被漏掉了。
4.3 记忆召回实测:一个跨会话检索的真实例子
讲一个实际跑通的场景来直观展示效果。前几天我在和 Claude 讨论一个数据迁移脚本的设计,对话里提到了“不想用现成的第三方库,打算手写一个解析器”这个决策。这句话被抽取器捕获后,重要度打到了 8/10,记忆类型被判定为“项目状态”。
两天后我开启了一个全新对话,输入是“帮我看看那个迁移脚本一直报编码错误怎么办”,claude-mem 在后台把这句话向量化后,从记忆库里召回了三条高相关记忆,其中就包含那条“想手写解析器”的决策记录。于是 Claude 在回答时非常自然地带出了这个背景,建议我先确认解析器在遇到畸形编码时的异常处理逻辑有没有覆盖到,而不是一上来就推荐我换个现成库。
这个例子说明记忆的跨会话价值不在于直接提供答案,而在于给模型补上“你为什么选择这条路”的上下文。模型基于这个上下文给出的建议,贴合度和可用度完胜无记忆的冷启动状态。
4.4 对话历史压缩:防止 SQLite 无限膨胀
用着用着你会发现,对话元数据表和记忆条目表会慢慢变大。我设计了一套归档机制:每完成十个会话,就对历史对话摘要做一次二级压缩,把超过三十天的会话摘要全部归并成一条月度总摘要,原来的会话摘要如果没有任何关联记忆,就直接删除。
记忆条目的清理逻辑则更讲究,按时间衰减和访问频率双维度计分。每个星期跑一次定期任务,把所有记忆条目的综合热度分重新计算,低于阈值且不是重要度 9 以上的,就移入归档状态,不在常规检索里出现,只有当用户手动搜到才能看到。这套机制保证了工具在长期运行下响应速度不劣化。
def decay_memories(conn): now = int(time.time()) cursor = conn.execute( """UPDATE memories SET importance = importance * 0.95, is_active = CASE WHEN importance < 4.0 AND confidence < 0.4 THEN 0 ELSE is_active END WHERE strftime('%s', last_accessed_at) < ? - 14 * 86400""", (now,) ) conn.commit()5. 常见问题与排查技巧实录
5.1 抽取器漏数据:先查对话上下文
最常遇到的坑是抽取器对某些记忆毫无反应。排查的第一步永远不是改提示词,而是回去看原始对话里这条信息所在的上下文长什么样。如果信息本身就藏在很长的技术讨论中间,比如模型输出了一整段部署建议中夹杂了一句“用户想用容器化的方式部署”,这种语境下抽取器把它漏掉的概率确实高。解决方案是让抽取器在抽取时也输出信息出处,也就是它在识别到候选记忆时,同时产出“对应的原文片段”,这样漏报时我能快速定位。
如果抽取器连明显的关键句都漏了,那就要检查是不是这次对话文本超出了抽取器的单次处理长度限制。我加了一个滑动窗口机制,把超长对话拆分到多个处理批次里,每个批次重叠保留最后几条消息作为上下文衔接,很大程度上缓解了长对话尾部信息丢失的问题。
5.2 召回结果不相关:调整向量权重的次序
召回结果不相关的核心症状是模型拿到了一些强行拼装在一起的旧记忆,回答显得牛头不对马嘴。我排查后发现问题常常出在向量检索的召回环节把太多相似但无用的记忆一并返回了。比如用户问了“今天天气如何”,向量库里有几条“之前聊过天气相关规划”的记忆也一并命中了,相关性打分居然还挺高。
这里的优化手段是给记忆检索加一个前置过滤条件:当用户当前输入短于四个汉字时,强制跳过向量检索,只在最高重要度的记忆里寻找直接匹配项。输入明显长这个阈值时,才执行完整检索。这个过滤条件生效后,误召回的频率大幅降低。
5.3 模型开始复述记忆:剥夺“复述权”
模型把记忆内容当答案直接复述出来,是最容易被发现也是最破坏体验的问题。这个问题的根因一般都在注入提示词的措辞上,“请参考以下记忆”这个措辞太弱了,模型被引导到复述的方向。我最终固定下来一段措辞,原文是“记忆仅供参考,你可以使用记忆中的信息作为回答的背景知识,但应当基于当前对话内容组织你的回答”。
如果调整措辞后仍然出现复述行为,还有一个降级方案是降低注入记忆的条数,从 8 条降到 5 条甚至 3 条。因为当模型上下文里塞进了过多外置记忆时,它会产生一种“必须回应这些记忆”的强迫症,控制条数能直接解决这个问题。我现在的默认值就是 top 5 条,只在用户主动搜索历史时才放宽到 8 条。
5.4 数据膨胀导致检索变慢:启动期全量扫描改为分片索引
暴力扫描在记忆规模达到几万条之后,单次检索耗时还是会缓慢上升,从几十毫秒涨到一两百毫秒。虽然对个人场景还能接受,但每隔一段时间还是要做个清理。我的做法是按月份对向量索引切片:每周跑一次定时任务,把超过九十天的记忆向量从活跃索引中摘除,转存到归档索引。活跃索引查询永远只扫描最近三个月的记忆,常用记忆和经验知识基本都在这个时间窗口内,感知不到任何损失。
6. 扩展方向和个人体会
6.1 后续还能加入的增强能力
claude-mem 现在的架构预留了两个明显的扩展空间。一个是多模态记忆,目前它只处理纯文本信息,但真实使用中经常会有图片、截图、文件这类非文本内容出现在对话里。提取图片里的关键信息需要额外接视觉理解模型,这个扩展技术上没有壁垒,只是成本会高一些。
另一个重要扩展是主动记忆修正。现在记忆的更新是被动的,只有用户在对话中提出新信息时,抽取器才知道旧记忆要修正。理想状态是定期的“记忆复查”机制,把高权重记忆拿出来让模型检查一遍,结合最近对话审视是否存在过时或矛盾的条目。这个功能我下一个版本已经预定要加了。
另外还可以把记忆导出为 Markdown 格式,方便人直接阅读和管理,也可以配合外部知识库工具做二次消费。这个数据自由度的价值,在长期使用后才会真正显示出来,它意味着你沉淀下来的记忆不会被困在单一工具里。
6.2 关于“AI 记忆”这件事的反思
工具本身的技术细节聊完了,最后说点个人的真实感受。做 claude-mem 一个多月,我最大的体会是:外部记忆层的价值不在于让 AI 看起来更聪明,而在于让 AI 的工作方式更接近一个真正的长期协作者。当你和 AI 对话时,它不需要你每次重复交代背景,不需要你反复解释“上次讨论过那个方案”,这种体验上的提升非常巨大,甚至比换一个更大的模型带来的提升还明显。
但同时也必须警惕一个反向问题:记忆不代表忠实记录一切。一个不筛选的记忆系统,最终会变成一个陈旧信息的垃圾场,反而会干扰模型对当下的判断。做记忆系统本质上是在做信息筛选和遗忘决策,这件事比大多数人想象中的重要得多。好的记忆系统应该像人脑一样,知道什么该记、什么该忘、什么时候该想起什么。
我现在已经把 claude-mem 融入了日常几乎所有和 Claude 的交互里,包括技术问题的讨论、个人计划的推演、甚至一些长期项目的推展。它带来的便利已经让我无法再回到没有记忆系统的 AI 工作流中,毫无记忆的 AI 对话对我来说已经像用一个不存档的文本编辑器一样别扭。如果你也被 AI 对话失忆困扰,这个方案值得花一个周末动手复刻。