本文是对 Datawhale《hello-agents》教程第八章的学习整理。文中两段代码均出自教程自带的示例,运行环境:macOS + Python 3.13。

一、为什么 Agent 需要记忆与检索

一个常见的场景:你和 AI 聊了一下午,告诉它你叫张三、正在学 Python、卡在装饰器那一章。第二天重新打开会话,问 "我上次学到哪儿了",它只能礼貌地回应:“抱歉,能再说一下您的具体情况吗?”

问题不在模型的智力,而在它天生没有状态。大模型的每次调用都是一次独立计算,上一次对话的内容不会被自动保留。这是第一个局限。第二个局限更隐蔽:模型的知识停留在训练数据截止的那一天,询问之后发布的新框架,它要么承认不知道,要么——更糟——编造一个看似合理的答案。

hello-agents 第八章针对这两个局限给出了两套方案:

  • 记忆系统(Memory):保留对话历史、用户偏好和历史经验,解决 "记不住" 的问题。

  • 检索增强生成(RAG):回答前先从外部知识库检索相关内容,再交给模型生成,解决 "知识过时、不可靠" 的问题。本质上是把模型的闭卷考试改成开卷考试。

这一章概念很多(四种记忆类型、向量库、图数据库、分块策略、高级检索……),初读容易迷失在架构图里。后来我发现抓住一条主线就清晰了:所有这些设计,最终都服务于同一个问题——在有限的上下文窗口里,应该放入哪些内容。记忆是向内检索自己的历史,RAG 是向外检索外部资料,核心动作是一样的。

二、总体设计:记忆四件套与一条流水线

教程先从认知科学讲起(感觉记忆、工作记忆、长期记忆的经典划分),乍看很抽象,对应到工程实现里其实很好理解:

记忆类型

通俗对应

特点

工作记忆 Working

桌面便签

默认只存 50 条,60 分钟自动过期,纯内存,快但易失

情景记忆 Episodic

日记本

记 "发生过什么事",按时间线回顾,SQLite + Qdrant

语义记忆 Semantic

百科全书

记 "知识点",构建知识图谱(Neo4j),支持实体关系推理

感知记忆 Perceptual

相册与录音

存图片、音频等多模态数据

有一个设计细节值得注意:教程没有把四种记忆做成四个 Agent 类,而是封装成一个 MemoryTool 工具挂到第七章的 Agent 上。记忆不再是框架的内置组件,而是工具箱里的一件工具——需要就注册,不需要就不占用资源。

RAG 那一半则是一条流水线:

任意格式文档 → MarkItDown 统一转 Markdown → 按标题智能分块 → 向量化 → 存进向量库
用户提问 → 向量检索(可选 MQE/HyDE 加持)→ 检索结果塞进 prompt → LLM 生成答案

MarkItDown 是微软开源的文档转换工具,PDF、Word、Excel、图片、音频都能处理,统一转为 Markdown 后,后续的分块、向量化只需要面对一种格式。这种 "先统一格式再处理" 的思路能有效降低工程复杂度。

下面进入实践部分。两段代码都是教程原例:一段来自 8.2.2 的记忆快速体验,一段来自 8.3.3 的 RAG 快速体验。

三、实践一:30 秒上手记忆功能

先说明一个配置门槛:完整体验教程需要三件套——Qdrant 向量数据库、Neo4j 图数据库、百炼 embedding API,注册和申请需要一定时间。

不过教程 8.2.2 的 "30 秒上手记忆功能" 示例只涉及工作记忆,而工作记忆是纯内存实现,不需要任何外部服务。我把示例原样摘出,去掉了 Agent 部分(那一步才需要 LLM 的 key),只保留记忆操作:

pip install "hello-agents==0.2.0"

新建 memory_demo.py(内容即教程中 "添加三条记忆、搜索、查看摘要" 的步骤,人物是教程原例的张三、李四、王五):

# memory_demo.py —— 教程 8.2.2 的记忆示例(离线可跑,无需任何 API key)
from hello_agents.tools import MemoryTool

