☰
从零搭建持久记忆层:对话系统跨会话记忆的设计与实现
2026/10/26 11:57:00 网站建设 项目流程

1. 从零搭建一个持久记忆层:为什么“记不住”是所有对话系统的通病

做过对话类应用的人都有一个共同体会:模型本身很聪明,但它像个失忆症患者。你上午跟它聊完一个项目的架构决策,下午再开一个会话,它完全不记得你是谁、之前定过什么方案。这不是模型能力问题,而是架构问题——绝大多数对话系统在设计之初就没有把“记忆”当成一等公民。

claude-mem这个项目要解决的就是这件事。它的核心定位很明确:给对话系统加一层持久化记忆层,让跨会话的上下文能够被存储、检索、注入,从而让模型在多轮、多天的使用中保持连贯性。说白了,它不是一个模型,也不是一个聊天界面,而是一个夹在应用和模型之间的“记忆中间件”。

我最初接触这类需求是在做一个内部知识助手的时候。用户反复抱怨“每次都要重新解释背景”,而开发侧每次都要把历史对话拼进 prompt,token 消耗巨大不说,还经常超出上下文窗口。后来我们尝试把历史对话做摘要、做向量检索,踩了一堆坑才慢慢摸清楚:记忆这件事,难点从来不在“存”,而在“取”——什么时候取、取多少、以什么形式塞回 prompt,才是真正决定效果的地方。

这篇文章我会围绕claude-mem这个项目,把持久记忆层的设计思路、核心机制、实操落地、参数调优和踩坑经验完整拆一遍。适合正在做对话应用、AI 助手、客服机器人、个人知识管理的开发者,也适合对“上下文工程”感兴趣、想自己动手搭一套记忆系统的朋友。哪怕你之前没接触过向量数据库,跟着思路走也能理解整套逻辑。

2. 记忆层的整体设计与方案选型:为什么不是简单存数据库

2.1 记忆系统的三层结构拆解

在动手写代码之前,先把“记忆”这个概念拆开。很多人一上来就想“把对话存数据库不就行了”,但真正跑起来会发现,原始对话是非结构化、冗余、噪声大的,直接存进去再取出来,效果很差。claude-mem这类项目的常见做法是把记忆分成三层:

  • 原始层(Raw Layer):完整保存每一次会话的原始消息,包括时间戳、角色、内容。这一层只做归档,不直接用于检索,相当于“冷存储”。
  • 摘要层(Summary Layer):对会话或会话片段做压缩摘要,提取关键事实、决策、偏好。这一层是检索的主力,因为它的信息密度高、噪声低。
  • 索引层(Index Layer):把摘要和关键事实转成向量,存进向量库,支持语义检索。同时保留关键词索引,做混合检索。

为什么要分三层?因为不同场景对记忆的需求不一样。用户问“我上次说的那个方案叫什么来着”,需要的是语义检索,走索引层;用户问“我们上周三聊了什么”,需要的是时间检索,走原始层或摘要层的时间过滤;用户问“帮我接着上次的思路继续”,需要的是最近上下文,走摘要层的最近 N 条。如果只有一层,要么检索不准,要么成本爆炸。

提示:三层结构不是必须的,小规模应用可以只做摘要层+索引层,原始层用日志文件代替。但一旦用户量上来,原始层的归档能力会救你的命——出问题时能回溯到底发生了什么。

2.2 为什么选向量检索而不是纯关键词

这里要解释一个关键选型:为什么记忆检索要用向量,而不是传统的LIKE '%关键词%'。原因在于语义鸿沟。用户说“那个关于用户登录的优化”,而记忆里存的是“认证流程改造”,关键词完全对不上,但语义是同一件事。向量检索能把这两者匹配上,关键词检索不行。

