之前把 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, window6, max_chars900, overlap2): 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( idchunk_id, vectorembedding, 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_k5, min_score0.35): query_vec embed(query_text) hits store.search(query_vec, top_ktop_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 上。
阅读完成 · 觉得有帮助?