你有没有遇到过这种情况:昨天还在终端里跟 AI 编程助手讨论某个跨平台系统的模块拆分,改了好几轮方案,今天重新打开终端,它竟然完全不记得项目背景了。我一开始以为这是模型能力问题,后来才想明白,本质是会话隔离机制在作祟——每次新会话都是一张白纸,模型再强也“记不住”上一次对话的结论。为了治这个“三秒失忆”的毛病,我写了 claude-mem,一个给 AI 命令行助手做长期记忆扩展的小工具。它不修改模型本身,而是在会话间隙做记录、索引和回灌,让 AI 下次开工时能直接带上“上次聊到哪、有哪些结论、哪些坑还没踩完”。这篇文章就围绕 claude-mem 的动机、设计、安装和调优展开,适合正在被“反复喂背景信息”折磨的开发者,也适合想给自己的 CLI 工作流加一层持久化记忆的朋友。
1. 让AI记住上次聊到哪:claude-mem 想解决的核心痛点
1.1 为什么AI CLI工具天生“记不住事”
绝大多数 AI CLI 工具在设计时,都把每一次会话看成一次独立的 HTTP 请求。模型看到的输入是“系统提示 + 用户消息 + 历史消息”,一旦会话结束,历史消息就被丢弃。这么做的好处是简单、省 token、好做并发控制,代价就是跨会话记忆为零。
你可能会问:能不能把历史消息全部塞到系统提示里?理论上可以,但实际行不通。一个项目跑三四天之后,对话记录轻松超过几十万 token,先不说成本,模型能容纳的上下文长度也有物理上限。更关键的是,历史消息里大量内容是“中间过程的试探”,比如某段报错、某个临时方案,这些对后续工作毫无帮助,全部堆进去反而干扰模型判断。
所以真正缺的并不是“把历史存下来”的存储能力,而是“从历史里提炼出关键记忆,并在合适的时机把它放回上下文”的调度能力。claude-mem 做的事情就是这个调度:它用独立的记忆层,在会话之间做蒸馏、检索和注入。
1.2 反复重述背景的隐性成本,比你想象的高
不解决记忆问题时,我体验过最典型的场景是这样的:周一讨论完数据库分表方案,周二要继续写迁移脚本。我把项目结构、表名、分片规则、之前踩过的坑,重新敲了一遍;AI 基于我给的描述给出了一个看起来合理的脚本;但写完后发现,它完全忽略了一个周一已经确认过的约束——某个字段不能直接改,因为历史数据没做清洗。
这种问题不是模型笨,而是它根本没机会知道这些约束。为了让它“知道”,我每次都要花 10 到 20 分钟重新交代上下文。表面上看只是多敲几个字,实际成本包括:打字和思考的时间、被误导后返工的时间、由于信息遗漏导致方案偏差的时间。在迭代密集的周期里,这些成本很快会超过模型本身的推理时间。
1.3 记忆分型:目录级、全局级、事实级
做 claude-mem 之前,我先把“记忆”分成了三类,因为不同记忆的生命周期和适用范围完全不同:
- 目录级记忆:跟着某个目录走。比如“这个仓库的日志模块使用自定义注解”“构建命令是 pnpm build”。换一个项目目录,这些记忆就不应该再注入。
- 全局级记忆:跟目录无关。比如“我习惯用驼峰命名”“测试环境地址需要写配置文件里”“提交信息默认用中文写”。
- 事实级记忆:来自某次会话的临时结论。比如“张三负责支付模块”“线上库的订单表已经加了 create_time 索引”。这类记忆可能是短期事实,也可能是长期结论,需要靠时间和关联度做衰减。
这三类记忆如果混在一个槽里,检索效果会很差。所以 claude-mem 的存储结构一开始就按这个分型来设计。注入的时候,全局级记忆永远在最前面,目录级记忆看当前工作目录匹配,事实级记忆才走相似度检索。这样既不浪费上下文,又能照顾到不同层级的优先级。
2. 把“记忆”拆开看:claude-mem 的核心组件与数据流
claude-mem 不是一个做出来的“单文件脚本”,它内部由四个相对独立的模块组成:采集端、记忆仓库、检索端、注入端。下面按数据流向逐层拆解。
2.1 采集端:监听终端输出,而不是侵入模型
采集端的核心逻辑是:在 AI CLI 进程的外层包一圈轻量代理,把用户输入和模型输出同时拷贝到本地日志流。这一步的关键在于“不侵入模型”——我不需要修改模型厂商的 API 调用,也不需要额外的埋点,只要能在终端这个层面拿到纯文本流就行。
具体实现上,我用了一个非常朴素的手段:用伪终端(PTY)拉起目标 CLI 进程,然后同时做两件事,一是把输入输出转发给真实的终端,二是在后台把它们异步写入一个增量日志文件。因为伪终端看到的只是字节流,所以它对任何基于标准输入输出交互的 CLI 工具都通用,这让我后续适配不同工具时几乎不用改采集逻辑。
采集端还需要做“会话切分”。我会在每次检测到新的会话主题时打一个标记,拆出会话片段。规则很简单:连续同一目录下超过 5 分钟没有交互,算一次新会话;用户显式输入某个记忆写入指令,也算一次新会话。这样做是为了避免把两个不相干的任务揉进同一条记忆。
2.2 存储端:SQLite 存事实,向量库存语义
记忆仓库我选了双存储方案。核心事实存在 SQLite 里,每条记忆记录五个字段:ID、来源会话ID、目录路径、记忆类型、原始文本、创建时间、最后访问时间。文本本身按原样保存,不做截断。这样在做审计、回溯时非常方便,能直接查到某条记忆是从哪次对话里抽出来的。
但光有 SQLite 不够,因为事实级记忆的召回不能只靠关键词匹配。“数据库连接池被移到了 config 模块”和“连接池配置在 config 目录下”这两句话字面不同,语义相近。如果只用 LIKE 查询,后一句检索不到前一句。所以我在 SQLite 之外还维护了一个轻量向量索引,用模型把每条记忆文本编码成语义向量,检索时把当前问题也编码成向量,做余弦相似度排序。
向量库的选择经历了几个迭代。一开始用本地文件直接裸写向量,但边界情况太多。后来换成了基于 SQLite 的向量扩展插件,也就是在同一个数据库文件里多建一张向量表,由插件负责计算相似度。这样好处很明显:不需要单独开一个向量数据库服务,备份只需拷贝一个.db文件,对个人项目和中小团队来说足够省心。
2.3 注入端:把记忆变成一段“伪系统提示”
注入端的职责,是在每次新会话启动时,从记忆仓库里选出当前场景最相关的若干条记忆,拼成一段结构化的文本,作为额外的“系统提示”注入到对话上下文中。
这里有一个容易被忽略的设计点:注入文本的格式必须和模型原始的 system prompt 风格保持一致,否则模型可能会把记忆内容当成普通用户消息,权重完全不一样。我用的是这种三明治格式:
[记忆上下文] 以下是你在本次会话之前已经了解到的背景信息。 它们来自历史会话摘要,可能与当前任务相关。 - [项目] 该仓库使用 pnpm 作为包管理器,统一在根目录执行 build。 - [偏好] 用户习惯将常量命名全部大写,测试文件与被测文件同目录。 - [事实] 订单表的 status 字段已从 int 改为 varchar,迁移脚本尚未执行。 请根据相关性自行判断是否参考,不要生硬复述。 [记忆上下文结束]这段文本放在系统提示的最末尾,用户第一条消息之前。模型读到它时,会默认把它当作“已经掌握的背景”,而不是“需要回复的内容”,因此更可能自然地用到其中的信息。
2.4 写入触发策略与去重机制
什么时机写一条记忆,比想象中难得多。一开始我粗暴地设定“每轮对话结束都写”,结果仓库里全是“用户问了快递费怎么算”“用户说好的”这种毫无价值的临时内容。后来我改成了四种触发策略,只有满足其一才写入:
- 显式指令:用户或模型通过
!记得 ...这样的自然语言指令,要求把某条信息记住。 - 会话自然结束:关闭终端前,采集端会对整段会话做摘要,并抽取结论性语句。
- 关键事件:检测到“修复了”“决定使用”“不需要再改”这类表示决策完成的信号词时,触发生成。
- 定时兜底:每过 10 分钟,如果会话里出现新的实体名词,就把增量内容追加到当前会话摘要里。
去重机制方面,我在写入前先用向量相似度扫描一遍现有记忆,如果相似度超过 0.92,就不再新增记录,而是更新原记录的最后访问时间和原文。如果相似度在 0.8 到 0.92 之间,说明两条记忆有交集但细节不同,我会把新文本追加到旧记录的“补充说明”字段里,而不是新建一条,避免记忆碎片化。
3. 上手实录:从安装到验证一条记忆被真正召回
理论讲再多,不如实际跑一遍。这一节我记录下 claude-mem 整个上手过程,包括安装、配置、产生记忆、召回验证,以及我第一次遇到的一个小问题。
3.1 环境准备与安装
claude-mem 目前以 Python 为主语言,需要 Python 3.10 以上,并依赖一个支持 SQLite 向量扩展的运行时。安装时我用的是虚拟环境加 pip 的方式,命令也很简单:
git clone https://example.git/claude-mem cd claude-mem python -m venv .venv source .venv/bin/activate pip install -e .安装完成后,二进制会落在虚拟环境的 bin 目录下,叫claude-mem。如果你希望全局都能调用它,可以把它软链到/usr/local/bin,或者把虚拟环境 bin 目录加到 PATH 里。我个人的习惯是只在需要记录的项目目录里启用它,不全局安装,避免它在所有目录下都主动监听。
3.2 初始化配置项逐条说明
在项目目录下执行claude-mem init,会生成一个claude-mem.yaml配置文件。我第一次看到生成的文件时,里面大概有十几个配置项,真正需要手动改的并不多。我把几个最重要的列出来:
| 配置项 | 默认值 | 作用 |
|---|---|---|
| watch.enabled | true | 是否启动终端监听 |
| watch.prompt_marker | !记得 | 触发显式写入的自然语言前缀 |
| store.db_path | .claude-mem/mem.db | 记忆数据库存放位置 |
| store.vector_model | 本地嵌入模型名 | 生成语义向量的模型 |
| retrieval.top_k | 5 | 每次召回多少条记忆 |
| retrieval.similarity_threshold | 0.72 | 低于该相似度的记忆不注入 |
| trivia.filter_enabled | true | 是否过滤“好的”“收到”这类寒暄 |
这里最需要注意的有两点。第一,store.db_path最好设置到项目内,但同时要在.gitignore里忽略掉.claude-mem/目录,否则记忆库里会混入项目队友的会话摘要,提交时还会把数据库文件推到仓库里。第二,retrieval.similarity_threshold不要一开始就调高,推荐先用默认值跑几天,再根据召回质量调整。阈值调太高会导致该用的记忆没被召回到,调太低又会让大量无关记忆涌入上下文。
3.3 把一次完整对话“喂”给 claude-mem
我第一次实际测试时,模拟了一段很典型的场景:在某个项目目录下问 AI 工具“这个项目的测试命令是什么”,AI 回答“仓库使用 pytest,测试文件放在 tests 目录下,执行pytest -q即可”。
因为这段对话里有“测试命令”“pytest”这类实体,claude-mem 在会话结束时自动把它写成了记忆。我可以通过claude-mem list命令看到这条记忆:
$ claude-mem list --type=project [1] 项目:当前仓库测试命令为 pytest -q,测试文件位于 tests 目录 来源:会话 #12 创建:2024-03-14 10:32为了验证它是否真的能在下一次会话中发挥作用,我又开了一个全新会话,只输入一句“帮我运行测试”。正常情况下,CLI 工具只会把这句话当成普通指令,但 claude-mem 在启动时已经将“测试命令为 pytest -q”注入到了系统提示里,所以工具直接执行了pytest -q,甚至没有追问“测试命令是什么”。
这就是 claude-mem 带来的最直观差别:模型不是在“猜测”,而是在使用它“知道”的信息。
3.4 用 query 命令确认召回结果
还有一种情况是手动检索。比如我明明记得之前聊过日志规范,但记不清具体结论。这时可以用claude-mem query直接查:
$ claude-mem query "日志格式规范" found 3 results in 18ms: 0.91 [fact] 日志统一使用 JSON 格式,字段包括 level、msg、ts、requestId 0.84 [fact] 错误日志需要额外带上 stack_trace 0.76 [project] 本仓库的日志框架为结构化日志库这个命令有两个好处:一是帮我快速回忆,二是我可以用它来验证记忆召回是否正常。如果某条明确写过的记忆在 query 时找不到,那就说明采集端或索引段出了问题。常见的故障包括:向量模型没有正确加载、SQLite 表被锁定、监听进程意外退出。后面我会专门讲一个排查案例。
4. 真正提升体验的调优技巧与踩坑记录
工具能跑通只是第一步。这段时间实际用下来,我总结了几个直接影响体验的调优点,以及一个让我头疼半天的内存泄漏排查过程。
4.1 召回阈值怎么调才不容易误伤
相似度阈值是 claude-mem 里最容易让人纠结的参数。阈值高了,召回结果少而精;阈值低了,全是一些八竿子打不着的记忆。我在默认项目上跑了一阵子后,发现 0.72 的阈值会偶尔召回一些非常边缘的内容,比如我在一个前端项目里问“数据库连接池怎么配”,居然把另一个后端项目的记忆召回了进来。
后来我意识到,单纯调整全局阈值并不能解决问题。真正的原因是“目录感知”没有参与检索。于是我给检索流程加了一个过滤条件:当前工作目录不匹配的记忆,相似度分数先乘以 0.85 的惩罚系数,目录匹配的记忆则保持不变。这样就变相提高了跨目录记忆的召回门槛,不需要动全局阈值。
如果你遇到类似问题,建议先不要急着抬阈值。优先检查记忆条目是否带了正确的目录标签,全局记忆和项目记忆是不是被混在同一个命名空间里。目录信息不全的话,任何阈值都救不了召回精度。
4.2 记忆过期与冲突处理的取舍
记忆里的信息不是永远正确的。项目重构之后,某个模块从 A 目录挪到了 B 目录,旧记忆如果还保留着,就会误导后续所有会话。所以还需要一个“遗忘机制”。
我的做法是给每条记忆加了一个冷热属性。每次成功注入并被模型引用后,对应记忆的热度加一。热度高的记忆即使旧了,也会在下一次召回时提醒我注意源会话时间。热度低的记忆,如果超过 30 天没有被访问,就会进入“待归档”状态,不再参与注入,但数据库里仍然保留。
冲突处理会更麻烦。比如有一天你说“日志格式统一用 JSON”,隔两天又说“日志文本用单行文本”。这两条记忆同时存在时,注入顺序会决定模型参考哪一条。我的处理原则是:来源时间越新的记忆,排序越靠后,因为模型对越靠后的上下文记忆越深刻。同时,如果检索到两条相似度超过 0.9 且时间相隔不远的记忆,我会在注入文案里生成一个“冲突提示”,让模型知道存在新旧两种说法,需要结合当前场景自行判断。
4.3 不要为了“看起来聪明”而过度注入
这是我最想强调的一个坑。很多人开发记忆工具,会忍不住把召回到的所有记忆全塞进上下文。感觉这样 AI 好像“什么都知道”,实际效果反而更差。上下文窗口是有限的,记忆内容占得多了,留给当前任务的空间就少了;而且记忆里如果有一两条边缘信息,很容易把模型的注意力带到岔路上去。
我后来给注入端加了一个硬预算:整个记忆上下文的文本长度不能超过 1500 个 token,超过之后按相关性从低到高裁剪。宁可少注入一条可能相关的记忆,也不能让记忆喧宾夺主。在多数场景下,对话开头的“背景信息”只需要点到为止,真正关键的事实模型自然会在后续推理中用上。
4.4 一次内存泄漏的完整排查链路
最后分享一个真实遇到的故障。版本迭代到 v0.3 之后,我发现 claude-mem 的后台进程内存占用会随着使用时间线性增长,跑了一天能从 80MB 涨到 700MB。
我第一反应是监听环节出问题了,可能每次会话都开了一个新的文件句柄,没有关闭。排查后没有发现句柄泄漏。然后我去看了向量检索部分的日志,发现一个奇怪现象:每次 query 之后,内存里都会多出一批向量,但按道理这些临时对象应该被垃圾回收了。
进一步跟踪后,问题出在缓存上。我在代码里用了一个全局字典做“最近查询结果缓存”,为了提升重复查询的速度,键是查询语句,值是向量结果。这个缓存没有设置过期时间,也没有做大小限制。当查询语句越来越多时,缓存的向量对象就越来越多,内存自然被吃满了。
修复方式很简单,用一个支持最大数量和 LRU 淘汰策略的缓存容器替代原来的普通字典,设置最多缓存 1024 条最近结果。同时加了一个定时器,每个小时清理一遍超过 5 分钟没有被访问的缓存项。处理完之后,进程内存长期稳定在 100MB 左右。类库中的很多“隐形坑”往往不是核心逻辑错了,而是这种看似无害的辅助数据结构在大量并发下积累成了问题。
写在最后
claude-mem 做到现在,给我最大的感受是:为 AI 工具增加记忆能力,真正的难点不是技术选型,而是“知道什么时候该记、记什么、什么时候该忘”。SQLite、向量索引、PTY 监听这些底层技术都很成熟,难的是把成千上万条零散会话蒸馏成几条能在关键时刻派上用场的记忆。如果你也在做类似的方向,建议从小范围开始:先手动标注几条记忆,跑几天看看召回效果,再逐步上线自动采集。记忆系统切出来的不是代码工作量,而是对“什么信息真正有价值”的判断力。