但纯向量检索也有问题:它对精确匹配不敏感。比如用户问“项目代号 XR-7 的配置”,向量检索可能返回一堆“配置相关”的记忆,但漏掉真正包含 XR-7 的那条。所以成熟方案都是混合检索:向量召回 + 关键词召回,再用重排序(Rerank)合并。claude-mem的常见实现里,向量权重和关键词权重的比例通常设在 7:3 到 6:4 之间,具体要看你的记忆内容偏语义还是偏实体。

2.3 存储选型:向量库、关系库、还是文件

存储这块我踩过最大的坑就是“一开始就上重型向量数据库”。对于个人项目或中小规模应用,完全没必要。我的建议是分阶段:

阶段记忆规模推荐方案理由
原型验证< 1万条SQLite + 内存向量零依赖,改起来快
小规模上线1万~50万条SQLite + FAISS / 本地向量索引单机够用,成本低
中等规模50万~500万条PostgreSQL + pgvector事务和向量一体,运维简单
大规模> 500万条专用向量库 + 关系库分离检索性能和扩展性优先

claude-mem的定位更偏向前两阶段,所以它的默认实现通常走轻量路线。这不是能力问题,而是场景匹配——记忆层的核心价值在于“取回质量”,不在于“存储吞吐”。你先把取回逻辑调好,存储随时可以换。

3. 核心机制拆解:记忆是怎么被写入、压缩和取回的

3.1 写入时机:什么时候该记一笔

记忆写入最忌讳“什么都记”。如果把每一句话都存下来,检索时噪声会淹没信号。常见的写入触发策略有三种:

  1. 会话结束触发:一次会话结束后,对整段对话做摘要,提取关键事实写入。优点是批量处理、成本低;缺点是会话中途崩溃会丢记忆。
  2. 轮次触发:每 N 轮对话触发一次摘要写入。N 通常取 5~10。优点是记忆新鲜;缺点是摘要碎片化,可能把一件事拆成好几条。
  3. 事件触发:检测到“决策”“偏好”“事实陈述”等信号时立即写入。比如用户说“以后都用 Python 写”,这就是一条偏好记忆。优点是精准;缺点是需要额外的分类逻辑。

claude-mem的实践里,会话结束 + 事件触发的组合最稳。会话结束保证完整性,事件触发保证关键信息不丢。我实测下来,纯轮次触发容易产生大量“半截记忆”,检索时反而干扰。

3.2 摘要压缩:怎么把一段对话变成一条好记忆

摘要质量直接决定检索质量。这里有个反直觉的经验:摘要不是越短越好,而是越“可检索”越好。一条好的记忆摘要应该包含:

  • 主体:谁、什么项目、什么对象
  • 动作/状态:做了什么决定、处于什么状态
  • 关键实体:专有名词、代号、参数值
  • 时间锚点:什么时候发生的

举个例子,原始对话可能是:

用户:我们那个登录模块,之前用的 session 方案,现在想换成 token,因为要支持多端。 助手:好的,那需要考虑 token 刷新和过期策略……

一条合格的记忆摘要应该是:

[2024-06-12] 项目 A 的登录模块决定从 session 方案迁移到 token 方案,原因是需要支持多端登录。待办:设计 token 刷新与过期策略。

注意这里保留了日期、项目名、决策内容、原因、待办。这些字段在检索时都是高价值信号。如果摘要只写“讨论了登录方案”,那这条记忆基本废了。

注意:摘要生成建议用便宜的小模型,不要用最贵的模型。因为摘要是高频操作,成本敏感。实测小模型在“提取关键信息”这个任务上,和旗舰模型差距不大,但成本差一个数量级。

3.3 检索策略:取多少条、怎么排序、怎么注入

检索是记忆层最核心的环节。这里要回答三个问题:

取多少条?常见做法是取 Top-K,K 在 3~10 之间。K 太小会漏,K 太大会把无关记忆塞进 prompt,反而干扰模型。我的经验是:先取 20 条候选,重排序后取前 5 条注入。这样召回率和精度都能兼顾。