tool = MemoryTool(user_id="user123", memory_types=["working"])

# 存三条记忆,importance 是"重要性打分",0 到 1
tool.execute("add", content="用户张三是一名Python开发者,专注机器学习和数据分析",
             memory_type="working", importance=0.8)
tool.execute("add", content="李四是前端工程师,擅长React和Vue.js",
             memory_type="working", importance=0.7)
tool.execute("add", content="王五是产品经理,负责用户体验设计",
             memory_type="working", importance=0.6)

print("=== 搜索:前端工程师(教程原例的搜索词) ===")
print(tool.execute("search", query="前端工程师", limit=3))

print("\n=== 我多试了一步:换个说法搜「写网页的」 ===")
print(tool.execute("search", query="写网页的", limit=3))

print("\n=== 遗忘:把重要性低于 0.65 的记忆清掉(教程的三种遗忘策略之一) ===")
print(tool.execute("forget", strategy="importance_based", threshold=0.65))

print("\n=== 再搜:产品经理(应该找不到了) ===")
print(tool.execute("search", query="产品经理", limit=3))

print("\n=== 记忆摘要(教程原例的最后一步) ===")
print(tool.execute("summary"))

运行 python memory_demo.py,真实输出如下:

INFO:hello_agents.memory.manager:MemoryManager初始化完成,启用记忆类型: ['working']
INFO:hello_agents.memory.manager:记忆遗忘完成: 1 条记忆
=== 搜索:前端工程师(教程原例的搜索词) ===
🔍 找到 1 条相关记忆:
1. [工作记忆] 李四是前端工程师,擅长React和Vue.js (重要性: 0.70)

=== 我多试了一步:换个说法搜「写网页的」 ===
🔍 未找到与 '写网页的' 相关的记忆

=== 遗忘:把重要性低于 0.65 的记忆清掉(教程的三种遗忘策略之一) ===
🧹 已遗忘 1 条记忆(策略: importance_based)

=== 再搜:产品经理(应该找不到了) ===
🔍 未找到与 '产品经理' 相关的记忆

=== 记忆摘要(教程原例的最后一步) ===
📊 记忆系统摘要
总记忆数: 2
当前会话: session_20261020_010251
对话轮次: 0

📋 记忆类型分布:
  • 工作记忆: 2 条 (平均重要性: 0.75)

二十几行代码,有四个值得注意的点:

1. 教程示例的搜索准确命中。 "前端工程师" 精确找到李四那条记忆,并附带重要性分数。

2. 换一种说法搜索就会失效——这是我特意加的一步。 搜 "写网页的" 没有任何结果,因为工作记忆使用 TF-IDF + 关键词匹配,只认字面,不理解语义。“写网页的 "和" 前端工程师 "对它来说是两段不相关的文本。这也解释了为什么情景记忆、语义记忆要引入 Qdrant 向量库和 embedding 模型:只有向量检索才能识别" 写网页的 "约等于" 前端”。记住这个现象,第六节的 "语义鸿沟" 就很好理解了。

3. 遗忘是真实生效的。 三条记忆中王五的重要性是 0.6,低于阈值 0.65,forget 执行后即被清除——之后搜 "产品经理" 一无所获。教程提供三种遗忘策略:按重要性(清除不重要的)、按时间(清除过期的)、按容量(超限时淘汰最不重要的),对应人脑 "不重要的事物记不住" 的机制。

4. 还有一个关键机制是 "整合"(consolidate)。 工作记忆中重要性超过 0.7 的条目可以升级为长期的情景记忆,情景记忆中更重要的还可以再升为语义记忆。这与人类睡眠中记忆固化的过程非常相似:白天的琐事逐渐淡忘,重要经历转为长期记忆,反复出现的经验浓缩为知识。通过 execute("consolidate", from_type="working", to_type="episodic", importance_threshold=0.7) 即可触发(这一步需要启用情景记忆,也就需要数据库了)。

