今天我们聚焦的是 Agent 记忆系统的基础认知与核心机制。记忆系统是构建真正可用的 AI Agent 最容易被低估、也最容易被误解的一个模块——很多开发者以为「把对话历史塞进上下文」就解决了记忆问题,直到他们的 Agent 在第 50 轮对话时「失忆」,才意识到这个认知是错的。
在本课程中,我们会重点解决三个核心问题:上下文窗口和记忆系统的本质区别是什么、短期记忆如何做到既不丢失关键信息又控制 Token 成本、以及长期记忆为什么要用文件而不是数据库。我们会从最简单的 20 行代码出发,逐步构建出 mini-OpenClaw 的完整记忆架构,最终串联出「一条请求的完整生命周期」。
本课程前三章展开:第一章建立认知体系,用演示和类比打通三个容易混淆的层次;第二章拉高视野看清记忆系统技术全景;第三章深入短期记忆的三种核心机制——存储、截断、压缩摘要,并完整实现一个可运行的 SessionManager。我们先从一个真实的对比演示开始。
> 适用学员:具备 Python 基础、了解 LLM 基本概念(知道什么是 API 调用、什么是 messages 列表)的开发者。无需机器学习背景,无需了解向量数据库。学完本课程后,你将能够独立实现一个具备短期记忆和长期记忆的 Agent,理解 mini-OpenClaw 记忆架构的设计逻辑,并能根据场景选择合适的记忆方案。
附录零:环境准备 链接到标题
> 📅 时效性说明:本课程代码基于以下固定版本测试,使用 DeepSeek API(deepseek-chat 模型)。完整依赖版本见同目录 requirements.txt。
在正式开始之前,我们先统一配置好本课程所需的运行环境。后续所有代码示例直接使用这里安装好的依赖,不再重复说明。
步骤一:安装依赖
课件同目录提供了 requirements.txt,记录了所有依赖的精确版本号(基于 conda skills 环境)。直接通过以下命令安装,避免版本不兼容问题:
# 确认 Python 版本(建议 3.10+)
import sys
print(f"Python 版本: {sys.version}")
!pip install -r requirements.txt
requirements.txt 内容如下,供参考:
openai==2.26.0
langchain==1.2.12
langchain-deepseek==1.0.1
langchain-openai==1.1.11
langgraph==1.1.2
tiktoken==0.12.0
python-dotenv==1.2.2
faiss-cpu==1.9.0
步骤二:创建 .env 文件
在项目根目录创建 .env 文件,填入以下内容。本课程使用 DeepSeek API,其接口与 OpenAI 完全兼容,切换只需修改 base_url 和模型名。所有 API Key 均通过环境变量读取,不在代码中硬编码。
# DeepSeek API 配置
OPENAI_API_KEY=your_deepseek_api_key_here
OPENAI_BASE_URL=https://api.deepseek.com
> ⚠️ 安全提醒:.env 文件包含敏感信息,务必将 .env 加入 .gitignore,不要提交到代码仓库。
> DeepSeek API Key 可在 platform.deepseek.com 申请,免费额度足够本课程所有示例使用。
步骤三:加载环境变量并验证连接
运行以下代码,验证 API Key 配置正确、网络连通正常,后续所有代码均依赖此 cell 已执行。
from dotenv import load_dotenv
from openai import OpenAI
import os
load_dotenv()
# 使用 DEEPSEEK_* 环境变量,与 mini-OpenClaw 源码保持一致
api_key = os.getenv("DEEPSEEK_API_KEY")
base_url = os.getenv("DEEPSEEK_BASE_URL", "https://api.deepseek.com")
if api_key:
print(f"API Key 加载成功(前8位:{api_key[:8]}...)")
print(f"Base URL:{base_url}")
else:
print("API Key 未找到,请检查 .env 文件中的 DEEPSEEK_API_KEY")
# 初始化全局 client,后续所有代码直接使用此对象
# 注意:虽然用 OpenAI SDK,但 base_url 指向 DeepSeek(兼容 OpenAI 协议)
client = OpenAI(api_key=api_key, base_url=base_url)
MODEL = "deepseek-chat" # DeepSeek 主力对话模型
print(f"client 初始化完成,模型:{MODEL}")
看到「API Key 加载成功」和「client 初始化完成」说明配置正确,可以继续后续内容。若报错,检查 .env 文件是否在当前工作目录下,以及 Key 是否完整粘贴(DeepSeek Key 以 sk- 开头)。
第一章:为什么 Agent 需要记忆系统 链接到标题
很多开发者在第一次接触 Agent 开发时,会有一种直觉——「LLM 不是已经能对话了吗?直接把历史消息传进去不就有记忆了?」这个直觉在短对话里确实成立,但在真实的 Agent 使用场景中,这个认知会让你撞上三堵墙:Token 上限、成本爆炸、跨会话失忆。本章的目标,就是通过一个真实的代码演示,帮你在 5 分钟内把这三堵墙看清楚,建立贯穿全课的核心认知体系。
1.1 开场演示:两个 Agent 的差距 链接到标题
我们不讲理论,先直接运行代码。下面用最简单的方式展示「有记忆」和「没记忆」的 Agent 在同一个问题上的回答差异——这个差距,就是本课程存在的动机。
首先是没有记忆的 Agent。每次调用都是全新的请求,上一次对话的内容完全不存在:
from openai import OpenAI
# client 和 MODEL 已在「环境准备」cell 中初始化,此处直接使用
def chat_no_memory(user_input: str) -> str:
"""无记忆 Agent:每次调用完全独立,不携带任何历史"""
response = client.chat.completions.create(
model=MODEL,
messages=[
{"role": "user", "content": user_input}
]
)
return response.choices[0].message.content
# 第一轮:用户自我介绍
print("第一轮:", chat_no_memory("我叫小明,我在学 Python,方向是数据科学"))
# 第二轮:问 Agent 记不记得
print("第二轮:", chat_no_memory("我叫什么名字?我在学什么?"))
运行后你会看到第二轮的回答类似「抱歉,我没有您之前对话的记录,无法得知您的名字。」这不是 Bug,这是 LLM API 的设计——每次调用天然无状态,没有自动的跨调用记忆。
接下来是有记忆的 Agent。通过维护一个消息列表,把完整历史随每次请求一起传入:
messages = [] # 短期记忆的本质:就是这个 list
def chat_with_memory(user_input: str) -> str:
"""有记忆 Agent:把完整对话历史传给 LLM"""
messages.append({"role": "user", "content": user_input})
response = client.chat.completions.create(
model=MODEL,
messages=messages
)
reply = response.choices[0].message.content
messages.append({"role": "assistant", "content": reply})
return reply
# 同样的两轮对话
print("第一轮:", chat_with_memory("我叫小明,我在学 Python,方向是数据科学"))
print("第二轮:", chat_with_memory("我叫什么名字?我在学什么?"))
这次第二轮会正确回答「你叫小明,你在学 Python,方向是数据科学」。两段代码的差异只有一个:是否把历史消息一起传给 LLM。这就是短期记忆最原始的实现形态。
> 对比小结:有记忆 vs 无记忆的差距,不是模型能力的差距,而是工程层面是否维护了消息历史。这也意味着——记忆系统是应用层的责任,不是 LLM 本身的功能。
为了让这个差距更直观,下图展示了两种调用方式的本质区别:

1.2 三个层次的认知:上下文窗口 ≠ 记忆系统 链接到标题
演示完对比,我们来打通三个层次的认知。很多学员在第二个层次卡住,觉得「上下文窗口够大就行了」,这个认知在生产环境会带来严重的成本和性能问题。
层次一(大家都知道):LLM 每次 API 调用是无状态的,上一次调用的结果不会自动保留到下一次。这是基础认知,演示里已经验证。
层次二(容易混淆):上下文窗口 ≠ 记忆系统。很多人觉得用 GPT-5.4 或者 Claude Opus 4.6(1M context) 就解决了记忆问题——把所有历史全部塞进去不就行了?这个思路有三个硬限制:
上下文窗口的三个硬限制
| 限制类型 | 具体表现 | 后果 |
|---|---|---|
| Token 上限 | 再大的窗口(128K、1M)也有上限 | 长对话必然超出,报错或截断 |
| 成本线性增长 | 每次请求都要发送完整历史 | 第 100 轮对话的成本是第 1 轮的 100 倍 |
| 推理延迟上升 | 上下文越长,首 Token 延迟越高 | 用户体验变差,响应从秒级变成十秒级 |
层次三(核心认知):Agent 真正需要的是跨对话、跨会话的持久化记忆。即使用户关闭了浏览器,明天再来打开,Agent 也能记得「你叫小明,你在学 Python,上次我们讨论到 sklearn 的 Pipeline」。这是上下文窗口根本无法解决的问题——上下文随进程结束而消失,持久化记忆需要写入存储介质。
下图直观展示了随对话轮次增长,「全量上下文」方案的成本爆炸问题,以及记忆系统如何通过「只传相关内容」把成本控制在合理范围:

1.3 人类记忆类比 链接到标题
在进入技术实现之前,我们先建立一套类比体系。这套类比会贯穿全课,后面每讲到一个新机制,都可以回到这里找到对应的人类直觉。人类的记忆系统经过几百万年演化,已经解决了「什么该记、什么该忘、什么该检索」的问题——Agent 的记忆系统设计,其实是在用工程手段复现这套机制。
人类记忆与 Agent 记忆的对应关系
| 人类记忆 | 日常类比 | Agent 记忆 | 技术实现 |
|---|---|---|---|
| 工作记忆 | 手边的便签纸,只记当前任务 | 短期记忆 | 当前会话对话历史 messages[] |
| 长期陈述记忆 | 翻出笔记本查阅上个月的记录 | 长期记忆-全文 | MEMORY.md 直接注入 System Prompt |
| 图书馆检索 | 不记得具体内容,但知道去哪找 | 长期记忆-RAG | 向量数据库相似度检索 |
| 朋友通讯录备注 | 「小李:喜欢喝咖啡,不喝茶」 | 用户画像 | USER.md |
| 睡前整理今日笔记 | 把今天的要点浓缩成一段话 | 会话压缩摘要 | compressed_context 字段 |
这张表里有一个关键洞察:人类不会把今天遇到的所有事都永久记住,而是有选择地提炼关键信息写入长期记忆。Agent 的记忆系统设计也遵循同样的原则——不是把所有对话历史都塞入长期存储,而是由 Agent 主动判断「这条信息值得长期保留吗」,再决定是否写入 MEMORY.md。这个设计哲学会在第三章和后续章节反复出现。
> ⚠️ 踩坑预警:「上下文窗口够大」≠「有记忆」。这是初学者最常见的认知误区。即使使用 128K 甚至 1M 上下文的模型,把所有历史塞进去会面临三个问题:第一,对话超过上限后会报错或被静默截断;第二,每次请求携带完整历史,Token 成本随轮次线性增长,第 100 轮的成本是第 1 轮的 100 倍;第三,上下文随进程结束而消失,用户明天再打开依然「失忆」。上下文窗口是短期工作区,记忆系统是持久化存储——两者解决的是完全不同的问题。
学完第一章,我们已经建立了贯穿全课的三条核心认知:
认知一:LLM API 天然无状态,「有记忆」是应用层工程实现的结果,不是模型自带的能力。
认知二:上下文窗口 ≠ 记忆系统。前者是单次请求的工作区,后者是跨会话的持久化存储——再大的上下文窗口也无法替代真正的记忆系统。
认知三:Agent 的记忆系统对应人类的三层记忆结构:短期记忆(
messages[])、长期全文记忆(MEMORY.md)、长期检索记忆(向量数据库)。不同类型的信息走不同的通道。
带着这三条认知,我们先在第二章建立技术全景认知,再进入第三章从工程实现层面把短期记忆做扎实。
第二章:记忆系统技术全景 链接到标题
第一章我们从「为什么需要记忆系统」切入,建立了核心认知框架。在进入短期记忆的具体工程实现之前,有必要先把视野拉高一层——看清楚「记忆系统」这个领域里有哪些主流技术路线、各自的设计哲学是什么、我们选择的方案在全景图中处于什么位置。
这一章的目标不是让你掌握所有方案的细节,而是帮你建立一张「技术地图」:知道有哪条路、各自通向哪里、mini-OpenClaw 走的是哪条。带着这张地图,后续每学到一个具体机制,你都能感受到它在全局中的位置,而不是迷失在碎片化的技术细节里。
我们从 Agent 系统的基础架构分层开始,依次展开四条主流记忆路线、短期与长期记忆的系统对比、mini-OpenClaw 的选型定位,最后预览整个课程的学习路径。
2.1 两层分离模型:执行层 vs 记忆层 链接到标题
在大多数入门教程里,Agent 被描述为一个「接收输入、调用 LLM、返回输出」的黑盒。这个描述在单次对话场景里没问题,但一旦涉及多轮对话、跨会话记忆、工具调用,你会发现这个黑盒内部其实有两个完全不同职责的子系统在协同工作——执行层和记忆层。
把这两层拆开理解,是构建可维护 Agent 系统的第一步。两层的职责边界非常清晰:
Agent 系统两层架构职责对比
| 层级 | 核心职责 | 典型组件 | 关键问题 |
|---|---|---|---|
| 执行层 | 推理与行动 | LLM API 调用、工具注册与执行、ReAct 推理循环、流式输出 | 「下一步该做什么?」 |
| 记忆层 | 状态管理与持久化 | 历史消息存储、跨会话持久化、记忆检索与注入、遗忘与压缩策略 | 「我知道什么?该记住什么?」 |
执行层的核心是 ReAct 推理循环(Reason + Act):接收用户输入 → 调用 LLM 推理 → 决定是否调用工具 → 执行工具 → 把结果再次传给 LLM → 生成最终回复。这个循环是 Agent「会做事」的基础。LangChain 1.0+ 的 create_agent()(基于 LangGraph 重构后的统一 Agent 接口)、LangGraph 的状态机,都是执行层的工程实现。
> ⚠️ 版本变更提醒:LangChain 1.0(2025年10月)对执行层 API 进行了重大重构,将 AgentExecutor 等旧版接口全部废弃,统一替换为基于 LangGraph 构建的 create_agent() 接口。若你看到旧教程中使用了 AgentExecutor,说明该教程基于 LangChain 0.x,需参考官方迁移指南升级。本课程所有代码基于 LangChain 1.x,使用新接口。
记忆层的核心是 状态的生命周期管理:哪些信息需要在本次会话内保留(短期记忆)、哪些信息需要跨会话持久化(长期记忆)、当记忆量超出上下文容量时如何压缩或检索(截断与 RAG)。记忆层是执行层的「基础设施」——执行层决定做什么,记忆层决定「记住什么、遗忘什么、何时调取」。
下图展示了两层的协作关系与数据流向:

