如果你跑过几个真正的 Agent 项目,大概率会遇到同一个尴尬:模型本身再聪明,换一个会话就彻底“失忆”。同一个用户刚说完的偏好,下一次对话里再问,它就跟第一次听见一样。我给自研助手项目 OpenClaw(内部代号“小龙虾”)做了一套持久化记忆层,核心思路是用 mem0 做记忆的抽取与管理,用 COS Vectors 做向量存储与语义召回。这套东西跑完以后,“小龙虾”终于能记住用户是谁、喜欢什么、讨厌什么。这篇文章主要讲记忆层怎么设计、关键代码怎么写、哪些坑是我实测踩过的,适合正在被 Agent 失忆问题折腾的开发者参考。
1. 先说清问题:Agent 的“失忆”到底缺在哪
1.1 会话内“伪记忆”和跨会话“真失忆”
很多人一开始觉得“模型记不住事情”是因为上下文窗口不够大,于是无脑加大窗口。但窗口再大,也只是当前的 session 内有效。你可以在一次对话里反复说“我不吃香菜”,模型每次都能准确避开香菜,看起来好像它记住了。
可只要这个会话结束,下一次开新会话,它又会踩进同一个坑:用户说“来碗面,不要香菜”,它照样给你推荐香菜拌牛肉。这时候你才意识到,之前的“记住”只是上下文窗口里的短期存在,不是真正的记忆。
我习惯用一个类比来解释这个问题:想象一个餐厅服务员,服务期间能记住你这桌的忌口和偏好,但你下次再来,换了一个服务员,他又得从头问一遍。Agent 没有长期记忆,就相当于餐厅永远不给你建立客户档案,每次都是第一次来。
OpenClaw 这个项目在早期就是这个状态。当时团队内部开玩笑给它起名“小龙虾”,意思是有很多只脚(功能多)、壳硬(能抗 bug),但脑子只有七秒记忆。后来发现这不是讽刺,是事实。用户连续问三遍同一个问题,它三遍的回答都像第一次,这是最让人崩溃的现场。所以记忆层这件事,不是锦上添花,是能不能继续用的底线。
1.2 直接把历史聊天记录全塞进 Prompt 不现实
既然上下文窗口能存住信息,那有人会想:把所有历史聊天记录全塞进 Prompt 不就行了?我早期也这么干过,把最近几十轮对话全部拼进 system prompt 里,最后发现三个问题。
第一个是 token 成本。一段 30 分钟的对话,光历史记录可能就有几千个 token,每次请求都要重新计算,费用肉眼可见地涨。
第二个是噪声。历史里大量信息是无用的,比如“今天天气不错”“你现在几点睡”这种寒暄,混在真正的偏好里,模型处理起来更费劲。
第三个是长上下文的注意力分散问题。模型对长文本中间部分的内容关注度明显偏低,那些对用户最重要的偏好信息如果恰好落在中间,照样可能被忽略。这不是模型能力问题,是处理长文本时的通病。
记忆层要解决的,不是“把全部内容存下来”,而是“把值得记住的内容提炼出来,并在需要的时候精准捞回来”。这跟人脑的记忆机制很像:你不会记住每天的早餐吃了什么,但你会记住“朋友对花生过敏”这种重要信息。
1.3 三种方案的取舍
市面上真正可行的方案大致有三类,我做一个简单的对比:
| 方案 | 实现方式 | 优点 | 缺点 |
|---|---|---|---|
| A. 原文存储 + 关键词检索 | 所有聊天记录入库,用关键词或正则匹配 | 实现简单,适合演示 | 语义理解差,用户换个说法就匹配不到 |
| B. 长上下文硬扛 | 把全部历史拼进 Prompt | 效果直接,短期可用 | 成本高,噪声大,无法规模化 |
| C. 抽取型记忆 + 向量语义检索 | 从对话中提炼结构化记忆,向量化后存储,按相似度召回 | 召回准确,成本可控,能跨会话 | 需要额外开发,有架构复杂度 |
我最终选了 C。虽然前期开发成本最高,但这是唯一能在真实用户场景下长期跑通的方案。具体的组合就是标题里写的:mem0 负责“抽记忆、管记忆、更新记忆”,COS Vectors 负责“存向量、检索向量、过滤向量”。
2. 记忆层的整体设计与数据流
2.1 记忆的分层管理
在动手写代码之前,必须先想清楚一个问题:到底要把什么“记”下来?如果只是存聊天记录,那我们绕了一圈还是在做日志,不是记忆。
我参考了认知科学里工作记忆和长期记忆的区分,把 OpenClaw 的记忆分成几个层级:
- 用户级记忆:跨会话长期保存,例如用户的姓名、职业、饮食忌口、明显偏好。这类记忆优先级最高,除非用户主动纠正,否则基本不删。
- 会话级记忆:只在当次会话内有效,例如“这次帮用户查了三个城市的房价”“用户刚才提到下周要去出差”。会话结束后可留作参考,但不作为长期事实。
- Agent 级记忆:记录 Agent 自身的行为规则和使用经验,比如“用户不喜欢太长的回复”“遇到复杂问题先列大纲再回答”。
- 知识级记忆:从文档、知识库里向量化后的内容,更接近 RAG 里的东西。
在一开始做的时候,我的建议是不要贪多,先做用户级和会话级两种就够了。Agent 级记忆看起来很炫,但实际上提取难度高,容易把模型带偏,等前面的基础打牢了再搞。
每条记忆记录的结构,也不只是存一句话那么简单。实际存储时会带上这些字段:
{ "id": "mem_0001", "user_id": "user_123", "session_id": "session_456", "type": "user_preference", # fact / preference / task_state "content": "用户不吃香菜,偏好清淡口味", "importance": 0.9, "created_at": "2025-01-01T10:00:00Z", "expires_at": None, # None 表示长期有效 "source_session": "session_456" }这些字段不只是为了记录,后面做向量检索的时候,过滤条件全靠它们。
2.2 写入链路:从对话到记忆
记忆不是凭空生成的,需要从用户对话中抽取。抽取这个环节,我强烈建议直接用 LLM,而不是写规则。原因很简单:用户的表达千奇百怪,规则永远写不完。
比如用户说“我讨厌香菜”“别放香菜”“香菜闻着就恶心”,这三句话表达了同一个意思,但写法完全不同。用规则匹配,你得写多少条 pattern?用 LLM,几行 prompt 就解决了。
抽取的触发时机也有讲究。第一版我是每条消息进来都跑一次抽取,结果对应服务的调用量直接翻倍,成本一下子失控。后来改成对话完成一轮后才异步触发抽取,也就是“用户消息 + Agent 回复”作为一组输入,让模型判断这里面有没有值得长期保存的信息。
抽取 Prompt 我大致是这么设计的:
你是记忆抽取助手。请从下面的对话中找出值得长期记住的信息。 值得记住的信息包括: 1. 用户明确的个人偏好 2. 用户提到的关键事实(家庭、工作、健康等) 3. 当前任务的关键状态 不值得记住的信息包括: 1. 一次性的寒暄 2. 临时性提问 3. 模型自身的思考过程 请输出 JSON 数组,每个元素包含 content、type、importance 三个字段。 importance 范围 0~1,数值越高表示越值得记住。抽取完成后,每条内容会被 embedding 模型转成向量,然后写入 COS Vectors。这里有一个容易被忽略的点:写入前要做相似度检查。如果库里面已经有一条“用户不喜欢香菜”,你又写入一条“用户讨厌香菜”,两条高度相似却互不关联,过几天就会产生一堆重复记忆。正确的做法是:先拿这条新记忆的向量去检索,如果相似度超过设定阈值,就走“更新”逻辑而不是“新增”逻辑。
2.3 读取链路:先检索后注入
读取链路是每次 Agent 接收用户消息时执行的,顺序大概是这样的:
- 用户发来一条消息。
- 系统先把这条消息做 embedding,得到查询向量。
- 带着 user_id 过滤条件,去 COS Vectors 里检索 top-k 条记忆。
- 按相似度阈值过滤掉低相关结果。
- 把命中的记忆拼进 system prompt,最后交给 Agent 生成回复。
这里有一个特别重要的经验:寄回来的记忆不能当成用户“亲口说的话”直接使用。模型容易过度信任注入的内容,如果某条记忆已经过期或者被纠正过,模型却还拿它当依据,就会出现很荒谬的回答。
我的做法是在 prompt 里把记忆区明确包裹成“参考信息”,并加上一句提示:这些是之前对话中的历史印象,可能已经变化,请以用户当前的真实输入为准。
以下是系统长期保存的用户记忆,仅供参考,不一定仍然准确: <memory> - 用户不吃香菜 - 用户偏好清淡口味 - 用户近期在关注租房信息 </memory> 请根据你的判断,结合当前对话内容回答用户的问题。这个细节看着不起眼,但能避免很多“AI 固执己见”的尴尬场景。
3. 实操:从 0 到 1 给“小龙虾”装上记忆
3.1 环境准备与依赖
先说环境。我用的是 Python 3.10,这是 mem0 运行时要求的最低版本。如果你的项目还在用 Python 3.8 或更早,建议先升级,不然后续兼容性问题会让你怀疑人生。
基础依赖安装:
pip install mem0 pip install sentence-transformers pip install cos-vectors-client这里需要说明一下,不同版本可能 SDK 名称和接口有细微变化,以官方文档为准。我写这篇文章时用的是这些包的稳定版本,跑起来没有问题。
接着是 embedding 模型的选择。这块同样没有标准答案,完全看你的数据场景。如果业务以中文为主,我建议用中文优化的通用 embedding 模型,例如 BGE 系列。我选了一个 768 维的模型,这个维度属于“够用且不吃力”的范围:检索效果不错,向量体积也不算大,写入和查询的延迟均可接受。
如果你嫌本地模型推理太慢,也可以调 API,效果其实差不多,就是每次调用多一笔费用。我后来为了控制成本,还是换回了本地部署。
3.2 把 mem0 的向量后端切换为 COS Vectors
mem0 本身的架构里,向量存储是独立抽象层。默认配置用的可能是本地向量库,但对需要跨机器共享的 Agent 服务来说,本地存储根本没法用:多实例部署时每个实例各自一套记忆,数据根本不同步。
于是我把注意力转向 COS Vectors。它的定位是托管式向量检索服务,可以把向量数据和元数据放在同一个地方管理,省去自建向量数据库的运维成本。
mem0 的向量存储层暴露了一套接口,我们需要实现一个自己的存储类,然后把它注入 mem0 的配置。大致代码如下:
from mem0.vector_store import VectorStore class CosVectorsStore(VectorStore): def __init__(self, endpoint, collection_name, dim=768): # 初始化客户端 self.client = create_cos_vectors_client(endpoint) self.collection = collection_name # 如果集合不存在,就创建 if not self.client.has_collection(collection_name): self.client.create_collection( name=collection_name, dimension=dim, metric="cosine" ) def insert(self, vectors, metadata, ids): # 批量写入向量,metadata 会作为过滤字段保存 self.client.upsert( collection=self.collection, ids=ids, vectors=vectors, metadata=metadata ) def search(self, query_vector, top_k=10, filters=None): # 检索时带上过滤条件 results = self.client.query( collection=self.collection, vector=query_vector, top_k=top_k, filter=filters ) return results def delete(self, ids): # 按 id 删除记忆 self.client.delete(collection=self.collection, ids=ids)这里有一个特别容易踩的坑:创建集合时指定的 dimension 必须和 embedding 模型实际输出的维度完全一致。如果你先拿 768 维的模型建了集合,后来换了 1024 维的模型,写入时就会直接报错,而且这个错误在查询阶段很难定位,往往是你发现“怎么查什么都查不到”之后才反应过来。
还有一点关于检索指标。COS Vectors 支持多种距离度量,包括欧氏距离、内积、余弦相似度。我做记忆召回时始终用 cosine,因为记忆内容的长短差异大,cosine 对向量模长不敏感,更适合文本语义相似度这种场景。
3.3 记忆抽取与写入流程
下面是我实际运行的抽取函数,简化过后保留核心逻辑:
def extract_memories(dialogue_turns): prompt = build_extract_prompt(dialogue_turns) result = llm.chat(prompt, response_format={"type": "json_object"}) items = json.loads(result) memories = [] for item in items["memories"]: memories.append({ "content": item["content"], "type": item["type"], "importance": float(item["importance"]) }) return memories注意我在 Prompt 里要求模型必须输出 JSON,并且用 response_format 参数约束了输出格式。这一步很关键,如果让模型自由发挥,回来的文本会让你解析到崩溃。
抽取完成之后,写入流程如下:
def save_memories(user_id, session_id, memories): for mem in memories: # 先检查是否存在高度相似的记忆 query_vec = embedding_model.encode(mem["content"]) similar = vector_store.search( query_vec, top_k=1, filters={"user_id": user_id} ) if similar and similar[0]["score"] > 0.9: # 高度相似,更新原有记忆 merge_and_update(similar[0]["id"], mem) else: # 新增记忆 vector_store.insert( vectors=[query_vec], metadata=[{ "user_id": user_id, "session_id": session_id, "content": mem["content"], "type": mem["type"], "importance": mem["importance"], "created_at": datetime.utcnow().isoformat() }], ids=[generate_memory_id()] )merge_and_update 的实现,我用的是 LLM 来做合并。比如旧记忆是“用户不吃香菜”,新记忆是“用户讨厌香菜味的东西”,让模型把两条合并为一条更完整的表述。这里如果用简单字符串拼接,很快会积累出一堆又长又啰嗦的重复记忆,而且后续检索时 token 占比还大。
3.4 读取与 Prompt 注入
读取端的函数相对简单,核心是按用户过滤和按阈值过滤两步:
def get_relevant_memories(user_id, query, top_k=20): query_vec = embedding_model.encode(query) results = vector_store.search( query_vec, top_k=top_k, filters={"user_id": user_id} ) # 阈值过滤,保留相关度足够的记忆 filtered = [r for r in results if r["score"] >= 0.35] # 按重要度排序,取前 8 条 filtered.sort(key=lambda x: x["metadata"]["importance"], reverse=True) return filtered[:8]这里有两个数值值得展开说说。
top_k 我设为 20,是想让召回阶段尽量多捞一些候选,宁可错杀也不要漏掉真正相关的记忆。但真正进入 prompt 的只有前 8 条,排序标准不是相似度,而是重要度。这和直觉不太一样,但我实测下来效果更好:相似度高不代表值得记,一个寒暄话题的相似度可能很高,但用户随口一句灰心话远不如“用户对花生过敏”这种信息重要。
阈值 0.35,是基于分数分布调出来的经验值。这个我们放到下一节展开。
最终拼进 prompt 的代码:
def build_memory_section(memories): if not memories: return "" lines = [f"- {m['metadata']['content']}" for m in memories] return "<memory>\n" + "\n".join(lines) + "\n</memory>"特别提醒一句:单条记忆不要存太长。我在实现里限制了 content 不超过 50 个中文字符,超出部分会被截断。因为一条记忆是给模型看的关键事实,不是一篇小作文。存得越长,检索时 token 浪费越多,而且太冗长的记忆会让模型抓不住重点。
3.5 定期清理与更新
记忆不是只增不减的。如果你让它无限膨胀,三个月后集合里会有几万条记录,检索速度下降还在其次,更容易出现“大量低相关记忆被误召回”的问题。
我每周跑一次清理任务,逻辑有两个维度:
- 过期清理:expires_at 字段非空且已经过期的直接删掉。比如“用户下周去出差”这种临时状态,任务结束后就没意义了。
- 低重要度合并:把同样用户下面 importance 低于 0.3 的记忆,按主题聚类合并。比如用户在不同时间说过“最近有点累”“这几天睡眠不好”,可以合并成一条“用户近期睡眠质量不佳,容易疲劳”。
清理任务的代码粗略如下:
def cleanup_expired_memories(): expired = vector_store.list( filter={ "expires_at": {"$lt": datetime.utcnow().isoformat()} } ) for item in expired: vector_store.delete(ids=[item["id"]])这个任务不要跑得太频繁,每天或者每周一次就够了。频繁跑只会增加负载,对数据质量帮助不大。
4. 踩过的坑和排查实录
4.1 向量维度不一致导致写入失败
这件事发生在我换 embedding 模型的那天,印象太深了。
项目一开始用的是社区里的一个老模型,768 维,后来发现它在专业领域的语义理解不够好,换成了一个更强的模型。兴致勃勃地配置完,一跑写入接口,直接报错。翻日志发现是维度对不上:新模型输出 1024 维,集合索引还是按 768 维建的。
解决办法有两种:一个是重建集合再全量导入,另一个是新建一个集合,用不同前缀区分。我选了后者,因为重建集合要跑全量数据迁移,太折腾了。
这个坑的教训是:每次更换 embedding 模型前,必须确认新模型的输出维度,并提前规划好集合的拆分或重建方案。
4.2 召回率低,Agent 还是像失忆
记忆层接上以后,我满心期待用户再也不用重复自己的偏好了。但跑了两天发现,效果并没有想象中好,用户还是会在对话里抱怨“我不是说过了吗”。
排查方式也很笨:把检索到的记忆和对应的相似度分数全部打印出来。
打印结果让我很意外:检索到的记忆其实挺多的,但分数普遍很低,很多只有 0.2 左右。我一开始设置的阈值是 0.6,结果就是几乎所有记忆都被过滤掉了,等于 Agent 根本没有拿到任何记忆。
后来我统计了一周的数据,发现大多数有效记忆的分数集中在 0.35 到 0.5 之间。低于 0.3 的基本是无关噪声,高于 0.6 的少之又少。于是我把阈值调到 0.35,召回率明显上升,用户体感也上来了。
这里给一个经验:阈值不是拍脑袋定的,一定要先跑一段时间看分数分布。每家的数据不一样,最靠谱的办法就是收集一批真实用户问题,离线计算分数分布,再决定阈值砍在哪里。
4.3 记忆重复膨胀
跑了两周以后,我有一个清晨打开向量集合看了一眼,差点没被吓到:同样是“用户不吃香菜”这条信息,集合里躺着六七条,写法分别是“用户不喜欢香菜”“用户不吃香菜”“用户对香菜过敏”“别放香菜”等等。
原因是写入链路里的相似度检查阈值 0.9 设得太高。这些句子语义明明是一回事,但向量相似度只有 0.75 到 0.85,根本达不到阈值,于是每条都变成了新记忆。
解决方式是把阈值降到 0.8,同时增加一个 LLM 判断:当相似度在 0.7 到 0.8 之间时,让模型判断这两条是否在说同一件事。这个方法能覆盖更多边界情况。
顺便说一句,重复记忆不是小问题。它不仅浪费存储,还会在召回时重复占用 prompt 的 token 额度。本来能塞 8 条不同维度的记忆,结果 4 条都在说同一个小事,召回质量自然拉胯。
4.4 Embedding 成本控制
如果你用 API 方式的 embedding,一定要做好成本控制。我第一版很随意,每条消息都调 embedding,后来看账单才意识到这个模块的消耗远超预期。
我做了三个优化:
- 对话级缓存:同一个用户十分钟内的消息,若内容高度相似,直接复用之前的 query 向量,不再重新计算。
- 批量处理:记忆抽取和写入放在异步队列里,攒够一批再统一跑 embedding,而不是一条一条调用。批量调用通常比单条调用便宜很多。
- 本地化部署:最终方案是在内网部署了一个 embedding 模型服务,成本变成硬件投入,调用费用直接清零。
4.5 常见问题速查表
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 写入失败 | collection 维度与模型维度不一致 | 检查 dim 配置 |
| 查询结果为空 | 阈值过高或过滤条件过严 | 打印分数分布,降低阈值 |
| 召回的都是一样的内容 | 重复记忆未合并 | 降低相似度合并阈值,增加 LLM 去重 |
| 回复仍然像失忆 | 记忆没有被注入 prompt | 检查读取链路是否执行到了注入那一步 |
| Agent 引用过期信息 | 记忆更新逻辑没生效 | 排查更新逻辑,确认旧记忆是否被替换或删除 |
5. 几个让我印象深刻的教训
5.1 记忆不是日志,别什么都往里面存
一开始我把抽取 Prompt 写得比较宽松,想让模型多抓一点信息,结果系统跑了两周后,集合里堆满“用户晚上 11 点问了天气”“用户说今天很忙”这类一次性信息。
这些内容不但浪费存储,还会在检索时抢占名额,把真正重要的记忆挤掉。后来我把 Prompt 里的规则收紧,加了明确判断:只有“用户偏好”“关键事实”“任务状态”三类可以存,其他的一律忽略。效果立竿见影,召回准确率上了一截。
5.2 记忆注入会影响模型的行为方式
有了记忆以后,模型不再是从零开始,它会对用户形成一种“预判”。这有好的一面,也有麻烦的一面。麻烦在于模型有时候太相信记忆,明明用户已经说“我现在不吃辣了”,它能给出一堆“记得您以前很喜欢吃辣”的废话。
我后面痛定思痛,在系统提示词里强制加了一行:“记忆只作为背景参考,不作为当前指令。”并且每次记忆注入时都会带上获取时间和来源标记,让模型有依据去判断这条记忆是否过期。
5.3 记忆模块要设计成可开关的
这可能是最容易被忽略的一点。
我在 Agent 的配置文件里加了一个环境变量MEMORY_ENABLED。当它设为 false 时,系统跳过读取和写入链路,Agent 退化为无记忆模式。
这个开关不是为了演示,是为了做对照实验。不加记忆时的回答延迟、成本、用户满意度是一组基线数据;加了记忆之后,同一组指标再测一遍。有了这个开关,你才能客观判断记忆层到底带来了多少收益,而不是凭感觉说“好像有点用”。
5.4 后续的扩展方向
这套记忆层跑稳之后,后面有很多可以继续做的事。
一个是给用户生成“画像摘要”。不用每次实时检索一堆碎片化记忆,而是定期把一个人的记忆汇总成一段结构化描述,读取时直接拿摘要当参考。
另一个是支持用户主动管理记忆。比如对话里用户说“记住这个”“忘了那个”,Agent 就应该实时增加或删除对应记忆条目。这个功能目前需要额外设计指令识别逻辑,但交互体验会明显上一个台阶。
还有就是把记忆和 RAG 结合起来。RAG 解决的是“知识从哪里来”,记忆层解决的是“用户是谁、想要什么”,两者场景不同,但数据层面可以统一用向量检索来打通,让 Agent 既要懂知识,也要懂用户。
最后说点实在的
这套持久化记忆层上线以后,最明显的变化是,用户说过的偏好真的不需要再重复了。我自己的体感是,同样一句话,以前“小龙虾”是礼貌地回答,现在更像是真的“记得”回答。
但要说最深的体会,其实是另一个:记忆层不是一个独立组件,它是会传染的。它会影响你的 prompt 策略、对话流设计、隐私边界,甚至影响你对“用户到底是谁”的理解方式。决定这套系统上限的,不是向量库的查询速度快不快,而是你愿不愿意花足够时间打磨抽取和更新的逻辑。
如果你正在给自己的 Agent 做记忆,我的建议是不要一上来就追求功能齐全,先把“读取 -> 召回 -> 注入”这个最基础的单向链路跑通,再逐步加更新、合并、清理。少走弯路最快的方式,就是先把简单路径走完。