先声明一下,我下面的文章里会提到“OpenClaw”,这是指一类开源智能体框架的通用代称,不代表任何具体的商业产品或公司。如果你们手上的框架名字略有不同,思路是一样照搬的。折腾了两小时,给我日常用的这个终端智能体OpenClaw装了一套永久记忆系统,终于治好了它那“金鱼记忆”的毛病。这篇就把完整过程、踩过的坑、实测效果全部分享出来,给被同样问题折磨的朋友一个可以抄作业的参考。
1. 先搞清楚:OpenClaw 为什么会“切换即忘”
1.1 会话隔离是默认设计,不是bug
很多朋友一开始遇到“切换回话就遗忘”这个问题,第一反应是自己配置有问题,或者框架有bug。其实真不是。绝大多数智能体框架,包括我日常用的OpenClaw,默认就把每一次会话当成一个独立的上下文单元。每次开启新对话,它面对的就是一块干净的白板。
这个设计思路其实是合理的。想象一下,你给同一个助手连续安排一百个完全不相关的任务,如果它每次都把上一个任务的细节带进来,反而会产生严重干扰。比如你上午让它帮你改Python脚本,下午让它帮你写一封商务邮件,它要是在写邮件时非得先回顾一遍上午的代码逻辑,那效率就太低了。所以会话隔离本身不是为了坑你,是为了保证单次任务的专注度。
但问题在于,OpenClaw 默认的“隔离”太彻底了。它不光隔离了任务细节,连你的长期偏好、项目背景、已经拍板的决定,也一并忘掉了。这就好比同一个助理,你上午跟他说好了“以后所有周报都用表格格式”,下午开个新会,他又问你要什么格式。这不是蠢,是它的记忆机制里压根没有“跨会话长期记忆”这个模块。
1.2 上下文窗口再大也是有限的
有人可能会说:那我把上下文窗口调大不就行了?把 max_tokens 和 context 参数拉满,让它记住更多东西。理论上讲,大窗口确实能容纳更多历史内容,实路上这个方案有两个硬伤。
第一个硬伤是成本问题。现在主流模型的上下文计费大多按输入token算,你把几万字的聊天记录每次都完整塞进去,每一次对话的API费用都会肉眼可见地涨。我实测过,一个中等活跃度的日常使用场景,如果每次对话都带全量历史,单日成本能翻三倍以上。
第二个硬伤更致命:信息过载。模型在长上下文里对“重点信息”的敏感度会明显下降,业内有个说法叫“中间遗忘现象”。你给它的历史记录越多,它反而越容易忽略真正关键的楼层信息。具体表现就是:你问它“按之前的决定,这个项目的截止日期是哪天”,它在几千行聊天记录里翻来翻去,最后给你一个含糊其辞的答案。大窗口不是用来做永久记忆的,它只是一个临时工作台。你非要把整个仓库都堆在桌面上干活,最后一定连工具都找不到。
1.3 真正缺的是“主动记忆”机制
OpenClaw 这类框架在功能上有“插件”“技能”“工作流”这些东西,唯独没有的就是一个内建的“启动时自动加载长期记忆”的机制。它不会主动问你“要不要加载昨天的进度”,也不会在每次会话结束时自动把关键信息提炼存档。
所以解决方案的方向就很明确了:我们自己补上这一层。不是去改模型,也不是去改框架核心,而是在 OpenClaw 的外围搭一套持久化记忆系统,让它每次新建会话时先加载摘要和关键事实,会话结束时再把新信息写回存档。这个思路有点像给人配了一个“秘书”:模型还是那个模型,但每次开会前,秘书先把之前的会议纪要和决策记录放到桌上。
我花的两小时基本都花在这外围系统上,核心代码量反而不大。整套方案的骨架可以用三句话概括:会话结束自动归档、启动时自动加载、长期事实去重沉淀。下面详细拆解。
2. 整体方案:给记忆分三层,而不是塞一个万能库
2.1 第一层:会话摘要(每次结束写一份)
最开始我犯过一个典型错误:想把所有聊天记录原封不动存下来,等模型需要时再查。实际效果非常差,原始聊天记录里噪声太多,而且格式乱、没有结构化。模型检索到了也未必知道哪句话是“用户的决定”,哪句话只是随口一提的备选方案。
所以第一层我设计成“会话摘要”。每次会话结束时,自动生成一份结构化的摘要文件,包含:本次会话的主要目标、达成的结论、明确的决定、待办事项、提及过的关键名词与定义。文件命名带上日期,方便回溯。
比如我某次会话是让它帮我梳理一个Python项目的模块依赖关系,结束时摘要会像这样记录:
session: 2025-01-12-1530 topic: project_dependency_analysis key_decisions: - 采用 graphviz 输出依赖图 - 暂时不处理 dev-dependencies follow_ups: - 生成 requirements.txt 去重后的最终版本 mentions: - FastAPI, SQLAlchemy, alembic这个文件的作用不是给用户看,是给未来的会话当“索引”。新会话启动时,只要读取最近N份摘要,就能快速了解“这个人之前在干什么、进展到哪里了”。标题里的“永久记忆”听起来玄乎,实际落地第一步就是这么朴素的东西。
2.2 第二层:结构化事实库(关于“你是谁、你的项目、你的偏好”)
摘要解决的是“上次做了什么”,但还缺一块是“长期不变的事实”。比如你的名字、你常用技术栈、你写代码时的风格偏好、你正在维护哪几个项目、每个项目的仓库路径是什么。这些事情不需要每次会话都重新记一遍,它们属于“事实库”。
我把这部分单独存成一个结构化文件,格式选JSON,因为OpenClaw处理JSON最顺手,而且后续如果要迁移到其他框架,JSON也是通用格式。结构大致是这样:
{ "user_profile": { "name": "某开发者", "preferred_language": ["Python", "JavaScript"], "code_review_style": "注重可读性,优先小函数,避免过度设计" }, "projects": [ { "name": "某跨平台系统", "repo_path": "/home/me/projects/cross-platform-system", "stack": ["FastAPI", "React Native", "PostgreSQL"], "status": "开发中,核心API已稳定" }, { "name": "某图像处理Demo", "repo_path": "/home/me/projects/image-demo", "stack": ["OpenCV", "PyTorch"], "status": "实验阶段" } ], "preferences": { "reply_tone": "简洁直接,避免客套", "output_format": "优先用表格展示对比结果" } }事实库的特点是更新频率低、查询频率高,每次会话启动都整份加载也不会浪费太多token。我的经验是,这类结构化的东西控制在5KB以内比较合适,内容太多反而稀释重点。
2.3 第三层:语义检索(解决“我记得看过但忘了在哪”)
摘要和事实库覆盖了大多数场合,但总有漏网之鱼:比如两个星期前你让它调研过一个关于“异步任务队列选型”的方案,当时讨论得非常细,但没有被写进摘要里。现在你又想找出来参考,如果只靠摘要目录,得翻很多文件才能命中。
这一层我用的是最轻量的方案:关键词倒排索引。不引入向量数据库,不加embedding模型,就用一个本地脚本给所有归档文件建一个关键词表。检索时先跑关键词,再跑正则捞上下文片段。对于没有复杂语义需求的场景,这已经够用了。
我理解很多教程一看“语义检索”就要上向量库,但做个人记忆系统,先分清需求层次:你是在一个特定的英文文件名列表里找文件,还是在几百万篇文档里做语义理解?前者用关键词足够,后者才需要向量。我选择从简,后续如果记忆量真的超过几千份文档,再把检索层替换成向量库也不迟。
2.4 为什么不用“把全部历史塞进提示词”
可能还有人问:你搞这么复杂干嘛?直接把所有历史文件读出来拼到system prompt里不就行了?
我试过,真不行。一方面token爆炸,会话启动就得带着几万字的历史内容,每问一个问题都烧钱,响应还变慢。另一方面,OpenClaw 对 system prompt 的长度和格式有实际限值的,超过一定量后会被截断,结果就是“新信息没记住,旧信息全乱套”。
更好的做法是:把记忆设计成“按需加载”。启动时加载精华摘要和事实库,遇到相关话题时用检索脚本精确提取片段,再注入当前上下文。你可以把它理解为:不是每天把整本日记念给自己听,而是看目录+翻到对应页码。系统里记的数量和注入提示词的数量是两个维度,前者可以很大,后者必须克制。
我把这三层关系整理成一张对照表,方便理解:
| 层级 | 存储内容 | 更新频率 | 加载时机 | 典型容量 |
|---|---|---|---|---|
| 会话摘要 | 单次会话的目标、决定、待办 | 每次会话结束 | 会话启动带最近5-10份 | 每份0.5-1KB |
| 结构化事实库 | 用户画像、项目列表、偏好 | 不定期手动/自动更新 | 每次会话全量加载 | 3-6KB |
| 检索索引 | 历史会话的关键词与上下文片段 | 每次归档后增量更新 | 仅当话题相关时检索 | 单个片段0.2-1KB |
这套结构的核心思想是控制“固定成本”:无论历史记录积累到多大多完整,每次会话固定的记忆基础开销就那几KB。剩下全部问题都交给按需扩散。下面进入实操环节。
3. 实操:2小时装好一套本地永久记忆系统
3.1 准备阶段:目录结构与工具选型
动手之前先把目录规划好。我给OpenClaw建的记忆目录叫~/.openclaw/memory/,下面分三个子目录,各自对应三层记忆:
~/.openclaw/memory/ ├── sessions/ # 会话摘要 yaml 文件,按日期命名 ├── facts/ # 事实库,JSON格式,目前只有一个核心文件 └── index/ # 关键词索引和临时检索缓存工具方面,我依赖的东西极少:sqlite3命令行工具(用来建索引表)、jq(处理JSON)、python3(写抽取脚本)。如果你机器上没装 sqlite3,用apt install sqlite3或brew install sqlite3补上即可,基础操作不难。
选 sqlite 建索引而不是直接用文本文件,是因为会话文件一多,grep 检索速度会退化到不可接受。sqlite 的 FTS5 全文检索扩展在个人笔记级的数据量上性能绰绰有余,而且索引文件就是一个单独文件,备份迁移都方便。
3.2 步骤一:写会话归档脚本
核心逻辑在归档脚本里。OpenClaw 每次会话结束时,会把完整的对话记录导出到本地,我用一个监听脚本检测到新导出文件后,自动生成摘要。
先交代一下我用的归档脚本形式,这是一段Python脚本,负责第一部分:生成会话摘要文件。假设OpenClaw已经把对话导出为/tmp/openclaw_last_session.json,脚本读取后按规则提炼关键字段:
#!/usr/bin/env python3 # session_archiver.py - 生成会话摘要 import json import yaml import re from datetime import datetime with open("/tmp/openclaw_last_session.json", "r", encoding="utf-8") as f: session_data = json.load(f) messages = session_data.get("messages", []) user_msgs = [m for m in messages if m.get("role") == "user"] assistant_msgs = [m for m in messages if m.get("role") == "assistant"] full_text = " ".join(m.get("content", "") for m in messages) # 简单提取决定:用户明确说“就用/决定了/按这个来/选择”等关键词后的句子 decision_pattern = r"(就用|决定|按这个来|选|采用)(.{5,100})" decisions = re.findall(decision_pattern, full_text) # 提取待办:识别“记得/下一步/还需要/之后需要”等表达 todo_pattern = r"(记得|下一步|还需要|之后需要)(.{5,100})" todos = re.findall(todo_pattern, full_text) summary = { "session": datetime.now().strftime("%Y-%m-%d-%H%M"), "topic": user_msgs[0].get("content", "")[:40] if user_msgs else "general", "user_message_count": len(user_msgs), "key_decisions": [d[1].strip() for d in decisions[:5]], "follow_ups": [t[1].strip() for t in todos[:5]], } # 写入 sessions 目录 out_path = f"{os.path.expanduser('~')}/.openclaw/memory/sessions/{summary['session']}.yaml" with open(out_path, "w", encoding="utf-8") as f: yaml.dump(summary, f, allow_unicode=True, sort_keys=False) print(f"[archiver] session summary saved to {out_path}")这段脚本的关键点在于“萃取决策句”的正则。坦白说,正则不可能完美理解语义,它只是提供一个粗筛候选,更精细的可以后续人工修订,或者交给更高智能的模型做二次提炼。但方向是对的:我们不需要把所有对话全存下来,只要把“决定”“待办”“主题”这三类关键信息抽出来,就已经解决80%的记忆需求了。
用系统d定时任务或者文件监听都可以触发这段脚本,我用的是一个简单的inotifywait监听导出目录,检测到新文件就跑一次。不展开讲,按你们手上框架的导出机制来即可。
3.3 步骤二:实现事实抽取与去重
事实库的维护要比摘要更克制。因为这是要“持久”住的东西,写错了会长期误导,所以我的做法是:半自动。脚本先提出候选事实,我再快速确认后写进facts文件。
抽取逻辑也不复杂:专门扫user消息里的“我叫/我常用/我用的是/我用过/我偏好/我的项目在”这类表述。比如下面这段:
# fact_extractor.py - 从会话中提取候选事实 import json import re fact_patterns = [ (r"我叫(.{1,20})", "user_profile.name"), (r"我(?:主要|一直)用(.{1,20})", "user_profile.preferred_language"), (r"我的项目.*?([A-Za-z0-9_\-]+).*?路径[::]\s*(.{5,80})", "projects"), ] with open("current_session_input.json", "r", encoding="utf-8") as f: data = json.load(f) messages = data.get("messages", []) candidates = {} for m in messages: if m.get("role") != "user": continue text = m.get("content", "") for pattern, category in fact_patterns: matches = re.findall(pattern, text) if matches: for match in matches[:3]: candidates.setdefault(category, set()).add(str(match)) for k, v in candidates.items(): print(f"[candidate] {k}: {v}")输出候选清单后,我看一眼,确认无误便合并进facts.json。你也可以把这个确认步骤改成用OpenClaw自己的模型判断,但我觉得关键事实让模型自动写入风险较大。试过一次,它把我随口说的“想试试Go”理解成了“已经切换到Go技术栈”。半自动虽然多一步人工,但长期积累下来的事实库质量才有保障。
去重逻辑同样重要。事实库最怕同一件事出现多个版本:昨天记录“项目A在开发中”,今天发现它已经上线了。我的做法是不直接覆盖旧条目,而是在每个条目上加updated_at时间戳,合并时保留最新的状态并记录变更历史。这样既不会丢历史,也不会让事实库越来越混乱。
3.4 步骤三:配置OpenClaw启动时自动加载记忆
记忆系统写完之后,最后一步是把加载逻辑接到OpenClaw上。通常这类框架会给一个“启动脚本”“初始化配置”或“插件钩子”的入口。我用的方法是用一个memory_loader.py脚本,在OpenClaw每次启动时执行,生成一段 “记忆上下文”,注入首轮system消息。
#!/usr/bin/env python3 # memory_loader.py - 生成注入提示词 import json import yaml import glob import os memory_dir = os.path.expanduser("~/.openclaw/memory") # 1. 读取事实库 facts_path = os.path.join(memory_dir, "facts", "core.json") with open(facts_path, "r", encoding="utf-8") as f: facts = json.load(f) # 2. 读取最近5份会话摘要 sessions_dir = os.path.join(memory_dir, "sessions", "*.yaml") recent_sessions = sorted(glob.glob(sessions_dir), reverse=True)[:5] session_summaries = [] for sp in recent_sessions: with open(sp, "r", encoding="utf-8") as f: session_summaries.append(yaml.safe_load(f)) # 3. 拼装成一段统一记忆文本 memory_context = "【长期事实】\n" memory_context += json.dumps(facts, ensure_ascii=False, indent=2) memory_context += "\n\n【最近会话摘要】\n" memory_context += json.dumps(session_summaries, ensure_ascii=False, indent=2) # 4. 写入启动加载文件 out_path = os.path.join(memory_dir, "current_memory_context.md") with open(out_path, "w", encoding="utf-8") as f: f.write(memory_context) print(memory_context)然后我在OpenClaw的配置里把这段文本作为默认的pre_prompt或system_reminder加载。每个框架的配置位置不一样,核心动作是等价的:把生成好的 memory_context 内容,变成每次新会话的第一条系统消息。这样你新建会话时,它就已经“知道”你是谁、在做什么项目、上次聊到哪了。
这里有个细节:注入位置越靠前,对模型后续行为的影响越稳定。如果你把它放在历史消息的中间,模型对它的权重会减弱。我踩过一次坑,把它当成普通消息插在对话中间,结果模型完全没参考历史记忆,等于白做。放在system层或作为第一轮消息,才能保证效果。
3.5 步骤四:验证记忆是否生效
系统装好之后,别急着正式使用,先用一套标准测试流程验证。这是很多教程忽略的部分,但恰恰是最重要的。我验证时用一组预设问题,每个问题都指向事实库里的某一条明确信息:
- 问“我的名字是什么?”(检查事实库名字字段)
- 问“我在开发哪几个项目?分别用什么技术栈?”(检查事实库项目列表)
- 问“根据前几天的讨论,目前待办事项有哪些?”(检查最近的会话摘要)
如果这三个问题都能准确回答,说明启动加载链路通了。如果答不上来,优先检查三处:一是 memory_context 是否真的生成了内容,二是 OpenClaw 的启动配置是否真的注入了,三是注入内容是否被截断。前两个问题最多,第三个容易被忽略,我遇到过因为摘要文件里有个异常字符导致整个 memory_context 加载失败的情况。用cat命令手动看看输出文件内容,基本一眼就能发现问题。
还有一个进阶验证方式:故意在会话A里说“帮我记住一个临时口令:XYZ123”,然后切换到新会话B问“那个临时口令是什么”。如果B能准确回答,说明会话结束归档脚本也正常工作了。这个测试能同时验证写入链路和读取链路,值得跑一遍。
4. 实测结果与效果对比
4.1 同一问题切换前后的回答差别
装完这套系统后,我连续用了三天,最直观的感受就是:它终于不再像个“每次都初次见面”的客服了。
以前新建会话问“上次那个异步任务队列的对比结论是什么”,它要么说“我没有这个上下文”,要么重新给我泛泛地讲一遍消息队列的通用知识。现在同一个问题,它会直接引用上上次会话摘要里记录的结论:“你当时倾向于使用某方案,理由是社区维护活跃、性能满足当前量级,但是还没有做压测。”
这种回答的差异不只是“记得”和“不记得”的区别,而是它开始具备一种连续工作的状态。你可以不在一次长会话里把所有事情聊完,中途关掉、隔天再来,它能无缝衔接。对于我这种习惯碎片时间处理多个项目的场景,这种连贯性带来的效率提升非常明显。
另外,用户画像类的问题提升也很大。以前每次让它写代码风格参考,都要重新描述一遍“我比较喜欢小函数、不要过度设计”。现在事实库里写了一次,之后所有会话都默认按这个风格输出,少了大量重复沟通成本。如果你们团队里有其他人共用同一OpenClaw实例,这个功能价值更大。
4.2 记忆加载耗时与成本
有人肯定会担心:加了这么多记忆加载,会不会拖慢响应速度或者增加费用?
我把实际测量结果放上来。事实库加最近5份摘要,总大小大约在6KB左右,折算成token大概是3000到4000个。以主流商用模型的价格计算,这部分额外输入成本约占总费用的2%到5%,完全可以接受。响应速度方面,增加这几千token的输入对推理时间的影响微乎其微,体感上甚至感知不到。
真正的成本大头在检索层:只有当用户问题明显涉及历史细节时,才会触发关键词检索并注入上下文片段。这种情况下,单次注入的token会额外增加500到2000,但它是按需触发的,不是每轮都发生。整体成本控制下来,比我最初设想“全量历史塞进提示词”的方案省了至少一个数量级。
我还试过只用摘要不用事实库的简化版本,成本更低,但回答的“个人感”明显弱很多。折中下来,事实库全量加载+摘要最近5份+按需检索,是性价比比较高的平衡点。
4.3 边界:这套方案不能做什么
做人要诚实,不能把方案吹上天。这套永久记忆系统有几个明确的边界,期待值需要管理。
第一,它解决的是“跨会话事实记忆”,不是“对话过程的强推理记忆”。如果在同一长会话里,你指望它记住前面23轮对话里的每一个数字细节,还是得靠模型自身的上下文窗口,我们的系统不介入同一个会话内的短期记忆管理。
第二,它依赖归档脚本正确运行。如果某次会话结束没有触发归档(比如会话被强杀、OpenClaw崩溃),那段记忆就永久丢失了。我吃了两次亏之后,给归档脚本加了一个定时兜底:每十分钟检查一次是否有未归档的对话导出文件。但这个兜底也只能降低概率,没法做到100%不丢。
第三,事实库是半人工维护的,意味着你偶尔需要花一点时间清理过期事实。比如一个项目已经结项半年了,它还在事实库里占位置。我大概每周花五分钟扫一遍facts.json,删掉过期项目,更新状态字段。这个维护成本不高,但别指望完全自动。
5. 常见问题与排查实录
5.1 记忆文件被反复写入,体积膨胀怎么办
跑了一周之后,我发现会话摘要目录里堆了几十份yaml文件,虽然单份只有几百KB,但时间一长文件总量还是会膨胀。索引也会跟着变大,检索速度就慢下来了。
我的处理策略是分层清理:三个月前的摘要文件不再参与“最近摘要加载”,但仍然保留在磁盘上供关键词检索。也就是说,启动加载永远只看最近N份(N通常设为5到10),但检索可以翻全历史。做法是在 memory_loader.py 里加载摘要时按时间过滤,而不是把所有文件一锅端。这样既保住历史检索能力,又控制启动时的负担。
5.2 加载的记忆太杂,反而干扰回答
有一次我在事实库里加了十几条项目信息,结果是启动时一次性全量加载,模型在处理当前问题时反而被无关的项目背景干扰,回答明显变“散”。典型表现是:我明明在问A项目的部署问题,它却老把B项目的技术栈扯进来类比。
这个问题让我反思了“全量加载”的必要性。后来我把事实库结构改了,不再一个文件塞所有内容,而是按项目拆开,启动时只加载“当前默认项目”的相关事实。如果用户在对话中切换到另一个项目,再通过检索把对应项目的事实拉进来。这样启动加载量从6KB降到2KB,干扰问题也基本消失了。这种设计与“按需加载”的记忆哲学是一脉相承的。
5.3 换设备后记忆不同步
本地记忆系统的最大短板就是跨设备同步。我在笔记本上积累的记忆,换到另一台机器上用OpenClaw时,它完全不认账。解决方案其实朴素:记忆目录整个纳入版本管理,用一个私有代码仓库托管。每次会话结束后,文档脚本顺带提交并推送,换设备时拉取即可。
这个方案成本极低,还附带一个好处:记忆的变更历史都记录在版本控制日志里,哪天事实被改错了,随时可以对比恢复。有人可能会想用云同步网盘,我没推荐,是因为版本管理对“历史回溯”的能力更强,而且能直接用命令行操作,跟我们的脚本体系更搭。
5.4 记忆读取脚本报错导致会话崩溃
有一次我准备开会,OpenClaw 突然启动失败,显示memory_loader.py 抛了异常。排查后发现是某个会话摘要文件里出现了一个非UTF-8编码的字符,导致yaml解析失败。
这个问题让我把脚本的健壮性重点放在异常处理上:读取文件时全部加errors="ignore",解析失败时跳过该文件而不是直接退出。必须记住:记忆系统是辅助层,它的最高优先级是不能影响主流程。任何单点故障都应该被隔离、跳过、记录,绝不能因为一条脏数据把整个会话搞崩。这也是整个设计里我认为最重要的一条经验。
5.5 实用自查清单
写完这套系统后,我总结了一个简单自查清单,分享给大家。遇到“好像没记住”的怪问题时,按顺序排查,基本都能快速定位:
- 记忆目录是否存在,权限是否正确
- 会话结束归档脚本是否运行成功,有没有生成新的摘要文件
current_memory_context.md是否正常生成,内容是否完整- OpenClaw 启动配置的 system 消息是否真的包含该文件内容
- 检索脚本是否把关键词误过滤掉,试试直接搜原始摘要文件
- 事实库条目是否有过时重复的旧数据干扰判断
如果这些检查做完还是不行,再用一个排除法:临时清空所有记忆文件,只保留一条事实,看会话是否能记住。逐步加回其他数据,定位到引起问题的具体内容。这个方法试过很多次,几乎总能见效。
这套记忆系统整体思路不是非得依赖某个特定的框架或模型,它本质上就是“会话摘要+事实库+按需检索”的组合拳。就算你现在用的不是OpenClaw,哪怕是一个自建的聊天机器人,把这套东西的自查清单和目录结构照搬过去,也能让它的记忆力提升一个档次。我个人在使用中的体会是:AI的永久记忆本质上不是技术问题,而是信息管理问题。只要你把该沉淀的沉淀、该检索的检索、该清理的清理,两小时真的足够干掉“金鱼记忆”这个老毛病。