四、检索评分:这一章最值得记住的公式

翻这章源码会发现,四种记忆类型各有一套检索评分公式,形式不同,骨架一致:

最终得分 = 相关性 × 时间衰减 × 重要性权重
重要性权重 = 0.8 + importance × 0.4   # 取值范围 [0.8, 1.2]

以情景记忆为例:(向量相似度 × 0.8 + 时间近因性 × 0.2) × (0.8 + 重要性 × 0.4)。

三个因子各管一件事:相似度决定 "像不像",时间衰减决定 "新不新"(感知记忆采用指数衰减模拟遗忘曲线,24 小时内接近满分,之后逐步下降,但最低保留 0.1),重要性权重决定 "重不重要"。

值得体会的是重要性这项的设计:范围被限制在 0.8 到 1.2 之间,一条 importance=1.0 的记忆最多只能把得分放大 1.2 倍——重要性可以加分,但不足以主导排序。一条不太重要的记忆只要足够相关、足够新,依然可以排在前面。这个约束比直觉写法 score * importance 更合理,避免了重要性字段绑架整个检索结果。

五、实践二:RAG 快速体验

RAG 的快速体验示例比记忆那个多一个门槛:RAGTool 初始化就要连接 Qdrant 向量库,并检查 LLM 配置。教程的设想是先配好 .env(Qdrant 云服务 + 百炼 API)。我找到了一个免配置的方案,用四小段 "离线补丁" 就能把 8.3.3 的示例原样跑起来:

  • 补丁 1:Qdrant 切到 :memory: 纯内存模式(数据只存在于本次运行,适合演示)

  • 补丁 2:注入一个离线嵌入实现。框架自带的 TF-IDF 兜底对中文基本失效(中文没有分词,整句会被当作一个词),我换成 "相邻两字拆分再哈希" 的简化实现,中文检索即可正常工作

  • 补丁 3:给 LLM 客户端一个占位配置,让它通过初始化检查(ask 问答才会真正调用它,本例只用 add/search/stats,涉及不到)

  • 补丁 4:静音离线模式下的无害日志

pip install "hello-agents==0.2.0" "qdrant-client==1.12.1"

注意 qdrant-client 需要 1.12.x 版本:最新版删除了教程代码依赖的 SearchRequest 接口,装新版会误报 qdrant-client未安装。这是实际验证过的版本组合。

# rag_demo.py —— 教程 8.3.3「30秒上手RAG功能」的离线跑法
# 正式流程:.env 配好 QDRANT_URL / 百炼 EMBED_API_KEY / LLM 的 key,
# 把"离线补丁"整段删掉即可,主例子代码一字不用改。
import os
import re
import math
import hashlib
from collections import Counter

# ========== 离线补丁(共四小段,想连真服务就整段删掉) ==========

# 补丁1:Qdrant 改用纯内存模式(正式流程配 QDRANT_URL 即可)
import hello_agents.memory.storage.qdrant_store as qs
from qdrant_client import QdrantClient

def _init_memory_client(self):
    self.client = QdrantClient(location=":memory:")
    self._ensure_collection()

qs.QdrantVectorStore._initialize_client = _init_memory_client

# 补丁2:嵌入换成离线的"字符二元组哈希向量"(正式流程配百炼 API 即可)
from hello_agents.memory import embedding

class OfflineEmbedder(embedding.EmbeddingModel):
    def __init__(self, dim=512):
        self._dim = dim

    def _tokens(self, text):
        text = text.lower()
        words = re.findall(r"[a-z0-9]+", text)          # 英文按词
        cjk = re.findall(r"[\u4e00-\u9fff]", text)      # 中文按相邻两字拆
        return words + [cjk[i] + cjk[i+1] for i in range(len(cjk) - 1)]

    def encode(self, texts):
        single = isinstance(texts, str)
        inputs = [texts] if single else list(texts)
        out = []
        for t in inputs:
            vec = [0.0] * self._dim
            for tok, cnt in Counter(self._tokens(t)).items():
                idx = int(hashlib.md5(tok.encode()).hexdigest(), 16) % self._dim
                vec[idx] += cnt
            norm = math.sqrt(sum(x * x for x in vec)) or 1.0
            out.append([x / norm for x in vec])
        return out[0] if single else out

    @property
    def dimension(self):
        return self._dim

