如果你也受够了 AI 的“金鱼记忆”这个标题里的每一个字我猜你都会共鸣。我在日常工作里重度依赖自己部署的一个开源对话智能体项目 OpenClaw前后用了挺长时间什么都好唯一让我崩溃的就是它会话切换后彻底失忆——上一个会话还在聊项目进度新建一个会话它连你叫什么都要重新问。反复折腾几次之后我实在受不了了抽出两个晚上里的有效两小时给它接上了一套跨会话的永久记忆系统。这篇文章就把当时的思考、实现方案和踩过的坑全部摊开。里面没有“高大上的架构”只有能让对话智能体真正记住你的实操手段不管你是普通用户还是开发者只要在接触这类带会话概念的工具应该都能找到可复用的思路。为什么 AI 会失忆这事儿看起来像 bug其实是架构设计造成的。搞清楚这一点后面的方案才有方向。1. 为什么 AI 会话一切换就“金鱼失忆”1.1 会话隔离机制看似安全实则信息孤岛要解决失忆问题先得明白为什么会失忆。大多数对话类应用都采用会话session机制会话之间相互隔离。这么做有两个原因一是隐私安全自己的对话记录不该被其他会话随便读取二是工程实现简单每个会话独立存储互不干扰。但站在使用者视角这就变成了典型的信息孤岛问题。我用 OpenClaw 的时候经常同时开着十几个会话有的是临时问答有的是深度讨论有的是帮我整理文档。一旦分完类每个会话就像独立世界同一个背景信息我要重复十几遍。会话隔离就像你在不同窗口排队办业务每个窗口都不共享你的资料你必须每次都重新填表。这不叫安全叫折磨。1.2 失忆的真正根源上下文窗口的“临时工”属性很多人误以为大模型本身有记忆。真实情况是模型参数里存的是训练阶段学到的通用知识你这个人聊过什么、喜欢什么、住在哪里模型参数里根本没有。我们感受到的“记忆”其实只是每次请求时把所有历史消息重新塞进上下文窗口让模型“现场阅读”一遍。上下文窗口是一个临时缓冲区请求结束就释放了。OpenClaw 这类框架做的“多轮对话”本质就是把同一个会话 ID 的历史消息从存储里拉出来重新拼接后再次塞进模型输入。新会话因为没有任何历史记录模型当然什么都不记得。这也是为什么换一个平台、新建一个会话AI 立刻变回陌生人。1.3 我踩的第一个坑以为“续聊”就是记忆OpenClaw 侧边栏有历史会话列表点进去可以继续聊。最开始我天真地以为这就是记忆系统后来发现它只是给同一个 session 追加消息换个新会话提问之前的上下文完全不会被带过去。有一次我在会话 A 里让 AI 帮我写了详细的周报模板然后切到会话 B 问“上次那个周报模板结构再给我一份”它一脸茫然。那一刻我才意识到“续聊”只是历史记录的延续不是记忆系统的延续。这也解释了为什么很多人用对话工具时会把同一类信息反复说很多遍——不是用户愿意而是产品没有跨会话记忆。这个坑直接催生了后面的完整改造。2. 两小时方案选型为什么最后选了“分层记忆本地持久化”2.1 永久记忆的三种常见路线对比动手之前我先在纸上列了三条可能的路线简单做了一个对比方案做法优点缺点全量历史回灌每次把历史消息全部拼接进上下文实现最简单几乎不用改逻辑上下文窗口爆炸、费用指数上涨、越聊越慢RAG 向量检索历史入库并向量化根据当前问题召回 Top-K 相关片段上下文可控、成本低、扩展性好检索质量依赖向量模型和阈值设置摘要档案记忆会话结束后让模型生成结构化摘要累积成用户档案信息密度高、直觉清晰细节容易丢需要额外模型请求真实产品往往三者结合。我最终选择“向量检索 档案摘要”的折中方案。全量历史回灌第一个被否决因为我做过一次实测把一周的聊天记录全部塞进一次调用瞬间消耗近两万 token追问三四轮之后就逼近窗口上限价格和延迟都完全不可接受。2.2 我的取舍逻辑成本、隐私、可控性为什么最终选定本地优先的架构核心逻辑有三条。第一是隐私。记忆是最敏感的数据我不想把对话记录全部交给某个云端服务。存储用 SQLite向量化用本地模型整个记忆系统跑在本机数据不出门。第二是成本。检索时只拿 Top-K 条记忆通常 3 到 8 条换算成 token 开销非常小几乎可以忽略。相比全量回灌长期使用的成本低一个数量级。第三是可控性。拿着数据库文件我可以一条条 SQL 查看到底存了哪些记忆、召回了哪些内容。出了问题直接改数据没有黑盒。这一点在调试阶段帮了我大忙。2.3 整体架构三层记忆怎么分工这套系统最终拆成三层短期记忆当次会话内的完整消息原样保留维持正常多轮对话。长期记忆所有历史消息经过向量化后进入本地索引根据当前问题召回相关片段。档案记忆定期或手动对重要事实做结构化摘要比如用户偏好、正在推进的项目、基础背景信息。运行流程是这样的用户发言先落库轮到给模型构造上下文时把当前问题向量化在历史向量表里做相似度检索取超过阈值的几条记忆拼进 system prompt再和原始对话历史一起交给模型。模型回复后同样写回数据库。整个实现时间分配大约是架构设计 30 分钟存储模块 40 分钟检索模块 30 分钟注入改造 15 分钟调优和踩坑 25 分钟加起来 140 分钟左右。能控制在 2 小时一个重要前提是我不追求优雅封装第一版能跑就行。3. 动手实现给 OpenClaw 挂上永久记忆的完整步骤3.1 环境准备与目录设计我建议先建一个独立目录把记忆系统和 OpenClaw 主逻辑分开方便以后维护。目录结构大概是这样openclaw-memory/ ├── app.py # 主入口改造 ├── memory/ │ ├── store.py # SQLite 读写 │ ├── embed.py # 向量化与检索 │ └── inject.py # 记忆注入 ├── models/ # 本地向量模型缓存目录 └── memory.db # 记忆数据库依赖很简单Python 3.10 以上sqlite3 是标准库自带的numpy 用来做向量计算再加一个本地向量模型库。OpenClaw 本身有自己的运行环境改造点主要在它调用模型前构造上下文的那一段。3.2 记忆存储模块基于 SQLite 的会话归档存储层我用 SQLite没有引入独立数据库服务。原因前面说过单文件、零部署、事务安全随时拷贝备份。总共两张核心表一张存会话信息一张存消息内容。import sqlite3 from datetime import datetime DB_PATH memory.db def init_db(): conn sqlite3.connect(DB_PATH) conn.execute( CREATE TABLE IF NOT EXISTS sessions ( session_id TEXT PRIMARY KEY, title TEXT DEFAULT , created_at TEXT ) ) conn.execute( CREATE TABLE IF NOT EXISTS messages ( id INTEGER PRIMARY KEY AUTOINCREMENT, session_id TEXT, role TEXT, content TEXT, created_at TEXT ) ) conn.commit() conn.close()保存消息和读取历史的函数也简单def save_message(session_id, role, content): conn sqlite3.connect(DB_PATH) conn.execute( INSERT INTO messages (session_id, role, content, created_at) VALUES (?, ?, ?, ?), (session_id, role, content, datetime.utcnow().isoformat()) ) conn.commit() conn.close() def get_history(session_id, limit50): conn sqlite3.connect(DB_PATH) rows conn.execute( SELECT role, content FROM messages WHERE session_id? ORDER BY id DESC LIMIT ?, (session_id, limit) ).fetchall()[::-1] conn.close() return rows注意时间戳字段我用的是 ISO 8601 格式的 UTC 时间。第一版图省事直接用了本机时间后面发现时区混乱检索时时间过滤出问题又回头改了一遍。3.3 向量化检索模块让 AI “想起来”相关旧事存储层只是把历史留住要“想起来”还得靠向量检索。我的做法是把每一条消息作为一个独立片段做向量化后存入单独的索引表。conn.execute( CREATE TABLE IF NOT EXISTS memory_vectors ( id INTEGER PRIMARY KEY AUTOINCREMENT, message_id INTEGER, session_id TEXT, content TEXT, embedding BLOB, created_at TEXT ) )向量化我用的是一个多语言小模型跑在本机不需要联网调用。好处是隐私安全、零延迟坏处是对中文的理解能力有上限这一点后面踩坑部分会细说。import numpy as np from sentence_transformers import SentenceTransformer _model None def get_model(): global _model if _model is None: _model SentenceTransformer(paraphrase-multilingual-MiniLM-L12-v2) return _model def embed_text(text): model get_model() vec model.encode(text, normalize_embeddingsTrue) return vec.astype(np.float32).tobytes()检索函数核心逻辑当前问题向量化和库里所有消息向量做点积因为已经做过归一化点积就等价于余弦相似度。只返回相似度超过阈值且时间在有效期内的记录。def search_memory(query, top_k5, threshold0.35, max_days30): q np.frombuffer(embed_text(query), dtypenp.float32) conn sqlite3.connect(DB_PATH) rows conn.execute( SELECT content, embedding, session_id, created_at FROM memory_vectors WHERE created_at datetime(now, ?) , (f-{max_days} days,)).fetchall() conn.close() scored [] for content, embedding, session_id, created_at in rows: v np.frombuffer(embedding, dtypenp.float32) score float(np.dot(q, v)) if score threshold: scored.append((score, content, session_id, created_at)) scored.sort(reverseTrue) return scored[:top_k]需要额外提一句的是我为什么按消息级而不是按会话级存向量。如果整个会话压缩成一个向量检索粒度太粗容易召回一堆无关内容消息级可以精确定位到某一条具体信息。代价是向量表会大一些但日常使用数据量完全扛得住。3.4 注入记忆到会话上下文的改造点存储和检索都完成之后最关键的一步就是把记忆注入到模型输入里。OpenClaw 改造点最终落在 app.py 里具体位置是调用模型前构造参数的地方。MEMORY_PREFIX [系统记忆-来自历史会话] def build_prompt_with_memory(session_id, question, history): memory_items search_memory(question, top_k3) memory_context if memory_items: lines [f时间{t}内容{content} for _, content, _, t in memory_items] memory_context MEMORY_PREFIX \n.join(lines) \n system_prompt ( 你是一位对话助手。下面是用记忆模块检索到的历史信息 如果与当前问题相关请自然融入回答如果无关请忽略。\n memory_context ) return [{role: system, content: system_prompt}] history记忆必须放进 system prompt而不是拼在用户消息后面。拼在用户消息后面的记忆会被模型当成“用户当前输入”导致它复述记忆而不是回答真实问题。这个问题我在踩坑部分会详细展开。4. 实测切换会话后的记忆恢复效果与回落4.1 测试场景设计三个典型用户提问装好之后最想验证的当然是跨会话提问到底能不能命中历史信息。我设计了三组典型场景场景一会话 A 里随口说了一句“我住在阳光小区”然后新建会话 B 问“你记得我住哪吗”。这测试最基础的事实记忆。场景二会话 A 里反复强调“我完全不能吃香菜”新建会话 B 让 AI 推荐晚饭而且要求避开忌口。这测偏好记忆。场景三会话 A 里让 AI 帮忙改了一段 Python 处理 Excel 的脚本第二天新建会话问“我上周在弄什么脚本”。这测项目状态记忆。三组场景分别记录了无记忆系统时和有记忆系统时的表现。4.2 效果对比恢复前 vs 恢复后直接上结果测试场景无记忆系统时的回答有记忆系统时的回答你记得我住哪吗抱歉这是新会话我还不了解您正确说出阳光小区并补充“这是之前聊天中提到的”推荐晚饭避开忌口推荐了普通菜品包含香菜明确避开香菜并说明这是根据历史偏好做的选择我上周在弄什么脚本完全不知道你在说什么能复述脚本用途还能记得当时卡在哪个问题从回答质量看改善是肉眼可见的。性能方面无记忆时单次调用模型返回约 0.8 秒有记忆时向量检索约 120 毫秒额外注入 3 条记忆增加约 300 token总耗时约 1 秒。体感上几乎没有差别费用也完全可以接受。但要说清楚这不是 100% 命中。模糊问法比如“我之前说过什么来着”在历史消息很大的情况下仍然可能召回不相关的内容。这也是为什么我后来又补了档案记忆的思路放在最后一部分讲。4.3 回落与误召回我的处理策略实测中最烦的不是召回不到而是误召回。有一次我问“今天天气怎么样”系统从历史里召回一条“上个月聊天气温变化”的记录模型就画蛇添足地回了一句“根据您上个月提到的天气情况”。虽然没有答错但非常啰嗦也容易误导。我的处理策略有三条第一设置相似度阈值。低于阈值就不注入宁可让模型“不知道”也不要让它拿似是而非的历史来说事。第二在召回的记忆前面加时间戳。让模型知道“这是多久前的信息可能已过期”避免用户现实情况变了它还在按旧记忆回答。第三在 system prompt 里写清楚“历史记忆仅供参考与当前问题无关就忽略”。这一步很重要相当于给模型一个明确边界。误召回最麻烦的地方是会污染回答基调让用户觉得 AI 鬼打墙。阈值宁可调高一点让记忆少而精也别追求全而杂。5. 踩坑记录两小时里最耗时间的三个问题5.1 中文切词与向量维度的“水土不服”第一个坑出现在最基础的向量化环节。第一版我用了一个英文表现很好的向量模型效果惨不忍睹。打印召回结果时发现“住在阳光小区”和“今天阳光很好”被算成了高度相似。原因很简单英文模型对中文词义区分能力差它只知道“sunshine”之类的高频概念无法分辨是“住的地址”还是“天气状况”。排查过程其实很笨我把测试消息一条条打印出来对比召回的相似度和排序才定位到是向量模型语言支持的问题。解决方式也很直接——换成多语言向量模型。换完以后中文召回准确率立刻上去一个台阶。经验总结就是中文场景务必选多语言模型而且不要光看模型榜单必须拿你自己的几组样本先跑一遍看看能不能命中你想命中的意思。5.2 SQLite 并发写入导致的会话卡顿第二个坑更隐蔽。OpenClaw 处理多会话请求时是并发的多个线程同时写 SQLite偶尔就会冒出一句database is locked。这个错不会每次都出现频率很低特别像偶发 bug很容易让人误以为是 OpenClaw 本身的问题。排查链路是这样的先打开 OpenClaw 的日志发现错误都发生在消息落库那一步然后给存储模块单独写了一个压测脚本一次性并发写入 100 条消息稳定复现了锁错误。确认问题在 SQLite 并发写入。解决办法是引入一个内存写队列由一个后台线程串行处理所有写请求同时开启 WAL 模式。import queue import threading _write_queue queue.Queue() def worker(): conn sqlite3.connect(DB_PATH, timeout5) conn.execute(PRAGMA journal_modeWAL) while True: sql, params _write_queue.get() if sql is None: break conn.execute(sql, params) conn.commit() def save_message(session_id, role, content): _write_queue.put(( INSERT INTO messages (session_id, role, content, created_at) VALUES (?, ?, ?, ?), (session_id, role, content, datetime.utcnow().isoformat()) ))读操作仍然走独立连接因为读多写少不需要排队。这个改动之后压测脚本再也没出现过锁错误OpenClaw 侧也稳定下来。5.3 记忆注入时机不对导致“答非所问”第三个坑完全是我自己犯的粗心错误。最开始实现注入时我没有把记忆放进 system prompt而是直接拼在用户消息后面。效果非常诡异你问“推荐一个周末能去的地方”模型会回复“您之前提到住在阳光小区那么阳光小区附近可以去……”这种生硬的召回式回答甚至有几次直接把记忆内容复述了一遍。我打印出最终发给模型的 messages 列表才恍然大悟用户消息后面出现的文本模型会默认是当前对话里用户说的新内容。记忆放在那里自然会被当成用户新增需求模型当然会围绕记忆本身回答。解决方式就是前面 3.4 节写的把记忆放到 system prompt并且用一句话明确“这是检索到的历史信息不是当前用户输入”。模型天生是遵循指令的给它一个清晰定位它就知道该怎么用了。这是整个改造中最容易被忽略的细节也是见效最明显的一个修正。6. 这套方案的局限性与后续扩展6.1 目前做不到的事这套方案解决了跨会话失忆但远不是完美的记忆系统。首先它不能跨设备同步。记忆文件只在当前机器上换了电脑就又是“陌生人”。其次模型不会自己意识到哪些记忆过期了目前靠时间衰减和相似度阈值硬控简单粗暴。向量检索还害怕高度模糊的表述。比如用户真的问“你上次说的那件事”这种没有任何关键词的问题向量相似度很难命中正确历史。数据量上也有瓶颈。目前几千条消息全表扫描还算能扛如果积累到几万条、几十万条每次检索全表扫描会明显变慢。到那个阶段就得上真正的向量检索方案了。6.2 后续可以怎么做记忆反思、主动遗忘、跨设备同步这套系统后续有几个很自然的演进方向。一个是定期记忆反思。每天让模型自动浏览当天的对话挑出 3 到 5 条值得长期记住的事实生成结构化“事实档案”单独存一张表。这样做能缓解模糊问法的召回难问题——模型主动把关键信息提炼出来了检索时更容易命中。另一个是主动遗忘。给每条记忆加一个 TTL或者提供手动删除入口。好的记忆系统不是“记住一切”而是知道什么时候该忘。用户换了城市、换了工作昨天的记忆反而可能误导今天的回答。还可以把 memory.db 纳入常用的局域网同步工具让多台设备共享同一份记忆。当然这会带来并发合并和冲突处理的问题需要在应用层做设计。我自己的体会是这套东西跑起来之后最明显的变化不是省了重复输入的功夫而是 OpenClaw 终于像一个真正了解我的助手不再是一个每句话都当作初见的陌生人。如果你也想给手上的 AI 工具装类似功能建议先用最小改动挂一个只读记忆跑几天观察召回的准确率和误伤率再逐步调阈值、加摘要。千万不要一上来就设计一个复杂的分布式记忆系统“需要时能想起来”才是第一优先级。