怎么排序?纯向量相似度排序不够,要引入多个信号加权:

  • 语义相似度(向量分数)
  • 关键词匹配度(BM25 或简单词频)
  • 时间衰减(越近的记忆权重越高)
  • 重要性分数(写入时打的标签)

时间衰减这块特别重要。用户问“我最近在做什么”,如果检索返回半年前的高相似记忆,体验会很差。常见的衰减函数是指数衰减,半衰期设在 7~30 天。claude-mem的实践里,半衰期 14 天是个不错的默认值。

怎么注入?检索到的记忆不能直接拼在 prompt 开头,那样会挤占系统指令的空间。常见做法是放在系统提示之后、用户消息之前,并用明确的分隔标记,比如:

[相关记忆] - [2024-06-12] 项目 A 登录模块决定迁移到 token 方案…… - [2024-06-10] 用户偏好:代码示例用 Python…… [/相关记忆] [用户消息] 接着上次的登录改造继续……

这样模型能清楚区分“记忆”和“当前指令”,不会混淆。

3.4 去重与冲突处理:记忆多了会打架

记忆系统跑一段时间后,一定会出现重复记忆和冲突记忆。重复好办,用向量相似度做去重,相似度超过 0.95 的合并。冲突就麻烦了,比如:

  • 记忆1:用户偏好用 Python
  • 记忆2:用户偏好用 TypeScript

这两条可能都是真的(不同场景),也可能是用户改主意了。处理策略有两种:时间优先(新的覆盖旧的)和并存标注(都保留,标注时间,让模型自己判断)。我倾向于后者,因为模型有能力根据上下文选择,强行覆盖反而丢信息。但要在注入时把时间标清楚,让模型知道哪条更新。

4. 实操落地:从零跑通一套记忆层

4.1 环境准备与依赖安装

先说明,下面这套方案是基于常见实践的合理补全,不是某个特定项目的官方文档。你可以根据自己的技术栈调整。

基础依赖:

pip install openai sqlite-vec numpy

这里用sqlite-vec是因为它把向量检索能力直接塞进了 SQLite,零运维,适合原型和小规模。如果你用 PostgreSQL,换成pgvector即可。

目录结构建议:

claude-mem/ ├── memory/ │ ├── store.py # 存储层 │ ├── embed.py # 向量化 │ ├── summarize.py # 摘要生成 │ └── retrieve.py # 检索 ├── data/ │ └── memory.db # SQLite 数据库 └── config.yaml # 配置

4.2 数据库表结构设计

核心表就三张,对应前面说的三层:

-- 原始会话归档 CREATE TABLE raw_sessions ( id INTEGER PRIMARY KEY, session_id TEXT, role TEXT, content TEXT, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); -- 记忆摘要 CREATE TABLE memories ( id INTEGER PRIMARY KEY, summary TEXT, entities TEXT, -- JSON 数组,存关键实体 importance REAL, -- 0~1 重要性分数 created_at TIMESTAMP, last_accessed TIMESTAMP ); -- 向量索引(sqlite-vec 虚拟表) CREATE VIRTUAL TABLE memory_vectors USING vec0( memory_id INTEGER, embedding FLOAT[1536] );

entities字段存 JSON 是为了支持关键词检索。importance是写入时打的分数,检索时参与排序。last_accessed用于实现“最近被用到的记忆权重更高”这类策略。

4.3 写入流程的完整实现

写入分两步:摘要生成和向量化入库。

import json from datetime import datetime def write_memory(session_messages, llm_client, embed_client, db): # 第一步:生成摘要 prompt = f"""把以下对话压缩成一条可检索的记忆,包含: 1. 主体(谁/什么项目) 2. 决策或事实 3. 关键实体(专有名词、代号、参数) 4. 时间锚点 输出 JSON 格式:{{"summary": "...", "entities": [...], "importance": 0.0~1.0}} 对话内容: {session_messages} """ resp = llm_client.chat(prompt) data = json.loads(resp) # 第二步:向量化 embedding = embed_client.embed(data["summary"]) # 第三步:入库 cursor = db.execute( "INSERT INTO memories (summary, entities, importance, created_at) VALUES (?, ?, ?, ?)", (data["summary"], json.dumps(data["entities"]), data["importance"], datetime.now()) ) memory_id = cursor.lastrowid db.execute( "INSERT INTO memory_vectors (memory_id, embedding) VALUES (?, ?)", (memory_id, embedding) ) db.commit() return memory_id