embedding._embedder = OfflineEmbedder()  # 注入全局单例,整条 RAG 流水线都用它

# 补丁3:LLM 客户端给个占位配置让它能初始化(ask 才会真调用,下面用不到)
os.environ.setdefault("OLLAMA_HOST", "http://localhost:11434/v1")

# 补丁4:静音离线模式下的无害日志(本地Qdrant的索引提示、占位LLM的健康检查重试)
import logging
for name in ("httpx2", "openai._base_client"):
    logging.getLogger(name).setLevel(logging.ERROR)
logging.getLogger().addFilter(
    type("F", (logging.Filter,),
         {"filter": staticmethod(lambda r: "Payload indexes" not in r.getMessage())})()
)

# ========== 以下完全是教程 8.3.3 的原例 ==========
from hello_agents.tools import RAGTool

# 创建RAG工具
rag_tool = RAGTool(
    knowledge_base_path="./knowledge_base",
    collection_name="test_collection",
    rag_namespace="test"
)

# 添加第一个知识
result1 = rag_tool.execute("add_text",
    text="Python是一种高级编程语言,由Guido van Rossum于1991年首次发布。Python的设计哲学强调代码的可读性和简洁的语法。",
    document_id="python_intro")
print(f"知识1: {result1}")

# 添加第二个知识
result2 = rag_tool.execute("add_text",
    text="机器学习是人工智能的一个分支,通过算法让计算机从数据中学习模式。主要包括监督学习、无监督学习和强化学习三种类型。",
    document_id="ml_basics")
print(f"知识2: {result2}")

# 添加第三个知识
result3 = rag_tool.execute("add_text",
    text="RAG(检索增强生成)是一种结合信息检索和文本生成的AI技术。它通过检索相关知识来增强大语言模型的生成能力。",
    document_id="rag_concept")
print(f"知识3: {result3}")

print("\n=== 搜索知识 ===")
result = rag_tool.execute("search",
    query="Python编程语言的历史",
    limit=3,
    min_score=0.1
)
print(result)

print("\n=== 知识库统计 ===")
result = rag_tool.execute("stats")
print(result)

运行 python rag_demo.py,输出如下(真实运行结果,第二、三条知识入库时重复的日志已省略):

INFO:hello_agents.memory.storage.qdrant_store:✅ 创建Qdrant集合: test_collection
✅ RAG工具初始化成功: namespace=test, collection=test_collection
[RAG] Universal loader start: files=1 chunk_size=800 overlap=100 ns=default
[RAG] Processing: ./knowledge_base/python_intro.md
[RAG] Universal loader done: total_chunks=1
[RAG] Embedding start: total_texts=1 batch_size=64
[RAG] Embedding progress: 1/1
[RAG] Qdrant upsert start: n=1
[RAG] Qdrant upsert done: 1 vectors indexed
知识1: ✅ 文本已添加到知识库: python_intro
📊 分块数量: 1
⏱️ 处理时间: 1860ms
📝 命名空间: default
(知识2、知识3 同样的一套入库日志,略)

=== 搜索知识 ===
搜索结果:

1. 文档: **./knowledge_base/python_intro.md** (相似度: 0.334)
   Python是一种高级编程语言,由Guido van Rossum于1991年首次发布。Python的设计哲学强调代码的可读性和简洁的语法。...

=== 知识库统计 ===
📊 **RAG 知识库统计**
📝 命名空间: default
📋 集合名称: test_collection
📂 存储根路径: ./knowledge_base
📦 存储类型: qdrant
📊 文档分块数: 3
🔢 向量维度: 512
📎 距离度量: Cosine

