ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

给Claude装上长期记忆:无状态API下的记忆提取与检索方案

给Claude装上长期记忆:无状态API下的记忆提取与检索方案 1. 为什么Claude用起来总像“失忆”先搞清楚问题本质经常用Claude的人八成都有过这种体验同一个项目上次聊完还约好了“下次继续优化”今天打开新会话它连你叫什么、项目用到哪个框架、当初为什么选Postgres而不是MySQL统统忘干净了。这不是Claude变笨了而是它的工作方式天生如此。Claude的API是无状态的。每一次调用都是独立的请求服务端不会保存你的对话上下文所有“记忆”都靠你在调用时手工把历史消息塞回去。官方有prompt caching缓解重复计费但本质还是“你给多少它看多少”。于是很长一段时间里我的做法是维护一个全局messages数组每轮对话全部拼进去token越来越长账单越来越贵最后模型开始“注意不到”早期信息回答质量反而下降。后来我看到了一个叫claude-mem的实践思路。它跟我的粗暴做法完全不同不是把全部历史都塞进上下文而是把对话内容变成可检索的记忆条目下次对话时只挑出相关的、最近的事注入进去。这名字其实就是claude memory的缩写目标就一个给Claude装上一套能跨会话的长期记忆。这篇文章就把我实践这套思路的过程、代码、踩过的坑完整写下来。适合三类人看一是深度使用Claude API做应用开发的二是自己在本地跑Claude相关的自动化脚本、想让它记住项目背景的三是对LLM应用里的记忆机制感兴趣、想了解“记忆到底怎么设计才靠谱”的。我不讲大而全的理论只讲能落地的方案。1.1 无状态API是记忆问题的根子先解释一下“无状态”这件事到底意味着什么。当你给Claude发送请求时其实是发了一个JSON数组里面按顺序排列着system、user、assistant消息。Claude不知道世界上还有“上一次对话”这回事它只能看到你这次发给它的内容。这就带来一个尴尬的三角矛盾想要它记住的东西多就得把所有历史都发过去token爆炸。想要控制token成本就得舍掉大量历史记忆随之丢失。想要精准记忆就得知道哪些历史重要但判断“重要”本身需要理解语义。claude-mem这类工具解决的就是第三个问题。它的核心不是“存得多”而是“取得好”。先通过离线或异步的方式把过往对话分析一遍提炼成短小精悍的记忆片段存进本地数据库等新对话开始时根据用户当前的问题检索出最相关的几条记忆注入到新对话的上下文中。整套过程有点像人脑经历过的事情并不会每件都刻进脑子里但关键信息会被海马体整理过一遍日后遇到相似场景时会主动调出来。1.2 “记忆”不是缓存要先拆成三层我刚动手做的时候就踩了一个概念坑以为记忆就是把messages数组落盘。实际上“记忆”至少能拆成三层每一层的存储策略和注入方式完全不一样。第一层是瞬时上下文也就是当前会话里刚聊过的几轮。这层靠常规的messages数组就够不需要持久化。第二层是项目背景和长期事实比如“用户是做跨境电商的主营美国市场”“项目后端用FastAPI数据库是PostgreSQL”“上次决定放弃Redis因为访问量还没到那个量级”。这类信息变化频率极低是记忆里最有价值的部分适合用结构化的键值或JSON存储每次对话都注入。第三层才是语义记忆就是某次对话里聊过的大段内容中有价值的点。比如上周你在对话里详细讨论过一个定时任务的调度方案本周再聊时它不一定需要完全记住调度逻辑但需要记得“讨论过这个方案倾向于用APScheduler”。这层信息量大、相关性随时间变化适合做向量化存储加相似度检索。claude-mem的聪明之处在于它把三层融合进了一套Pipeline对话日志先做实体抽取和摘要提炼形成结构化的记忆条目再对每条记忆做向量化查询时结合关键词和向量相似度双路召回最后统一注入。如果你自己动手写最简单的方式就是照着这个方向拆模块。2. claude-mem的核心设计提取、存储、检索一条龙这一节是重点也是claude-mem真正的精髓所在。我拆开讲因为它不是单一技术点而是一套完整流程。看懂这套流程你甚至不依赖任何现成工具自己用Python加SQLite就能搞定一个够用的版本。先给整体流程画个坐标记忆从哪来记成什么样存在哪怎么取出来怎么送回去。五个环节缺一不可每个环节都有取舍。2.1 记忆提取抓什么、丢什么、怎么过滤提取是整个链路里最容易被低估的一环。很多人以为把Claude的对话日志存下来就行了但那是“存档”不是“记忆”。两者区别在于存档是流水账记忆是结构化后的信息。我的做法是两步走。第一步给对话日志设置黑白名单。白名单里明确要提取的信息类型用户偏好、项目决策、技术选型、待办事项、重要结论、用户明确表达的诉求。黑名单里要过滤的信息包括日常寒暄、重复性确认、错误尝试过程中的无用信息、以及那种“我试试看”式的随口一说。第二步不能纯靠规则硬筛一定要让Claude自己做一次提取。怎么提取我用过一个很顺的模式——离线批量跑一个提取Prompt把一段对话丢给它让它输出JSON结构{ summary: 一句话概括这段对话主题, facts: [ 项目使用FastAPI作为后端框架, 用户计划下个月迁移到K8s ], preferences: [ 用户偏好异步任务用ARQ而非Celery ], decisions: [ {time: 2025-11-02, decision: 放弃Redis当前量级Postgres足够} ] }这就是把大语言模型当成信息压缩器来用。它读完整段对话吐出一个几百字的结构化摘要你只需要把它落库就行。注意别让提取过程阻塞主流程最好是异步任务——用户聊天完毕后几秒钟记忆后台悄悄更新完全无感知。过滤还有一个隐藏价值省存储、降噪声。把同样的对话日志直接丢进向量库你检索时可能会召回一堆“哈哈哈确实”这种废话模型注入后也会被带偏。提取后再存噪声天然减少一个量级。2.2 存储结构向量索引配metadata缺一不可存储层很多人一上来就选ChromaDB、FAISS之类的向量数据库。我理解这种冲动但实际运行下来发现如果只是给Claude做本地记忆没必要上重型武器。我现在用的是SQLite加sqlite-vec扩展。为什么第一单机场景零运维不需要启服务第二记忆条目的增删改查基本都是低频操作SQLite的写入吞吐完全够第三我可以把向量索引和普通的关系数据放同一份库文件里备份、迁移都非常简单。表结构大致长这样CREATE TABLE memory_items ( id INTEGER PRIMARY KEY AUTOINCREMENT, content TEXT NOT NULL, -- 记忆内容文本 memory_type TEXT NOT NULL, -- fact / preference / decision / summary session_id TEXT, -- 来源会话ID created_at TEXT NOT NULL, -- 提取时间 updated_at TEXT NOT NULL, -- 更新时间 access_count INTEGER DEFAULT 0, -- 被检索命中次数 embedding BLOB -- 向量sqlite-vec的存储格式 );注意最后两个字段access_count和updated_at。它们不是摆设是后面做记忆衰减和优先级排序的关键数据。一个记忆如果长时间没被检索命中说明它可能已经过时权重应该降低如果反复被命中说明这个记忆对当前用户很重要应该排到更前面。向量维度不用选太大。我用的是bge-m3产出的向量也可以用OpenAI的text-embedding-3-small或者干脆本地跑一个all-MiniLM-L6-v2维度384已经够用了。记忆条目本身都是短文本不需要上万维的超大模型。2.3 检索与注入别把所有历史都塞进上下文检索策略决定了记忆系统的最终体验。我见过的失败案例几乎都踩同一个坑检索太粗把大量低相关度内容塞进prompt导致模型焦点被稀释。Claude的上下文窗口确实在变大但注意力资源依然有限——无关信息越多它对关键信息的关注越少。我的检索策略是混合式召回分三步走用BM25或SQLite的FTS5做关键词查询找到跟当前问题字面上相关的记忆。好处是精准、速度快不会出现向量召回那种“语义相关但字面无关”的怪结果。用向量检索找语义相似的记忆覆盖那些换个说法其实在讲同一件事的情况。把两条结果合流做去重和重排重排公式是score 0.4 * bm25_score 0.4 * vector_similarity 0.2 * recency_scorerecency_score根据记忆的created_at计算越近的得分越高。还可以额外加一个bonus如果某个记忆条目的access_count很高给它加0.05的权重。这样相关度、时效性、活跃度三者兼顾不会出现几条半年前的旧记忆挤掉了最近重要决策的情况。注入方式我踩过几次坑最后稳定下来是这么做的把检索出的记忆块作为一小段附加上下文的文本放进user消息的开头或者放system消息里都行。关键是格式要固定让Claude一眼看出“这段是记忆不是用户当前说的话”。我的常用模板是[Memory Start] - Fact: 项目后台使用FastAPI - Decision (2025-11-02): 放弃Redis当前量级Postgres足够 - Preference: 用户偏好异步任务用ARQ [Memory End] 请基于以上记忆信息回答用户当前问题。这套模板我用下来比直接拼接历史消息效果稳得多。原因是它给模型了一种“检索结果”的心理预期模型会把这部分当成参考资料而不是对话主线。你要是直接拼消息模型经常混淆“你之前说过”和“你从未说过”的信息边界。3. 手把手实现一个迷你claude-mem附完整代码前面聊了原理但原理不落地都是纸上谈兵。我直接放一套我自己在用的实现思路代码量不大跑通之后你就知道整个记忆系统是怎么回事了。这节的核心是一个能在本地直接跑起来的Python脚本。它的能力边界很清晰接收一条用户消息从记忆库里检索相关内容拼好System Prompt和Messages数组交给Claude API等回复后再把这段对话异步写入记忆提取队列。简单说就是一个带记忆的Claude API调用封装。3.1 环境准备与技术选型对比先交代我跑这套代码的环境Python 3.11安装anthropic、sqlite-vec、sqlalchemy这几个关键依赖。向量模型用的本地嵌入服务不需要外部API。只要你的电脑能跑Python这套东西就能跑。做选型对比时我横向比过几类方案直接放结论组件可选方案我最终选的原因向量存储ChromaDB / FAISS / sqlite-vec / LanceDBsqlite-vec单机零运维SQLite易备份数据量在十万级以下完全够用嵌入模型text-embedding-3-small / bge-m3 / all-MiniLM-L6-v2bge-m3或all-MiniLM-L6-v2本地运行、免API费用短文本语义检索够用关键词检索SQLite FTS5 / ElasticsearchSQLite FTS5轻量跟主存储同一数据库无数据同步问题记忆提取正则规则 / LLM抽取 / 混合LLM抽取为主、规则兜底LLM语义理解强规则覆盖特殊格式这里的核心决策是“别一上来就上重型组件”。ChromaDB不是不好但它对“单机、单进程、几千条记忆”的场景来说太重了。而且多一个服务就多一个故障点能塞进SQLite的功能绝不用外部服务。3.2 记忆层的写入与检索实现先看写入的核心代码。记忆提取通常走Landmark模式——每轮对话结束后异步调用Claude提取然后入库。这里我给出一个简化但可运行的版本import json import sqlite3 import numpy as np import anthropic client anthropic.Anthropic() DB_PATH memory.db embedding_dim 384 def init_db(): conn sqlite3.connect(DB_PATH, check_same_threadFalse) conn.execute(CREATE VIRTUAL TABLE IF NOT EXISTS memory_items USING vec0(content TEXT, embedding FLOAT[384])) conn.execute( CREATE TABLE IF NOT EXISTS memory_meta ( id INTEGER PRIMARY KEY AUTOINCREMENT, content TEXT, memory_type TEXT, created_at TEXT, updated_at TEXT, access_count INTEGER DEFAULT 0 ) ) conn.commit() return conn我额外建了一张普通表存元数据FTS虚拟表只存向量和content。分开的原因在于向量表不支持复杂的条件查询按类型过滤、按时间排序元数据表和它通过content字段做关联。写入逻辑附带提取的调用本质就是“先抽取再存储”def extract_memories_from_dialogue(dialogue: str): prompt f 这是用户与AI的一段对话 {dialogue} 请提取需要长期记住的信息。只输出JSON数组不要额外内容。 每个元素包含 type 字段fact/preference/decision/summary和 content 字段。 过滤寒暄、临时性内容、重复确认。 resp client.messages.create( modelclaude-sonnet-4-5, max_tokens1024, messages[{role: user, content: prompt}] ) text resp.content[0].text.strip() # 去掉可能的markdown代码块 if text.startswith(json): text text.split(, 2)[1].strip() return json.loads(text) def store_memory(conn, item: dict): content item[content] emb embed(content) # 返回长度为384的list conn.execute( INSERT INTO memory_items (content, embedding) VALUES (?, ?), (content, np.array(emb, dtypenp.float32).tobytes()) ) conn.execute( INSERT INTO memory_meta (content, memory_type, created_at, updated_at) VALUES (?, ?, ?, ?), (content, item.get(type, fact), datetime.now().isoformat(), datetime.now().isoformat()) ) conn.commit()检索我需要强调一个点混合召回必须写。纯向量检索在“同义词表达”上确实强但当用户问的问题里包含明确关键词时FTS的精确匹配往往更准。两路结果合并后再按前面说的公式重排。def retrieve_memories(query: str, top_k5): conn get_db() fts_results [] # FTS5关键词搜索 try: fts_results conn.execute( SELECT content, bm25(memory_items) as score FROM memory_items WHERE memory_items MATCH ? ORDER BY score LIMIT 10, (query,) ).fetchall() except sqlite3.OperationalError: pass # 向量检索 q_emb embed(query) vec_results conn.execute( SELECT content, distance FROM memory_items WHERE embedding MATCH ? AND k 10 ORDER BY distance , (np.array(q_emb, dtypenp.float32).tobytes(),) ).fetchall() # 合并重排这里省略具体打分函数的实现 return rerank(fts_results, vec_results, top_k)这里用到的embed函数可以是本地模型也可以是对外API。本地跑的话用sentence-transformers加载模型一行代码就行from sentence_transformers import SentenceTransformer model SentenceTransformer(all-MiniLM-L6-v2) def embed(text): return model.encode(text).astype(np.float32).tolist()3.3 接入Claude API完成闭环跑通完整会话记忆检索出来了最后一步就是组装完整的API调用。我封装成一个函数外部只传用户消息内部自动完成记忆检索、记忆注入、历史拼装和模型调用def ask_with_memory(user_message: str): mems retrieve_memories(user_message, top_k5) memory_block \n.join([f- {m} for m in mems]) system_prompt ( 你是一个拥有持续记忆的AI助手。\n 以下是关于用户和项目的记忆信息如有相关请主动利用\n f[Memory Start]\n{memory_block}\n[Memory End]\n ) messages [ {role: user, content: user_message} ] resp client.messages.create( modelclaude-sonnet-4-5, max_tokens2048, systemsystem_prompt, messagesmessages ) # 异步把当前这轮对话放进提取队列 submit_extraction_task(user_message, resp.content[0].text) return resp.content[0].text看到没有真正的改动只有system_prompt。你不需要改变应用的其他任何逻辑只要在SysPrompt里注入记忆块Claude就能“凭空”拥有对项目和用户的长期理解。跑完这条链路之后我实际体验到的变化是新会话里不用再自我介绍一遍项目背景也不用每次重复命令偏好。它连我“写SQL时偏好用CTE而非子查询”这种细节都能记住第二次问SQL问题时就直接用CTE风格回答了。这就是记忆的价值。4. 跑了一段时间后我踩过的坑和调优经验工具能用和好用之间隔着无数个细节。我把自己跑claude-mem这套思路过程中遇到的几个典型问题整理成速查表每个问题都附带排查思路和最终解法。这是整篇文章里我觉得最值钱的部分。4.1 上下文被记忆塞爆了怎么办答案是用预算控制一开始我犯过特别蠢的错误检索top_k设成20想着“多给点信息总没错”。结果发现Claude回复质量反而下降而且token用量暴涨。后来我悟了记忆检索跟人脑一样能想起多少信息是次要的能“形成连贯判断”才行。上下文里放20条互相不相关的记忆模型根本不知道该信哪条。我的解法是设置“记忆预算”用token作为度量单位。每次注入的记忆块总token数控制在600到800之间。具体做法就是先把检索结果按相关度排好序然后逐条累加token数超过预算就不再注入。这样既保证信息量又不会污染主对话。预算数值不是拍脑袋定的它取决于你的任务复杂度和上下文窗口。经验法则是记忆块token数不要超过系统提示词总长度的八成。我试过把预算放到2000回复就明显开始“记忆泛化”——模型会把不同时间的记忆混合在一起说甚至编造出从未讨论过的结论。4.2 旧记忆污染新会话Time-Aware加权怎么设计刚开始跑的时候系统会把很久之前的一条Decision和最新的Decision同时注入。比如两个月前决定“用Celery做异步任务”上周改成“用ARQ替代Celery”两条记忆都留在库里Claude看到后就会困惑时而说用Celery时而说用ARQ。排查过程很有意思。我一开始以为是提取过滤不彻底后来发现是检索排序不够及时。解决办法分两层第一层在存储端加updated_at字段同主题记忆如果被新内容覆盖旧的那条要标记为“过时”或直接软删除。实现上我会额外跑一个“记忆合并”的定期任务它检测到两条记忆向量相似度极高时保留新的一条把旧的那条降权。第二层在检索端让时间衰减直接参与排序。具体实现上recency_score不按线性比例而是按幂函数衰减recency_score (1 days_since_created) ** -0.15这个衰减系数是我试了几组数据后挑出来的。太强的话三个月前的关键决策会完全被忽略太弱的话时间排序等于没有。0.15是一个折中值你可以根据自己的使用习惯去调整。4.3 向量召回不准的排查思路看这五个地方向量召回结果是“玄学”这件事相信大家都体会过。我调优时总结了一套固定排查顺序命中率比瞎调高得多先看嵌入模型是不是“太弱”。all-MiniLM-L6-v2对短句效果好但对长句和复合语义理解有限。如果记忆里经常是长句建议换成bge-m3或更大的模型。再看查询语句的粒度。用户经常问“下周的部署计划怎么做”而记忆里存的是“计划用GitHub Actions做自动部署”这两句话的向量相似度可能只有0.6左右并不算高。解法是查询前先做一个查询改写从问题里抽取实体和意图作为检索入口。检查是否存在“记忆碎片”。一条完整决策被截断成三条碎片后语义会丢失。这种情况要回到提取环节把抽取Prompt写得结构化一点强制输出完整的“主体-决策-原因”。看存储的向量是不是太旧。如果新记忆已经大量加入但旧检索结果还排在前面说明检索脚本没做增量索引。我通常每次写入后直接更新向量表不用定期重建。最后才考虑调k值。k值不是越大越好我常用k20召回、重排后再取前5效果比直接k5稳。如果按这个顺序还查不出来我会直接写一条测试用例打进记忆库然后单独debug看哪一步打分不对。这比猜测整个流程快得多。4.4 后续扩展从单机版到更通用的记忆服务单机版跑通之后我把它做成了一个后端服务这样多个应用可以共享同一套记忆库。扩展用的还是FastAPI暴露三个接口写入记忆、检索记忆、查询记忆详情。前端应用不再直接操作SQLite而是走HTTP。再往后我计划把它做成一个MCP Server的形式。Claude对MCP的支持越来越成熟如果记忆系统作为一个MCP工具暴露出来那任何支持MCP的客户端都能使用统一的记忆能力不需要每次都在代码层面做集成。安全问题也不能忽视。记忆库积累了大量用户偏好、项目内部决策一旦泄漏就是灾难。我只做了两个基础操作备份前加密管理接口加本地IP白名单。如果部署在服务器上还应该加一层密钥管理所有embedding和模型的API Key不要写进配置文件。最后分享一个我调整最多的细节。记忆系统的价值不在于“记住”而在于“用得出来”。我见过很多人在提取和存储上花大功夫检索和排序草草了事结果用户体验反而更差。我的经验是如果只做一个优化优先把检索重排做好。一条好记忆适时出现在需要的位置胜过一千条躺在数据库里吃灰。
返回列表