ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

从零搭建本地记忆增强系统:claude-mem 项目拆解与实操

从零搭建本地记忆增强系统:claude-mem 项目拆解与实操 1. 从零搭建一个本地记忆增强系统claude-mem 项目拆解第一次看到 claude-mem 这个项目名的时候我脑子里蹦出来的第一个念头是终于有人把「记忆」这件事从大模型的上下文窗口里拎出来单独做了。做过对话类应用的朋友应该都有体会模型本身再聪明只要对话轮次一多前面聊过的东西就开始模糊用户说「上次我提到的那个方案」模型一脸茫然。这不是模型不行而是它的工作记忆天生就受限于上下文长度超出窗口的内容要么被截断要么被压缩成摘要细节全丢。claude-mem 要解决的就是这个问题。它本质上是一套围绕对话记忆做持久化、检索和注入的机制让模型在每次对话时能够「想起」之前发生过的事情。你可以把它理解成给对话助手配了一个外挂笔记本平时把关键信息记下来需要的时候翻出来塞回上下文里。这个项目适合谁看如果你在做聊天机器人、个人助理、客服系统或者任何需要跨会话保持状态的应用那这套思路值得你花时间研究。哪怕你只是想让自己的日常 AI 工作流更连贯理解记忆管理的原理也能帮你少走很多弯路。我接下来会从设计思路、核心机制、实操落地、问题排查几个角度把这个项目拆开讲透。不是照本宣科地念文档而是把我自己踩过的坑、试过的参数、想明白的道理都摊开来说。你照着做大概率能跑起来就算不照着做理解这套逻辑之后自己造一个也不难。2. 记忆系统的整体设计与思路拆解2.1 为什么不能只靠上下文窗口硬扛很多人第一反应是上下文窗口不是越来越大了吗几十万 token 的模型都有了还需要单独做记忆吗这个问题我一开始也纠结过后来算了一笔账就明白了。假设一次对话平均 2000 token一个 20 万 token 的窗口理论上能装 100 轮对话。听起来够用但实际场景里用户可能今天聊完明天再来中间隔了十几个小时会话是断开的。你不可能把昨天的全部内容原封不动塞进今天的上下文那样既浪费 token 又引入噪音。更关键的是成本。上下文越长每次推理的费用越高延迟也越大。如果每轮对话都带着几万 token 的历史响应速度会肉眼可见地变慢。所以正确的做法不是「全都记住」而是「记住该记的需要时再取出来」。这就是记忆系统的核心价值用可控的存储和检索成本换取跨会话的连贯性。claude-mem 的设计思路正是如此。它不追求把所有对话都塞进上下文而是把对话内容结构化地存下来通过检索机制在需要的时候召回相关片段。这个「存—取—注入」的闭环是整个项目的骨架。2.2 存储层选型的考量记忆存哪里这是第一个要拍板的问题。常见的选择有几种纯文件、关系型数据库、向量数据库或者混合方案。claude-mem 这类项目通常会采用「结构化存储 向量检索」的组合我拆解一下背后的逻辑。纯文件存储最简单一个 JSON 或者 Markdown 文件就能搞定适合个人使用、数据量小的场景。优点是零依赖、可读性强你打开文件就能看到记了什么。缺点是检索能力弱只能靠关键词匹配语义相近但用词不同的内容就找不到了。关系型数据库比如 SQLite适合需要按时间、按会话 ID、按标签做精确查询的场景。它能很好地回答「上周三那次对话说了什么」这类问题但回答不了「之前有没有聊过类似的话题」这种语义问题。向量数据库解决的就是语义检索。把每段记忆转成向量存起来查询时把问题也转成向量算余弦相似度找出最接近的几条。这样即使用户换了说法只要意思相近就能召回。缺点是向量模型本身有成本而且相似度阈值需要调。我的经验是成熟方案基本都是混合的用 SQLite 存原始文本和元数据用向量索引做语义召回两者通过 ID 关联。这样既能精确查询又能语义搜索兼顾了准确性和灵活性。claude-mem 如果要做成一个通用工具大概率也是这个路子。2.3 记忆的粒度设计存什么、怎么切分这是最容易被忽视但影响最大的设计决策。我见过不少项目把整轮对话当成一条记忆存进去结果检索出来的内容又长又杂注入上下文后反而干扰模型判断。合理的做法是按「记忆单元」来切分。一个记忆单元可以是一句话、一个事实、一个偏好、一段摘要。比如用户说「我住在杭州平时喜欢喝美式咖啡周末经常去爬山」这里其实包含三个独立事实居住地、饮食偏好、运动习惯。把它们拆开存检索时就能精准命中。用户问「推荐个周末活动」系统召回「喜欢爬山」这条就够了不需要把咖啡的事也带出来。切分的粒度需要平衡。太细了记忆条数爆炸检索噪音大太粗了召回内容冗余浪费上下文。我的建议是以「一个可独立理解的事实」为单位通常一到两句话。同时给每条记忆打上标签比如「偏好」「事实」「事件」「待办」方便后续按类型过滤。2.4 检索策略与注入时机记忆存好了什么时候取、取多少、怎么塞回上下文这三个问题决定了系统的实际体验。取多少条这是个权衡。取太少可能漏掉关键信息取太多上下文被占满还引入噪音。实践中我一般设置一个上限比如 5 到 10 条再配合相似度阈值过滤掉明显不相关的。如果召回的条数超过上限就按相似度排序取前几条。什么时候取有两种模式每轮对话都检索或者按需检索。每轮都检索简单直接但会增加延迟和成本。按需检索需要判断当前问题是否涉及历史信息判断本身又要消耗一次模型调用。我倾向于折中对用户输入做一次轻量的相关性判断只有可能涉及历史时才触发检索。这样大部分闲聊轮次可以跳过检索节省开销。怎么注入最朴素的方式是把召回的记忆拼成一段文本放在系统提示或者用户消息前面。但要注意格式最好明确标注这是「历史记忆」让模型知道这些是背景信息而非当前指令。我习惯用类似「以下是之前对话中记录的相关信息」这样的引导语效果比直接拼接好很多。3. 核心机制解析与实操要点3.1 记忆写入的触发逻辑记忆不是每句话都值得记。如果用户说「好的」「嗯嗯」「继续」这些没有信息量的内容存下来只会污染检索结果。所以写入环节需要一个过滤和提取机制。我的做法是分两步走。第一步做规则过滤把明显无意义的短句、纯确认词、表情符号直接丢掉。第二步用模型做信息提取让模型判断这段话里有没有值得长期记住的事实、偏好或事件如果有就提取成结构化的记忆条目。这个提取过程可以异步做不阻塞主对话流程用户感知不到延迟。提取的 prompt 设计很关键。我一般会要求模型输出 JSON 格式包含content记忆内容、type类型标签、confidence置信度三个字段。置信度低的条目可以先存着但不参与检索等后续被多次提及时再提升权重。这样能有效过滤掉模型误判产生的噪音记忆。注意提取环节一定要做去重。用户可能在不同时间反复提到同一件事如果每次都存一条检索时会返回一堆重复内容。简单的做法是用向量相似度做去重新记忆和已有记忆相似度超过阈值就合并或跳过。3.2 向量化与索引构建把文本转成向量是语义检索的基础。选哪个嵌入模型直接决定了检索质量。我的经验是如果追求效果选维度高一些的模型比如 768 维或 1024 维如果追求速度和成本384 维的轻量模型也够用。关键是模型要支持中文很多英文模型在中文语义上的表现会打折扣。索引构建有几个细节要注意。第一向量要归一化这样余弦相似度计算可以简化为点积速度快很多。第二索引要支持增量更新不能每加一条记忆就重建整个索引。第三要定期做索引压缩和重建删除已失效的记忆避免索引膨胀。如果记忆量不大比如几千条以内直接用 numpy 做暴力检索完全够用没必要上专业的向量数据库。我实测过一万条 768 维向量的暴力检索单次查询在几十毫秒级别对大多数应用来说可以接受。等数据量上到十万级再考虑引入专门的索引结构。3.3 上下文注入的格式与位置召回的记忆怎么放进 prompt这个细节很多人不重视但它直接影响模型的使用效果。我试过几种方案说说各自的优劣。方案一拼在系统提示里。优点是位置固定模型容易识别缺点是系统提示通常会被缓存动态内容放进去可能破坏缓存增加成本。方案二拼在用户消息前面。这是我最常用的方式。把记忆作为用户消息的一部分用分隔符和当前问题隔开。比如[历史记忆] - 用户住在杭州 - 用户喜欢喝美式咖啡 - 用户周末经常爬山 [当前问题] 推荐个周末活动这样模型能清楚区分背景和问题回答时自然会结合记忆。方案三用多轮消息模拟。把记忆包装成之前的对话轮次让模型以为这些是真实发生过的对话。这种方式最自然但会占用更多 token而且可能让模型混淆时间线。我一般选方案二简单可控。注入的位置放在用户消息最前面紧跟着才是当前问题这样模型的注意力分配比较合理。3.4 记忆的更新与遗忘机制记忆系统不能只增不减。用户搬家了旧的居住地记忆就过时了用户说「我最近改喝拿铁了」美式的偏好就该被更新。没有更新和遗忘机制的记忆系统用久了会变得又慢又准。更新策略我推荐「新记忆覆盖旧记忆」。当新提取的记忆和已有记忆高度相似但内容冲突时用新的替换旧的同时保留一个版本号或时间戳。这样既能保证信息新鲜又能追溯历史。遗忘策略有两种思路。一种是基于时间的衰减越老的记忆权重越低低于阈值就归档或删除。另一种是基于访问频率长期没被检索到的记忆说明不重要可以清理。我倾向于两者结合给每条记忆算一个「活跃度」分数由最后访问时间和访问次数共同决定定期清理低分记忆。提示删除记忆前建议先归档万一用户回头问起旧信息还能从归档里捞出来。归档可以放在单独的冷存储里不参与日常检索只在明确需要时查询。4. 完整实操流程与关键环节实现4.1 环境准备与依赖安装先把基础环境搭起来。我假设你用 Python 做开发这是这类项目最主流的选择。需要准备的东西不多Python 3.9 以上、一个嵌入模型、一个存储方案。嵌入模型我推荐用 sentence-transformers 库加载它封装好了常见的开源模型几行代码就能用。存储方面SQLite 是 Python 内置的零配置向量检索用 numpy 或者 faiss前者简单后者快。pip install sentence-transformers numpy如果你要用 faiss 做大规模检索再装一下pip install faiss-cpu模型下载第一次会慢一些因为要从远端拉权重文件。建议提前下载好放到本地缓存目录避免每次启动都联网检查。我一般会把模型路径写死在配置里减少不确定性。4.2 数据库表结构设计存储层我设计了三张表分工明确。第一张是memories表存记忆主体CREATE TABLE memories ( id INTEGER PRIMARY KEY AUTOINCREMENT, content TEXT NOT NULL, type TEXT DEFAULT fact, confidence REAL DEFAULT 1.0, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, last_accessed TIMESTAMP, access_count INTEGER DEFAULT 0, is_archived INTEGER DEFAULT 0 );第二张是embeddings表存向量和 memories 通过 id 关联CREATE TABLE embeddings ( memory_id INTEGER PRIMARY KEY, vector BLOB NOT NULL, dim INTEGER NOT NULL, FOREIGN KEY (memory_id) REFERENCES memories(id) );第三张是sessions表记录会话和记忆的关联方便按会话追溯CREATE TABLE sessions ( id INTEGER PRIMARY KEY AUTOINCREMENT, session_key TEXT UNIQUE, started_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP );向量存成 BLOB用 numpy 的tobytes()序列化读出来用frombuffer()还原。这样比存 JSON 数组省空间读写也快。4.3 记忆提取的完整实现提取环节是整个系统的入口我把它写成一个独立函数输入是一段对话文本输出是提取出的记忆列表。import json from your_llm_client import call_llm EXTRACT_PROMPT 从下面这段对话中提取值得长期记住的信息。 只提取事实、偏好、事件、待办这四类。 每条记忆用一句话表述独立可理解。 输出 JSON 数组每个元素包含 content、type、confidence 三个字段。 如果没有值得记住的内容输出空数组。 对话内容 {text} def extract_memories(text): prompt EXTRACT_PROMPT.format(texttext) response call_llm(prompt) try: memories json.loads(response) except json.JSONDecodeError: return [] # 过滤低置信度 return [m for m in memories if m.get(confidence, 0) 0.6]这里有几个实操细节。第一prompt 里明确限定类型避免模型提取出乱七八糟的东西。第二要求「独立可理解」防止出现「他说的那个」这种依赖上下文的表述。第三置信度阈值我设的 0.6低于这个值的基本是模型瞎猜的直接丢。提取出来的记忆在入库前还要做一次去重。我写了个简单的去重逻辑把新记忆向量化和库里已有的记忆算相似度超过 0.9 就认为是重复跳过在 0.75 到 0.9 之间的标记为「可能重复」人工或者后续逻辑再判断。4.4 检索与注入的代码实现检索函数接收用户当前的问题返回最相关的几条记忆。import numpy as np def retrieve_memories(query, top_k5, threshold0.5): query_vec embed(query) query_vec query_vec / np.linalg.norm(query_vec) rows db.execute( SELECT m.id, m.content, e.vector, e.dim FROM memories m JOIN embeddings e ON m.id e.memory_id WHERE m.is_archived 0 ).fetchall() scored [] for row in rows: vec np.frombuffer(row[vector], dtypenp.float32) vec vec / np.linalg.norm(vec) score float(np.dot(query_vec, vec)) if score threshold: scored.append((score, row[id], row[content])) scored.sort(reverseTrue) results scored[:top_k] # 更新访问记录 for _, mid, _ in results: db.execute( UPDATE memories SET last_accessed CURRENT_TIMESTAMP, access_count access_count 1 WHERE id ?, (mid,) ) db.commit() return [content for _, _, content in results]阈值 0.5 是我调出来的经验值。太低会召回一堆不相关的太高又可能漏掉有用的。你可以根据自己的数据特点微调建议先跑一批测试用例看看召回结果的准确率和召回率再定阈值。注入环节就简单了把检索结果拼成文本def build_prompt_with_memory(query, memories): if not memories: return query memory_text \n.join(f- {m} for m in memories) return f[历史记忆]\n{memory_text}\n\n[当前问题]\n{query}注意记忆条目的顺序我一般按相似度从高到低排让最相关的排在最前面模型的注意力更容易落在上面。4.5 参数调优的实测记录我把几个关键参数的调优过程记录一下供你参考。top_k 的选择我试过 3、5、10 三个值。3 条的时候偶尔会漏掉关键信息10 条的时候上下文里塞了一堆不太相关的内容模型回答反而变啰嗦。5 条是比较平衡的选择大多数场景够用。相似度阈值0.4 的时候召回率高但噪音多0.6 的时候干净但容易漏。0.5 是我最终定的值。如果你的记忆库比较小可以适当降低阈值因为候选本来就少库大了就要提高阈值控制噪音。提取置信度阈值0.5 太松会存进很多垃圾0.7 太严会漏掉一些有价值但表述模糊的记忆。0.6 是我试下来比较合适的。去重相似度阈值0.9 以上算重复这个比较安全。如果你发现库里还是有重复可以降到 0.85如果误删了不同但相似的内容就提到 0.95。这些参数没有绝对的最优值跟你的数据特点、模型能力、应用场景都有关。建议你搭好系统后用真实数据跑一批根据效果微调。5. 常见问题与排查技巧实录5.1 记忆检索不准的排查思路检索不准是最常见的问题表现是召回的内容和当前问题不相关或者该召回的没召回。排查我一般按这个顺序来。先看嵌入模型。如果模型本身对中文支持不好语义相似度算出来就是乱的。换一个中文效果好的模型试试很多时候问题直接解决。再看记忆内容。如果记忆条目本身表述模糊比如「用户提到了那个东西」向量化之后语义信息很弱检索自然不准。这时候要回头优化提取环节的 prompt要求记忆必须具体、完整。然后看阈值设置。把阈值调低看看能不能召回目标内容。如果能召回但被阈值卡掉了说明阈值偏高如果调低也召不回说明是向量本身的问题。最后看数据量。记忆库太小的时候向量检索的效果不稳定因为候选太少。这种情况可以考虑先用关键词检索兜底等数据积累起来再切到语义检索。5.2 记忆冲突与过时信息的处理用户信息会变记忆系统必须能处理冲突。我遇到过用户先说「我在北京」后来说「我搬到上海了」如果两条都存着检索时可能同时召回模型就懵了。我的处理方式是引入「时效性」判断。提取新记忆时先检索是否有语义相近的旧记忆。如果有比较两者的时间戳新的覆盖旧的旧的标记为归档。同时在新记忆里保留一个「更新自」的字段记录它替换了哪条旧记忆方便追溯。对于无法自动判断冲突的情况比如用户说「我最近在考虑换工作」这既不是明确的事实更新也不是偏好我一般会存成「事件」类型并设置一个较短的过期时间比如 30 天。过期后自动归档避免过时信息干扰。注意覆盖旧记忆时不要直接删除归档保留。用户可能回头问「我之前不是说过我在北京吗」这时候能从归档里查到解释清楚。5.3 性能瓶颈的定位与优化记忆系统用久了会变慢主要瓶颈在两个地方检索和提取。检索慢通常是因为记忆库太大暴力检索扛不住。解决办法是引入近似最近邻索引比如 faiss 的 IVF 或者 HNSW。这些索引用少量精度损失换取大幅速度提升十万级数据量下能把查询时间从几百毫秒降到几毫秒。提取慢是因为每次都要调模型。优化方向是批处理把多轮对话攒起来一次性提取减少模型调用次数。另外可以用小模型做提取大模型做对话分工明确。还有一个容易被忽视的瓶颈是数据库写入。如果每提取一条记忆就单独写一次库频繁的 IO 会很慢。我一般用批量写入攒够一批再提交事务。5.4 常见问题速查表问题现象可能原因排查方向解决建议检索结果不相关嵌入模型中文能力弱换模型测试选用中文优化的嵌入模型该召回没召回相似度阈值过高调低阈值观察降到 0.4 测试逐步上调召回内容重复去重逻辑失效检查去重阈值降低去重阈值到 0.85记忆过时缺少更新机制检查冲突处理引入时间戳覆盖逻辑检索变慢数据量过大看查询耗时引入 ANN 索引提取噪音多prompt 不够明确检查提取结果收紧类型限定和置信度阈值上下文被占满top_k 过大看注入 token 数减小 top_k 或加长度过滤模型忽略记忆注入格式不清检查 prompt 结构用明确分隔符标注记忆区这张表是我自己踩坑总结的基本覆盖了八成以上的常见问题。遇到新问题先对照排查能省不少时间。5.5 几个容易踩的坑第一个坑是过度依赖模型提取。模型有时候会「脑补」把用户没说的东西提取出来。我遇到过用户说「今天天气不错」模型提取出「用户喜欢晴天」。这种推断性的记忆很危险会污染整个记忆库。解决办法是在 prompt 里明确要求「只提取用户明确表达的信息不要推断」。第二个坑是忽略记忆的时效性。有些信息天然有保质期比如「我下周要出差」过了一周这条记忆就没意义了。我建议给记忆加一个可选的过期时间字段提取时如果模型判断是临时性信息就设置一个合理的过期时间。第三个坑是不做记忆容量控制。记忆库无限增长检索越来越慢噪音越来越多。一定要设置容量上限比如最多存一万条超出后按活跃度淘汰最不重要的。第四个坑是注入位置不当。把记忆放在系统提示末尾模型可能注意不到放在用户消息中间又会打断问题的完整性。我试下来最好的位置是用户消息的最前面用清晰的分隔符隔开。6. 记忆系统的扩展方向与个人体会这套记忆机制跑通之后能扩展的方向其实不少。比如做记忆的可视化把用户的所有记忆按时间线或者主题聚类展示出来用户自己就能看到系统记住了什么也方便手动修正。再比如做记忆的共享多个应用共用一套记忆库用户在 A 应用里说过的偏好B 应用也能用上体验会连贯很多。还有一个我觉得很有价值的方向是记忆的主动召回。现在的机制是被动等查询触发其实可以做成主动的当系统判断当前场景和某条记忆相关时主动把记忆推给模型而不是等用户问。这需要更精细的相关性判断但效果会更好。我自己做这类系统最大的体会是记忆的质量比数量重要得多。与其存一万条模糊的记忆不如存一百条精准的。提取环节多花点心思做过滤和结构化后面检索和注入都会轻松很多。另外一定要给用户留一个「查看和编辑记忆」的入口让用户知道系统记了什么能改能删。这既是功能也是信任的基础。最后分享一个小技巧调试记忆系统的时候把每次检索的 query、召回的条目、相似度分数都打日志。跑一段时间后回头看日志你会发现很多设计上的问题比凭空想有效得多。我当初就是靠日志发现阈值设高了漏掉了一大批本该召回的记忆。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进