Claude 用着用着就“失忆”,这件事想必折腾过的人都有体会。聊到一半,它忘了你前面提过的关键约束;换个会话,又得从头自我介绍。claude-mem这个名字,我最初是在翻本地工具库时看到的,核心思路其实非常朴素:给 Claude(或者说任何对话式大模型)补一层长期记忆,把散落在会话里的重要信息沉淀下来,下次再聊的时候自动调出来。这套做法不只是一个命令行小工具的事,它背后牵扯到上下文窗口如何管理、记忆怎么结构化存储、检索策略怎么设计,以及最终怎么把记忆无损塞回模型输入里。这篇文章不吹概念,直接把我实际搭这类记忆层时踩过的坑、用过的方案、调整过的参数,从头到尾捋一遍。
我自己在本地跑的方案,是把记忆做成一个独立的服务,跟模型本身解耦。你既可以在命令行里用,也可以把同一套逻辑接到自己的 Agent 或者工作流里。想搞清楚claude-mem这类工具为什么值得花时间折腾,得先明白当前对话模型的记忆瓶颈在哪。这一步想通了,后面的存储、检索、注入设计才走得顺。
1. 记忆模块的整体设计思路:先想清楚要记什么
1.1 上下文窗口不是越大越好,记忆优先级才是核心矛盾
现在 Claude 的上下文窗口已经能做到百万 token 级别,看起来很大,但真正用过的人都知道,窗口大不等于记得住。模型对输入中不同位置的注意力权重不一样,长对话塞到最后,早期信息会被严重稀释。更麻烦的是,很多重要信息是你随口提了一句,比如“用户偏好用简洁风格回复”“这个项目部署在杭州的机器上”,之后隔了二十轮对话,模型早记不清了。
所以记忆层做的事情,不是把全部对话原封不动存下来,而是做信息筛选和优先级重排。我在设计信息采集时只用一条规则:凡是影响后续行为的信息,都必须单独抽出来存;凡是只能算临时客套的内容,一律不存。这个规则看起来简单,实际落地时最难的是“影响后续行为”怎么判定。
我试过几种策略,最简单的是用 Claude 自己来做抽取,给它一段对话原文,让它输出一个数据结构化的记忆条目。这种方式效果最好,因为模型本身对语义的理解远超任何规则引擎。比如下面这段提示词,我一直在用:
请从以下对话中提取所有对未来对话有价值的信息,包括: 1. 用户的明确偏好和禁忌 2. 项目中涉及的技术选型和环境约束 3. 当前进行到一半的任务状态 4. 用户提到的具体名称、编号、地址 只返回 JSON 数组,每条记忆包含字段: - content: 记忆内容 - category: preference / project / task / fact - importance: 0.0 到 1.0 的分数 对话内容: {conversation_text}抽取频次也有讲究,不是每轮都抽,那样既浪费 token 又容易抽出一堆垃圾。我是每 5 轮对话或者每次用户主动结束一个阶段性任务时做一次快照式抽取,这样上下文里的信息密度已经足够高,抽出来的东西才有效。
1.2 记忆的三种存储形态:缓存层、持久层、向量索引
信息抽出来以后,怎么存是下一道坎。我见过不少人一开始图省事,全部丢进一个 JSON 文件里,结果数据一多就乱成一锅粥。分层是必须的。
第一层是短期缓存,基本就用内存里的字典结构,承接当前会话内部的临时状态,比如“我们刚才聊到第 3 个方案”。这一层不需要持久化,会话一结束直接清空。
第二层是持久化的结构化存储,我选的是 SQLite。为什么不用 MySQL?因为个人项目和中小型团队根本没必要上重数据库,SQLite 单文件、零运维、支持 JSON 字段,配合 Python 的sqlite3标准库,读和写都非常顺手。这里存的是记忆条目的元信息,比如分类、重要度、创建时间、来源会话 ID。
第三层是向量索引,用来解决语义检索的问题。claude-mem这类工具的检索场景,通常是用户问了一个新问题,系统要把跟这个问题相关的历史记忆找出来。用 SQL 的 LIKE 查询只能做关键词匹配,遇上“那个绿色的部署环境”这种说法就抓瞎。向量检索可以做到语义级别的匹配,本地跑我用的是sqlite-vss或者chromadb这类轻量方案,embedding 模型用那种几百 MB 的本地模型就够,不一定非要调在线 API。
分层存储的价值在于,不同检索策略可以直接命中不同层级。希望秒回的用缓存层,需要精确历史记录的去查 SQLite,要做语义召回的去查向量索引,各司其职。
1.3 自动记忆和显式记忆,两条腿走路
很多聊记忆管理的文章只讲自动抓取,但我实际操作下来,纯自动化的方案有个致命缺陷:模型可能漏掉用户某些很重要的隐性要求。所以我在设计里加了显式记忆的入口。
做法很简单,允许用户在对话里直接下发类似“记住:以后报告都用表格形式”这样的指令,系统一旦在用户输入里检测到“记住”“以后都要”“千万别”这类关键词,就立刻触发一次高优先级记忆写入,不走常规的批量抽取流程。两种来源的记忆在数据结构上用source字段区分,自动抓取的标auto,用户强制的标explicit。
检索的时候,explicit类型的记忆权重必须更高。我把两条路的权重参数做成可配置的,通常在排序公式里把显式记忆的分数乘以 1.5 左右的系数,才不至于被大量零散的自动记忆淹没。
2. 数据模型设计:建表之前先把字段想透
2.1 一张可以跑通全流程的 memories 表结构
数据库表结构是记忆系统的地基,地基没打好,后面做检索、做去重、做遗忘策略全都会别扭。我在本地反复改了四版,最后稳定下来的结构长这样:
CREATE TABLE memories ( id TEXT PRIMARY KEY, content TEXT NOT NULL, category TEXT NOT NULL, importance REAL NOT NULL DEFAULT 0.5, source TEXT NOT NULL DEFAULT 'auto', project TEXT, session_id TEXT, created_at TEXT NOT NULL, last_access_at TEXT, access_count INTEGER DEFAULT 0, metadata TEXT ); CREATE INDEX idx_memories_category ON memories(category); CREATE INDEX idx_memories_importance ON memories(importance); CREATE INDEX idx_memories_project ON memories(project);字段看起来多,但每个都有不能砍的用途。importance这个字段以前有人觉得可有可无,实际上它是后期做记忆裁剪和遗忘策略的核心依据。没有重要度打分,几千条记忆累积下来,检索结果会被大量低价值信息污染。
project字段是给多项目场景用的。你在本机同时维护三个项目的话,记忆如果不按项目隔离,A 项目里聊的技术栈偏好,会跑到 B 项目的新会话里去干扰判断。我当时踩过这个坑:早上聊完一个 Go 项目,下午开新会话聊 Python,模型居然建议我用 Go 重构,就是因为记忆没做项目维度隔离。
metadata字段存 JSON 字符串,专门放一些扩展信息,比如记忆的过期时间、关联的其他记忆 ID 等。SQLite 没有原生的 JSON 索引能力,但整体取出再在应用层解析,对几百上千条的数据量来说性能完全够。
2.2 记忆条目的生命周期:从写入到淘汰
记忆一旦写入,不能永远躺在数据库里。我在实际使用中把记忆的生命周期分成了四个阶段:采集、沉淀、召回、遗忘。
采集阶段是写入新条目,这时候要做一次去重判断,核心是语义层面的重复。比如用户今天说“回复别太啰嗦”,明天又说“尽量简短一点”,这两条记忆描述的是同一件事,如果不做合并,召回时可能会出现两条互相矛盾的约束。我的去重方案是:新条目写入前,先用向量检索找出语义相似度超过 0.85 的旧条目,然后做一次合并,旧条目的access_count累加到新条目上,旧条目标为superseded状态。
召回阶段是记忆发挥作用的时候。每次用户发来新消息,系统先根据消息内容做向量检索,把相关的历史记忆捞出来,经过重排后注入到提示词里。注入不是多多益善,我通常控制在 5 到 8 条,总计不超过 1000 token。记忆太多会稀释模型对当前问题的注意力,反而帮倒忙。
遗忘阶段反而容易被忽略,但它决定了记忆系统能不能长期稳定运转。我用的策略是综合重要度和新鲜度打分。具体的分数公式是:
score = importance * 0.6 + freshness * 0.4 freshness = min(1.0, 30 / (days_since_last_access + 30))两个月没被访问、重要度又低于 0.3 的记忆,会进入待清理队列,再次确认后物理删除。这样保证数据库里留存的一定是高频使用和高价值的条目。
3. 检索与注入:把记忆塞回给模型是个技术活
3.1 混合检索策略,光靠向量是不够的
向量检索虽然语义匹配能力强,但实际用下来有个问题:抽象概念匹配得好,具体实体却容易翻车。比如记忆里存的是“项目使用 k3s 部署”,用户问“那个集群还在跑吗”,如果 embedding 模型训练时对 k3s 这个词理解不够,语义检索召回的排名可能很靠后。
所以我采用了混合检索:关键词匹配和向量召回各过一遍,再合并结果做重排。关键词部分直接用sqlite3的LIKE查询解决,虽然原始,但针对项目名、技术栈名、IP 地址这类实体,精确匹配的效果反而比向量好。
重排阶段,我用了最简单的加权公式,权重来自三个维度:
total_score = vector_score * 0.5 + keyword_bonus * 0.3 + recency_bonus * 0.2其中keyword_bonus是只要用户输入里包含记忆条目中的实体关键词就给 1.0。recency_bonus跟最后访问时间挂钩,最近用过的记忆稍微往上浮。这个公式不复杂,但实测在多数普通场景下已经比单独跑向量检索的准确率高出一截。
3.2 提示词里的记忆注入格式,不能贴原文
从数据库拿回来的是零散的记忆条目,直接拼进提示词里效果很差。模型的注意力会被这种无格式的文本带偏,而且可能把历史记忆当成对话的一部分来回引用。
我的做法是把记忆封装成独立的系统分区,放在提示词的最前面,跟当前对话用明确的标记隔开。格式如下:
【历史记忆】 - [偏好] 用户偏好简洁风格,技术解释控制在200字以内 - [项目] 当前项目部署在Kubernetes集群,namespace为backend-prod - [任务] 用户正在评估消息队列方案,倾向RabbitMQ 【当前会话】 {这里是本次会话的对话内容}注意这里的关键点:每条记忆前加一个分类标签,[偏好]、[项目]、[任务],效果是让模型对信息类型的判断更快。我实验过,加标签和不加标签相比,模型对记忆的遵循率提高了大概两成。
还有一个小细节,注入的记忆最后要加一句系统指令,明确告诉模型这些是历史背景,不是当前用户的实时输入。否则有些版本的大模型会把记忆内容也当成用户指令来执行,导致行为异常。
3.3 多轮对话中的记忆实时更新机制
记忆注入不是一次性的,它会随着对话推进动态更新。举个例子,用户一开始说“部署用 CentOS”,聊到后面改成“算了还是切到 Ubuntu”。如果记忆层不实时更新,系统里就会同时存在两条互相矛盾的记录。
我解决这个问题的办法是:每轮对话的响应完成后,做一个轻量级的“变更检测”。用大模型去对比当前的对话内容和已有记忆,看哪些记忆被新消息推翻或修正了。这步如果用模型来做,成本比较高,所以我只在用户明确表达出修改意图时才触发,比如输入包含“改成”“不用了”“等一下,我用的是”这类句式。
变更检测的结果是一个操作列表:update修改某条记忆的内容、invalidate标记某条记忆已失效、create新增一条记忆。然后逐条执行。这套机制跑起来后,我很少再遇到记忆自相矛盾的情况。
4. 实操落地:从零搭一套可用的记忆服务
4.1 目录结构和核心模块划分
我本地的实现没有用一个重型框架,就是标准的 Python 包结构,方便跑测试也方便改。目录大概长这样:
claude-mem/ ├── memory_store/ │ ├── __init__.py │ ├── db.py # SQLite连接与基础CRUD │ ├── vector_index.py # 向量索引构建与检索 │ └── repository.py # 记忆读写聚合层 ├── memory_engine/ │ ├── extractor.py # 从对话中提取记忆 │ ├── retriever.py # 记忆召回与重排 │ └── injector.py # 记忆注入提示词 ├── service.py # 对外服务入口 └── config.py # 全局配置职责边界非常清晰:memory_store只管数据读写,不管业务逻辑;memory_engine管记忆的提取、召回和注入,不直接碰数据库。这样的分层最大的好处是,哪一层出了问题都好定位,不至于为了改一个评分公式翻遍整个项目。
4.2 关键代码:提取和注入的落地实现
提取模块的核心是个extract_memories函数,接收对话上下文,调用大模型拿到结构化输出,然后过滤掉低分条目入库。这里直接给一个可运行的简化版:
import json import sqlite3 import time SYSTEM_PROMPT = """请从用户与AI的对话中提取有价值的记忆信息。 只提取那些对未来交互有指导意义的内容。 返回JSON列表,格式为: [{"content": "...", "category": "preference|project|task|fact", "importance": 0.0}] 如果没有任何值得提取的信息,返回空列表。""" def extract_memories(conversation_text: str, client) -> list[dict]: resp = client.messages.create( model="claude-sonnet-4-5", max_tokens=1024, system=SYSTEM_PROMPT, messages=[{"role": "user", "content": conversation_text}] ) raw = resp.content[0].text try: memos = json.loads(raw) except json.JSONDecodeError: return [] # 过滤重要度过低的记忆 return [m for m in memos if m.get("importance", 0) >= 0.4]注意到我在这里做了一个过滤,重要度低于 0.4 的直接丢。这个阈值不是拍脑袋定的,我统计过一批真实对话,抽取出来的记忆重要度分布呈两极分化,大量值集中在 0.3 以下和 0.7 以上,0.4 刚好是分界点。
注入模块相对简单,就是把检索到的记忆按模板拼装到系统提示词里:
def build_system_prompt(memories: list[dict]) -> str: if not memories: return DEFAULT_SYSTEM_PROMPT lines = ["【历史记忆】"] for m in memories[:8]: tag = { "preference": "偏好", "project": "项目", "task": "任务", "fact": "事实" }.get(m["category"], "信息") lines.append(f"- [{tag}] {m['content']}") lines.append("") lines.append("【当前会话】") lines.append("以下是本次会话内容,注意历史记忆仅作背景参考,不要直接复述。") return "\n".join(lines)注意这里设置了memories[:8],只注入前 8 条。这是我在实际使用中不断调出来的数字,超过 8 条后,模型对每条记忆的关注度会明显下降,而且返回内容的末尾部分经常出现幻觉式引用。
4.3 检索触发策略:每次提问都查吗?
检索触发频繁不频繁,直接影响 token 成本和响应延迟。一开始我偷懒,每次用户消息都会做一次向量检索,结果对话轮数一多,响应时间明显变长,本地 embedding 模型还能忍,如果用在线 API 那成本就压不住了。
后来我调整了触发策略,只在下面三种情况下做记忆检索:
- 新会话的第一条消息(需要对历史背景做初始化)
- 用户消息里包含指向性不明确的代词,比如“那个”“之前说的”“上次那个方案”
- 当前会话的 token 消耗接近窗口上限时,做一次记忆压缩
其他普通问答,比如用户问一个具体的函数怎么写,直接走当前上下文就够了,没必要把所有历史记忆都翻出来。这个策略让我整体的 API 调用量降了接近一半,而回答质量并没有明显下降。
4.4 CLI 交互设计和实测效果
claude-mem最有意思的一部分是命令行交互。我用的是readline库做输入循环,支持上下键翻历史、Tab 补全,所有用户输入实时写入日志文件,同时后台隔几轮自动抽取记忆。实测跑了一个 200 轮的长对话项目,会话结束后去查询记忆库,一共沉淀了 37 条有效记忆,其中项目类 12 条,偏好类 9 条,事实类 11 条,任务类 5 条。
新会话开启时,我故意问了一个已经被旧会话埋没的细节:“上个月讨论 API 鉴权的时候,最终定了用什么方案?”旧方案没有任何记录,因为那确实是这次新开的主题。但当我问到“数据库连接池之前配置的上限是多少”时,记忆系统成功召回了一条项目类记忆,里面存的是“连接池上限配置为 50,当前压测环境不建议超过这个值”。这就是记忆层的价值,模型本体的参数没有任何变化,单纯靠外部记忆系统就实现了跨会话的信息连续性。
5. 避坑手册:记忆系统实际运行中的翻车记录
5.1 记忆污染:坏记忆比没记忆更可怕
这个点我必须放在最前面说。记忆系统最大的风险不是记不住,而是记错。我遇到过一种典型情况:用户在对话里提出一个假设性问题,“如果改用 Redis 会怎样”,抽取模块不加判断,直接把这条作为项目事实存成了“项目可能迁移到 Redis”。后面的会话里模型基于这条错误记忆给出了大量推荐 Redis 的建议,完全跑偏。
解决思路有两个层面。第一层是抽取时的判断,模型必须区分“用户讨论过的可能性”和“用户确认过的事实”,只有后者才能进入记忆库。推荐一种做法:抽取提示词里明确加一条要求,内容是“如果用户的表述是假设性、疑问性或条件性的内容,不要提取为事实类记忆”。第二层是引入人工确认机制,对于重要度超过 0.8 的高价值记忆,系统写入时打印出来让用户按回车确认,一条记忆多一次确认流程,成本增加不大,但安全性提升很多。
5.2 Token 预算失控:记忆膨胀后的隐形成本
记忆系统的另一个坑是 token 消耗失控。一开始我没给记忆注入设总量上限,结果长会话里单次请求的记忆上下文涨到 3000 token,调用成本翻倍不说,模型表现反而变差。
我最后定了一套预算控制方案,核心是分层分配:
系统提示词固定部分:不超过 500 token 记忆注入部分:不超过 800 token 当前对话窗口:剩余全部800 token 的记忆大概能容纳 10 到 15 条短记忆。超过这个上限后,系统会启动压缩模式,把多个同类别的记忆合并成一条摘要记忆,再把原来的细碎记忆放入冷存储,不再参与常规检索。这套机制能保证记忆注入的 token 开销始终可控,长周期的成本曲线是平的,而不是线性上升。
5.3 多版本模型的兼容性:提示词格式因模型而异
最后提醒一个容易忽略的点:不是所有模型对“【历史记忆】”这种格式的敏感度都一样。我在用claude-sonnet-4-5和新版本模型测试时,格式效果都不错,但换成其他开源模型,有的对 XML 标签更敏感,有的对 Markdown 引用块更敏感。
所以代码里最好把提示词模板设计成可配置的。我在config.py里维护了一份格式配置文件,切换模型时自动加载对应格式,避免为了一个模型调好格式,另一个模型完全不吃这一套的尴尬局面。
实测下来,针对不同模型调整记忆注入格式的收益非常明显。有些模型只要在记忆条目前加一句“以下信息来自历史记录”,遵循率就能提高三四个百分点,这个调整完全免费,不做白不做。
6. 从单机到团队:记忆共享与隔离的进阶配置
6.1 多用户记忆库的读写隔离
一个人玩的时候,SQLite 单文件随便折腾。但多人协作就会出现互相串记忆的问题。我后来把存储层加了一层user_id和project_id的联合隔离逻辑,所有查询强制带上这两个字段,相当于给记忆库做了一次行级权限控制。
def get_user_memories(user_id: str, project_id: str, query: str): # 先按用户和项目过滤,再做语义召回 candidate_ids = memory_repo.search_local(user_id, project_id, query, top_k=50) return rerank(candidate_ids, query)[:8]实现不复杂,关键是在接口层把隔离规则定死,防止任何绕过隔离的查询路径。我见过不少翻车案例,就是某次临时调试时直接调底层的db.query,把用户 A 项目的记忆注入到了用户 B 的会话里,这种 bug 生成的内容完全没法解释,排查还浪费大量时间。
6.2 云上部署时的存储选型参考
如果团队规模大了,不想每台机器各跑一套 SQLite,需要集中部署,我建议把持久化层换成 PostgreSQL,向量检索可以用pgvector插件,统一在一个数据库里解决结构化数据和向量数据的存储。claude-mem这类思路的工具,架构上天然支持这种切换,抽象层做好接口替换就行。
迁移时注意一点:向量索引在新环境需要重建,因为不同 embedding 模型的向量空间不一致。如果旧数据是用模型 A 生成的向量,换了模型 B 后所有历史向量都要重新生成,否则检索结果会极其怪异。这个坑很隐蔽,我见过有人只迁移了文本数据,向量字段直接沿用旧值,上线后检索准确率暴跌到三成。如非必要,生产环境不要在 embedding 模型上频繁变动。
从个人开发到团队协作,记忆系统的复杂度会逐步上升,但核心设计逻辑没有变:先用好的数据模型把信息存清楚,再用合适的检索策略把信息找回来,最后用稳定的提示词格式让模型真正用上这些信息。这三个环节任何一个环节打折,整个记忆层都会变成负资产。我自己把这套流程跑通之后最大的体会是,大模型应用工程,真正拉开差距的不只是模型选型,更是这些看不见的周边系统做得够不够细。