🟢 **系统状态**
✅ RAG 管道: 正常
✅ LLM 连接: 正常

这份日志把整条流水线完整地展示了出来,对照教程 8.3 节的架构图阅读很有收获:

每次 add_text 都走完了全部工序。 [RAG] Processing: ./knowledge_base/python_intro.md——文本先被保存为 Markdown 文件;chunk_size=800 overlap=100——按 800 token 分块、块间保留 100 重叠(就是前面提到的 overlap 策略,教程的分块逻辑在这里执行);Embedding start → Qdrant upsert done——向量化、入库,一步不少。三条知识最终在统计中体现为 "文档分块数: 3"。

搜索结果验证了检索环节确实在工作。 问 "Python 编程语言的历史",排第一的是 python_intro(相似度 0.334),机器学习和 RAG 那两条被 min_score=0.1 的门槛筛掉了——检索不是把所有内容全部塞给模型,而是先打分、再筛选。另外说明:0.334 这个分数偏低,是离线简化嵌入模型导致的;换成教程推荐的百炼 embedding,分数和排序质量都会更好,但流程完全一致。

统计面板相当于系统状态报告。 向量维度 512(我注入的模型是 512 维,正式配置百炼后为 1024 维)、距离度量 Cosine——这些概念在架构图里是抽象的,在自己机器上跑一遍就具体了。

至于 "生成" 那半步(ask 操作),就是把检索到的片段拼进提示词交给 LLM——这一步必须配置真实的 key,教程文档中记录的效果是带参考来源的回答。流水线的前四步都已亲眼验证,最后一步没有任何黑盒。

六、应对语义鸿沟:MQE 与 HyDE

第三节 "写网页的搜不到前端工程师" 的现象,在 RAG 领域有个正式的名字,叫语义鸿沟:用户的说法和文档的说法字面不一致(用户问 "怎么让 AI 不瞎编",文档里写的却是 "幻觉")。即使上了向量检索,问句和陈述句在语义空间中的分布也存在差异。教程在 8.3.5 节给出了两种对策,都不需要更换数据库或模型,属于纯 prompt 工程:

MQE(多查询扩展):让 LLM 先把问题改写成多个不同表述——“怎么让 AI 不瞎编 "改写成" 如何减少大模型幻觉”“怎么提升模型回答的准确性”——分别检索后合并去重。一个问句变成一张网,召回率随之提升,教程给出的数字是 30%-50%。

HyDE(假设文档嵌入):思路更巧妙,可以概括为 "用答案找答案"。先让 LLM 生成一段假设性的答案(其中大概率会出现 "幻觉""事实性" 这类术语),再用假设答案的向量去检索真实文档。之所以有效,是因为假设答案是陈述句,与真实文档的文本形态更接近,比用疑问句直接检索的距离更近。即使假设答案的内容不准确,它携带的术语和文风也足以引导检索找到正确方向。

这两点给我的启发很大:检索质量的提升不一定依赖硬件和模型的升级,在 "问法" 上做文章性价比很高。

七、写在最后

学完这一章,我最大的收获可以归结为一句话:

记忆和 RAG 是同一件事的两个方向——都是在有限的上下文窗口里,挑选合适的内容放进去。

记忆是向内检索(这个用户是谁、我们聊过什么、我做过什么),RAG 是向外检索(外部文档里有什么相关知识)。挑选得当,Agent 表现得聪明;挑选不当,上下文窗口被无关内容占满,模型的表现反而下降。所以教程才在 "挑选" 上做了大量设计:重要性打分、时间衰减、多路召回、去重合并,全都是围绕这个环节。

最后说一个教程习题中我认为最值得思考的问题:用户提出一个问句,Agent 什么时候该查记忆(“用户之前说过什么”),什么时候该查知识库(“文档里写了什么”),什么时候两者都查?如果由你来设计这个路由,规则怎么写?

想清楚这个问题,这一章的内容才算真正消化。