
1. 对话程序最大的毛病它真的什么都记不住1.1 无状态不是缺陷是架构选择只要写过聊天机器人早晚都会面对同一个尴尬场景用户上一分钟刚告诉你我住在杭州平时九点后才下班下一分钟你问他住在哪里他得到的回答依然是我是一个AI无法记住之前的对话内容。这不怪模型也不怪你写的代码——对话程序本质上就是无状态的每一次 API 调用都是全新的开始服务器不会在两次请求之间替你保存任何印象。从架构角度说无状态是刻意的选择。它让服务可以水平扩展让请求可以随意路由到任意一台机器也让故障恢复变得毫无负担。代价就是AI 永远活在当下没有昨天没有上次也没有用户档案。早期的解决办法很粗暴——把所有历史消息全部打包塞进新的请求里让模型从长上下文里自己翻找。这种方案在对话题量小的时候确实能用但随着轮数增加很快就会被两个现实问题卡死第一个是成本所有历史消息都要重复计费一轮长对话下来 token 消耗翻好几倍第二个是 context window 有硬顶聊到后半程要么手动截断早期内容要么眼睁睁看着请求报错。我开始做 claude-mem 的动机就在这里我不想每次和 Claude 对话都从零开始也不想为了让它记得我叫什么而把整本聊天记录再喂一遍。真正需要的是一套机制让它只记住该记住的并且在需要的时候自动取出来用。这个工具的本质就是给无状态的对话引擎外挂一块长期记忆。1.2 上下文窗口塞满 everything 的做法为什么不行有人会说直接把整个历史都塞进 system prompt 不就行了我确实这么试过而且是在一个模拟客服项目里。那个项目最多只跑了几百轮对话历史数据塞进请求就已经逼近上下文上限回复开始变得迟钝更离谱的是模型偶尔会把早期某条不重要的细节当作当前指令来执行因为整个上下文中有效信息和垃圾信息的占比严重失衡。这里有个容易被忽略的机制问题模型对上下文里不同位置的注意力并不是均匀分配的。塞入的海量旧消息会稀释掉当前指令的权重你会发现它越来越走神。而且 token 长度增长还带来另一个隐性成本——单次请求的延迟会明显增加因为 prefill 阶段要把所有输入都过一遍。实测下来当我从 2000 token 的历史增加到 20000 token 时首字返回时间从不到 1 秒涨到了 3 秒以上还是在网络条件理想的情况下。所以 claude-mem 的第一个设计原则是永远不要让模型看到用不上的历史。它只需要在一堆记忆里挑出和当前对话相关的几条注入到请求中其他全部留在外部存储里。这就像你上班时不会把大学教材背在身上而是遇到问题再去翻相关的那一页。1.3 真实需求AI 该记住什么在动手写之前我先花了不少时间想记忆到底该存什么。这个问题不想清楚后面写出来的只会是一个烂大街的聊天记录存档工具。我的结论是把记忆分成四类用户偏好称呼、语言风格、作息时间、喜欢简洁还是详细。事实信息用户所在城市、职业、正在用的技术栈、家庭成员这类客观信息。任务状态上次聊到哪个需求、方案选型选到哪一步、待办事项有哪些。互动结论已经否决的选项、明确认可过的方案、关于某个问题的最终判断。一个完整的记忆系统必须能区分这四种信息因为它们的生命周期完全不同——用户偏好几乎永远有效任务状态可能几天就过期互动结论需要在新冲突出现时被覆盖。如果一股脑全存进去、全注入那跟塞历史记录没有区别。claude-mem 此后所有的设计都是围绕这四类信息的识别、存储和调用来展开的。2. claude-mem 的整体设计一条稳定不绕弯的记忆流水线2.1 记忆流水线的三个环节提取、存储、注入claude-mem 的核心可以拆成三个环节我把它们串成一条流水线对话结束时提取提取后结构化存储下次对话前按需注入。听起来简单真正落地时每个环节都有不少细节要注意。提取环节解决的是哪些话值得记。我自己没有用正则这种土办法去抓关键词因为自然语言里的信息表达方式太灵活了。比如用户说以后都喊我老张吧和你可以叫我老张语义几乎一样但句式不同正则很难覆盖全。claude-mem 的做法是借助模型自身的能力每当一次对话结束之后我调用一次廉价的 Claude 模型把对话摘要和完整记录一起丢进去让它按照约定的 JSON 格式输出该记的内容。这样提取质量直接复用模型的语言理解能力比自己维护一套关键词规则省心得多。存储环节解决的是记在哪、怎么组织。我用的是 SQLite JSON 的组合没有引入任何重量级组件。每条记忆存储时都会打上类型标签、重要度评分、状态标签并且额外存一个经过切词处理的关键词集合用于后续的快速检索。注入环节解决的是什么时候把哪些记忆拿出来。这一步比大多数人想象的要复杂不是所有记忆都要注入否则就跟塞历史记录一样了。claude-mem 会根据新对话的开场文本做一次意图分析决定需要哪些类别的记忆再把命中的记录经过一轮相关性排序后按预算拼接成固定的记忆段落放进请求里。这三个环节合在一起就是一个完整的记忆生命周期管理。你不需要在每个请求里手动指定要记住什么也不需要自己写检索逻辑流水线会自动运转。2.2 目录结构与数据模型设计我把 claude-mem 设计成了一个 Python 库同时提供了一个命令行入口claude-mem wrap用来包裹任意的 Claude API 调用。整体的目录结构是这样claude-mem/ ├── claude_mem/ │ ├── __init__.py │ ├── config.py # 配置读写记忆预算、模型选择等 │ ├── extract.py # 对话结束后的记忆提取 │ ├── store.py # SQLite 读写与记忆更新 │ ├── retrieve.py # 相关性检索与排序 │ ├── inject.py # 将记忆拼装进请求 │ └── models.py # 数据模型dataclass 定义 ├── memories.db # 运行时自动创建 └── prompt_templates/ ├── extract.md # 记忆提取的 prompt 模板 └── inject.md # 记忆注入的 system prompt 模板数据库表结构是经过几轮调整才稳定下来的。最初我把所有信息塞在一张宽表里后来发现不同类型记忆的更新频率差异太大混在一起会导致频繁的全表扫描所以最终拆成了两张表CREATE TABLE memories ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id TEXT NOT NULL, session_id TEXT NOT NULL, content TEXT NOT NULL, memory_type TEXT NOT NULL, -- preference / fact / task / conclusion importance INTEGER NOT NULL, -- 1~55 表示最高 tags TEXT NOT NULL, -- JSON 数组关键词标签 created_at TEXT NOT NULL, updated_at TEXT NOT NULL, hit_count INTEGER DEFAULT 0, last_hit_at TEXT ); CREATE TABLE memory_sessions ( session_id TEXT PRIMARY KEY, user_id TEXT NOT NULL, started_at TEXT NOT NULL, ended_at TEXT NOT NULL, summary TEXT, -- 本段对话的一句话摘要 extraction_status TEXT -- pending / done / failed );两张表的配合逻辑很直接memory_sessions负责记录每次对话的元信息memories负责存真正会被注入的原子记忆。这个拆分让提取和使用完全解耦即使某次提取失败也不会影响已有记忆的正常使用。2.3 为什么选 SQLite JSON 而不是重型的向量数据库技术选型的时候不少人第一反应是上向量数据库。我仔细评估过最后放弃了原因有三。第一场景的数据量根本撑不起向量检索的复杂度。claude-mem 跑的是个人级或小团队级的对话记录一天下来能产生几十条有效记忆就算很多了几千条记忆放在 SQLite 里做关键词匹配绰绰有余完全没必要为一个几千行的表引入一套分布式服务。第二向量检索的召回质量在短文本场景下并不比关键词标签检索更好——记忆通常是简短的一句话不像长文档那样有丰富的语义上下文两条记忆的区分度主要靠实体词和标签而这些用传统匹配就能抓住。第三运维负担必须压到最低做工具的人都知道一个东西越难部署越容易被弃用。SQLite 是单文件拷贝即备份跨平台零配置没有比自己更合适的选择了。但这不代表我完全抛弃了语义匹配。在检索阶段我会把用户的当前问题先做一次切词切出来的词与记忆的tags字段和content内容做加权匹配同时在匹配结果里用简单的人工规则做一次语义筛选——比如喜欢和不喜欢同时出现在两条记忆里时后者在注入时会被特殊标记防止模型被相互矛盾的记忆带偏。这套轻量方案实测下来在小规模数据上的效果已经足够好而我省下的是一个向量服务的部署和调优成本。3. 核心实现拆解让 Claude 学会写便签和翻便签3.1 会话结束后如何自动提取记忆提取记忆是整个流程里最依赖模型能力的环节也是最容易出错的地方。我最开始的版本很简单每次对话结束后调用一次 API把完整对话丢进去让它总结出值得记住的信息。结果发现模型经常把两条语义相近的信息重复输出还会把无关紧要的寒暄当成重要事实记录。后来我改了思路给模型一份严格的记忆提取规范明确告诉它什么值得记、什么不值得记并且用 JSON schema 约束输出。这是提取用的关键 prompt 模板的核心片段你正在帮助用户整理一段对话中的长周期信息。请从对话中提取需要长期保存的内容并只用 JSON 数组输出不要输出其它任何文字。 每条记忆必须严格按照以下 schema { content: 记忆内容不超过30字保留关键实体词, memory_type: preference | fact | task | conclusion, importance: 1到5之间的整数 } 提取规则 1. 只提取用户在对话中明确表达的信息不要推断或脑补。 2. preference 用于用户偏好比如称呼、风格、作息、习惯。 3. fact 用于客观事实比如地址、职业、技术栈、家庭成员。 4. task 用于未完成或进行中的任务状态比如方案选型、待确认事项。 5. conclusion 用于明确达成的结论比如已排除方案A确定用方案B。 6. 寒暄、临时性请求、与用户无关的中间推理过程全部忽略。 7. importance 为 1~53 以下表示模糊或低确定性信息4 表示明确且长期有效5 表示直接影响后续交互的强约束。每次提取时我会让模型输出原始 JSON 数组然后在我这端做两层校验第一层是 JSON 格式校验解析失败就重新请求一次第二层是去重检查如果新提取的记忆和已有记忆的规范化文本相似度超过一定阈值就转为更新旧记忆而不是新增一条。这里有个很实用的经验提取环节要使用 delay 的调用方式不要在用户还等着响应的时候就阻塞式提取。claude-mem 的做法是把提取任务丢进后台线程池通过memory_sessions.extraction_status字段追踪状态。这样用户感知不到任何延迟记忆的写入完全发生在后台。3.2 检索时怎么从记忆库里挑出有用的片段提取之后真正考验功夫的是检索。检索做得差要么注入一堆无关记忆让模型变笨要么漏掉关键记忆跟没做一样。claude-mem 的检索函数是这么设计的新对话一进来先对开场文本做切词和标签提取得到一组候选关键词。然后按记忆类型分组打分——如果开场文本出现了你觉得你认为这类询问意见的表达conclusion类型的分值会调高如果只说了句早上好那就是寒暄系统只注入preference里最轻量的几条。打分公式是关键词命中次数、记忆重要度、距上次命中时间的加权和def score_memory(memory, query_tokens, now): tag_hits len(set(memory.tags) set(query_tokens)) content_hits sum(1 for token in query_tokens if token in memory.content) recency_bonus max(0, 1 - (now - memory.last_hit_at) / (7 * 86400)) return 0.5 * tag_hits 0.3 * content_hits 0.2 * recency_bonus memory.importance这个打分函数里recency_bonus是后来补上的。最初没有它的时候一条几个月前的旧记忆和一条昨天刚更新的记忆权重完全相同导致 AI 偶尔会提早已失效的信息。加了时间衰减之后近期确认过的记忆天然排在前面整体正确率高了不少。选中的记忆按分值排序后还要经过一道预算裁剪默认只允许注入 1200 token 的记忆内容超过预算就从分值最低的开始丢弃。这个预算必须写进配置因为不同场景下可用上下文不同——做代码生成时可能只留 800 token 给记忆做角色扮演时则可以放宽到 2000 token。3.3 记忆的合并、去重与过期管理记忆系统真正复杂的不是存进去而是维护它。跑了一周之后我最大的体会是如果没有合并与过期的机制记忆库迟早会变成一锅粥。合并的场景很典型用户第一次说我平时喜欢喝美式第二次说最近在戒咖啡改喝茶了。这两条preference内容相互矛盾如果同时注入模型会左右为难。claude-mem 的处理方式是在提取端做语义去重时如果发现同类型的新记忆和旧记忆内容冲突就把旧记忆标记为superseded新记忆取代它成为主记忆。标记历史保留一个月方便出问题时回溯。还有一个我一开始忽略、后来才补上的机制——记忆遗忘。不是所有记忆都值得永久保存任务状态类记忆在任务完成后就失去了价值低频命中的记忆会逐渐变成噪声。现在 claude-mem 里有一个定期清理任务超过 45 天没有被命中过的task类型记忆自动归档到archive表importance低于 2 且长期未命中的fact记忆也会被降权。这其实是在模拟人脑的遗忘曲线而效果很直接——注入到模型里的记忆平均相关度提升了因为噪声变少了。4. 实测数据记忆到底带来多少收益又付出多少成本4.1 上下文节省与回复准确率的对比任何工具不能光说设计好得有数据。我用一个模拟生活助理的场景做了两轮对比测试第一轮不使用 claude-mem每次请求把所有历史记录全量塞入第二轮使用 claude-mem只注入系统筛选后的记忆片段。测试条件是同一批对话数据同样的问题集总共跑了两百多轮。对比结果如下指标全量塞历史claude-mem 注入平均单次请求 token 数85002300上下文节省比例-约 73%个人信息回忆准确率94%96%回复中记错的次数5 次2 次平均首字返回延迟2.8s1.1stoken 节省是预料之中的因为不再重复发送整段历史。有意思的是准确率也提升了——全量塞历史时模型偶尔会从老旧的早期对话里摘取错误信息而 claude-mem 因为记忆在提取阶段已经做过结构化和去重注入的内容反而更干净。这说明少而精的记忆确实比多而全的历史更有效。4.2 端到端延迟多了一次检索但整体反而更快有人会问每次请求前做检索不也会增加延迟吗确实切词、数据库查询、排序这几步加起来大约需要几十毫秒在我本地的测试环境里大概是 40ms 到 80ms 不等。这部分开销对整体体验的影响非常小因为它是纯本地计算不涉及网络请求。更大的延迟收益来自 context 变短调用模型 API 时输入 token 从 8500 降到 2300prefill 时间明显缩短了。两种方案一对比端到端延迟反而从 2.8 秒降到了 1.1 秒。也就是说花几十毫秒做检索省下了接近两秒的模型处理时间这笔账怎么算都划算。不过我得提醒一句如果你是拿 claude-mem 去包一个本来上下文就不长的轻量对话比如单轮问答那收益就不明显了。它适合的是那些真正需要记得上次聊到哪的持续性场景比如个人助理、项目顾问、陪伴式学习辅导这类。4.3 一个完整的实测对话样例光看数字不够直观我放一段真实跑过的对话过程模拟数据。用户第一次聊天的部分内容用户以后叫我小李就行。我平时晚上九点以后才有空学习。 助手好的小李那我们把学习时间安排在晚上九点后进行……这次对话结束后claude-mem 提取到了两条记忆[ {content: 用户自称小李偏好被称呼为小李, memory_type: preference, importance: 5}, {content: 用户晚上九点后有空学习, memory_type: preference, importance: 4} ]隔了三天用户开启一个新会话只发了一句话今晚开始看深度学习那部分。claude-mem 在检索阶段通过晚上学习等标签命中了两条偏好记忆自动在 system prompt 里追加了这段内容[记忆上下文] - 用户自称小李偏好被称呼为小李。 - 用户晚上九点后才有空学习安排内容时请考虑这一点。模型回复时直接说好的小李晚上九点后我们开始看深度学习部分大概安排 40 分钟可以吗——既用了正确的称呼又避开了八点这种不合适的时段。这就是记忆的价值用户不需要重复自己说过的话AI 也不会像个初次见面的陌生人一样反问您平时什么时候有空。5. 踩过的坑与兜底方案记忆不是越多越好5.1 记忆污染的典型场景AI 被过时的信息带偏跑了一段时间后我遇到了一个典型问题用户换了手机号在最近一次对话里明确说以后用这个新号码但 claude-mem 里几个月前还存着一条旧号码的fact重要性为 4。检索时关键词手机号同时命中了新旧两条记忆模型犹豫之后采信了旧号码。这个 case 让我意识到单纯在提取端做冲突标记还不够注入端也得有对应的控制逻辑。现在的检索规则里加了一条硬性约束同一条fact下如果存在新旧版本即使新版本 importance 更低也优先注入新版本并且旧版本会被临时降权 30 天。与其让模型在做决断时纠结不如从源头上只给它看可信的那条。类似的问题还有记忆碎片化用户说我搬到成都了提取阶段只会存下这一句而历史记忆里可能还有我住在北京我在北京上班好几条相关记录。这时候如果不做版本合并模型依然会看到互相矛盾的地址信息。所以我后来在提取流程里增加了一步关联整合——新记忆入库时系统会根据标签找到同主题的旧记忆自动把旧的地址、职业等事实标记为 superseded并生成一条合并后的综合记录。5.2 隐私边界怎么划哪些话绝对不能进记忆库这个坑比较严重我必须单独拿出来说。有一次测试时用户聊到了自己的一些敏感信息claude-mem 原样把它提取进了记忆库。虽然数据库是本地文件但原样存敏感信息这件事本身就是个风险一旦数据库文件被同步到网盘或被日志系统收集后果不堪设想。最终我加了双层过滤。第一层在提取 prompt 里明确写了禁止提取任何与账号密码、身份证号、银行卡、医疗记录、情感隐私相关的内容属于安全底线第二层在存储前做一次本地规则校验用敏感词正则库和简单的实体识别比如识别 18 位数字连续串拦截明显不该入库的信息。凡是命中的内容一律在提取阶段直接丢弃连content都不落盘。这个兜底方案很重要但我在文档里反复强调过它只能降低风险不能消除风险。如果你要给别人部署 claude-mem一定要在配置里放开自定义过滤规则让使用者自己决定哪些领域可以被记忆。默认的过度保守可能丢记忆但过度激进可能惹出大麻烦两害相权我选择保守。5.3 程序崩溃与记忆损坏的恢复SQLite 单文件存储虽然方便但最怕的就是写入中途断电或者进程被杀导致数据库文件损坏。我第一次遇到这个问题时整个记忆库打不开之前的测试记录全部丢失只能从备份恢复。现在 claude-mem 做了三件事来降低这类风险。第一所有写操作都走事务避免半截写入第二每次会话结束并成功完成提取之后自动把数据库文件复制一份到同级目录的backups/文件夹文件名带时间戳只保留最近 7 份第三启动时做一次PRAGMA quick_check检查发现表结构损坏就自动回退到最近的备份文件。说实话这种兜底机制平时看起来多此一举但一旦遇到意外就知道多重要了。记忆这个东西是长期积累的资产丢一次就是几个月的心血成本非常高。任何做记忆系统的工具都应该把备份和恢复作为一等需求来对待而不是事后补救。6. 后续可以怎么扩展从单用户记忆到团队记忆6.1 用户隔离与会话路由claude-mem 当前默认是单用户工具也就是所有记忆都挂在同一个隐式的 user_id 下。但这套设计天然可以扩展成多用户版本只要把memories表的user_id字段当作第一维隔离键在检索阶段强制按user_id过滤就能做到用户 A 的记忆永远不可能注入到用户 B 的对话里。实际部署时需要注意的一点是user_id 如果靠前端传入很容易被伪造导致跨用户读取记忆。建议在网关层用签名或会话凭证来绑定 user_id而不是让客户端任意填写。会话路由则是配合用户隔离做的一个新会话到达时先根据用户身份确定读取哪一批记忆快照再走正常的检索流程。这个改造我已经在代码里预留了接口目前在小规模测试中运行稳定。6.2 记忆可视化编辑工具命令行工具什么都好就是看记忆这件事不够直观。我最近在补一个简单的 Web 界面用来列出所有记忆、按类型筛选、手动删除某条错误记忆或者把一条低重要度的记忆调高。这个界面对我这种天天调试的人特别实用因为在终端里看 JSON 总有一天看腻而且出问题时最快的定位方式就是直接翻记忆库。可视化界面还有一个比较实际的用法——手动给记忆写评注。比如某条记忆是用户喜欢喝美式但实际上是老黄历了你可以直接在界面上把它的状态改成已过期而不是等系统自己发现。这个功能本质上是在给记忆系统开一个人工监督的入口AI 的经验再丰富也需要人来做最终裁判。6.3 给记忆打分让 AI 学会主动遗忘最后一个扩展方向是我自己最感兴趣的让记忆系统学会给每条记忆打分来自动决定什么都别留还是必须留。目前的机制是 importance 由提取阶段的模型决定之后只能靠时间衰减来慢慢降权。更理想的做法是结合后续对话的实际表现反向修正评分——如果某条记忆注入之后模型回答获得了用户的正面反馈比如用户说对就是这样这条记忆的分数就该上调如果引发了用户纠正比如不是我说的是另一个分数就要下调。这个反馈闭环做起来不难难的是怎么自动判断正面和负面。我现在用的是一个简化方案在后续对话里监测修正性表达比如不对不是错了你记错了这些出现时回溯最近注入过的记忆并降权。虽然粗糙但已经能在不少案例里自动纠偏。真正完善的版本可能需要引入独立的反馈标注模型这是我下一步打算折腾的方向。回过头来看claude-mem 这个项目最核心的收获不是代码本身而是一个判断对 AI 对话程序来说记忆不是存储历史而是提炼知识。把每次对话当成一次信息萃取的过程只留下高价值、结构化、可更新的记忆片段才能在无限对话和有限上下文之间找到平衡。如果你也在做类似的项目我的建议是从小规模开始先用最简单的 SQLite 把完整的提取-存储-检索链路跑通再谈向量化和复杂排序——记忆系统的复杂度应该跟着数据量一起增长而不是一开始就把自己淹没在架构里。