这里有个细节:importance让模型自己打分,可能不稳定。更稳的做法是用规则打分,比如包含“决定”“以后”“记住”等词的记忆加分。我实测规则打分比模型打分更可控。

4.4 检索流程的完整实现

检索是重头戏,包含召回、重排、注入三步。

import numpy as np def retrieve_memories(query, embed_client, db, top_k=5, candidate_k=20): # 第一步:向量召回 query_vec = embed_client.embed(query) candidates = db.execute( """SELECT m.id, m.summary, m.entities, m.importance, m.created_at, vec_distance_cosine(v.embedding, ?) AS distance FROM memories m JOIN memory_vectors v ON m.id = v.memory_id ORDER BY distance ASC LIMIT ?""", (query_vec, candidate_k) ).fetchall() # 第二步:多信号重排 now = datetime.now() scored = [] for c in candidates: semantic_score = 1 - c["distance"] # 距离转相似度 keyword_score = keyword_match(query, c["entities"]) time_score = np.exp(-days_between(c["created_at"], now) / 14) # 半衰期14天 final_score = ( 0.6 * semantic_score + 0.2 * keyword_score + 0.1 * time_score + 0.1 * c["importance"] ) scored.append((final_score, c)) scored.sort(reverse=True, key=lambda x: x[0]) return [s[1] for s in scored[:top_k]]

权重0.6/0.2/0.1/0.1不是拍脑袋来的。语义是主信号,给 0.6;关键词保证实体不漏,给 0.2;时间和重要性各 0.1 做微调。你可以根据业务调整,比如客服场景时间权重可以提到 0.2。

4.5 注入 prompt 的格式与位置

检索到记忆后,注入格式要固定,让模型形成稳定预期:

def build_prompt(user_message, memories): if not memories: return user_message memory_block = "\n".join( f"- [{m['created_at'].strftime('%Y-%m-%d')}] {m['summary']}" for m in memories ) return f"""[相关记忆] {memory_block} [/相关记忆] [用户消息] {user_message}"""

提示:记忆块一定要放在用户消息之前、系统提示之后。放在系统提示里会污染指令,放在用户消息后会削弱记忆的权威性。这个位置是实测下来最稳的。

5. 参数调优与效果验证:怎么知道记忆层有没有用

5.1 关键参数速查表

参数推荐值作用调整方向
候选召回数 candidate_k20向量初筛数量记忆多时调大
注入条数 top_k5最终注入 prompt 的记忆数模型上下文大时调大
语义权重0.6向量相似度占比语义场景调大
关键词权重0.2实体匹配占比实体密集场景调大
时间半衰期14 天时间衰减速度高频场景调小
去重阈值0.95相似度合并阈值记忆冗余时调小

5.2 效果验证:三个可量化的指标

记忆层好不好,不能靠感觉,要有指标:

  1. 召回率(Recall@K):构造一批“问题-应召回记忆”的测试集,看 Top-K 里有没有命中。目标 > 80%。
  2. 注入精度(Precision@K):注入的记忆里有多少是真正相关的。目标 > 70%。
  3. 端到端满意度:让用户对“助手是否记得之前的事”打分。这个最真实,但成本高。

我自己的做法是先跑召回率和精度,这两个达标后再上人工评估。构造测试集有个技巧:从历史记忆里随机抽 50 条,让模型反向生成“用户可能会问的问题”,然后用这些问题去检索,看能不能召回原记忆。这个方法叫反向生成测试集,比人工编问题快得多。

