1. 为什么Claude无状态这件事逼着我想自己写个记忆层先说我碰到的真实场景。接手一个基于Claude API的问答机器人之后前期一切都顺风顺水——单轮问答、文档摘要、代码生成效果都挺惊艳。可是只要涉及多轮对话或者让模型记住用户上次提到过的偏好、项目背景、某个技术选型的原因问题就来了上一轮聊得好好的下一轮它全忘了跟失忆了一样得把所有背景重新铺一遍。这不是模型能力的问题而是Claude API本身的设计就是无状态的。每次调API模型都不会记得你之前跟它说过什么服务端没有帮你存任何会话历史。你要想让它有记忆只能自己做把历史对话、关键上下文、长期偏好之类的信息在下一次请求的时候重新塞给它。这个塞的过程就是claude-mem这类记忆层工具存在的意义。我最初是手动拼prompt把聊天记录全部塞进system prompt里。一开始还能凑合可对话一长就露馅了token暴涨每次请求都越来越贵、越来越慢而且历史多了之后真正重要的信息反而被大量无关闲聊稀释了。查了几次资料、试了几种方案之后我开始意识到光有把历史存下来这一步是远远不够的关键是得有一个独立的记忆管理模块它得能筛选、提炼、压缩、按需检索才能既省token又让模型像真的记住了东西一样。于是就有了自己动手写一个类似claude-mem项目的念头。当然那时还不知道Claude官方后来出了memory tool和上下文管理能力但那时候自己造轮子其实是很多开发者的常态。我更想分享的不是再做一个工具而是把一个记忆层拆开之后里面到底有哪些必须想清楚的设计问题。这篇就按我实际摸索的路径来讲。2. 记忆层这个思路到底要解决哪几件具体的事很多人在给Claude加记忆的时候第一反应都是存对话记录就完事了。但真正跑起来之后会发现记忆系统要干的事比想象中多得多。我自己在动手之前把需求拆成了四块所有后面写的代码都在围绕这四块打转。第一件事把对话里值得记的东西捞出来。原始对话记录是又臭又长的里面充斥着嗯好的哈哈这类无效内容。如果原样全存过不了多久记忆库就变成垃圾场。所以需要一个提取环节最好能让模型自己判断这段对话里哪些信息值得放进长期记忆比如用户的职业、项目约束、明确的偏好、已经敲定的决定这些就该记随口寒暄、临时提问细节就没必要记。第二件事把记忆按照主题整理好而不是按时间堆叠。这是很容易忽略的一点。如果我存了五十条用户说喜欢简洁风格下一次检索返回五十条重复的废话那就是灾难。正确做法是把记忆做合并、去重比如用户偏好喜欢简洁风格作为一条记录出现新的相关细节时再往里补充而不是新开一条。第三件事在下一次请求时把相关的记忆找出来并拼进上下文。这一步看着简单实际上最考验设计。因为记忆库是不断变大的你不能把所有记忆都塞进去必须有一个检索机制只把和当前对话最相关的记忆挑出来。我试过用关键词匹配、用向量相似度检索效果差异很大后面细说。第四件事控制记忆内容的数量和质量。就算检索机制再准也得有个上限。比如一次最多注入多少条记忆、记忆多久之后没有被引用就衰减或归档、用户主动要求遗忘怎么处理。这些问题不做约束跑上一个月就会出现prompt越拼越长上下文快被记忆占满的尴尬局面。所以你看看似一个给Claude加记忆的小工具拆开之后其实是四个子系统的组合提取、存储、检索、注入。说它是提示词工程进阶版也好说它是轻量级RAG也好本质都一样——给无状态模型配一个外挂的记忆中枢。3. 存储层设计我为什么选了JSON文件起步后来又换成了SQLite存储是记忆层的地基但也是最容易被人一开始就搞复杂的地方。我第一版直接用JSON文件理由很简单快零依赖debug的时候打开文件就能看到全部记忆内容。每条记忆的设计也简单大概长这样{ id: mem_001, content: 用户偏好简洁风格代码注释使用中文, topic: user_preference, importance: 0.8, access_count: 5, created_at: 2025-01-12T10:30:00Z, last_access_at: 2025-01-13T09:00:00Z }字段看着不多但每个都有讲究。content是经过提取和归纳之后的记忆文本不是原始对话摘录topic用于分组和检索过滤importance是重要度打分access_count和last_access_at是为了做使用频率相关的排序和衰减策略记过多久没用就降权甚至淘汰。后来项目聊得多、记忆条数超过几百条之后JSON方案的缺点就暴露了每次全量读取、全量写回性能越来越差而且并发问题没法解决两个请求同时写文件会直接把内容覆盖掉。于是我把存储换成了SQLite。选SQLite而不是上MySQL或者Redis主要是考虑到这个工具本质上是单机使用的SQLite只需要一个文件不需要起服务加上Python标准库自带sqlite3迁移成本极低。SQLite表结构也很直接CREATE TABLE memories ( id TEXT PRIMARY KEY, content TEXT NOT NULL, topic TEXT DEFAULT general, importance REAL DEFAULT 0.5, access_count INTEGER DEFAULT 0, created_at TEXT, last_access_at TEXT ); CREATE INDEX idx_memories_topic ON memories(topic);从JSON迁到SQLite之后查询用的SQL也开始变得顺手比如按重要度取前N条按最近访问时间排序过滤某个主题都是几行SQL搞定。这件事给我的教训是记忆工具的存储层起步能简单就简单但一定要在一开始就预留好数据模型的扩展方向。我第一版JSON里的字段后来几乎原封不动搬进了SQLite这就是数据模型设计没返工的红利。4. 核心流程实现提取、写入、检索、注入是怎么串起来的现在讲整个记忆管理流程的实际代码骨架。为了避免讲成空泛的概念我直接把关键函数列出来都能跑通。4.1 用模型做记忆提取调用Claude做的第一件事是提取。我定义了一个简单的系统提示词要求模型从对话中抽出值得长期记住的信息并返回结构化的JSON。一开始我用的是普通文本让模型自由发挥后来发现结构化输出对后续存储帮助很大就用JSON格式约束它。import json import anthropic client anthropic.Anthropic(api_keyyour_api_key) def extract_memories(conversation: list[dict]) - list[dict]: sys_prompt 你是一个记忆提取器。阅读以下对话抽取其中值得长期记忆的信息。 规则 1. 只提取稳定的、可能影响未来对话的信息偏好、约束、已决定的方案、身份背景等。 2. 忽略寒暄、临时性细节、与任务无关的内容。 3. 每条记忆控制在30字以内使用第三人称陈述。 4. 输出JSON数组格式[{content: ..., topic: user_preference|project_info|decision|other}] resp client.messages.create( modelclaude-sonnet-4-20250514, max_tokens800, systemsys_prompt, messagesconversation[-6:], # 只取最近几轮避免多余消耗 temperature0, ) text resp.content[0].text # 需要做JSON解析容错模型偶尔会输出多余文字 try: start text.find([) end text.rfind(]) 1 return json.loads(text[start:end]) except json.JSONDecodeError: return []这里有一个非常容易踩的点模型对max_tokens消耗比想象中大。你以为只是提取几段对话但每次都调一次模型成本是叠加的。我实际跑了一个月之后统计发现记忆提取调用大概占了总API费用的15%。为了控制成本我把提取频率从每轮对话都提取改成了每隔N轮批量提取一次效果几乎没变费用降了不少。4.2 写入和合并去重提取出记忆之后写入前先做一步合并检查。比如用户上一次说我偏好Python这一次又说了我在用FastAPI写后端如果直接当成两条独立记忆存将来检索的时候会同时返回两句话既占token又显得啰嗦。更聪明的做法是同一个topic下检查现有记忆里有没有语义相近的如果有就追加更新而不是新增。合并这块我不推荐上来就搞太复杂的算法。初期用主题相同文本相似度超过阈值就合并的简单策略就够用了。文本相似度里用简单的字面重合率或者用difflib都可以不必一上来就上向量模型。等记忆量大了再换embedding方案也不迟。import sqlite3 from difflib import SequenceMatcher def merge_and_insert(cursor: sqlite3.Cursor, memory: dict): cursor.execute( SELECT id, content FROM memories WHERE topic ? ORDER BY last_access_at DESC LIMIT 5, (memory[topic],) ) rows cursor.fetchall() for mem_id, existing in rows: similarity SequenceMatcher(None, existing, memory[content]).ratio() if similarity 0.55: # 追加更新原记忆 cursor.execute( UPDATE memories SET content ?, access_count access_count 1, last_access_at ? WHERE id ?, (existing memory[content], time_now(), mem_id) ) return # 无相似记忆插入新记录 cursor.execute( INSERT INTO memories (id, content, topic, importance) VALUES (?, ?, ?, ?), (new_id(), memory[content], memory[topic], memory.get(importance, 0.5)) )注意一个细节追加更新原记忆时记忆文本会越来越长。所以每次更新都要对长度做检查超过字数上限比如200字就丢弃最旧的部分保证单条记忆的纯度。4.3 检索先用BM25/关键词再考虑向量检索这块我一开始想得很理想直接上向量数据库每个记忆都embedding然后余弦相似度检索。实验下来发现问题记忆条目本身是短文本往往只有一句话短文本的embedding相似度区分度不高检索结果经常不准。后来我改成了粗筛细排的两阶段方案。粗筛阶段用关键词匹配或者sqlite的FTS5全文检索把候选记忆降到二十条以内细排阶段再按当前对话的关键词重叠度记忆重要度最近访问时间加权排序。这个方案实现成本低实测效果反而比纯向量检索稳定得多。def retrieve_memories(query: str, limit: int 5) - list[dict]: # 粗筛从库里按关键词相关度捞一批候选 cursor.execute( SELECT id, content, topic, importance, access_count, last_access_at FROM memories WHERE content LIKE ? OR topic LIKE ? ORDER BY importance DESC, access_count DESC, last_access_at DESC LIMIT 20 , (f%{query}%, f%{query}%) ) candidates cursor.fetchall() # 细排计算与当前提问的关键词重合度 query_tokens set(query.lower().replace(, ).split()) def relevance_score(item): content_tokens set(item[content].lower().replace(, ).split()) overlap len(query_tokens content_tokens) return overlap * 0.6 item[importance] * 0.4 candidates.sort(keyrelevance_score, reverseTrue) return candidates[:limit]老实讲这个检索代码很朴素但在我实际项目里跑得很好。原因也好理解记忆检索不是搜索引擎用户问的往往就是几个固定主题下的内容关键词重合已经能覆盖大部分场景。向量检索适合语义差异大、关键词匹配不上的情况等以后记忆库上千条再说。4.4 注入拼进system prompt的正确姿势拿到检索结果之后怎么注入也有讲究。我踩过最直接的坑是把检索到的记忆直接拼在system prompt最前面结果模型把记忆片段当成了新的指令来执行做出莫名其妙的事。正确做法是明确告诉你模型以下是关于用户的长期记忆只作为背景参考不要向用户汇报你正在使用这些记忆。我用的是这个模板def build_system_prompt(memories: list[dict]) - str: if not memories: return SYSTEM_PROMPT_BASE memory_text \n.join([f- {m[content]} for m in memories]) return f{SYSTEM_PROMPT_BASE} 你能够内隐且自然地利用以下记忆内容让回答更贴合用户长期偏好。 如果记忆与当前问题无关则忽略它们。 【长期记忆】 {memory_text} 除了注入内容注入条数也要严格控制。我的经验值是一次最多5条超过5条之后模型不但记不住反而会因为记忆和当前问题混杂在一起而产生干扰。宁可少给也要给得准。5. 实测过程中踩过的四个真坑以及排查链路如果只讲设计思路这篇就变成了理论文。真正动手之后我先后遇到过四个让人抓狂的问题每一个都花了不少时间排查写出来做个记录。坑一模型输出非预期JSON导致提取静默失败。表现是记忆库里长期没有新内容。排查链路是这样的先查API日志发现调用都成功了再打印提取函数的返回值发现模型有时候会在JSON数组外面包一层描述性文字导致json.loads直接抛异常然后被我的except吞掉返回空列表。所以静默失败就是语法解析失败之后被兜底逻辑掩盖了。修复方案就是之前代码里写的find([)和rfind(])先截取JSON片段再解析。这个坑的教训是任何依赖模型结构化输出的环节一定要在解析层做容错并且最好把解析失败的样本留日志否则排查起来像大海捞针。坑二检索命中率低得吓人。某次上线后观察了一周发现很多对话明明之前聊过检索却返回不了相关记忆。排查发现是关键词匹配太僵硬用户当时说的是后端现在问的是服务端字面上完全不交集自然匹配不上。后来我在提取记忆时额外生成一组同义词标签字段比如tags: [后端, 服务端, API]检索时对tags也做匹配命中率立刻上来了。坑三Prompt膨胀一场对话后半段token暴涨。这是因为每轮对话后都把全部检索结果注入到system prompt而system prompt是全程生效的越到后面累积越多。排查之后做了两个改动一是system prompt只注入前几轮检索到的稳定记忆二是把记忆按照会话级和长期级分层会话级记忆只在有限轮数内注入长期级记忆才常驻。改完之后token消耗直接降了约三成。坑四SQLite并发写入互相锁死。我的服务用了多线程处理请求多个线程同时往SQLite写记忆时偶尔报database is locked。排查到原因SQLite的写锁粒度很粗并发写容易撞车。解决办法很简单——用一个全局锁包住写操作或者把写入操作统一丢进单线程队列。数据量没那么大的场景全局锁是成本最低的解法不用为了这个去换PostgreSQL。这四坑有个共同特征都是第一版能跑跑几天才暴露的潜伏问题。所以现在我把日志看得比功能重记忆工具的每一次增删改查我都会记录操作来源和耗时排查问题的时候能少走很多弯路。6. 从可用到好用重要度加权、遗忘机制和记忆可视化基础链路跑通之后我花了差不多两周时间打磨体验这里分享几个真正提升效果的设计。重要度加权是让检索质量发生质变的一步。单纯按关键词命中去检索会出现关键词碰上了但信息不重要的情况。我最后采用的权重公式是score 关键词重合 * 0.5 重要度 * 0.3 最近访问时间衰减 * 0.2。重要度在写入时由模型打分设定在0到1之间用户说记住我最讨厌红色这类明确偏好时重要度给到0.9随口提到的背景信息给0.4。实际跑下来排序结果比以前准了一截。遗忘机制也别忽略。记忆库是越攒越多的如果从不清理总有一天会把上下文挤爆。我设计的策略是每条记忆超过30天没有访问且重要度低于0.6就自动归档到archived状态被用户明确说忘掉的记忆直接物理删除。实现不难就是一个定时任务加两个SQL语句但它保证了记忆库的长期健康。记忆可视化是给调用者也就是我自己看的一块面板。我用Flask写了个简单的本地网页把SQLite里的记忆拉出来按主题和重要度排列可以手工编辑、删除、置顶。为什么要做可视化因为自动提取再怎么准也会有误提取。看过几次模型把玩笑话当成用户偏好的情况之后我就决定把最终把关的权力留给人工。这个面板不需要多好看能看、能改、能删就够了。这些优化做完后整体效果已经明显好于第一版用户连续对话三十多轮模型依然记得第2轮提到的需求细节API token成本比全量塞历史记录的方案省了将近一半偶尔抽风的情况也在可控范围——因为记忆库内容都是可见、可修正的。7. 如果现在重新做一遍我会在哪些地方直接换方案写完这套东西我有几个比较深的体会也整理一下给后来者做参考。能不用自研就别自研。如果只是想给Claude加记忆现在可用的方案已经不少很多框架都内置了上下文管理能力。自研记忆层的真正价值不在于实现了它而在于过程中搞懂了记忆系统的设计权衡。如果目标是解决业务问题优先选现成方案。存储别用纯JSON撑太久。我第一版JSON跑了不到两周就换SQLite了其实一开始就该用SQLite成本真的不高。数据量的分水岭大概在五百条记忆左右超过之后JSON的IO开销就不划算了。提取环节用大模型是值得的。一开始我想过自己写规则抽取记忆但真实对话的复杂性直接让规则方案破产。用模型抽取的成本确实存在但它是整个记忆层里不可替代的一环。降低成本的正确方式不是换规则而是调低频批量提取。记得留一条人工兜底的路。不管算法做得多聪明记忆这种和用户强相关的数据都应该允许人工查看和修正。我那个本地可视化面板看起来土实际上是整个系统里最让我安心的东西。最后再补充一个实用小技巧如果要测试自己的记忆层是否真的有效不要光靠主观对话感受要写一个批处理验证脚本——准备十组先提出某项偏好、隔若干轮之后再引用该偏好的测试用例跑一遍统计引用正确率。这个数字比任何体感都可靠也方便将来改版时做前后对比。我实测首版正确率只有六成左右加了同义词标签和记忆合并之后升到了八成以上。数据不会骗人建议你也用同样的方式测。