之前把 OpenClaw 部署好之后,我一度觉得这工具已经够用了,直到某天我新建会话问它“我上周让你记的那几件事呢”,它回我一串礼貌的抱歉,我才反应过来:这不就是传说中的金鱼记忆吗。每次切换回话,所有上下文全部清零,聊过的偏好、交代过的任务、说好的称呼,统统蒸发。那段时间我甚至怀疑自己为什么还要花时间调教它。后来我冷静想了想,问题不在模型,而在于 OpenClaw 本身没有任何跨会话的记忆机制。与其抱怨,不如动手,我花了大概 2 小时给它做了一个外挂式的永久记忆系统。这篇文章就把完整的解决思路、核心代码和踩坑过程都整理出来,给同样被 AI 失忆折腾到崩溃的朋友一条可以直接复制的路径。不管你是刚接触这类 AI 助手框架的初学者,还是已经在做二次开发的进阶玩家,照着这套方案改,基本都能让你手头的助手第一次拥有“长期记忆”。
1. 先说清楚:AI 的“记忆”到底丢在了哪里
1.1 无状态的大模型,和有状态的聊天窗口
想解决一个问题,先得定位真凶。大模型本身是无状态的推理引擎,它每次生成回答,靠的是我们在请求里临时塞进去的对话上下文。你可以把它想象成一个非常聪明但完全不记事的人,你给它一张白板和一支笔,它就基于白板上的内容思考;对话结束,白板一擦,脑子里一点残留都没有。
OpenClaw 这类 AI 助手框架本质上只是在这个无状态模型外面包了一层“会话壳”。它把同一会话内的多轮内容拼接起来,通过上下文窗口传给模型,让你产生“它在和我连续对话”的错觉。可一旦你新建会话,或者切换回话,这个上下文拼接就会从零开始,模型自然就把你当成一个全新的陌生人。
很多人把这个问题归结为“模型太蠢”,其实这是架构决定的,不是模型能力问题。上下文窗口是临时内存,不是持久化存储,这个底层逻辑不变,你换再聪明的模型也一样会失忆。
1.2 “金鱼记忆”背后,其实是三种需求混在一起
我在被这个问题折磨的过程中发现,用户所说的“记忆不好”往往不是单指一件事,而是三种不同需求的混合体:
| 需求类型 | 典型表现 | 会话内能不能解决 | 是否需要永久记忆 |
|---|---|---|---|
| 短期对话连贯 | 刚才聊的话题,下一轮别接不上 | 能 | 不需要 |
| 用户偏好记忆 | 我喜欢简洁回答、叫我“老张” | 不能 | 需要 |
| 长期事实记忆 | 我下周二体检、我已经完成的计划 | 不能 | 需要 |
| 任务进度记忆 | 那个生成脚本写到哪一步了 | 不能 | 需要 |
如果只是会话内不连贯,那属于上下文长度或轮次限制问题,调参就行。但用户真正抱怨“金鱼记忆”“切换回话遗忘”的时候,痛点集中在后三类。这类记忆的特点是不会因为新建一个会话就失效,但它和你聊天的即时上下文又没有任何关系。
所以,永久记忆系统的设计目标非常明确:让 AI 在每次新会话开始时,能从某个持久化存储里取出和当前用户相关的历史信息,并把它当作背景知识注入到对话中。听起来不难,但真做起来,需要在合适的位置采集、合适的时机检索、合适的格式注入,每一步都有坑。
2. 三条路线对比:为什么我选了“外挂记忆层”
2.1 微调、硬编码、外挂记忆,哪条路适合个人项目
知道要继续动手后,我认真比较了给 AI 装记忆的常用路线,不夸张地说,我连“直接微调模型”这种重路线都考虑过。
第一条路是微调模型权重。把用户的历史对话整理成训练数据,让模型在参数层面“记住”一个人。但这条路基本劝退,一是成本极高,本地机器跑大模型微调,显存和耗时都让人绝望;二是你每次想让 AI 记住一个新事实,都得重新训练一轮,时效性太差;三是有过拟合风险,把某个用户的口头禅永久焊死在模型里,换个人用就麻烦了。这套方案适合产品级的个性化定制,不适合个人助理。
第二条路是把常用信息硬编码进系统提示词。比如把“用户叫老张,喜欢简洁回答”直接写进 system prompt。这个方法非常简单,改一行配置就行,但它只能覆盖静态信息,对动态变化的信息无能为力。我今天新交代的待办,不可能每次对话前手动改一次配置文件,治标不治本。
第三条路就是最终方案:外挂记忆层。核心思路是把记忆看成一种可以被动态读取的外部数据,每次对话前实时检索、动态注入,对话结束后又实时写入新记忆。它不碰模型权重,不需要重新训练,所有记忆数据都以结构化文件的形式存放在本地,既能随时增删改查,又能灵活控制注入内容。
三条路线放在一起对比,差异非常明显:
| 方案 | 实现成本 | 动态更新 | 适用场景 | 维护难度 |
|---|---|---|---|---|
| 微调模型 | 极高 | 极差 | 产品级个性化 | 很高 |
| 硬编码提示词 | 低 | 差 | 静态偏好 | 低 |
| 外挂记忆层 | 中 | 好 | 个人长期助理 | 中 |
我为什么坚持选第三条?因为对于 OpenClaw 这种已经在用的助手,外挂记忆层可以做到不改变原有对话逻辑的前提下独立运行,出问题随时回滚,而且记忆文件是纯本地的,读取和备份都非常自由。
2.2 记忆层的六个核心组件,一环都不能少
外挂记忆层说起来只是一个概念,落地时我把它拆成了六个具体模块,分别是采集、切片、向量化、存储、检索、注入。每个模块负责一件明确的事,缺一不可。
采集模块负责从 OpenClaw 的对话流程里把每一轮用户消息和 AI 回复捞出来,统一成标准格式。没有这一步,后面一切都是空中楼阁。切片模块解决的是“记忆不能太碎也不能太长”的问题。一整段长对话直接存入向量库,检索时既不精准又浪费算力,按合理的窗口切成小段,才能让后续检索命中率更高。向量化模块负责把文本变成机器能算相似度的向量,这是决定检索质量的核心。存储模块其实就是那个带持久化能力的向量数据库,负责把向量和原始文本落盘。检索模块是记忆的“读取口”,每次新会话开始时,用它把和当前问题最相关的记忆捞出来。注入模块则是最后一公里,把捞出来的记忆伪装成背景信息塞回给模型。
这六个模块是一条完整的数据流,任何一个环节偷懒,都会直接影响最终效果。我在实际开发中一度想跳过切片,结果就是检索结果非常粗糙,后来老老实实把切片模块补上,效果立刻好了很多。
3. 2 小时实操实录:从零给 OpenClaw 接上永久记忆
3.1 准备阶段(0~10 分钟):目录与存储规划
动手前先规划目录结构。永久记忆系统和主程序要尽量解耦,所以我单独建了一个记忆模块目录,里面放了三个子目录和两个核心文件:
memory_store/:向量数据落盘目录logs/:原始对话日志目录backup/:定期备份目录memory_engine.py:记忆系统主逻辑config.json:记忆参数配置
安全起见,我给 OpenClaw 单独建了一个 Python 虚拟环境,所有依赖都装在虚拟环境里,避免污染系统级环境。然后打开 OpenClaw 的配置文件,找到它的数据目录配置项,把记忆模块的路径加进去,保证主程序启动时会加载我们这个外挂模块。
这里有个容易被忽略的点:记忆库文件路径必须用绝对路径或者相对于主配置的固定路径,绝对不能写相对当前工作目录的路径。我第一次部署就是用了相对路径,结果从不同目录启动 OpenClaw 时,记忆库路径不一致,程序直接找不到数据,表现就是“明明存了记忆却一点读取不到”。
3.2 采集通道(10~30 分钟):先把对话流水记录下来
记忆系统要工作,首先得保证每一轮对话都被记录。OpenClaw 本身预留了消息事件钩子,在消息发送和消息返回这两个时机都会触发回调,我在钩子里把对话内容统一写入一个 JSONL 日志文件,每一行对应一条结构化消息。
日志格式大致长这样:
{"session_id": "sess_20250218", "role": "user", "content": "记得帮我记录下周二体检", "ts": 1735000000} {"session_id": "sess_20250218", "role": "assistant", "content": "好的,已记下你下周二体检", "ts": 1735000010}为什么用 JSONL 而不是 JSON?因为 JSONL 是逐行追加的,每一行可以独立解析,写入时性能好、崩溃时也容易恢复。如果 OpenClaw 没有提供事件钩子版本,另一个通用做法是在网络请求层做包装,拦截所有进出的大模型接口请求体,从请求里的消息数组里解析出对话内容,效果是一样的。
采集这一步的核心原则是“先存原始数据,再做加工”。不要当场就做向量化,因为向量化需要时间,会拖慢对话响应;先把原始文本落盘,再用异步任务去处理,这样对原有对话速度几乎没有影响。
3.3 切片与向量化(30~55 分钟):让旧对话变成可检索的记忆
原始对话日志越积越多,不能直接整段往向量库里丢。原因很简单:检索时是一次性把用户当前问题转成向量,然后在整个库里找相似度最高的那一段。如果库里面是一整段几百轮的对话,向量相似度会被大量无关信息稀释,很难精准命中某一小段事实。
我的切片策略是按“轮次窗口 + 字符上限”双重约束。窗口默认取最近 6 轮对话,包含用户消息和 AI 回复,同时限制每块文本最多 800 到 1000 字符。如果 6 轮内容太长,就按字符截断,并让相邻切片之间保留大约 20% 的重叠。重叠是为了防止关键信息恰好落在切片边界被切碎。
核心伪代码大概是这样的:
def chunk_messages(messages, window=6, max_chars=900, overlap=2): chunks = [] i = 0 while i < len(messages): end = i + window piece = messages[i:end] text = format_piece(piece) if len(text) > max_chars: # 按字符截断,同时把尾部 200 字保留到下一段开头 head, tail = text[:max_chars], text[max_chars:] chunks.append({"text": head, "meta": piece[0]}) # 让下一段窗口从尾部的起点开始 i = end - overlap else: chunks.append({"text": text, "meta": piece[0]}) i = end return chunks向量化我用的方案是本地的轻量 Embedding 模型,不需要额外联网,几百 MB 的模型文件 CPU 也能跑。你可以把它理解成一个把文字变成一串数字的工具,数字越接近,代表语义越相关。日常个人项目用这种本地模型完全够,速度比云端接口慢一点,但胜在免费、私有、无网络依赖。
3.4 持久化写入(55~75 分钟):别每次启动都全量重建
向量化完成后,就要把切片文本、向量和元数据一起写入持久化存储。我选的向量库是嵌入式轻量方案,不需要单独起服务进程,数据直接存在本地文件夹里。写入流程非常简单,遍历切片,生成唯一的切片编号,然后调用写入接口:
import uuid def save_chunk(session_id, ts, text, embedding): chunk_id = str(uuid.uuid4()) store.upsert( id=chunk_id, vector=embedding, payload={ "session_id": session_id, "ts": ts, "text": text, } )有个关键点必须注意:这里是增量写入,不是全量重建。我最初犯过一个典型错误,每次对话结束时把整个日志重新向量化一遍再全量写入库里,结果随着对话变多,一次重建要几十秒,而且 OpenClaw 重启后第一次对话被卡得死死的。改成增量写入后,只有新的轮次会被切片入库,老数据原封不动,性能问题完全消失。
另外,存储时一定要把 session_id 和时间戳存进 payload。这两个字段后面有大用途:session_id 用来做会话隔离,时间戳用来做记忆衰减和摘要压缩,没有它们,这个记忆库很快就变成一团乱麻。
3.5 召回与注入(75~100 分钟):新会话也能“想起”旧事
记忆写进去了,还差临门一脚:在新会话时把它捞出来喂给模型。我在 OpenClaw 的会话初始化流程里挂了一个记忆检索入口,每次新会话建立时,取用户当前第一条消息作为查询向量,从库里召回最相关的记忆片段:
def recall_memory(query_text, top_k=5, min_score=0.35): query_vec = embed(query_text) hits = store.search(query_vec, top_k=top_k) results = [] for hit in hits: if hit.score < min_score: continue results.append({ "ts": hit.payload["ts"], "text": hit.payload["text"], }) return results这里的分数阈值需要根据实际测试调整,是个关键参数。阈值设太高,比如 0.6,大部分记忆都会被过滤掉,等于没装;阈值设太低,又会把一堆无关旧账翻出来,加重模型负担。我实测下来 0.35 到 0.45 这个区间对短文本提问比较舒服。
召回的片段要经过格式化才能注入系统提示词。我采用的模板结构是“历史记忆”专用块,把它放在系统提示词靠后的位置,紧挨着用户消息:
以下内容来自用户的历史对话,请参考它们,但不要主动强调“根据记忆”: 【记忆记录·时间戳: 1735000000】 用户:记得帮我记录下周二体检 助手:好的,已记下你下周二体检为什么要放在靠后位置?我观察发现,大模型对系统提示词后半段和靠近用户消息的内容敏感度明显更高,放在太靠前容易被长篇系统指令淹没。注入时还要在模板前面加一句“参考历史,但不要主动提及”,避免模型每次回答都画蛇添足地说“我记得你说过”,那样对话看起来非常机械。
3.6 记忆维护(100~120 分钟):给旧记忆做折旧和压缩
记忆系统跑起来之后,下一个问题很快浮现:对话越来越多,向量库里的记忆碎片成倍增长,检索速度变慢,召回噪声变大。这时候需要给旧记忆做“折旧”处理。
我的做法是轻量级的两层策略。第一层是时间衰减,检索时对超过 30 天的记忆片段做分数打折,让新记忆在竞争中有天然优势。第二层是摘要压缩,每天晚上定时任务跑一次,把 30 天以上的连续对话切片汇总成几条短摘要,单独存进一个“长期事实区”。比如 2 月 18 日和 2 月 20 日聊了三次关于体检安排的事,摘要压缩后就变成“用户下周二上午在综合医院体检,需要提前一天晚上 10 点后禁食”。
这个设计的好处是,短期记忆追求细节,长期记忆只保留事实结论。模型回答时先看长期事实区,再看最近的短期记忆,既不会遗漏关键信息,也不会被几十天前的对话噪音干扰。定时压缩任务我用系统计划任务来跑,完全不影响日常对话流程。
4. 故障实录:永久记忆系统最常见的四个翻车现场
4.1 存了却召回不了:阈值和调用时机是重灾区
我遇到的第一个翻车现场,是记忆明明存进库了,新会话里却完全想不起来任何东西。排查下来原因有两个。
第一个原因是召回函数根本没有被调用。我在 OpenClaw 的初始化钩子里写了调用逻辑,但新会话和旧会话共用了同一个初始化流程,导致有些会话跳过了检索入口。排查方法是在召回函数里加一条调试日志,新会话建立时看有没有打出“recall”日志就知道有没有触发。
第二个原因是分数阈值设得太高。当时我保守地把阈值设成 0.6,结果大多数记忆片段都被拦在门外,看起来就像系统完全失忆。把阈值降到 0.35 并打印每次召回的分数分布后,效果立刻正常了。建议第一次上线时先把阈值调到零,看一轮实际检索分数,再根据分布曲线定合理的阈值。
4.2 召回关联太差:短问题怎么做混合检索
另一个高频问题是,用户新会话问的是“我的体检安排是哪天?”这种短问题,但召回来的却是一堆关于工作、饮食、运动的杂七杂八内容,基本等于瞎猜。问题出在纯向量检索对短文本不敏感。用户一句话的向量和库里几百字的切片向量做相似度计算时,语义区分度不高,召回结果自然不精准。
我后来把单路召回改成了混合检索:向量相似度检索和关键词检索同时进行,然后把两组结果合并,取交集或者按加权分数重排。关键词命中的结果优先级高,向量相似度结果作为补充,两者结合后短问题的召回准确率明显提升。另外,把切片时间戳纳入排序条件也很有效,分数相近时优先取最近的记忆,更符合实际使用习惯。
4.3 记忆挤占上下文:限制条数与动态截断
记忆注入太多,还会把正常对话的上下文预算挤占掉。我最初设置找回 10 条记忆,每条切片平均 900 字,等于每次对话开头硬生生塞进去 9000 字的历史记忆。模型对当前问题的关注度会下降,而且如果底层模型上下文窗口较小,可能直接报超出长度限制。
解决办法是按 token 预算动态截断,而不是固定条数。给记忆块分配一个固定上限,比如 1500 token,实际召回时从分数最高的记忆开始攒,攒满预算就停止。这样既保证了记忆参考量,又不会透支上下文。考虑兼容不同模型,我还在配置里加了最大记忆 token 数,换模型时只需要调这个参数,不用改代码。
4.4 多人多会话共库:按会话隔离才是正确姿势
最后一个典型坑来自多人或多会话共用同一个向量库。我一开始设计的库把所有会话数据混在一起,结果发现 A 会话的记忆跑到 B 会话里去,回答时张冠李戴。如果你只是个人使用,这个问题不凸显;但如果你把 OpenClaw 跑成家庭共享助手,或者给不同项目各开了独立会话,就必须做隔离。
最轻量的做法是在每个记忆片段的 payload 里加一个 namespace 字段,比如用户 ID 或者会话类别。检索时在内存边构造过滤条件,只搜索当前命名空间下的记忆。这样不同用户、不同项目用的还是同一个向量库和同一套记忆代码,互不污染,管理起来也不复杂。
4.5 常见问题速查表
顺手把最近调试时遇到的问题整理成一张表,遇到同类故障可以直接按表排查:
| 问题现象 | 原因分析 | 解决办法 |
|---|---|---|
| 新会话完全无记忆 | 召回函数未触发 / 阈值过高 | 检查初始化钩子,打印召回日志;阈值降为 0.35 再观察 |
| 召回的旧记忆杂乱无章 | 纯向量检索对短问题不敏感 | 改用向量+关键词混合检索,加入时间衰减排序 |
| 对话响应变得又慢又贵 | 每次写入全量重建向量库 | 改为增量写入,只处理新增切片 |
| 上下文窗口被记忆塞满 | 返回记忆条数或 token 过多 | 设置记忆 token 上限,按分数动态截断 |
| 多会话记忆串味 | 缺少命名空间隔离 | payload 加 namespace 字段,检索时过滤 |
| 记忆数据损坏无法读取 | 启动时被多个进程并发写 | 用写锁或队列串行化写入,存储目录单独隔离 |
5. 收尾前再说几句:这套外挂记忆还能往哪延伸
这套永久记忆系统跑了一个多月,我最大的体会是:记忆的数量不重要,记忆的质量才重要。最初我以为把每句话都保存下来就是最完整的,结果发现重点应该放在那些真正具备长期价值的信息上。后来我给采集模块加了一个简单过滤器,把每天的寒暄、无意义闲聊直接丢掉,只保留用户明确交代的事实、偏好和任务状态,检索效率和回答准确度反而上去了。
这套架构往后扩展的空间也很大。比如可以给记忆加入标签体系,让老张的体检安排、项目计划、饮食偏好分成不同分类,召回时按分类匹配实时意图;还可以把记忆文件做成定时导出,换新机器时直接迁走,等于给 AI 助手做了一次无损的“记忆搬家”。另一个值得尝试的方向是把摘要压缩升级为分层记忆,短期细节、中期摘要、长期事实各占一层,检索时逐层向上推导,理论上可以让记忆系统覆盖更长的时间跨度。
最后分享一个实用的小习惯:每次改完记忆模块参数,先拿同一个问题做三组对比测试,一组不带记忆、一组带记忆、一组带记忆并调高阈值,看回答差异。这个方法能快速判断改动是正向还是负向。我在调参阶段一直坚持这套测试流程,省掉了大量盲试时间。永久记忆这件事听起来玄乎,拆开了其实就是一个采集、存储、检索、注入的工程问题,愿意动手的人都能把它装到自己那台爱失忆的 AI 上。