5.3 成本控制:记忆层的隐性开销

记忆层不是免费的,主要成本在三块:

  • 摘要生成:每次会话结束调一次 LLM。用便宜模型,单次成本可压到几分钱。
  • 向量化:每次写入和检索都要调 embedding。embedding 便宜,但量大起来也不小。
  • 检索计算:向量检索本身很便宜,但重排序如果用了模型,成本会上来。

控制成本的核心是减少不必要的写入和检索。比如闲聊类会话不写入,简单查询不检索。我实测下来,加了“是否值得记忆”的判断后,写入量能降 60%,效果几乎不变。

6. 常见问题与排查技巧实录

6.1 记忆检索不准的排查路径

检索不准是最常见的问题,排查按这个顺序走:

  1. 看召回:候选 20 条里有没有正确答案?没有就是向量化或索引问题。
  2. 看排序:正确答案在候选里但没进 Top-5?是权重问题,调语义/关键词比例。
  3. 看摘要:正确答案压根没被写入?是写入触发或摘要质量问题。
  4. 看注入:注入了但模型没用?是 prompt 格式或位置问题。

这个顺序能帮你快速定位问题在哪一层,避免瞎调参数。

6.2 记忆冲突与过期的处理

前面提过冲突处理,这里补充一个实操技巧:给记忆加“有效期”字段。有些记忆是临时的,比如“这周用测试环境”,过期就该失效。写入时让模型判断是否临时,临时记忆设 TTL,检索时过滤掉过期的。这个机制能显著减少“模型记着过时信息”的尴尬。

6.3 常见问题速查表

现象可能原因解决方向
检索返回无关记忆向量质量差 / 权重失衡换 embedding 模型 / 调权重
该记的没记住写入触发太保守增加事件触发
记忆重复去重阈值太高降到 0.9 左右
模型忽略记忆注入位置或格式问题调整到用户消息前
成本超预期写入/检索太频繁加价值判断过滤
记忆过时缺 TTL 机制加有效期字段

6.4 几个我踩过的坑

坑一:一开始就用大向量库。我最早用了一个分布式向量库,结果运维复杂度远超收益。后来换回 SQLite,性能完全够用。教训是:先验证取回质量,再考虑扩展性。

坑二:摘要写太短。早期为了省 token,把摘要压到一句话,结果检索时区分度极低。后来改成结构化摘要(主体+决策+实体+时间),召回率直接涨了 20 多个点。

坑三:忽略时间衰减。有段时间用户抱怨“助手总提很久以前的事”,加了时间衰减后体验明显改善。时间这个信号,在记忆系统里比想象中重要。

坑四:注入位置随意。试过把记忆放在系统提示里,结果模型把记忆当成了指令,行为变得很奇怪。固定放在用户消息前之后,问题消失。

7. 记忆层的扩展方向:从“记住”到“会用”

跑通基础记忆层之后,还有几个值得探索的方向。第一个是记忆的主动遗忘,不是所有记忆都值得长期保留,低价值记忆应该被逐步淘汰,这需要一个衰减和清理机制。第二个是记忆的关联图谱,把相关记忆连成图,检索时能顺着关系找到间接相关的记忆,这对复杂项目场景特别有用。第三个是记忆的个性化权重,不同用户对“什么值得记”的偏好不同,让系统学习用户的偏好,能进一步提升体验。

我自己在实际操作中的体会是,记忆层最难的不是技术实现,而是判断什么值得记。这个判断做得好,系统就聪明;做得差,记忆越多越乱。所以如果你要动手做,建议先把“写入策略”想清楚,再写代码。技术方案可以换,但写入策略决定了整个系统的上限。

最后分享一个小技巧:在记忆摘要里保留“原始对话的引用 ID”,这样当模型对某条记忆有疑问时,可以回溯到原始对话。这个设计在调试阶段特别有用,能帮你快速定位是摘要错了还是检索错了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询