mini-OpenClaw 的选型正是基于这个分层原则:执行层使用 LangChain(负责工具注册、ReAct 循环、流式输出),记忆层遵循「OpenClaw 哲学」(负责文件存储、会话持久化、记忆注入)。两层解耦,各司其职,修改记忆策略不会影响推理循环,升级执行框架也不会丢失历史数据。
2.2 四种记忆层实现路线速览 链接到标题
理解了两层分离模型之后,我们把目光聚焦在记忆层本身。在工程实践中,记忆层有四条主流实现路线,各自背后有不同的设计哲学和适用场景。你不需要现在就掌握所有细节,但需要知道这四条路的存在——这样当你在其他项目或教程中看到 Chroma、LlamaIndex、MemGPT 这些名词时,能快速定位它们属于哪条路线。
四种记忆层实现路线对比
| 路线 | 代表框架 | 存储介质 | 检索方式 | 核心设计哲学 | 适用场景 |
|---|---|---|---|---|---|
| 文件系统记忆 | OpenClaw | Markdown/JSON 文件 | 全文注入 / BM25+向量混合检索 | 透明可控,人机共同维护,消灭黑盒焦虑 | 个人 Agent、需要透明审计、本地优先部署 |
| 多后端存储 | LangChain 1.x + LangGraph | Checkpointer(PostgreSQL/Redis/内存)+ Store(SQL/向量/内存) | 短期:checkpointer 状态机;长期:store.search 语义检索 | 执行层与记忆层统一框架,多后端灵活切换 | 快速原型到生产全阶段,生态优先场景 |
| 多源召回 | LlamaIndex | ChatMemoryBuffer + VectorMemory + 可组合后端(PostgreSQL 等) | 多路召回融合(滑动窗口 + 向量检索 + SimpleComposableMemory) | 结构化与非结构化数据并重,多记忆源组合 | 企业知识管理、多源数据融合、RAG 密集场景 |
| 分层自管理 | Letta(原 MemGPT) | Core Memory(上下文内 blocks)+ Archival Memory(向量档案)+ Recall Memory(对话历史库) | LLM 自主决策:主动调用工具读写各层记忆;支持 sleep-time 后台整理 | 让 LLM 像操作系统管理内存一样管理记忆,最大自主性 | 超长对话、自主 Agent 研究、需要 Agent 自主维护记忆的场景 |
文件系统记忆路线(我们的路线):OpenClaw 是这条路线最具代表性的开源实现。它把记忆存储为人类可读的 Markdown 文件——MEMORY.md 存储用户画像与长期事实,会话历史存储为 JSON 文件。检索方式默认为「全文注入」:每次对话开始时,直接把 MEMORY.md 的全部内容拼接进 System Prompt;当文件体积超过阈值时自动降级为 BM25+向量混合检索。这条路线的最大优势是透明可控,消灭黑盒焦虑:你随时可以打开文件查看 Agent「记住了什么」,也可以直接编辑文件纠正错误记忆。mini-OpenClaw 是我们课程中按 OpenClaw 设计哲学实现的教学版本。
多后端存储路线:LangChain 1.x + LangGraph 是这条路线的代表。它把记忆分为两层:短期记忆通过 Checkpointer 实现(开发时用 InMemorySaver,生产时接 PostgreSQL、Redis 等持久化后端),保存每个 thread_id 对应的完整对话状态;长期记忆通过 LangGraph Store 实现(store.put/get/search),支持语义检索,后端同样可接 SQL 或向量数据库。两层共同构成「短期状态机 + 长期知识库」的完整记忆架构。优势是执行层与记忆层在同一框架内统一管理,从开发原型到生产部署只需切换后端配置。
多源召回路线:LlamaIndex 的记忆方案以可组合性为核心。它提供三类记忆组件:ChatMemoryBuffer(滑动窗口管理短期对话历史)、VectorMemory(把对话批量向量化存入向量后端,支持 PostgreSQL、Pinecone 等)、SimpleComposableMemory(把多个记忆模块组合为主记忆 + 辅助记忆源,检索时多路召回融合)。这条路线在 RAG 密集场景和企业多源数据管理中表现出色,但组件组合的复杂度也更高。
分层自管理路线:Letta(原 MemGPT)的核心思想是把记忆管理本身交给 LLM。它维护三层记忆:Core Memory(始终在上下文内的结构化内存块,包含 human/persona/自定义 blocks,LLM 可通过工具调用直接修改)、Archival Memory(可无限扩展的向量化长期档案,LLM 主动搜索调取)、Recall Memory(对话历史数据库,通过 conversation_search 检索)。Letta 还支持 sleep-time agent:在对话间隙异步运行后台 Agent,自动整理和压缩记忆,无需打断主对话流程。这是工程复杂度最高、自主性最强的方案,目前主要用于研究和长自主对话场景。
> 横向对比启示:四条路线没有绝对优劣,选型取决于场景。个人 Agent 和教学项目优先文件系统记忆方案(透明、易调试);需要快速落地且有成熟生态支持的优先 LangChain 多后端方案;企业多源知识库优先 LlamaIndex 多源召回;研究型自主 Agent 可探索 Letta(最大自主性)。本课程聚焦文件系统记忆路线,这也是最适合「从零理解记忆系统设计」的切入点。
四条路线建立了完整的技术视野,但视野越宽,越需要聚焦。从下一节开始,我们把全部注意力放在文件系统记忆路线上——其余三条路线将在第四章用代码对比的方式再度登场,届时你将从实现层面真正理解它们的差异。
2.3 短期记忆 vs 长期记忆:系统对比 链接到标题
在技术路线的宏观视角之外,还有一个维度需要厘清:即使在同一个记忆系统内,「短期记忆」和「长期记忆」也是完全不同的机制,服务于不同的目的,有不同的工程实现。很多开发者在这里模糊处理,导致系统设计混乱。下面用一张对比表把两者的差异说清楚:
短期记忆 vs 长期记忆核心维度对比
| 对比维度 | 短期记忆 | 长期记忆 |
|---|---|---|
| 生命周期 | 单次会话内有效,进程结束即消失(除非持久化) | 跨会话永久保留,显式删除才消失 |
| 存储介质 | 内存中的 messages[] 列表 / 会话 JSON 文件 | MEMORY.md 文件 / 向量数据库 |
| 检索方式 | 直接传入上下文(全量或截断) | 全文注入 System Prompt / RAG 相似度检索 |
| Token 成本 | 随对话轮次线性增长,需要截断或压缩控制 | 相对固定,取决于记忆文件大小 |
| 信息类型 | 当前会话的对话流水、临时任务状态 | 用户画像、跨会话事实、稳定的偏好与背景 |
| 典型场景 | 「你刚才说想用 Python 实现,我来继续」 | 「你上次说你是数据科学方向,所以我推荐…」 |
| 主要挑战 | 超出上下文长度后如何截断而不丢失关键信息 | 记忆何时写入、何时更新、何时删除 |
一个直观的类比:短期记忆是「今天的便签纸」——用完即扔,只服务于当前任务;长期记忆是「多年的日记本」——需要定期整理,记录的是稳定的、值得跨时间保留的信息。系统设计的关键不是「选哪种记忆」,而是「哪类信息该走哪条通道」。
本章聚焦短期记忆的三大机制(存储、截断、压缩摘要),第四章展开长期记忆的写入与检索策略。两者相互独立,可以单独学习,但组合起来才是完整的记忆系统。
2.4 mini-OpenClaw 在全景图中的定位 链接到标题
现在我们把「技术地图」和「mini-OpenClaw」对应起来,明确「今天我们要走哪条路」。
在四条路线中,mini-OpenClaw 走的是文件系统记忆路线。选择这条路线有三个明确的理由:
理由一:透明可读。记忆文件是普通的 Markdown 文件,用任意文本编辑器就能打开查看。Agent 「记住了什么」,对开发者完全透明,不存在黑盒。这对于调试和教学场景来说是不可替代的优势。
理由二:热更新。你可以在 Agent 运行期间直接编辑 MEMORY.md,下一次对话开始时,Agent 就会读取到更新后的内容。不需要重新训练,不需要重启服务,人工干预的成本极低。
理由三:人机共同维护。MEMORY.md 既可以由 Agent 自动写入(通过工具调用),也可以由用户手动编辑。这意味着记忆的质量不完全依赖 AI 的判断——人类可以随时介入,纠正错误记忆、添加重要背景、删除过时信息。
当然,文件系统记忆路线也有其局限性:当记忆文件体积超过上下文容量(比如 MEMORY.md 积累了几万字),全文注入就会失效,需要引入 RAG 机制进行语义检索。这正是第四章的核心内容。本章我们先把文件系统记忆路线的基础机制做扎实,第四章再在此基础上扩展 RAG 能力。

2.5 本课程学习路径预览 链接到标题
在正式进入技术实现之前,我们把整个课程的学习路径过一遍。这样你在学习每一个具体机制时,都清楚自己处于哪个阶段、这个知识点为什么在这里出现、学完之后能解锁什么能力。
Agent 记忆系统课程学习路径
| 章节 | 核心主题 | 覆盖内容 | 学完后能做什么 |
|---|---|---|---|
| 第一章 | 记忆系统认知 | 演示对比、上下文窗口 vs 记忆系统、人类记忆类比 | 建立对 Agent 记忆系统的整体认知框架 |
| 第二章 | 技术全景 | 四种记忆路线、短期 vs 长期对比、mini-OpenClaw 定位 | 能根据场景选型,理解各路线的适用边界 |
| 第三章 | 短期记忆 | 对话历史存储、滑动窗口截断、压缩摘要、SessionManager 完整实现 | 构建有记忆的对话 Agent,控制 Token 成本 |
| 第四章 | 长期记忆架构 | 向量数据库、KV 存储、图数据库、关系型数据库四种类型选型 | 为不同检索需求选择合适的长期存储方案 |
| 第五章 | 写入与检索 | 写入时机、Direct 注入、RAG 注入、sleep-time 重组、去重冷热分层 | 构建跨会话记忆 Agent,用户再次回来时能「记得」历史 |
| 第六章 | 协同架构实战 | MemoryManager 完整实现、ImprovedMemoryManager 中文语义去重、端到端验证 | 独立实现完整 Agent 记忆系统,覆盖写入/检索/去重全链路 |
三章构成一条完整的学习曲线:第三章打地基(短期记忆工程实现),第四章建墙体(长期记忆持久化策略),第五章看差异(两种记忆层实现方式的代码对比)。第五章不再重复第二章中已讲过的四种路线理论对比,而是直接用代码展示 LangChain 和 mini-OpenClaw 在「同一功能」上的实现差异,让你在代码层面真正理解两种设计哲学的不同。
现在,带着这张完整的技术地图,我们进入第三章,从工程实现层面把短期记忆做扎实。
第三章:短期记忆——上下文窗口管理 链接到标题
第一章建立了认知框架,第二章给出了技术全景。现在进入第一个技术章节:短期记忆的工程实现。短期记忆听起来简单——不就是把消息历史存起来吗?但真正的挑战在于:对话轮次增长后,如何在不丢失关键信息的同时控制传入 LLM 的 Token 数量。
这一章我们会从最简单的存储开始,逐步引出截断策略和压缩摘要机制,最终实现一个完整的 SessionManager。三个小节环环相扣:先有数据,再谈截断,最后引入摘要——这是任何生产级短期记忆系统都必须经历的设计演进路径。
短期记忆的上下文窗口管理本质上只解决一件事:让传入 LLM 的消息历史永远不超过 Token 上限。工程上有三个层次的手段——截断是最直接的,超出就丢弃最旧的消息;压缩是更精细的,把旧消息摘要后压缩替换,用更少的 Token 保留更多的语义;两者共同服务于同一个目标:在有限的上下文窗口内,最大化保留对当前对话有用的信息。下图直观呈现了这三者的关系。

3.1 最简单的短期记忆:对话历史存储 链接到标题
第一章的演示已经给出了短期记忆最原始的形态——一个 messages 列表。现在我们把它稍微工程化一点,加上会话持久化的能力:关闭程序后重启,历史对话依然在。
mini-OpenClaw 使用 JSON 文件存储每个会话。下面是最小可运行的会话存储实现:
import json
import os
import time
SESSIONS_DIR = "./sessions" # 会话文件存储目录
os.makedirs(SESSIONS_DIR, exist_ok=True)
def load_session(session_id: str) -> dict:
"""加载会话,不存在则创建新会话"""
path = os.path.join(SESSIONS_DIR, f"{session_id}.json")
if os.path.exists(path):
with open(path, "r", encoding="utf-8") as f:
return json.load(f)
# 新会话的初始结构
return {
"title": "",
"created_at": int(time.time()),
"updated_at": int(time.time()),
"compressed_context": "", # 压缩摘要(暂时为空,3.3节会填充)
"messages": [] # 当前对话历史
}
def save_session(session_id: str, session: dict):
"""保存会话到 JSON 文件"""
session["updated_at"] = int(time.time())
path = os.path.join(SESSIONS_DIR, f"{session_id}.json")
with open(path, "w", encoding="utf-8") as f:
json.dump(session, f, ensure_ascii=False, indent=2)
# 测试:加载会话 → 添加消息 → 保存 → 重新加载验证
session = load_session("test_user_001")
session["messages"].append({"role": "user", "content": "我叫小明"})
save_session("test_user_001", session)
# 重新加载,验证持久化
loaded = load_session("test_user_001")
print(f"持久化验证:{loaded['messages'][-1]['content']}")
这段代码建立了短期记忆的存储骨架:每个用户对应一个 JSON 文件,messages 字段存放完整对话历史,compressed_context 字段预留给压缩摘要(下一小节会用到)。注意 ensure_ascii=False——这是中文存储的必要配置,否则中文内容会被转义成 \u 序列。
下图展示了一个真实会话 JSON 文件的结构,以及各字段在 SessionManager 生命周期中的作用:

现在来思考一个问题:这个 messages 列表会无限增长,第 50 轮对话时 messages 里已经有 100 条消息。如果把 100 条消息全部传给 LLM,Token 成本是第 1 轮的 100 倍,推理延迟也会显著上升。怎么解决?
3.2 消息截断策略 链接到标题
最直接的解法是只保留最近 N 条消息传给 LLM。mini-OpenClaw 的默认配置是 MAX_HISTORY = 20,即最多传入最近 20 条对话记录。
为什么是 20 条?deepseek-chat 模型(DeepSeek-V3.2)支持 128K Token 的上下文窗口。20 条消息的设定来自工程经验:在典型对话场景下,这个数量能覆盖完整的多轮问答上下文,同时把单次请求的 Token 占用控制在窗口的极小比例,为 System Prompt、压缩摘要和模型推理空间留出充足余量。相比之下,设置过小(如 5 条)会让 Agent 显得「健忘」,在连续对话中频繁失去上下文;设置过大(如 100 条)则会在长会话中推高 Token 成本,增加响应延迟。20 是在「记忆完整性」和「成本控制」之间的合理平衡点,实际项目中可根据平均消息长度和业务需求按比例调整。如需精确计算 Token 数,可使用 tiktoken 库对实际消息进行测量。
MAX_HISTORY = 20 # mini-OpenClaw 默认值,可根据模型上下文窗口调整
def get_messages_for_llm(session: dict) -> list:
"""
构造实际传给 LLM 的消息列表。
策略:如果有压缩摘要,先注入摘要;再取最近 MAX_HISTORY 条。
"""
messages_to_send = []
# 如果存在压缩摘要,作为 system 消息插在最前面
if session.get("compressed_context"):
messages_to_send.append({
"role": "system",
"content": session["compressed_context"]
})
# 只取最近 MAX_HISTORY 条对话
recent_messages = session["messages"][-MAX_HISTORY:]
messages_to_send.extend(recent_messages)
return messages_to_send
# 验证:超过 20 条时只取最近 20 条
test_session = {"compressed_context": "", "messages": [{"role": "user", "content": f"消息{i}"} for i in range(30)]}
result = get_messages_for_llm(test_session)
print(f"截断验证:传入 LLM 的消息数 = {len(result)}(应为 20)")
print(f" 第一条内容:{result[0]['content']}(应为 消息10)")
截断策略实现简单,但有一个明显的副作用。
> ⚠️ 踩坑预警:直接截断会丢失早期的关键上下文。用户在第 3 轮说「我叫小明,我在做机器学习项目」,到第 25 轮问「帮我继续优化上次那个模型」——如果第 3 轮的消息已经被截断,Agent 不知道「小明」是谁、「上次那个模型」是什么。这就是压缩摘要要解决的核心问题。
3.3 压缩摘要机制(本章核心) 链接到标题
压缩摘要的核心思路是:用 LLM 来总结 LLM。当对话历史超过阈值时,把较早的对话让 LLM 压缩成一段自然语言摘要,存入 compressed_context 字段。下次对话时,先注入这段摘要,再跟上最近 N 条历史——既保留了早期关键信息,又把 Token 数量控制在可接受的范围内。
整个机制的工作流如下:

下面是 mini-OpenClaw 中 compress.py 的核心实现:
COMPRESSED_CONTEXT_PREFIX = "[以下是之前对话的摘要]"
def compress_session(session: dict, client) -> dict:
"""
压缩会话历史。
策略:取前 50% 的消息作为待压缩部分(至少 4 条),
调用 LLM 生成摘要后更新 compressed_context 字段,保留后 50% 的消息继续使用。
"""
messages = session["messages"]
if len(messages) < 4:
return session # 消息太少,不压缩
# 取前 50% 作为待压缩部分(至少 4 条)
compress_count = max(4, len(messages) // 2)
to_compress = messages[:compress_count]
to_keep = messages[compress_count:]
# 构造压缩 prompt
history_text = "\n".join(
f"{m['role'].upper()}: {m['content']}" for m in to_compress
)
# 滚动摘要:把已有摘要 + 新消息一起压缩成统一摘要
existing = session.get("compressed_context", "")
if existing:
to_compress_text = f"[之前的摘要]\n{existing}\n\n[新增对话]\n{history_text}"
else:
to_compress_text = history_text
compress_prompt = f"""请将以下内容压缩成一段简洁的统一摘要,保留所有关键信息(用户身份、重要决策、技术细节、未完成的任务)。
对话历史:
{to_compress_text}
要求:
- 用第三人称描述(「用户」「助手」)
- 保留所有关键事实,不遗漏重要细节
- 100-200字以内
- 以「[以下是之前对话的摘要]」开头"""
response = client.chat.completions.create(
model=MODEL,
messages=[{"role": "user", "content": compress_prompt}],
timeout=30
)
summary = response.choices[0].message.content
# 用新的统一摘要替换旧的(不追加)
session["compressed_context"] = summary
session["messages"] = to_keep
return session
# 演示压缩效果
test_session = {
"compressed_context": "",
"messages": [
{"role": "user", "content": "我叫小明,我在学 Python 数据科学"},
{"role": "assistant", "content": "你好小明!Python 数据科学很有前途。"},
{"role": "user", "content": "我已经学完了 pandas 的 groupby"},
{"role": "assistant", "content": "很好!下一步可以学 sklearn。"},
{"role": "user", "content": "sklearn 的 Pipeline 怎么用?"},
{"role": "assistant", "content": "Pipeline 是 sklearn 的核心工具..."},
]
}
print(f"压缩前:{len(test_session['messages'])} 条消息")
compressed = compress_session(test_session, client) # 取消注释可实际运行
print(f"压缩后:{len(compressed['messages'])} 条消息")
print(f"摘要内容:{compressed['compressed_context']}")
print("压缩函数定义完成(取消注释可实际调用 LLM 执行压缩)")
这段实现有几个值得关注的设计细节。首先是取前 50% 而非固定数量——这样随着对话增长,每次压缩的幅度是自适应的,不会出现「压缩后仍然超过阈值」的问题。其次是压缩 prompt 的措辞:明确要求保留「用户身份、重要决策、技术细节、未完成的任务」,这四类信息是 Agent 在后续对话中最常需要的。最后是结构分离:compressed_context 和 messages 分开存放,压缩摘要以 system 角色注入,不混入对话历史,语义边界清晰。
> ⚠️ 常见误区:很多初学者把压缩摘要和对话历史混在一起存储,导致下次传给 LLM 时摘要被当成普通对话处理,LLM 可能误以为是用户说的话。mini-OpenClaw 的做法是将摘要作为独立的 system 消息注入,明确区分「背景知识」和「对话过程」。
3.4 完整 SessionManager:把三个机制串起来 链接到标题
存储、截断、压缩——三个机制现在可以组合成一个完整的 SessionManager。这是 mini-OpenClaw 短期记忆层的核心类,所有与会话相关的操作都通过它来完成。
> 前置依赖:本节代码中的 add_message 方法内部调用了 3.3 节定义的 compress_session 函数。如果你跳过了 3.3 节直接运行本节代码,将会报 NameError: name 'compress_session' is not defined。请确保先执行 3.3 节的 cell 后再运行本节代码。
import json, os, time
from openai import OpenAI
SESSIONS_DIR = "./sessions" # session会话的存储路径
MAX_HISTORY = 30 # 消息数达到此值时触发截取,存入messages
COMPRESS_TRIGGER = 20 # 消息数达到此值时触发压缩,存入system Prompt
os.makedirs(SESSIONS_DIR, exist_ok=True)
# 注意:此处直接使用「环境准备」cell 中已初始化的全局 client 和 MODEL 变量
# 不再重复调用 OpenAI(),避免覆盖 base_url 配置导致 DeepSeek API 连接失败
class SessionManager:
"""mini-OpenClaw 短期记忆管理器"""
def load(self, session_id: str) -> dict:
path = os.path.join(SESSIONS_DIR, f"{session_id}.json")
if os.path.exists(path):
with open(path, "r", encoding="utf-8") as f:
data = json.load(f)
# 向后兼容:v1 格式是纯 list,自动迁移到 v2
if isinstance(data, list):
return {"title": "", "created_at": 0,
"updated_at": 0, "compressed_context": "",
"messages": data}
return data # v2 格式直接返回
# 文件不存在 → 初始化空会话结构
return {"title": "", "created_at": int(time.time()),
"updated_at": int(time.time()),
"compressed_context": "", "messages": []}
def save(self, session_id: str, session: dict):
session["updated_at"] = int(time.time()) # 刷新最后更新时间戳
path = os.path.join(SESSIONS_DIR, f"{session_id}.json")
with open(path, "w", encoding="utf-8") as f:
json.dump(session, f, ensure_ascii=False, indent=2)
def add_message(self, session: dict, role: str, content: str) -> dict:
"""添加一条消息,超过阈值时自动触发压缩"""
session["messages"].append({"role": role, "content": content})
# 消息数达到阈值 → 调用 LLM 生成摘要并替换旧消息,释放 context 空间
if len(session["messages"]) >= COMPRESS_TRIGGER:
session = compress_session(session, client)
return session
def get_messages_for_llm(self, session: dict) -> list:
"""构造传给 LLM 的消息列表(摘要 + 最近 N 条)"""
result = []
# 若存在压缩摘要,作为 system 消息放在最前面,补充历史上下文
if session.get("compressed_context"):
result.append({"role": "system",
"content": session["compressed_context"]})
# 滑动窗口:只取最近 MAX_HISTORY 条,避免超出 context window
result.extend(session["messages"][-MAX_HISTORY:])
return result
# ── 完整演示:存储 + 截断 + 压缩三机制联动 ──
sm = SessionManager()
# 清理旧测试数据,确保从零开始
import shutil
if os.path.exists(os.path.join(SESSIONS_DIR, "demo_full.json")):
os.remove(os.path.join(SESSIONS_DIR, "demo_full.json"))
session = sm.load("demo_full")
print("="*60)
print("【阶段一】模拟 25 轮对话,观察三机制触发时机")
print("="*60)
for i in range(1, 26):
question = f"第{i}轮问题:Python 的第{i}个技巧是什么?"
answer = f"技巧{i}:这是关于 Python 的第{i}个实用技巧的详细解答。"
session = sm.add_message(session, "user", question)
session = sm.add_message(session, "assistant", answer)
# 在关键节点打印状态
if i in [5, 10, 19, 20, 25]:
msgs_for_llm = sm.get_messages_for_llm(session)
print(f"\n--- 第 {i} 轮结束 ---")
print(f" messages[] 实际条数:{len(session['messages'])}")
print(f" 传给 LLM 的条数: {len(msgs_for_llm)}")
print(f" compressed_context:{'有(已压缩)' if session.get('compressed_context') else '无'}")
sm.save("demo_full", session)
print("\n" + "="*60)
print("【阶段二】验证持久化:重新加载并检查")
print("="*60)
reloaded = sm.load("demo_full")
msgs_final = sm.get_messages_for_llm(reloaded)
print(f" 重新加载后 messages[] 条数:{len(reloaded['messages'])}")
print(f" 传给 LLM 的条数:{len(msgs_final)}")
print(f" compressed_context 前100字:{reloaded['compressed_context'][:100]}..." if reloaded['compressed_context'] else " compressed_context:无")
print(f"\n 传给 LLM 的第一条消息(应为压缩摘要):")
print(f" role: {msgs_final[0]['role']}")
print(f" content: {msgs_final[0]['content'][:80]}...")
这段演示模拟了 25 轮完整对话,让你在输出中直接看到三机制的触发时机:前 19 轮 messages[] 正常增长、compressed_context 为空;第 20 轮触发压缩,早期消息被浓缩为摘要、messages[] 条数骤降;第 25 轮继续正常追加。阶段二验证持久化——关闭后重新加载,摘要和消息都完好,传给 LLM 的第一条就是压缩摘要(system 角色)。
SessionManager 把三个机制封装成四个方法:load(加载)、save(保存)、add_message(添加消息并按需触发压缩)、get_messages_for_llm(构造 LLM 输入)。注意 load 方法里的 v1/v2 格式兼容逻辑——如果读到的 JSON 是旧版的纯列表格式,自动迁移成新结构,保证系统升级时历史数据不丢失。这是一个小细节,但在生产系统中非常重要。
下图展示了 SessionManager 中一条消息从「用户输入」到「LLM 接收」的完整数据流,把四个方法的调用顺序和数据变换串联起来:

到这里,第三章的三个核心机制全部打通:存储确保会话跨进程持久化,截断控制传入 LLM 的 Token 上限,压缩摘要在截断的同时保留早期关键信息。三者共同构成了 mini-OpenClaw 短期记忆层的完整实现。
3.5 从原始 API 到 LangChain:与 mini-OpenClaw 代码对齐 链接到标题
前面四节我们一直使用 openai SDK 直接调用 DeepSeek API——client.chat.completions.create()、response.choices[0].message.content——这是 LLM 调用的最底层方式,帮你理解每一步到底发生了什么。但如果你打开 mini-OpenClaw 的源码会发现:它没有用 openai SDK,而是用 langchain-deepseek 的 ChatDeepSeek。
为什么?因为 mini-OpenClaw 是一个 LangGraph Agent 应用,而 LangGraph 的 Agent、Tool、Memory 生态全部建立在 LangChain 之上。如果 LLM 调用用原始 SDK,那 Agent 编排、工具绑定、checkpoint 持久化就都需要自己手写胶水层。用 ChatDeepSeek 不是为了「高级」,而是为了和 LangGraph 生态无缝集成。
下面用 LangChain 重写前面几节的核心代码,让你看到两种方式的一一对应关系——原理完全相同,只是 API 封装层不同。
首先初始化 ChatDeepSeek。注意环境变量名的变化:mini-OpenClaw 使用 DEEPSEEK_API_KEY 和 DEEPSEEK_BASE_URL(而非前面 openai SDK 使用的 OPENAI_*)。请先在 .env 文件中补充以下配置(Key 值与前面的 OPENAI_API_KEY 相同,指向同一个 DeepSeek Key):
配置完成后,初始化 ChatDeepSeek:
from langchain_deepseek import ChatDeepSeek
from langchain_core.messages import HumanMessage, AIMessage, SystemMessage
from dotenv import load_dotenv
import os
load_dotenv()
# mini-OpenClaw 的标准初始化方式
llm = ChatDeepSeek(
model=os.getenv("DEEPSEEK_MODEL", "deepseek-chat"),
api_key=os.getenv("DEEPSEEK_API_KEY"),
base_url=os.getenv("DEEPSEEK_BASE_URL", "https://api.deepseek.com"),
temperature=0.7,
)
print("ChatDeepSeek 初始化完成")
对比前面的 client = OpenAI(api_key=..., base_url=...),ChatDeepSeek 把模型名、temperature 都收进了构造函数,调用时不需要再传。接下来我们用 create_agent 创建真正的 Agent,验证一个关键问题:create_agent 默认有没有自动记忆?
from langchain.agents import create_agent
# ── 用 create_agent 创建 Agent(与 mini-OpenClaw 源码一致)──────────
# 注意:不传 checkpointer 参数,和 mini-OpenClaw 的 agent.py 第81行用法完全一样
agent = create_agent(
model=llm,
tools=[], # 暂不绑定工具,聚焦记忆行为
system_prompt="你是一个测试助手,请简短回答。",
)
print(f"Agent 创建成功,类型: {type(agent).__name__}")
Agent 创建完成后,我们用两轮对话验证:第一轮告诉 Agent 名字,第二轮问它记不记得。
# ── 测试:不传 checkpointer 时,Agent 有没有自动记忆 ──────────
# 第一轮:告诉 Agent 名字
result1 = agent.invoke({"messages": [HumanMessage(content="我叫小明,请记住我的名字")]})
print("第一轮回复:", result1["messages"][-1].content)
# 第二轮:问 Agent 记不记得(注意:不手动传入第一轮的历史)
result2 = agent.invoke({"messages": [HumanMessage(content="我叫什么名字?")]})
print("第二轮回复:", result2["messages"][-1].content)
print()
# 验证结果
if "小明" in result2["messages"][-1].content:
print("Agent 记住了(有自动记忆)")
else:
print("Agent 没记住 → create_agent 默认无记忆,每次调用独立无状态")
运行结果会显示 Agent 没记住名字。这就是 mini-OpenClaw 为什么要自建 SessionManager 的根本原因——create_agent 的 checkpointer 参数默认为 None,不传 checkpointer 的 Agent 每次调用都是独立无状态的,和前面 1.1 节的「失忆 Agent」本质相同。
那如果传入 checkpointer 呢?LangGraph 提供了 InMemorySaver,可以让 Agent 自动按 thread_id 保存对话状态:
from langgraph.checkpoint.memory import InMemorySaver
# ── 对照组:传入 InMemorySaver 作为 checkpointer ──────────
agent_with_ckpt = create_agent(
model=llm,
tools=[],
system_prompt="你是一个测试助手,请简短回答。",
checkpointer=InMemorySaver(), # 关键区别:传入了 checkpointer
)
# 使用 checkpointer 时,必须通过 config 指定 thread_id(相当于会话 ID)
config = {"configurable": {"thread_id": "test-thread-001"}}
# 第一轮
r1 = agent_with_ckpt.invoke(
{"messages": [HumanMessage(content="我叫小明,请记住我的名字")]},
config=config,
)
print("第一轮回复:", r1["messages"][-1].content)
# 第二轮:同一个 thread_id,不手动传历史
r2 = agent_with_ckpt.invoke(
{"messages": [HumanMessage(content="我叫什么名字?")]},
config=config, # 同一个 thread_id → 自动恢复上次对话状态
)
print("第二轮回复:", r2["messages"][-1].content)
print()
if "小明" in r2["messages"][-1].content:
print("Agent 记住了 → InMemorySaver 自动保存了对话状态")
这次 Agent 能记住名字了。InMemorySaver 会在每次调用后把完整的 messages 状态保存到内存中,下次同一 thread_id 调用时自动恢复。但它有两个关键局限:
内存存储:进程重启后数据丢失,不适合生产环境
无压缩机制:对话越长 messages 越大,没有截断和摘要能力
这就解释了 mini-OpenClaw 的设计选择:不用 InMemorySaver,而是自建 SessionManager,用 JSON 文件实现持久化、用截断控制 Token 成本、用 LLM 压缩保留关键信息。前面四节实现的三套机制,正是对 InMemorySaver 这两个局限的工程化解决方案。
最后是 3.3 节压缩摘要的 LangChain 版本。mini-OpenClaw 的 compress.py 就是用这个模式实现的:
# 压缩专用 LLM(低 temperature 保证摘要质量)
llm_compress = ChatDeepSeek(
model=os.getenv("DEEPSEEK_MODEL", "deepseek-chat"),
api_key=os.getenv("DEEPSEEK_API_KEY"),
base_url=os.getenv("DEEPSEEK_BASE_URL", "https://api.deepseek.com"),
temperature=0.3,
)
def compress_session_lc(session: dict) -> dict:
"""用 LLM 压缩对话历史(LangChain 版,prompt 与 3.3 节完全一致)"""
msgs = session["messages"]
if len(msgs) < 4:
return session
compress_count = max(4, len(msgs) // 2)
to_compress = msgs[:compress_count]
to_keep = msgs[compress_count:]
history_text = "\n".join(
f"{m['role'].upper()}: {m['content']}" for m in to_compress
)
# 滚动摘要:把已有摘要 + 新消息一起压缩成统一摘要
existing = session.get("compressed_context", "")
if existing:
to_compress_text = f"[之前的摘要]\n{existing}\n\n[新增对话]\n{history_text}"
else:
to_compress_text = history_text
prompt = f"""请将以下内容压缩成一段简洁的统一摘要,保留所有关键信息(用户身份、重要决策、技术细节、未完成的任务)。
对话历史:
{to_compress_text}
要求:
- 用第三人称描述(「用户」「助手」)
- 保留所有关键事实,不遗漏重要细节
- 100-200字以内
- 以「[以下是之前对话的摘要]」开头"""
result = llm_compress.invoke([HumanMessage(content=prompt)])
# 用新的统一摘要替换旧的(不追加)
session["compressed_context"] = result.content.strip()
session["messages"] = to_keep
return session
print("compress_session_lc 定义完成")
# ── 测试压缩逻辑 ──────────────────────────────────────────
# 构造测试 session(8条消息,足够触发压缩)
test_session = {
"compressed_context": "",
"messages": [
{"role": "user", "content": "我叫小明,正在学 Python 数据科学"},
{"role": "assistant", "content": "你好小明!Python 数据科学很有前途。"},
{"role": "user", "content": "我已经学完了 pandas 的 groupby"},
{"role": "assistant", "content": "很好!下一步可以学 sklearn。"},
{"role": "user", "content": "sklearn 的 Pipeline 怎么用?"},
{"role": "assistant", "content": "Pipeline 是 sklearn 的核心工具,可以把预处理和模型串联起来。"},
{"role": "user", "content": "我现在在做一个房价预测项目"},
{"role": "assistant", "content": "房价预测很适合用 Pipeline,建议先做特征工程。"},
]
}
print("【第1次压缩】")
print(f"压缩前:messages={len(test_session['messages'])}条,compressed_context={'有' if test_session['compressed_context'] else '无'}")
result1 = compress_session_lc(test_session)
print(f"压缩后:messages={len(result1['messages'])}条")
print(f"compressed_context 内容:\n{result1['compressed_context']}")
# 验证:messages 减少,compressed_context 有内容
assert len(result1["messages"]) < 8, "压缩后消息数应减少"
assert result1["compressed_context"], "compressed_context 不应为空"
# ── 测试滚动摘要:第2次压缩是否合并了旧摘要 ──
# 再往 session 里加消息,模拟第2次触发
result1["messages"] += [
{"role": "user", "content": "特征工程具体做哪些?"},
{"role": "assistant", "content": "主要做缺失值填充、异常值处理、特征编码。"},
{"role": "user", "content": "我的数据集有 10 万条记录"},
{"role": "assistant", "content": "10万条足够了,注意训练集测试集 8:2 切分。"},
]
print("\n【第2次压缩(滚动摘要验证)】")
print(f"压缩前:messages={len(result1['messages'])}条,已有摘要={'是' if result1['compressed_context'] else '否'}")
result2 = compress_session_lc(result1)
print(f"压缩后:messages={len(result2['messages'])}条")
print(f"新摘要是否包含「小明」:{'是 ' if '小明' in result2['compressed_context'] else '否 '}")
print(f"新摘要是否提到「房价」:{'是 ' if '房价' in result2['compressed_context'] else '否 '}")
print(f"compressed_context 内容:\n{result2['compressed_context']}")
assert "小明" in result2["compressed_context"], "滚动摘要应保留第1次压缩中的用户身份信息"
print("\n所有断言通过 ")
对比 3.3 节的原始版本,变化就三处:client.chat.completions.create() → llm_compress.invoke(),response.choices[0].message.content → result.content,以及不再需要传入 client 参数(LLM 实例在模块级别创建)。
OpenAI SDK vs LangChain ChatDeepSeek 对照表
| 维度 | OpenAI SDK(本章前四节) | LangChain ChatDeepSeek(mini-OpenClaw) |
|---|---|---|
| 初始化 | client = OpenAI(api_key=..., base_url=...) | llm = ChatDeepSeek(model=..., api_key=..., temperature=...) |
| 调用 | client.chat.completions.create(model=M, messages=[...]) | llm.invoke([HumanMessage(...)]) |
| 提取结果 | response.choices[0].message.content | result.content |
| 消息格式 | {"role": "user", "content": "..."} | HumanMessage(content="...") |
| 环境变量 | OPENAI_API_KEY / OPENAI_BASE_URL | DEEPSEEK_API_KEY / DEEPSEEK_BASE_URL |
| 适合场景 | 理解底层原理、快速原型 | LangGraph Agent 生态集成 |
从第四章开始,所有代码将统一使用 LangChain ChatDeepSeek,与 mini-OpenClaw 源码保持一致。
附录一:本章学习成果 链接到标题
完成本章的学习,你已经掌握了:
认知层:上下文窗口 ≠ 记忆系统;Agent 需要持久化的跨会话记忆
类比体系:人类工作记忆 / 长期记忆 / 图书馆检索 → Agent 三层记忆架构
短期存储:用 JSON 文件实现会话持久化,
compressed_context + messages双字段结构截断策略:
MAX_HISTORY = 30,只取最近 N 条传给 LLM压缩摘要:用 LLM 压缩早期对话,摘要以
system消息注入,结构清晰SessionManager:四个方法封装完整短期记忆生命周期
LangChain 对齐:掌握
ChatDeepSeek与原始 SDK 的一一对应关系,理解 mini-OpenClaw 选择 LangChain 的工程理由
第四章将进入长期记忆的深度讲解:文件系统记忆 vs 向量数据库、Direct 注入 vs RAG 注入的触发逻辑、MD5 变化检测、以及向量检索入门。
回顾一下你的进步:本章开始时,我们面对的是一个「每次调用都失忆」的无状态 LLM;本章结束时,我们已经有了一个能跨会话记住用户、自动压缩早期历史、控制 Token 成本的完整短期记忆层。这是从 0 到 1 的关键一跳——后续章节的长期记忆、RAG 检索都建立在这个基础之上。
在前三章中,我们完整实现了短期记忆层的 SessionManager——它能跨进程保存会话、在历史超长时自动截断、并用 LLM 压缩早期对话生成摘要。那一层解决的是「当前会话内的连贯性」问题,但还有一个更深的问题没有触碰:用户明天再打开 Agent,它还认识他吗?上次讨论的项目背景还在吗?用户说过「我不喜欢 SQL,更偏向 Python」这件事,Agent 还记得吗?
这就是长期记忆需要解决的问题。和短期记忆不同,长期记忆的核心挑战不是「如何控制 Token 数量」,而是「如何存储、如何判断写入时机、如何在海量历史中精准检索出相关片段」。后三章覆盖三个章节:第四章建立长期记忆的存储选型认知;第五章深入写入与检索的工程机制;第六章把短期与长期记忆串联成完整的 Memory Manager,写出整门课的最终实战代码。
后三章沿着「选型→写入→检索→协同」这条主线推进。每一章都会在前一章留下的问题上继续深挖,章节之间不是平行罗列,而是递进式的工程展开。我们从长期记忆的底层存储开始。
第四章:长期记忆架构——四种存储类型与选型逻辑 链接到标题
从短期记忆过渡到长期记忆,第一个要解决的问题是:「长期记忆应该存在哪里?」这个问题看似简单,实则是整个记忆系统架构设计的起点。存储类型选错,后续的写入逻辑和检索逻辑都会跑偏——就像把图书馆的书随机堆在仓库里,查阅效率必然灾难级别。
工程实践中,长期记忆的存储方案主要分为四类:向量数据库、KV 存储、图数据库、关系型数据库。这四类工具在技术社区里都很成熟,但它们解决的是完全不同维度的问题。mini-OpenClaw 选择了「文件系统(KV 轻量实现)+ 向量数据库(LlamaIndex VectorStoreIndex)」的组合路线——底层使用内存向量索引,通过 LlamaIndex 的 SentenceSplitter + VectorStoreIndex 实现文档分块和语义检索。这个选择背后有清晰的工程逻辑,本章的目标就是把这套逻辑讲透,让你在面对不同场景时能独立做出判断。
本章按「先认识每种存储类型的核心机制和适用边界,再给出选型指南」的顺序展开。读完本章,你不仅知道 mini-OpenClaw 用了什么,更清楚它为什么这样选、什么场景下应该换一种方案。

4.1 向量数据库:语义相似度检索 链接到标题
向量数据库是长期记忆系统中最核心、也最容易被误解的存储类型。它解决的问题是:当用户说「上次我提到的那个 Python 项目」时,Agent 怎么在几百条历史记忆中找到相关片段——即便用户的措辞和原始记忆里的文字完全不同。
它的工作机制分三步:首先用 Embedding 模型将文本转换为高维向量(比如 1536 维的浮点数数组),代表文本的「语义位置」;然后把所有记忆条目的向量存入向量索引;检索时,将用户 query 转换为同维度向量,计算它与所有存储向量的距离(通常是余弦相似度或 L2 距离),返回距离最近的 Top-K 条记忆。关键洞察:向量距离近 = 语义相近,而不是字面相近。「想吃东西」和「有点饿了」在向量空间里距离很近,传统关键词搜索找不到,向量检索能找到。

mini-OpenClaw 使用 LlamaIndex 的 VectorStoreIndex 配合 OpenAIEmbedding 实现向量检索。这里我们完全复用源项目的技术栈——用 SentenceSplitter 切分文档、用 VectorStoreIndex 构建索引、用 as_retriever 执行语义检索,和 memory_indexer.py 中的实现方式一致:
from llama_index.core import Document, VectorStoreIndex
from llama_index.core.node_parser import SentenceSplitter
from llama_index.core.settings import Settings
from llama_index.embeddings.openai import OpenAIEmbedding
from dotenv import load_dotenv
import os
load_dotenv()
# 配置 Embedding 模型(与 mini-OpenClaw memory_indexer.py 一致)
# 注意:Embedding 用 OPENAI_API_KEY,与 LLM 的 DEEPSEEK_API_KEY 分开
Settings.embed_model = OpenAIEmbedding(
model=os.getenv("EMBEDDING_MODEL", "text-embedding-3-small"),
api_key=os.getenv("OPENAI_API_KEY"),
api_base=os.getenv("OPENAI_BASE_URL", "https://api.openai.com/v1"),
)
# 构造记忆条目(模拟 Agent 积累的用户画像)
memory_texts = [
"用户叫小明,是一名 Python 开发者,主要做数据分析",
"用户正在学习 LangChain,上次讨论到 RAG 架构",
"用户不喜欢 SQL,更偏向用 Pandas 处理数据",
"用户的项目用 FastAPI 作为后端框架",
"用户有 3 年 Python 经验,对机器学习基础了解",
]
# 用 Document + SentenceSplitter 构建索引(与 memory_indexer.py 相同流程)
docs = [Document(text=t) for t in memory_texts]
splitter = SentenceSplitter(chunk_size=256, chunk_overlap=32)
nodes = splitter.get_nodes_from_documents(docs)
index = VectorStoreIndex(nodes)
print(f"向量索引创建完成,共 {len(nodes)} 个节点")
# 语义检索:用 retriever 找最相关的 2 条记忆
retriever = index.as_retriever(similarity_top_k=2)
query = "用户用什么数据处理工具"
results = retriever.retrieve(query)
print(f"\n查询:'{query}'")
print("\n检索结果(分数越高越相似):")
for node in results:
print(f" [{node.score:.4f}] {node.text}")
执行后可以看到:即便 query「用什么数据处理工具」和记忆条目「不喜欢 SQL,更偏向用 Pandas」字面上几乎没有重叠词,向量检索仍然能把它排在前列。这正是向量数据库的核心价值所在——OpenAIEmbedding 将文本转换为真实的语义向量,语义相近的文本在向量空间中距离自然更近。retriever.retrieve 返回的 score 是余弦相似度,分数越高代表语义越相关。
注意这里的技术栈选择:我们用的是 LlamaIndex 的 VectorStoreIndex + SentenceSplitter,而不是 LangChain 的 FAISS。这与 mini-OpenClaw 源码中 memory_indexer.py 的实现完全一致——LLM 调用走 LangChain(ChatDeepSeek),Embedding/RAG 检索走 LlamaIndex,两套框架各司其职。
⚠️ 踩坑预警:
VectorStoreIndex默认是纯内存索引,程序退出后数据丢失。生产环境必须调用index.storage_context.persist(persist_dir="./storage")持久化,下次启动用load_index_from_storage(StorageContext.from_defaults(persist_dir="./storage"))加载。mini-OpenClaw的memory_indexer.py正是这样做的——忘记 persist 是最常见的「重启后记忆全没了」bug。
4.2 KV 存储:精确 Key 检索 链接到标题
KV 存储(Key-Value Store)是最直观的存储类型:给每条数据一个唯一的键,通过键可以 O(1) 时间精确取回对应的值。它不做任何语义推断,只做精确匹配——你知道 key 就能拿到 value,你不知道 key 就什么都拿不到。
在 Agent 记忆系统中,KV 存储的天然使用场景是「已知标识符,需要取回完整数据」的场景:session_id → 会话 JSON、user_id → 用户画像、memory_hash → 记忆条目。这些场景下不需要语义检索,有精确的 key 可用,KV 存储是最合适的选择。mini-OpenClaw 的 sessions/ 目录本质上就是一个文件系统级的 KV 存储——session_id 作为 key,对应的 JSON 文件作为 value。

下面展示 mini-OpenClaw 中 KV 存储的实际使用方式,这段代码直接复用了 SessionManager 的核心逻辑,帮助你看清 KV 的本质:
import json
import os
SESSIONS_DIR = "sessions"
os.makedirs(SESSIONS_DIR, exist_ok=True)
def kv_save(key: str, value: dict) -> None:
"""KV 写入:key → JSON 文件"""
path = os.path.join(SESSIONS_DIR, f"{key}.json")
with open(path, "w", encoding="utf-8") as f:
json.dump(value, f, ensure_ascii=False, indent=2)
def kv_load(key: str) -> dict:
"""KV 读取:通过 key 精确取回 value"""
path = os.path.join(SESSIONS_DIR, f"{key}.json")
if not os.path.exists(path):
return {} # key 不存在,返回空
with open(path, "r", encoding="utf-8") as f:
return json.load(f)
# 演示:用 user_id 作为 key,存储用户画像
kv_save("user_xiaoming", {
"name": "小明",
"role": "Python 开发者",
"preferences": ["Pandas", "FastAPI"],
"skill_level": "中级"
})
profile = kv_load("user_xiaoming")
print(f"用户画像:{profile}")
这段代码刻意保持简单,目的是展示 KV 的本质——文件系统本身就是最轻量的 KV 存储,无需引入任何额外依赖。真实生产环境可以替换为 Redis(分布式高并发场景)或 DynamoDB(云原生场景),但接口语义完全相同:save(key, value) / load(key)。
KV 存储方案对比:文件系统 vs Redis vs DynamoDB
| 方案 | 实现复杂度 | 并发能力 | 透明度 | mini-OpenClaw 使用 |
|---|---|---|---|---|
| 文件系统(JSON) | 极低,零依赖 | 单进程 | 高,可直接查看 | 是,sessions/ 目录 |
| Redis | 中,需启动服务 | 高,支持并发 | 低,需命令行查看 | 否 |
| DynamoDB | 高,需 AWS 配置 | 极高,云原生 | 低,控制台查看 | 否 |
mini-OpenClaw 选择文件系统而非 Redis,核心原因是透明度:开发者随时可以用文本编辑器打开 sessions/xiaoming_20260324.json 查看完整内容,这对调试和教学场景是不可替代的优势。当你的 Agent 进入多用户、高并发的生产场景时,再迁移到 Redis 即可,接口不需要改变。
4.3 图数据库:关系网络检索 链接到标题
图数据库解决的是「多跳关系推理」问题——当记忆内容本身存在复杂的网络关系,且你需要沿着关系路径查询时,向量库和 KV 存储都无法胜任。典型场景:「小明的导师的研究方向是什么」、「和小明协作过的同事都用什么技术栈」——这类查询需要跨越多个节点和边,只有图数据库的图遍历算法才能高效处理。

图数据库的数据模型是节点(Node)+ 边(Edge)+ 属性(Properties)。节点代表实体(人、概念、项目),边代表关系(认识、使用、属于),属性是附加的元数据(姓名、时间、权重)。Neo4j 是最主流的图数据库,使用 Cypher 查询语言,语法类似 SQL 但专为图遍历设计。
mini-OpenClaw 不使用图数据库,原因很明确:对话 Agent 的记忆内容以「事实陈述」为主(用户偏好、项目背景、历史对话摘要),这些内容之间的关系相对扁平,不需要多跳推理。引入图数据库会带来额外的服务依赖和运维成本,而收益微乎其微。
何时应该考虑图数据库:当你的 Agent 需要管理复杂的知识图谱(如企业内部知识库、学术文献关系网络),或者记忆条目之间存在大量显式关联关系(「A 是 B 的上级」「C 和 D 曾经合作过项目 E」),且需要频繁沿这些关系路径查询时,图数据库是合适的选择。对于大多数对话 Agent,向量库已经足够。
4.4 关系型数据库:结构化精确查询 链接到标题
关系型数据库是开发者最熟悉的存储类型,PostgreSQL、MySQL、SQLite 都属于这一类。它的优势是:结构化数据的精确查询、聚合统计、范围筛选、多表 JOIN——这些操作在关系型数据库里极为高效,是其他任何存储类型都无法替代的能力。

在 Agent 记忆系统中,关系型数据库适合存储有明确 Schema、需要统计分析的数据:用户行为日志(统计「用户平均每天发多少条消息」)、工具调用记录(分析「哪个工具被调用最频繁」)、费用账单(按时间范围统计 Token 消耗)。这些场景的共同特点是:数据是结构化的,查询需求是聚合/范围类型的,而不是语义相似类型的。
mini-OpenClaw 同样不使用关系型数据库存储核心记忆内容。核心记忆(用户偏好、对话摘要、项目背景)是自然语言文本,不是结构化数据;它的检索需求是「找和当前话题相关的记忆」,而不是「找 2026 年 3 月之后写入的记忆条目数量」。把自然语言文本塞进关系型数据库再用 LIKE '%偏好%' 查询,是典型的工具误用。
⚠️ 常见误区:很多开发者第一反应是「用 SQLite 存记忆,简单又熟悉」。SQLite 存结构化元数据(写入时间、来源、标签)完全没问题;但如果把长文本内容也存进去,用
LIKE做「相关性检索」,检索质量会极差,且随着数据量增长性能急剧下降。正确姿势:结构化元数据用 SQLite,文本内容语义检索用向量库,两者可以配合使用。
4.5 四种存储类型选型指南 链接到标题
前四节分别介绍了四种存储类型的机制、适用场景和边界。现在把它们放在一张表里对比,帮你在实际工程中快速做出选型决策:
长期记忆存储类型选型对比
| 存储类型 | 检索方式 | 核心优势 | 典型工具 | mini-OpenClaw | 不适合场景 |
|---|---|---|---|---|---|
| 向量数据库 | 语义相似度 | 自然语言模糊查询 | FAISS / Chroma / Pinecone | 是(MEMORY.md > 阈值时) | 精确键值查找、结构化统计 |
| KV 存储 | 精确 Key | O(1) 精确查找,零推断 | Redis / JSON 文件 | 是(sessions/ 目录) | 语义检索、关系查询 |
| 图数据库 | 图遍历路径 | 多跳关系推理 | Neo4j / ArangoDB | 否 | 扁平文本存储、无关系数据 |
| 关系型 DB | SQL 精确/聚合 | 结构化统计分析 | PostgreSQL / SQLite | 否 | 非结构化文本语义检索 |
一个快速判断的决策流程:记忆内容是自然语言文本,需要按语义相关性检索→ 向量数据库;需要通过已知标识符精确取回→ KV 存储;数据之间存在复杂多跳关系需要路径查询→ 图数据库;数据是结构化的,需要聚合统计→ 关系型数据库。大多数对话 Agent 只需要向量库 + KV 存储的组合,这也是 mini-OpenClaw 的选择。
到这里,第四章的核心内容已经全部覆盖。我们从向量化存储的基本原理出发,完整走过了长期记忆的三种实现路线——向量数据库、KV 存储与文件系统记忆——并重点实践了 mini-OpenClaw 实际采用的 LlamaIndex VectorStoreIndex 方案。学完本章,你已经具备:
- 理解语义相似度检索的工作机制(Embedding → 向量空间 → 余弦相似度)
- 能用
SentenceSplitter+VectorStoreIndex构建可持久化的本地向量索引 - 掌握 Direct 模式(全文注入)与 RAG 模式(语义检索)的适用边界与切换逻辑
- 了解三种长期记忆存储方案的优劣势,能根据项目规模做出选型判断
在此基础上,下一章我们将进入记忆的「管理」维度——当记忆越积越多,如何保证它不失控?去重、冷热分层、sleep-time 重组,这些都是工程落地绕不过去的问题。
第五章:长期记忆的写入与检索 链接到标题
第四章回答了「存在哪里」,第五章解决「怎么存进去、怎么取出来」。这两个问题比选型更复杂,因为它们涉及时机判断、策略选择和工程权衡。一个常见的误区是:「把所有对话都写入长期记忆,检索时用向量库找最相关的」——这个思路在理论上听起来合理,实际执行会带来两个严重问题:一是噪音爆炸(无价值信息把有价值信息淹没),二是检索漂移(高度相似的重复条目互相干扰,真正需要的信息反而排名靠后)。
本章覆盖四个核心机制:写入触发逻辑(什么时候写)、Direct 注入(小文件全文读取)、RAG 注入(大文件语义检索)、以及 sleep-time 重组与去重策略。每个机制都配套可运行的代码,第六章将把这些组件整合进完整的 MemoryManager。
这四个机制不是相互独立的——它们共同构成一个自适应的长期记忆系统:写入触发控制「什么值得记」,Direct/RAG 切换控制「如何高效读」,sleep-time 和去重保证「记忆质量不随时间退化」。

5.1 写入机制:Agent 何时写入长期记忆 链接到标题
写入触发是长期记忆系统最关键的设计决策之一。写入太频繁,记忆库充满噪音;写入太保守,有价值的信息被遗漏。mini-OpenClaw 使用「LLM 主动判断」策略:每次对话结束后,Agent 对本轮对话进行一次价值评估,决定是否有内容值得写入长期记忆。
价值评估的判断标准是:事实性、稳定性、跨会话复用性。「用户叫小明,是 Python 开发者」——事实性强、稳定、跨会话都用得到,值得写入。「用户刚才问了一个 for 循环的语法」——临时性任务性信息,明天就没用了,不写入。下面的代码展示这个判断函数的实现:
from langchain_deepseek import ChatDeepSeek
from langchain_core.messages import HumanMessage
from dotenv import load_dotenv
import json
import os
load_dotenv()
# LLM 工厂函数:统一管理连接配置,仅 temperature 按场景不同
def create_llm(temperature: float = 0.7) -> ChatDeepSeek:
return ChatDeepSeek(
model=os.getenv("DEEPSEEK_MODEL", "deepseek-chat"),
api_key=os.getenv("DEEPSEEK_API_KEY"),
base_url=os.getenv("DEEPSEEK_BASE_URL", "https://api.deepseek.com"),
temperature=temperature,
)
# 记忆筛选用低 temperature(0.1),减少随机性
llm_judge = create_llm(0.1)
def is_worth_memorizing(conversation_snippet: str) -> tuple[bool, str]:
"""
判断对话片段是否值得写入长期记忆。
返回:(是否写入, 提炼后的记忆文本)
"""
prompt = f"""你是一个记忆筛选助手。请分析以下对话片段,判断其中是否包含值得长期记忆的信息。
值得长期记忆的信息特征:
- 用户的身份、职业、技术背景
- 用户的明确偏好或厌恶
- 正在进行的项目的关键背景
- 用户提出的明确要求或约束
不值得长期记忆的信息:
- 临时性的任务(「帮我写一段代码」执行完就结束了)
- 常识性问题的问答
- 纯粹的闲聊
对话片段:
{conversation_snippet}
请严格用 JSON 格式回复,不要输出其他内容:
{{"worth_memorizing": true/false, "memory_text": "如果值得记忆,提炼成一句话;否则留空"}}"""
result = llm_judge.invoke([HumanMessage(content=prompt)])
parsed = json.loads(result.content)
return parsed["worth_memorizing"], parsed.get("memory_text", "")
# 测试:两个对话片段,一个值得记忆,一个不值得
snippet1 = "用户:我叫小明,做了 3 年 Python,现在在做一个 FastAPI 项目。\nAgent:了解!"
snippet2 = "用户:Python 的 list comprehension 怎么写?\nAgent:[x for x in range(10)] 这样写。"
for snippet in [snippet1, snippet2]:
worth, text = is_worth_memorizing(snippet)
print(f"值得记忆:{worth}")
if worth:
print(f"记忆内容:{text}")
print()
这段代码与 mini-OpenClaw 保持一致,使用 ChatDeepSeek 调用 DeepSeek API。JSON 输出通过 prompt 约束(「请严格用 JSON 格式回复」)+ json.loads() 手动解析实现。temperature=0.1 降低随机性,让判断更稳定。实际运行时,第一个片段(用户身份信息)会被判断为值得记忆,第二个(临时语法问题)会被丢弃。
⚠️ 踩坑预警:把所有对话都写入长期记忆是最常见的错误。100 条对话里通常只有 5-10 条包含真正值得长期保留的信息。写入过多导致 MEMORY.md 快速膨胀,向量检索时噪音条目把有价值条目「压下去」,检索准确率反而下降。宁可漏记,不要滥记。
5.2 Direct 注入:全文读取注入 System Prompt 链接到标题
确定了「何时写入」之后,下一个问题是「如何读取」。最简单的读取方式是 Direct 注入:每次对话开始时,把 MEMORY.md 的全文读出来,直接拼接到 System Prompt 的头部,让 LLM 在推理时能看到所有长期记忆。
Direct 注入的实现极其简单,信息完整无遗漏,是 mini-OpenClaw 的默认模式。它的唯一限制是体积——当 MEMORY.md 超过约 2000 tokens(约 1500 中文字符)时,Direct 注入的 Token 成本开始显著上升,且有触碰上下文窗口限制的风险。
下面的代码展示 Direct 注入的完整实现,包含 MD5 变化检测优化——只有文件内容真正发生变化时才重新读取,避免频繁 IO:
import hashlib
import os
# === 配置常量 ===
MEMORY_FILE = "MEMORY.md"
# 当 MEMORY.md 的估算 Token 数超过此阈值时,从 Direct 注入切换为 RAG 检索
# 阈值设置依据:主流模型 System Prompt 上限约 4K tokens,预留一半给对话历史
MEMORY_TOKEN_THRESHOLD = 2000
# === MD5 缓存 ===
# 避免每次对话轮次都重新读磁盘:只有文件内容变化(MD5 不同)时才重新加载
# 这是 mini-OpenClaw 的性能优化手段——高频调用场景下减少 IO 开销
_memory_cache = {"content": "", "md5": ""}
def _file_md5(path: str) -> str:
"""计算文件 MD5,用于检测 MEMORY.md 是否被外部修改(如用户手动编辑)"""
if not os.path.exists(path):
return ""
with open(path, "rb") as f:
return hashlib.md5(f.read()).hexdigest()
def load_memory_direct() -> str:
"""
Direct 注入模式:读取 MEMORY.md 全文,拼接到 System Prompt 中。
工作流程:
1. 计算当前文件 MD5
2. 与缓存 MD5 比较——相同则直接返回缓存(跳过磁盘读取)
3. 不同则重新读取文件,更新缓存
返回值:记忆全文字符串,由调用方拼接到 System Prompt 末尾
"""
current_md5 = _file_md5(MEMORY_FILE)
print(f"MD5转换后的值:{current_md5}")
# 缓存命中:文件未变化,直接返回上次读取的内容
if current_md5 == _memory_cache["md5"]:
return _memory_cache["content"]
if not os.path.exists(MEMORY_FILE):
return ""
with open(MEMORY_FILE, "r", encoding="utf-8") as f:
content = f.read().strip()
# 更新缓存:下次调用时如果 MD5 相同就不再读磁盘
_memory_cache["content"] = content
_memory_cache["md5"] = current_md5
return content
def append_to_memory(memory_text: str) -> None:
"""
向 MEMORY.md 追加一条记忆。
写入后立即使缓存失效(md5 置空),确保下次 load 能读到最新内容。
"""
with open(MEMORY_FILE, "a", encoding="utf-8") as f:
f.write(f"- {memory_text}\n")
# 关键:写入后必须使缓存失效,否则 load_memory_direct 会返回旧内容
_memory_cache["md5"] = ""
def estimate_tokens(text: str) -> int:
"""
粗估 Token 数(不依赖 tiktoken,零依赖实现)。
中文约 1.5 字符/token,英文约 0.75 词/token。
生产环境建议用 tiktoken 精确计算。
"""
return int(len(text) / 1.5)
def should_use_rag() -> bool:
"""
判断是否应从 Direct 切换为 RAG 模式。
当 MEMORY.md 内容超过 MEMORY_TOKEN_THRESHOLD 时返回 True。
调用方根据此结果决定:
- False → load_memory_direct() 全文注入 System Prompt
- True → 走 RAG 检索路径,只注入相关片段
"""
content = load_memory_direct()
return estimate_tokens(content) > MEMORY_TOKEN_THRESHOLD
# === 演示:模拟 Agent 写入和读取长期记忆 ===
# 确保 MEMORY.md 存在(首次运行时创建)
if not os.path.exists(MEMORY_FILE):
with open(MEMORY_FILE, "w", encoding="utf-8") as f:
f.write("# 长期记忆\n\n")
# 模拟 Agent 在对话中积累的用户画像
append_to_memory("用户叫小明,Python 开发者,3 年经验")
append_to_memory("用户偏好 FastAPI + Pandas,不喜欢 SQL")
# 读取并展示当前记忆状态
memory = load_memory_direct()
print(f"当前记忆内容({estimate_tokens(memory)} tokens 估算):")
print(memory)
print(f"\n是否需要切换 RAG 模式:{should_use_rag()}")
MD5 缓存是一个小但重要的优化。Agent 每次对话开始都会调用 load_memory_direct(),如果每次都做文件 IO,在高频场景下会产生不必要的开销。通过对比文件 MD5,只有内容真正变化时才重新读取,大多数情况下直接返回缓存,性能开销接近零。
5.3 RAG 注入:语义检索后注入 链接到标题
当 MEMORY.md 体积超过阈值,Direct 注入的成本开始变得不可接受——每次对话都要把几千 tokens 的记忆全部传给 LLM,其中大多数内容和当前话题毫无关联。RAG 注入(Retrieval-Augmented Generation)解决的正是这个问题:不再全量读取,而是根据当前用户的输入,语义检索最相关的 Top-K 条记忆注入。
RAG 注入的完整流程分为两个阶段:索引阶段(MEMORY.md 内容读取 → SentenceSplitter 分块 → 向量化 → 存入 VectorStoreIndex)和检索阶段(用户 query → 向量化 → 相似度检索 → 取 Top-K → 注入 System Prompt)。索引阶段在文件内容变化时触发一次,检索阶段在每次对话开始时执行。
mini-OpenClaw 的 RAG 检索使用 LlamaIndex 而非 LangChain——这是一个刻意的技术选型。LlamaIndex 的 VectorStoreIndex + SentenceSplitter 在文档索引和检索场景下比 LangChain 的 FAISS 封装更简洁,且内置了存储持久化和自动分块能力。LLM 调用用 LangChain ChatDeepSeek,文档检索用 LlamaIndex——两个框架在 mini-OpenClaw 中各司其职。
下面的代码直接对照 mini-OpenClaw 的 graph/memory_indexer.py,展示完整的 LlamaIndex RAG 流程:
# ============================================================
# Cell 1:依赖导入 & 写入模拟记忆文件
# ============================================================
from llama_index.core import Document, VectorStoreIndex
from llama_index.core.node_parser import SentenceSplitter
from llama_index.core.settings import Settings
from llama_index.embeddings.openai import OpenAIEmbedding
from dotenv import load_dotenv
import os, hashlib
load_dotenv()
# 配置全局 Embedding 模型
Settings.embed_model = OpenAIEmbedding(
model=os.getenv("EMBEDDING_MODEL", "text-embedding-3-small"),
api_key=os.getenv("OPENAI_API_KEY"),
api_base=os.getenv("OPENAI_BASE_URL", "https://api.openai.com/v1"),
)
MEMORY_FILE = "MEMORY.md"
# 写入模拟长期记忆(实际项目中由 Agent 自动维护)
memory_content = """# 长期记忆
- 用户叫小明,Python 开发者,3 年经验
- 用户偏好 FastAPI + Pandas,不喜欢 SQL
- 用户正在开发一个数据分析 API 项目
- 用户上次讨论了 LangChain RAG 架构
- 用户习惯用 conda 管理 Python 环境
"""
with open(MEMORY_FILE, "w", encoding="utf-8") as f:
f.write(memory_content)
print("✅ MEMORY.md 写入完成")
print("文件内容预览:")
print(memory_content)
# ============================================================
# Cell 2:文档切分(Chunking)
# 目标:把 MEMORY.md 切成若干小块(Node),每块是后续 Embedding 的最小单元
# ============================================================
content = open(MEMORY_FILE, "r", encoding="utf-8").read()
# 将整个文件包装成 LlamaIndex Document
doc = Document(text=content, metadata={"source": "MEMORY.md"})
# SentenceSplitter:按句子边界切割
# chunk_size=256 → 每块最多 256 个 token
# chunk_overlap=32 → 相邻块重叠 32 token,防止语义在边界处断裂
splitter = SentenceSplitter(chunk_size=256, chunk_overlap=32)
nodes = splitter.get_nodes_from_documents([doc])
print(f"切分结果:共 {len(nodes)} 个文本块\n")
print("=" * 50)
# 逐一打印每个 Node 的内容(这就是将来被 Embedding 的原始文本)
for i, node in enumerate(nodes):
print(f"【Node {i}】")
print(f" node_id : {node.node_id}") # LlamaIndex 自动生成的唯一 ID
print(f" 文本内容: {repr(node.get_text())}") # repr() 保留换行符可见
print(f" token数 : 约 {len(node.get_text().split())} 词")
print(f" metadata: {node.metadata}")
print()
# ============================================================
# Cell 3:对每个 Node 做 Embedding,打印向量形状与前几个维度
# 目标:直观感受"文本 → 浮点数向量"的转换结果
# ============================================================
embed_model = Settings.embed_model
print("正在调用 Embedding API,请稍候...\n")
for i, node in enumerate(nodes):
text = node.get_text().strip()
# 调用 Embedding 模型,返回一个浮点数列表(即向量)
vector = embed_model.get_text_embedding(text)
print(f"【Node {i}】文本片段(前30字): {text[:30]}...")
print(f" 向量维度 : {len(vector)}") # text-embedding-3-small → 1536 维
print(f" 前5个分量 : {[round(v, 6) for v in vector[:5]]}") # 展示向量开头几个值
print(f" 值域范围 : [{min(vector):.4f}, {max(vector):.4f}]")
print()
print("💡 说明:每个文本块被压缩成一个固定长度的向量(1536 维)")
print(" 语义相近的文本,向量之间的余弦相似度越接近 1.0")
# ============================================================
# Cell 4:构建向量索引,执行语义检索,展示召回结果的溯源信息
# 目标:看清"检索到的内容"究竟对应哪个 Node,相似度得分是多少
# ============================================================
# 构建索引(内部自动对所有 Node 做 Embedding 并建立向量数据库)
index = VectorStoreIndex(nodes)
print("✅ 向量索引构建完成\n")
# 执行语义检索
query = "用户用什么工具做数据处理"
retriever = index.as_retriever(similarity_top_k=2) # 召回最相似的 2 个 Node
result_nodes = retriever.retrieve(query)
print(f"Query: {query}\n")
print("=" * 50)
print(f"召回 {len(result_nodes)} 个文本块:\n")
for rank, node_with_score in enumerate(result_nodes):
node = node_with_score.node # 原始 Node 对象
score = node_with_score.score # 余弦相似度得分(越高越相关)
# 找出该 Node 在原始 nodes 列表中的索引位置
original_idx = next(
(i for i, n in enumerate(nodes) if n.node_id == node.node_id), -1
)
print(f"【召回第 {rank+1} 名】")
print(f" 来源 Node 索引: Node {original_idx}") # 对应 Cell 2 中打印的编号
print(f" node_id : {node.node_id}")
print(f" 相似度得分 : {score:.4f}") # 0~1,越接近1越相关
print(f" 文本内容 :\n {node.get_text().strip()}")
print()
# ============================================================
# Cell 5:组装最终注入 LLM 的记忆字符串
# 目标:展示从"检索结果"到"可注入 prompt"的最后一步格式化
# ============================================================
def format_rag_memory(result_nodes) -> str:
"""
将检索结果格式化为 Markdown 字符串。
该字符串会被拼接到 LLM 的 system prompt 头部,
让 LLM 在回答时"记得"用户的历史信息。
"""
if not result_nodes:
return ""
lines = [f"- {n.node.get_text().strip()}" for n in result_nodes]
return "## 相关记忆(语义检索)\n" + "\n".join(lines)
injected_memory = format_rag_memory(result_nodes)
print("最终注入 LLM 的记忆字符串(放入 system prompt):")
print("-" * 50)
print(injected_memory)
print("-" * 50)
# 模拟拼接 system prompt(实际 Agent 代码中的用法)
system_prompt = f"""你是一个智能助手。以下是用户的历史记忆,请结合这些信息回答问题:
{injected_memory}
请根据以上记忆,回答用户的问题。
"""
print("\n组装后的 system_prompt 示例(前200字):")
print(system_prompt[:200], "...")
上面的代码展示了 LlamaIndex RAG 检索的三个核心环节。
第一,分块使用
SentenceSplitter(chunk_size=256, chunk_overlap=32)自动按句子边界切割,且支持重叠以避免信息在边界丢失——不需要手写分割逻辑。第二,检索接口
index.as_retriever().retrieve()返回的NodeWithScore对象同时携带文本和相似度分数,比裸向量库更易用。第三,
index.storage_context.persist(persist_dir="./memory_index_storage")将向量索引持久化到磁盘,下次启动时可通过StorageContext.from_defaults(persist_dir="./memory_index_storage")加载,避免重复构建索引——这与mini-OpenClaw的memory_indexer.pyL85 实现一致。忘记调用persist是最常见的「重启后索引消失、构建开销重复浪费」bug。

⚠️ 踩坑预警:按固定字符数分块(如每 200 字一块)很容易把一条记忆从中间切断。例如「用户正在开发一个基于 LangChain 的…」被截断成「用户正在开发一个基于」和「LangChain 的…」两块,两块都失去了完整语义,检索时都排名很低。
MEMORY.md的每行一条记忆格式天然适合按行分块,不要改变这个约定。
5.4 sleep-time agent:离线记忆重组 链接到标题
实时写入解决了「有价值的信息能及时存入记忆」的问题,但随着时间积累,MEMORY.md 会出现新的质量问题:同一个事实被多次写入(「用户喜欢 Python」在不同会话中反复出现)、早期的记忆已经过时(用户当时在做 A 项目,现在已经换成 B 项目了)、部分记忆措辞混乱需要整理。这些问题无法在实时写入阶段解决,因为实时写入追求的是速度,没有时间做全局扫描。
sleep-time agent 是解决这个问题的经典模式:Agent 在「非工作时间」(两次对话之间的空闲期)异步运行一个整理任务,对 MEMORY.md 进行全量扫描、去重、提炼和重组。它不参与实时对话,只做「记忆质量维护」这一件事。
下面是一个最小可用的 sleep-time 重组函数,它读取当前 MEMORY.md,用 LLM 提炼出一份更高质量的版本并写回:
# ============================================================
# Cell 1:构造一份包含"重复、矛盾、冗余"的脏记忆文件
# 目标:为 sleep-time 重组提供有明显整理空间的测试数据
# ============================================================
MEMORY_FILE = "MEMORY.md"
# 故意设计以下几类问题(用注释标出,实际文件不含注释):
# [重复] 用户偏好 Pandas 出现了两次
# [矛盾] 用户喜欢 SQL vs 不喜欢 SQL
# [冗余] 用户信息分散在多条而非一条
# [过时] 用户"正在学习 Python"vs"已有 3 年经验"
dirty_memory = """# 长期记忆
- 用户叫小明
- 用户是 Python 开发者
- 用户有大约 3 年的 Python 开发经验
- 用户正在学习 Python 基础(过时)
- 用户偏好使用 Pandas 做数据处理
- 用户不喜欢写 SQL,更喜欢用 Pandas 操作数据
- 用户喜欢用 SQL 做数据查询(与上条矛盾)
- 用户在开发一个数据分析 API 项目
- 用户的项目使用 FastAPI 作为后端框架
- 用户正在开发基于 FastAPI 的数据分析接口(与上条重复)
- 用户上次讨论了 LangChain RAG 架构
- 用户对 LangChain 的 RAG 实现方式很感兴趣(与上条重复)
- 用户习惯用 conda 管理 Python 环境
- 用户使用 conda 创建虚拟环境(与上条重复)
- 用户询问过如何优化向量检索速度
- 用户希望部署到云端,询问过 AWS 和阿里云的费用对比
- 用户表示预算有限,倾向于选择性价比高的方案
"""
with open(MEMORY_FILE, "w", encoding="utf-8") as f:
f.write(dirty_memory)
# 打印整理前的状态统计
lines = [l for l in dirty_memory.strip().split("\n") if l.startswith("- ")]
print("=" * 50)
print("📋 整理前 MEMORY.md 状态")
print("=" * 50)
print(f"总条目数 : {len(lines)} 条")
print(f"总字符数 : {len(dirty_memory)} 字符")
print(f"\n已知问题(人工标注):")
print(" [重复] 'Pandas'条目 × 2,'FastAPI 项目'条目 × 2,'conda'条目 × 2,'LangChain RAG'条目 × 2")
print(" [矛盾] 喜欢 SQL vs 不喜欢 SQL")
print(" [过时] '正在学习 Python 基础' vs '3 年经验'")
print(f"\n原始内容:\n{dirty_memory}")
# ============================================================
# Cell 2:执行 sleep-time 记忆重组
# 流程:读取 MEMORY.md → 构造整理 prompt → 调用 LLM → 备份原文件 → 写回整理结果
# ============================================================
from langchain_deepseek import ChatDeepSeek
from langchain_core.messages import HumanMessage
from dotenv import load_dotenv
import os
load_dotenv()
def create_llm(temperature: float = 0.7) -> ChatDeepSeek:
"""
LLM 工厂函数。
temperature=0.2:低随机性,保证整理结果稳定、不乱发挥
"""
return ChatDeepSeek(
model=os.getenv("DEEPSEEK_MODEL", "deepseek-chat"),
base_url=os.getenv("DEEPSEEK_BASE_URL", "https://api.deepseek.com"),
temperature=temperature,
)
# 整理任务用低 temperature,避免 LLM 随机删掉重要条目
llm_organizer = create_llm(0.2)
def sleep_time_reorganize() -> str:
"""
sleep-time 记忆重组主函数。
整理策略(通过 prompt 指令 LLM 执行):
1. 去重:相同含义的条目只保留一条
2. 去矛盾:时间靠后或更具体的信息优先
3. 去冗余:把分散的同一主题信息合并为一条
4. 精炼:每条压缩为一句话
5. 限制总量:最多 20 条,强制聚焦核心信息
"""
if not os.path.exists(MEMORY_FILE):
return ""
with open(MEMORY_FILE, "r", encoding="utf-8") as f:
current_memory = f.read().strip()
# 内容太少(<100字符)说明刚写入,不值得整理
if not current_memory or len(current_memory) < 100:
return current_memory
# ---- 构造整理 prompt ----
# 关键:用明确的规则约束 LLM 的整理行为,避免随意删改
prompt = f"""你是一个记忆整理助手。请对以下 Agent 长期记忆进行整理:
整理规则:
1. 去除重复信息,合并相似条目
2. 删除过时或矛盾的信息(保留更新的、更具体的)
3. 将每条记忆精炼为一句话,以 '- ' 开头
4. 保留文件头 '# 长期记忆'
5. 最多保留 20 条最重要的记忆
当前记忆:
{current_memory}
请直接输出整理后的完整 Markdown 内容,不要添加任何解释。"""
print("正在调用 LLM 进行记忆整理,请稍候...\n")
result = llm_organizer.invoke([HumanMessage(content=prompt)])
reorganized = result.content.strip()
# ---- 写回前备份原始内容 ----
# 防止 LLM 整理出错时无法恢复
backup_path = MEMORY_FILE + ".bak"
with open(backup_path, "w", encoding="utf-8") as f:
f.write(current_memory)
# 写回整理后的内容
with open(MEMORY_FILE, "w", encoding="utf-8") as f:
f.write(reorganized)
before_lines = current_memory.count("\n")
after_lines = reorganized.count("\n")
print(f" 整理完成:{before_lines} 行 → {after_lines} 行(压缩率 {(1 - after_lines/before_lines)*100:.0f}%)")
print(f" 原始备份:{backup_path}")
return reorganized
reorganized = sleep_time_reorganize()
# ============================================================
# Cell 3:整理前后对比,量化整理效果
# ============================================================
# 读取备份(整理前)和当前文件(整理后)
with open(MEMORY_FILE + ".bak", "r", encoding="utf-8") as f:
before = f.read().strip()
with open(MEMORY_FILE, "r", encoding="utf-8") as f:
after = f.read().strip()
# 提取条目列表(以"- "开头的行)
before_items = [l.strip() for l in before.split("\n") if l.strip().startswith("- ")]
after_items = [l.strip() for l in after.split("\n") if l.strip().startswith("- ")]
# ---- 打印对比报告 ----
print("=" * 60)
print(" sleep-time 记忆重组效果对比")
print("=" * 60)
print(f"\n【数量变化】")
print(f" 整理前:{len(before_items)} 条")
print(f" 整理后:{len(after_items)} 条")
print(f" 减少了:{len(before_items) - len(after_items)} 条({(1 - len(after_items)/len(before_items))*100:.0f}% 压缩)")
print(f"\n【字符量变化】")
print(f" 整理前:{len(before)} 字符")
print(f" 整理后:{len(after)} 字符")
print("\n" + "-" * 60)
print("整理前(原始脏记忆):")
print("-" * 60)
for i, item in enumerate(before_items, 1):
print(f" {i:2d}. {item}")
print("\n" + "-" * 60)
print("整理后(LLM 提炼结果):")
print("-" * 60)
for i, item in enumerate(after_items, 1):
print(f" {i:2d}. {item}")
print("\n" + "-" * 60)
print(" 预期效果验证:")
print(" 重复条目(Pandas/FastAPI/conda/LangChain)是否合并为 1 条?")
print(" 矛盾条目(喜欢SQL vs 不喜欢SQL)是否保留了正确的一条?")
print(" 过时条目(正在学习Python基础)是否被删除?")
print(" 分散的用户信息是否合并为一条?")
代码中有一个关键的工程保障:写回前先备份。sleep-time 整理是一个有损操作——LLM 可能判断失误,把某条重要记忆认为是「重复」而删除。有了 .bak 备份文件,即使整理结果不满意,也能手动恢复。这是生产级系统必须具备的容错设计。
sleep-time 的触发时机在工程上有多种选择:对话结束后异步触发(适合高频对话场景)、每天定时运行(适合轻量场景)、手动调用(适合开发调试)。mini-OpenClaw 采用「手动调用为主」策略——记忆质量下降时(条目数超过 30 条,或发现明显重复),显式调用 sleep_time_reorganize(),不做全自动调度。
第五章覆盖了长期记忆的完整生命周期管理。从写入时的去重筛选,到存储中的冷热分层,再到 sleep-time 异步重组,我们把记忆系统从「能用」推进到了「好用」。学完本章,你已经具备:
- 理解为什么需要记忆管理:不加控制的追加会导致噪声积累与检索质量下降
- 掌握基于 LLM 语义判断的去重机制,理解其相比关键词去重的优势
- 理解 sleep-time compute 的核心价值:用离线时间换在线质量
- 能将这些管理策略映射到 mini-OpenClaw 的
MemoryIndexer实现
至此,长期记忆的「存」与「管」都已经打通。第六章我们将把短期记忆和长期记忆真正串联起来,用一个端到端的 MemoryManager 完整实现三阶段记忆主循环,验证整个系统在跨 session 场景下的实际表现。
第六章:短期与长期记忆协同——Memory Manager 架构 链接到标题
前四章分别深入了两个记忆层:批次一实现了 SessionManager(短期记忆),本章前两节建立了长期记忆的存储选型认知和读写机制。现在到了整门课最关键的一步——把这两个独立模块串联成一个有机整体,设计一个统一的 MemoryManager 来协调它们。
这个架构设计不仅仅是把两段代码拼在一起。真正的协同需要解决三个问题:统一入口(调用方只和 MemoryManager 交互,不直接操作任何底层存储)、正确的调用时序(加载记忆→构造 Prompt→LLM 推理→更新记忆,每个步骤的顺序不能乱)、可降级设计(长期记忆文件不存在时,短期记忆仍然正常工作,不因为一个模块的缺失导致整个 Agent 崩溃)。
本章的最终产出是一个完整可运行的 MemoryManager,把批次一和批次二所有代码串联起来,用一个交互式测试 loop 验证整个记忆系统的端到端行为。这是整门课的「最终答案」。

6.1 Memory Manager 设计原则 链接到标题
在写代码之前,先明确 MemoryManager 的设计原则——这些原则决定了接口设计,而接口一旦确定,实现细节反而不重要。
原则一:单一入口。调用方(Agent 的主循环)只和 MemoryManager 打交道,不直接操作 SessionManager 或 MEMORY.md。这意味着 MemoryManager 必须暴露足够简洁的接口,把所有底层复杂性封装在内部。
原则二:对 LLM 透明。MemoryManager 的最终输出是一个 messages 列表,LLM 直接消费这个列表做推理,不感知背后有多复杂的记忆管理逻辑。从 LLM 的视角看,它只收到了一个精心构造的上下文。
原则三:可降级。MEMORY.md 不存在、LlamaIndex 索引构建失败、网络超时——任何一个异常都不应该让 Agent 崩溃。每个模块独立失败,其他模块继续工作。
基于这三个原则,我们先用最小代码实现 MemoryManager 的三阶段骨架——只包含核心流程,不含压缩和长期记忆写入,验证「load → get_messages → update」这条主链路能跑通:
import json, os
from langchain_core.messages import HumanMessage, AIMessage, SystemMessage
class MemoryManagerSkeleton:
"""最小可运行的 MemoryManager 骨架:只实现三阶段主链路"""
def __init__(self, session_dir="sessions", memory_file="MEMORY.md", max_history=20):
self.session_dir = session_dir
self.memory_file = memory_file
self.max_history = max_history
os.makedirs(session_dir, exist_ok=True)
def load(self, session_id: str, user_query: str) -> tuple[dict, str]:
"""阶段一:加载短期记忆(session)+ 长期记忆(MEMORY.md 全文)"""
# 短期记忆:从 JSON 文件加载
path = os.path.join(self.session_dir, f"{session_id}.json")
if os.path.exists(path):
with open(path, "r", encoding="utf-8") as f:
session = json.load(f)
else:
session = {"messages": [], "compressed_context": ""}
# 长期记忆:Direct 全文读取(骨架版不含 RAG 切换)
long_term = ""
if os.path.exists(self.memory_file):
with open(self.memory_file, "r", encoding="utf-8") as f:
long_term = f.read().strip()
return session, long_term
def get_messages_for_llm(self, session: dict, long_term_context: str,
system_base: str = "你是一个有记忆能力的 AI 助手。") -> list:
"""阶段二:构造 LangChain Message 列表(直接传给 ChatDeepSeek)"""
messages = []
# System Prompt = 基础指令 + 长期记忆
system_content = system_base
if long_term_context:
system_content += f"\n\n## 关于用户的长期记忆\n{long_term_context}"
messages.append(SystemMessage(content=system_content))
# 压缩摘要(若有)
if session.get("compressed_context"):
messages.append(SystemMessage(content=f"## 早期对话摘要\n{session['compressed_context']}"))
# 最近 N 条对话历史(dict → LangChain Message)
for msg in session["messages"][-self.max_history:]:
if msg["role"] == "user":
messages.append(HumanMessage(content=msg["content"]))
elif msg["role"] == "assistant":
messages.append(AIMessage(content=msg["content"]))
return messages
def update(self, session_id: str, session: dict,
user_input: str, assistant_response: str) -> None:
"""阶段三:追加本轮对话 + 保存 session"""
session["messages"].append({"role": "user", "content": user_input})
session["messages"].append({"role": "assistant", "content": assistant_response})
# 保存到文件
path = os.path.join(self.session_dir, f"{session_id}.json")
with open(path, "w", encoding="utf-8") as f:
json.dump(session, f, ensure_ascii=False, indent=2)
# ── 验证三阶段主链路 ──────────────────────────────────────
mm = MemoryManagerSkeleton(session_dir="./sessions", memory_file="MEMORY.md")
# 阶段一:加载(首次为空)
session, long_term = mm.load("test_session", "你好")
print(f"短期记忆:{len(session['messages'])} 条消息")
print(f"长期记忆:{'有内容' if long_term else '空'}")
# 阶段二:构造 LLM 输入
messages = mm.get_messages_for_llm(session, long_term)
print(f"\n传给 LLM 的消息数:{len(messages)}")
for msg in messages:
print(f" [{type(msg).__name__}] {msg.content[:50]}...")
# 阶段三:模拟更新
mm.update("test_session", session, "我叫小明", "你好小明!")
print(f"\n更新后消息数:{len(session['messages'])}")
# 验证持久化:重新 load 看数据是否保存
session2, _ = mm.load("test_session", "")
print(f"重新加载后消息数:{len(session2['messages'])}(持久化验证通过)")
# 清理
import shutil
shutil.rmtree("/tmp/sessions", ignore_errors=True)
这个骨架版省略了压缩、长期记忆写入、RAG 切换——这些机制在前面的章节都已经单独讲过并验证过。骨架版的价值是让你在一个代码块里看清三阶段的调用顺序和数据流向:load 返回 session + long_term_context → get_messages_for_llm 把它们组装成 LangChain Message 列表 → update 追加本轮对话并持久化。5.2 节的完整版在这个骨架上添加压缩、RAG 切换、长期记忆写入,但三阶段的主链路完全不变。
6.2 请求处理完整生命周期 链接到标题
有了接口设计,现在来看完整实现。下面的代码是本课程最重要的一段——它把批次一和批次二所有组件整合在一起,实现了完整的 MemoryManager。代码分为三个层次:基础工具函数(Token 计数和对话压缩)、MemoryManager 的内部私有方法(session 读写、Direct/RAG 自适应切换)、以及三个公开方法(load/get_messages_for_llm/update)。整个 class 必须在同一个代码 cell 中定义,否则 Notebook 执行会报错。
# ── 依赖导入 ──────────────────────────────────────────────
from langchain_deepseek import ChatDeepSeek
from langchain_core.messages import HumanMessage, AIMessage, SystemMessage
from llama_index.core import Document, VectorStoreIndex
from llama_index.core.node_parser import SentenceSplitter
from llama_index.core.settings import Settings
from llama_index.embeddings.openai import OpenAIEmbedding
from dotenv import load_dotenv
import json, os, hashlib, tiktoken
load_dotenv()
# Embedding 走 OPENAI_API_KEY(OpenAI 兼容代理),与 mini-OpenClaw memory_indexer.py 保持一致
# LLM 走 DEEPSEEK_API_KEY,两套凭证来源不同,不可混用
Settings.embed_model = OpenAIEmbedding(
model=os.getenv("EMBEDDING_MODEL", "text-embedding-3-small"),
api_key=os.getenv("OPENAI_API_KEY"),
api_base=os.getenv("OPENAI_BASE_URL", "https://api.openai.com/v1"),
)
def compress_messages(messages: list[dict], existing_summary: str = "") -> str:
"""将一批对话历史压缩为摘要(滚动摘要:旧摘要 + 新消息 → 统一新摘要)"""
history_text = "\n".join(f"{m['role']}: {m['content']}" for m in messages)
# 如果已有旧摘要,把旧摘要和新消息一起交给 LLM 压缩
if existing_summary:
to_compress = f"[之前的摘要]\n{existing_summary}\n\n[新增对话]\n{history_text}"
else:
to_compress = history_text
result = llm_compress.invoke([HumanMessage(
content=f"请将以下内容压缩为一段简洁的统一摘要(100-200字以内),保留所有关键信息(用户身份、重要决策、技术细节、未完成的任务):\n\n{to_compress}"
)])
return result.content.strip()
def _maybe_compress(self, session: dict) -> dict:
"""消息数超过 max_history 时触发压缩,滚动摘要:旧摘要 + 新消息 → 统一新摘要"""
msgs = session["messages"]
if len(msgs) <= self.max_history: # 未超出阈值,无需压缩
return session
old_msgs = msgs[:-self.max_history // 2] # 较旧的一半:交给 LLM 压缩
keep_msgs = msgs[-self.max_history // 2:] # 较新的一半:保留在 messages 中
existing = session.get("compressed_context", "") # 读取已有摘要(可能已有多轮积累)
# 滚动摘要:旧摘要 + 新消息一起交给 LLM 压缩为统一新摘要,长度始终可控
summary = compress_messages(old_msgs, existing_summary=existing)
session["compressed_context"] = summary # 直接覆盖,不拼接
session["messages"] = keep_msgs # 丢弃已压缩的旧消息
return session
update 方法中的 _is_worth_memorizing 调用包裹在 try/except 中——这是「可降级设计」的具体体现。长期记忆写入失败(网络超时、API 限流、JSON 解析错误)时,只是静默跳过,不会中断 Agent 的主对话流程。短期记忆的 session 保存不受影响。
6.3 完整实战代码:端到端验证 链接到标题
MemoryManager 的三个阶段方法已经实现完毕。现在把它们串联进一个完整的对话主循环,同时加入一个交互式测试场景,验证整个记忆系统的端到端行为:第一轮对话告知用户信息,第二轮验证短期记忆正常工作,第三轮验证长期记忆写入并跨 session 读取。
# ── ImprovedMemoryManager:覆盖 _is_worth_memorizing,解决中文去重失效问题 ─────
# 中文无空格,整句话是单 token,不同措辞的语义相同句子交集永远为 0。
# 修复方案:将「已有记忆」也传给 LLM,在同一次调用内完成「值不值得记」+「有没有记过」两个判断。
# 与 mini-OpenClaw 概念对齐(LLM 判断是否值得记),但实现路径不同:
# mini-OpenClaw 通过 write_file tool call 由 LLM 主动触发写入;
# 课件用 Python 框架在每轮 update() 后代理触发——两者行为语义等价,前者需要完整 Agent 工具体系,后者更适合教学演示。
class ImprovedMemoryManager(MemoryManager):
"""继承 MemoryManager,只覆盖 _is_worth_memorizing——其余已测试逻辑不动。"""
def _is_worth_memorizing(self, snippet: str) -> tuple[bool, str]:
"""
在原版基础上,将「已有记忆」一起传给 LLM,
让模型同时回答两个问题:
1. 这段对话是否包含值得记忆的新信息?
2. 该信息是否已在现有记忆中有语义相同的记录?
只有「值得记 + 尚未记」时才返回 worth=True。
"""
# 读取已有记忆(有 MD5 缓存,内容未变时无 IO 开销)
existing = self._load_long_term_direct()
try:
prompt = (
"请判断以下对话是否包含值得长期记忆的【新】信息。\n\n"
"## 已有记忆\n"
+ (existing if existing else "(暂无记录)") + "\n\n"
"## 本轮对话\n"
+ snippet + "\n\n"
"判断规则:\n"
"1. 对话中的关键信息若在【已有记忆】中已有语义相同的记录(即使措辞不同),视为重复,worth_memorizing 返回 false\n"
"2. 确实是新信息(用户身份/偏好/项目背景等),才返回 worth_memorizing=true 和一句话摘要\n"
"3. 仅是闲聊、重复确认、无实质信息的对话,返回 worth_memorizing=false\n\n"
'请严格用 JSON 回复:{"worth_memorizing": true/false, "memory_text": "一句话摘要或空字符串"}'
)
resp = llm_judge.invoke([HumanMessage(content=prompt)])
parsed = json.loads(resp.content)
return parsed.get("worth_memorizing", False), parsed.get("memory_text", "")
except Exception:
return False, "" # 失败时静默跳过,不中断主流程
def handle_message(mm: MemoryManager, session_id: str,
user_input: str, system_base: str = "你是一个有记忆能力的 AI 助手。") -> str:
"""
单次消息处理的完整流程:load → get_messages → LLM → update
"""
# 阶段一:加载记忆
session, long_term_context = mm.load(session_id, user_input)
# 阶段二:构造 LLM 输入
messages = mm.get_messages_for_llm(session, long_term_context, system_base)
# 阶段三:LLM 推理(messages 已经是 LangChain Message 列表)
response = llm_chat.invoke(messages)
assistant_reply = response.content.strip()
# 阶段四:更新记忆
mm.update(session_id, session, user_input, assistant_reply)
return assistant_reply
# ── 端到端测试场景 ─────────────────────────────────────────
import shutil
# 清理测试环境
if os.path.exists("test_sessions"):
shutil.rmtree("test_sessions")
if os.path.exists("TEST_MEMORY.md"):
os.remove("TEST_MEMORY.md")
mm = ImprovedMemoryManager( # ← 替换为 ImprovedMemoryManager,去重逻辑由 LLM 语义判断
session_dir="./sessions",
memory_file="MEMORY.md",
memory_token_threshold=2000,
max_history=20
)
SESSION_A = "session_xiaoming_day1"
SESSION_B = "session_xiaoming_day2" # 模拟第二天的新会话
print("=" * 50)
print("场景一:第一天对话(会话 A)")
print("=" * 50)
# 第1轮:用户自我介绍
msg1 = "你好!我叫小明,是一名 Python 开发者,正在做一个 FastAPI 项目。"
reply1 = handle_message(mm, SESSION_A, msg1)
print(f"用户:{msg1}")
print(f"Agent:{reply1}")
print()
# 第2轮:短期记忆验证(同一会话内)
msg2 = "你还记得我刚才说我在做什么项目吗?"
reply2 = handle_message(mm, SESSION_A, msg2)
print(f"用户:{msg2}")
print(f"Agent:{reply2}")
print()
print("=" * 50)
print("场景二:第二天新会话(会话 B)")
print("=" * 50)
# 第3轮:新 session,验证长期记忆跨 session 注入
msg3 = "嗨,我又来了!你还记得我吗?"
reply3 = handle_message(mm, SESSION_B, msg3)
print(f"用户:{msg3}")
print(f"Agent:{reply3}")
print()
print("=" * 50)
print("当前长期记忆内容:")
if os.path.exists("MEMORY.md"):
with open("MEMORY.md", "r", encoding="utf-8") as f:
print(f.read())
else:
print("(长期记忆尚未写入,或写入判断认为本轮无值得记录信息)")
这个测试场景刻意设计了两个验证点。第2轮对话(「你还记得我在做什么项目吗」)验证短期记忆:同一 session_id 的上下文应该完整保留,Agent 能正确回答「你在做 FastAPI 项目」。第3轮对话(新 SESSION_B)验证长期记忆跨 session 能力:如果第一天的对话触发了长期记忆写入,第二天的新 session 应该能通过 MEMORY.md 注入「看到」小明的信息,Agent 能认出他。
注意最后的 TEST_MEMORY.md 打印。如果显示「长期记忆尚未写入」,说明 is_worth_memorizing 判断本轮对话不包含足够价值的信息,或 API 调用失败被静默跳过。这是正常现象,不是 bug——记忆写入是有条件触发的,不是每轮必写。

6.4 扩展方向 链接到标题
完成了完整的 MemoryManager 实现,我们已经有了一个功能完备的双层记忆系统。但工程实践永远没有终点——这里列出四个最有价值的扩展方向,帮你在掌握基础之后继续深化。
方向一:接入 LangGraph InMemorySaver。mini-OpenClaw 用文件系统管理 session,适合单进程场景。LangGraph 的 InMemorySaver(或生产级的 SqliteSaver、PostgresSaver)提供了框架级的 checkpoint 持久化,支持多线程并发、自动序列化、时间点回溯。当你的 Agent 需要支持多用户并发或需要「回滚到某个历史状态」时,迁移到 LangGraph checkpointer 是自然的下一步。
方向二:多用户命名空间隔离。当前 MemoryManager 通过 session_id 隔离不同会话,但共用同一个 MEMORY.md。多用户场景需要每个用户有独立的长期记忆文件(memories/user_xiaoming.md、memories/user_xiaohua.md),并在初始化 MemoryManager 时传入 user_id 参数。这是一个较小的改动但架构意义重大——记忆隔离是多用户 Agent 系统的基础要求。
方向三:记忆权限控制。某些记忆只应对特定 Agent 可见。例如,一个「财务顾问 Agent」能看到用户的收支偏好,但不应该暴露给「旅行规划 Agent」。通过为每条记忆添加 tags 字段(如 [finance]、[travel]、[general]),在 _load_long_term 时按 tag 过滤,可以实现细粒度的记忆权限控制。
方向四:记忆版本管理。长期记忆会随时间演化,有时 Agent 会写入错误的记忆(「用户讨厌 Python」——实际上是上下文误解),需要能够回溯和纠正。把 MEMORY.md 放在 Git 仓库中管理,每次写入后自动 commit,就能实现完整的版本历史和回滚能力。sleep_time_reorganize 整理后也应该触发 commit,让每次整理都有记录可查。
第七章:走进真实项目——mini-OpenClaw 记忆系统全解析 链接到标题
在前六章中,我们完成了 Agent 记忆系统从认知建立到代码实现的完整路径:短期记忆的三种核心机制(第三章)、长期记忆的四种存储类型(第四章)、写入与检索的工程决策(第五章),以及把两层记忆串联成完整 MemoryManager 的实战代码(第六章)。这些内容构成了记忆系统的完整理论体系。
但课程中的代码是教学简化版——它清楚展示了核心机制,但有意省略了一些工程细节。真实项目里的记忆系统在文件组织、写入触发方式、System Prompt 构建顺序上,都有值得深究的设计决策。第七章的目标,就是用 mini-OpenClaw 这个真实的轻量级 Agent 系统,让你第一次清晰看到:课程中每一个概念,在工程代码里的真实面貌。
这不是一次代码走读,而是一次从「课程设计」到「工程实现」的对照映射。每个小节都会先回顾课程中的概念,再展示 mini-OpenClaw 对应的真实实现,帮助你建立从「会设计」到「能看懂真实项目」的完整认知闭环。
7.1 项目概览:mini-OpenClaw 是什么 链接到标题
mini-OpenClaw 是一个轻量级 AI Agent 对话系统,采用前后端分离架构,核心设计理念是「透明优先」——工具调用过程完全可视化、记忆以 Markdown 文件形式存储(任何人都可以直接打开编辑)、技能通过文件系统热扩展。这个项目刻意保持了「最小工程骨架」:每一层的代码量都控制在可以快速读懂的范围内,让你能聚焦在架构思路上,而不是被工程细节淹没。
从技术选型上看,记忆系统由三个核心层承担:短期记忆层(session_manager.py)负责 JSON 文件读写与压缩摘要;长期记忆层(memory_indexer.py)负责向量索引与 MD5 感知重建;注入层(prompt_builder.py)负责按固定顺序组装 System Prompt,决定 Agent 在每次对话时能"看到"什么。
mini-OpenClaw 技术栈与记忆系统职责
| 层级 | 技术选型 | 记忆系统职责 |
|---|---|---|
| Agent 引擎 | LangChain + DeepSeek | 多轮推理、工具调度 |
| 短期记忆 | JSON 文件(sessions/) | 会话持久化、截断、压缩摘要 |
| 长期记忆 | Markdown 文件(memory/MEMORY.md) | 跨会话关键信息存储 |
| 记忆检索 | LlamaIndex + OpenAI Embedding | 向量化、RAG 相似度检索 |
| System Prompt 组装 | prompt_builder.py | 6 层文件按序拼接注入 LLM |
| 记忆写入 | write_file_tool.py(工具调用) | LLM 主动决策写入 MEMORY.md |

7.2 项目文件速览:记忆系统的真实骨架 链接到标题
在 mini-OpenClaw 项目里,记忆系统相关的文件分布在三个目录:graph/(核心逻辑)、sessions/(短期记忆存储)、memory/ + workspace/(长期记忆存储)。下面这张文件树把每个文件的课程对应关系标注了出来:
backend/
├── graph/
│ ├── session_manager.py ← 【第三章】短期记忆:JSON读写 + 截断 + 压缩摘要
│ ├── memory_indexer.py ← 【第五章】长期记忆RAG:向量索引 + MD5检测
│ ├── prompt_builder.py ← 【第五章】注入机制:6层文件按序组装System Prompt
│ └── agent.py ← 【第六章】协同主循环:两层记忆的串联入口
├── sessions/
│ ├── session-xxx.json ← 【第三章】短期记忆会话文件(含 compressed_context)
│ └── archive/ ← 被归档的旧会话(压缩后移至此处)
├── memory/
│ └── MEMORY.md ← 【第四/五章】跨会话长期记忆
├── workspace/
│ ├── SOUL.md ← Agent 人格(每次请求都全文注入)
│ ├── IDENTITY.md ← Agent 身份与风格
│ ├── USER.md ← 用户画像(对话中自动更新)
│ └── AGENTS.md ← 操作协议与记忆规范
└── tools/
└── write_file_tool.py ← 【第五章】LLM 写入 MEMORY.md 的工具(write_file)
下图展示了 mini-OpenClaw 记忆系统的完整架构,把五种记忆类型在技术实现层面串联起来,帮助建立全局视野:

课程章节与工程文件对照
| 课程章节 | 核心概念 | mini-OpenClaw 对应文件 |
|---|---|---|
| 第三章 | 对话历史存储、截断策略 | sessions/session-xxx.json |
| 第三章 | 压缩摘要机制 | session_manager.py → compress_history() |
| 第四章 | 四种长期记忆存储类型 | 项目选型:向量数据库(LlamaIndex) |
| 第五章 | RAG 注入 + Direct 注入 | prompt_builder.py + memory_indexer.py |
| 第五章 | 写入时机与触发 | write_file_tool.py(LLM 主动工具调用) |
| 第六章 | 两层记忆协同 | agent.py → graph_stream() |
7.3 短期记忆的真实形态:sessions/ 目录 链接到标题
课程第三章讲解了会话 JSON 的双字段结构——messages(最近对话)与 compressed_context(压缩摘要)。在 mini-OpenClaw 里,每个用户会话都对应 backend/sessions/ 目录下的一个 JSON 文件,真实的文件格式如下:
{
"title": "创建日期获取技能",
"created_at": 1773927418.3,
"updated_at": 1773927847.6,
"compressed_context": "用户请求创建新技能,助手首先介绍了自身作为透明AI助手的功能特点,包括技能调用、文件操作、代码执行、网络请求和知识检索,并强调透明操作和安全边界。随后,助手引导用户描述新技能的具体需求...",
"messages": [
{"role": "user", "content": "帮我创建一个获取当前日期的技能"},
{"role": "assistant", "content": "好的,我来帮你创建一个日期查询技能..."},
"..."
]
}
对比课程中的示例,多了两个字段:title(会话标题,由 LLM 自动生成)和 created_at / updated_at(时间戳)。这是工程版本在教学骨架之上增加的实用功能。核心字段 compressed_context 和 messages 与课程设计完全一致:当对话条数超过 MAX_HISTORY=20 时,compress_history() 方法把前半段对话压缩成一段摘要,写入 compressed_context,然后把旧版 session 文件移入 archive/ 目录存档。
session_manager.py 的三个核心方法,与课程第三章的三个设计点一一对应:
课程设计 vs session manager.py 实现
| 课程概念(第三章) | session_manager.py 方法 | 核心逻辑 |
|---|---|---|
| 对话历史存储 | load_session_for_agent() | 读取 JSON 文件,v1 格式自动迁移到 v2 |
| 消息截断策略 | get_messages_for_llm() | 只取最近 MAX_HISTORY 条传给 LLM |
| 压缩摘要机制 | compress_history() | 前半段→LLM压缩→写入 compressed_context,旧文件归档至 archive/ |
源码路径:
backend/graph/session_manager.py,共约 180 行。压缩摘要用的是LangChain ChatDeepSeek(与课程第三章末尾对齐),压缩触发由前端「压缩会话」按钮或 API 手动调用,不自动触发。

7.4 长期记忆的真实形态:workspace/ + memory/ 链接到标题
课程中把长期记忆笼统称为「写入 MEMORY.md」。在 mini-OpenClaw 里,长期记忆其实分布在两个目录:workspace/ 存放相对静态的配置型记忆(人格、身份、用户画像、操作协议),memory/MEMORY.md 存放动态生长的对话记忆(LLM 在对话中主动写入)。这两类记忆的更新频率完全不同:workspace 文件通常由人工配置,MEMORY.md 则随每次对话自动演化。
workspace/ 目录的四个文件,构成了 Agent 的「永久人格层」:
- SOUL.md:核心设定与行为边界(「透明优先」「文件即记忆」「安全边界」)
- IDENTITY.md:Agent 的名称、风格与 emoji 设定
- USER.md:用户画像与偏好(Agent 可在对话中自动补充)
- AGENTS.md:操作协议,规定 Agent 能做什么、不能做什么、如何使用记忆和技能
这四个文件的内容在每次请求时始终全文注入 System Prompt,不受 RAG 模式影响——因为它们是 Agent 的「身份基础」,不能被截断或替换。
memory/MEMORY.md 的内容由 LLM 在对话中主动写入。以下是项目运行一段时间后,真实的 MEMORY.md 内容(节选):
# 长期记忆
> 此文件由 mini OpenClaw 自动维护,记录跨会话的重要信息。
## 用户偏好
用户喜欢更加严谨的回复
## 重要事项
### 技能路径映射
- get_weather 技能的正确路径:skills/get_weather/SKILL.md
- 技能快照中的路径(./backend/skills/get_weather/SKILL.md)是错误的
### 新技能创建
- 创建了新的天气查询技能:get_weather_open
- 使用 OpenWeather API 替代 wttr.in,提供更稳定的天气信息
### 日期时间技能创建
- 创建了新的日期时间查询技能:get_date
- 创建时间:2026-03-19
- 技能文件位置:skills/get_date/SKILL.md
注意两个工程细节:第一,这个文件是「人类可读可编辑」的——你可以直接打开它,手动修改或删除某条记忆,下次对话时立即生效;第二,LLM 是通过 write_file_tool.py(SandboxedWriteFileTool)写入这个文件的,文件有沙箱白名单保护(只允许写入 skills/、workspace/、memory/ 目录),防止越权写入系统文件。这就是课程中说的「LLM 主动 tool call 触发,而非 Python 框架自动触发」的工程实现。

7.5 System Prompt 的六层组装:prompt_builder.py 解析 链接到标题
课程第五章讲解了 Direct 注入和 RAG 注入两种模式。在 mini-OpenClaw 里,prompt_builder.py 的 build_system_prompt() 方法把这个决策做得更加精细:System Prompt 由 6 个组件按固定顺序拼接,每个组件都有独立的最大长度限制(MAX_COMPONENT_LENGTH = 20000 字符),超长时自动截断并附加提示,确保 System Prompt 不会超出 LLM 的 context 窗口。
System Prompt 六层组装顺序
| 顺序 | 组件 | 来源文件 | 说明 |
|---|---|---|---|
| 1 | 技能快照 | SKILLS_SNAPSHOT.md | 排在最前,确保 Agent 第一眼看到可用技能 |
| 2 | Agent 人格 | workspace/SOUL.md | 行为边界与核心原则 |
| 3 | Agent 身份 | workspace/IDENTITY.md | 名称、风格、emoji 设定 |
| 4 | 用户画像 | workspace/USER.md | 用户偏好与背景 |
| 5 | 操作协议 | workspace/AGENTS.md | 能做什么、不能做什么 |
| 6 | 长期记忆 | memory/MEMORY.md | Direct 模式:全文注入;RAG 模式:跳过,由检索结果替代 |
这个顺序不是随意的:技能快照排第一,是因为 LLM 对 System Prompt 开头部分的注意力最强,技能的「可发现性」取决于它是否在 LLM 最先感知的区域;MEMORY.md 排最后,是因为它是最动态的部分,也最可能超长——即使被截断,前五个组件已经保证了 Agent 的基本行为是正确的。
RAG 模式切换:通过 API 端点
/api/config/rag-mode可以在 Direct 和 RAG 两种模式间切换。切换后rag_mode标志被持久化到config.json,下次启动自动恢复。
7.6 一条请求的完整生命周期 链接到标题
把所有组件串联起来,一条用户请求在 mini-OpenClaw 里的完整处理链路如下。这与课程第六章 MemoryManager 的三阶段设计(load → get_messages → LLM → update)高度对应,但工程版本多了 Skills 扫描和 System Prompt 组装两个额外环节:
步骤一:加载短期记忆
session_manager.load_session_for_agent(session_id) 读取对应的 JSON 文件,提取 compressed_context(若有)和最近 N 条 messages,构成对话历史列表。
步骤二:组装 System Prompt
prompt_builder.build_system_prompt() 按 SKILLS_SNAPSHOT → SOUL → IDENTITY → USER → AGENTS → MEMORY(或 RAG 检索结果)的顺序拼接,输出完整的 System Prompt 字符串。这是 Agent 在每次对话开始时的「认知底座」。
步骤三:调用 LLM
[SystemMessage(system_prompt)] + history + [HumanMessage(user_input)] 拼成完整消息列表,传入 LangChain Agent。Agent 在 ReAct 推理循环中决定是否调用工具(技能、write_file、搜索等)。
步骤四:写入记忆(可选)
若 Agent 判断本轮对话有值得记录的信息,会主动调用 write_file_tool 向 memory/MEMORY.md 追加内容。这是 LLM 的主动决策,不是 Python 代码自动触发。
步骤五:保存会话
session_manager.save_session() 把本轮对话追加进 messages 列表,连同 compressed_context 一起写回 JSON 文件,完成短期记忆的持久化。
7.7 课程知识点与 mini-OpenClaw 工程实现映射表 链接到标题
下面这张完整映射表,把本课六章的核心概念与 mini-OpenClaw 的工程实现做了全面对照,帮你建立从「课程设计」到「真实代码」的完整视图:
课程知识点与 mini-OpenClaw 工程实现全景映射
| 课程章节 | 核心概念 | mini-OpenClaw 工程实现 | 核心文件 |
|---|---|---|---|
| 第三章 | 对话历史存储 | sessions/session-xxx.json 的 messages 字段 | session_manager.py |
| 第三章 | 消息截断(MAX_HISTORY=20) | get_messages_for_llm() 取最近 N 条 | session_manager.py |
| 第三章 | 压缩摘要写入 | compress_history() 调用 LLM 生成摘要,写入 compressed_context | session_manager.py |
| 第三章 | 压缩摘要读取注入 | load_session_for_agent() 把摘要作为 system 消息插入 | session_manager.py |
| 第四章 | 长期记忆存储类型选型 | 选用 LlamaIndex VectorStoreIndex(向量数据库方案) | memory_indexer.py |
| 第五章 | Direct 注入 | rag_mode=False 时全文注入 MEMORY.md | prompt_builder.py |
| 第五章 | RAG 注入 | rag_mode=True 时跳过 MEMORY.md,向量检索相关片段 | memory_indexer.py |
| 第五章 | MD5 变化检测 | .memory_hash 文件存储上次哈希,内容未变时跳过重建 | memory_indexer.py |
| 第五章 | 写入时机与触发 | LLM 主动调用 write_file_tool 写入 memory/MEMORY.md | write_file_tool.py |
| 第六章 | 两层记忆协同 | graph_stream() 串联 session + prompt_builder + LLM | agent.py |
| 第六章 | 可降级设计 | 三级编码回退(UTF-8→GBK→latin-1),组件独立失败不崩溃 | prompt_builder.py |
7.8 本章总结:从设计到工程的认知闭环 链接到标题
到这里,整门课程的最后一块拼图就补完了。回顾我们走过的路径:从为什么需要记忆(第一章)到建立全景认知(第二章),从短期记忆的三种机制(第三章)到长期记忆的存储选型(第四章),从写入与检索的工程决策(第五章)到两层协同的完整实现(第六章),最后用 mini-OpenClaw 这个真实项目把所有设计知识收拢进一个可运行、可观察、可扩展的系统里(第七章)。
这个收口有一个具体的价值:它回答了一个贯穿全课的隐性问题——我们在课程中设计的记忆机制,在真实系统中是被怎么对待的?现在我们知道了答案:短期记忆以 JSON 文件形式持久化,通过 session_manager.py 的三个方法完成加载、截断、压缩三步闭环;长期记忆以 Markdown 文件形式存在于 memory/MEMORY.md,由 LLM 通过工具调用主动写入,并在每次请求时通过 prompt_builder.py 的六层组装注入给 LLM。
从这门课出发,你已经具备了读懂真实 Agent 项目记忆系统的基础能力:能够识别短期与长期记忆的分工边界、能够判断 Direct 注入和 RAG 注入的触发逻辑、能够理解会话 JSON 文件的每个字段背后的设计意图。接下来的成长方向,是在真实项目中把这些能力用起来——阅读 mini-OpenClaw 源码、修改配置参数、观察记忆行为、理解工程决策的取舍。
附录二:本课程学习成果 链接到标题
完成本课程的学习,你已经掌握了:
- 存储选型:四种长期记忆存储类型(向量库/KV/图数据库/关系型DB)的核心机制、适用边界和选型决策流程
- 向量检索:LlamaIndex
VectorStoreIndex+SentenceSplitter+OpenAIEmbedding实现语义检索(3.1 节),以及 RAG 模式的完整工程实现(4.3 节) - 写入触发:基于 LLM 判断的写入筛选策略,以及关键词去重保护机制
- Direct vs RAG:两种注入模式的触发条件、实现方式和工程权衡,MD5 缓存优化
- sleep-time 重组:离线记忆质量维护的模式,包含备份保护的实现
- Memory Manager:统一入口的三阶段设计(load / get_messages_for_llm / update),可降级架构
- 端到端验证:跨 session 记忆工作的完整测试场景
回顾整门课的进步路径:课程开始时,我们面对的是一个每次调用都失忆的无状态 LLM;批次一结束时,我们有了能跨进程持久化、自动压缩的短期记忆层;现在,我们拥有了一个完整的双层记忆系统——短期记忆保障会话内的连贯性,长期记忆跨越时间边界保留用户知识,MemoryManager 统一协调两层,对 LLM 完全透明。这已经是一个具备生产级记忆能力的 Agent 架构基础。
后续可以在此基础上继续扩展:接入 LangGraph 获得框架级支持、引入多用户命名空间、实现记忆权限控制——但那些都是「锦上添花」。核心机制你已经完全掌握。