lab03 · 记忆与 RAG(对应教程 Part 4)
墨叔 vs 老哥 · 边聊边跑的实战 lab。代码见
main.py,默认 mock、零依赖零 key 即可跑。
0. 怎么跑
# 默认 mock:不联网、不需要 API key
python main.py
# 想接真实大模型(需 OPENAI_API_KEY)
export OPENAI_API_KEY=sk-xxx
AGENT_MODE=real python main.py跑出来你会看到五个 demo 的完整轨迹:[think] 模型要求调用工具 → [act] 工具结果 → [think] 模型说。
1. 短期记忆(工作台)vs 长期记忆(档案室)
老哥:墨叔,前面循环、工具都懂了,记忆又是啥新词?
墨叔:别觉得玄。就一句话——agent 什么时候把东西摊在工作台(上下文窗口),什么时候归档到档案室(外部存储),以及归档了怎么在需要时调回来还不添乱。
- 短期记忆 = 上下文窗口:快、贵、易失、且是唯一真相源。模型只信桌上摊着的,桌下档案室里的它压根看不见,除非你主动递过去。
- 长期记忆 = 外部存储(文件/数据库/KV/向量库):慢、便宜、持久,但**“不调出来就等于不存在”**。
老哥:那把长期记忆全塞进窗口不就完事了?省得调。
墨叔:这是新手第一个坑。窗口是稀缺且昂贵且会反噬的:按 token 计费、越长越慢,最致命的是塞太多无关内容,模型注意力被稀释(lost in the middle),反而更傻。所以后端”内存全量缓存”的直觉在这里不能直接套——长期记忆的价值恰是不占窗口、按需精准调回。
本 lab 的 search_kb / recall_memory 就是”档案室派去工作台送资料的信使”:run_loop 每轮拼上下文时,把检索/记忆结果塞回消息流,模型才看得见。
2. RAG 不是银弹:什么时候该检索、什么时候不该
墨叔:RAG = Retrieval-Augmented Generation,原理就一句:答题前先去知识库搜一段相关资料,拼进提示,再让模型基于”资料+问题”作答。但它经常是毒药。
为什么?模型是”续写机器”,极度容易被眼前最显眼的那段文字带偏。你喂它一段检索来的资料,它本能顺着那段语气和结论往下写,哪怕资料是错的、过时的、或只是字面相关。这叫上下文污染。
RAG 变毒药的三类场景(本 lab Demo B 复现的是第一类+第二类):
- 检索噪声:语义相似 ≠ 事实相关。召回一篇”另一个项目部署踩坑”的文章,字面全是部署/K8s,模型照着给你编。
- 过时资料压过正确记忆:你检索来半年前的旧文档说”A 可行”,现在早改 B 了,模型却采信”刚给我的”。
- 无关资料稀释注意力:塞八篇文档,模型反而抓不住你要问的那一点。
老哥:那到底什么时候该 RAG?
墨叔:一条线——RAG 该用在”模型原本绝对没有、且必须靠外部资料才答对”:私有知识库、一周前的新 API 文档、某仓库某函数定义。反过来,资料就在手边、范围可控(当前目录的 README/配置),就直接 Read 读进来塞窗口,别检索——读取给的是零噪声真相,检索给的是带噪候选。一句话:能直接读就不检索,检索只在”量大且需语义筛选”时才划算。
本 lab 怎么演的
- Demo A(基础链路):
retrieve(query)用关键词重叠打分取 top-k →search_kb拼成上下文 → 模型基于资料答对。 - Demo B(噪声污染):查询”生产环境用什么数据库”,
retrieve同时召回了正确资料和一条过时资料(“SQLite 性能很好”)。mock 模型顺着过时资料续写 → 自信答错。这就是 Part 4.2 的”检索到的东西过时,反而压过正确记忆”。 - Demo C(防污染修正):
search_kb(..., filter_noise=True)在检索层丢弃fresh=False的过时资料,上下文只剩正确片段 → 模型答回正确。
关键认知:检索的质量无条件决定生成的质量,而检索几乎没有纠错机制。防污染不是”不检索”,而是检索回来的东西进窗口前必须降级为”候选参考”——加相关性/新鲜度过滤(Demo C 的
filtered就是最朴素实现)。
3. 结构化记忆 vs 非结构化
墨叔:长期记忆该以什么形态存?三类,每类住不同的”档案格”:
- 非结构化(笔记/复盘/自由文本):存文档/Markdown,按语义找 → 适合向量索引。
- 结构化 KV(“语言=Go""禁用=SQLite”):精确事实,按 key 取,绝不走向量——向量是”相似度排序”,容得下”第二相似”把你带偏。
- 向量:只是索引,不是存储本身。原文通常另存,向量是找回原文的路标。
本 lab 的 Memory 把精确事实(禁用 SQLite、用 1Panel)存成结构化 dict/KV,save() 落 JSON 文件——这就是”精确事实走 KV、不走向量”的最小落地。你后端”配置项用 Redis 精确字段、文章用 ES 语义搜”的选型功力,平移到模型记忆上,一字不差。
4. 记忆分层:user-level 与 project-level
墨叔:落到具体记忆,分两层(本质是你后端”全局/项目/请求”配置作用域的平移):
- user-level:跟人走的跨项目偏好,如”老哥用 Go、讨厌水字数”。
- project-level:只在本项目生效的约定,如”本项目禁用 SQLite、用 1Panel”。
老哥:分层有啥用?
墨叔:把项目约定塞进 user-level,你开新项目会被旧约定约束;把 user 偏好塞进某项目文件,换项目就丢”讨厌水字数”。分层 = 作用域隔离。
读/写纪律(本 lab Demo D / E 演示):
- 读:user 常驻精简、project 涉及时才加载。Demo D 里模型动手前先
recall_memory("project")拿到”禁用 SQLite”,再据此选 Postgres。 - 写:克制。只有”稳定、跨会话有用、用户显式/反复印证”的才写;临时发现用完即弃,绝不静默外置污染档案室。Demo E 用
remember写新约定并save(),再load()证明跨会话能找回。 - 可修剪:记忆只增不删会膨胀、最终淹没关键约定(又是 lost in the middle)。真实系统要给记忆文件设体积上限和定期压实。
八字总结:分层、按需、精简、可修剪。
5. 本 lab 工具速查
| 工具 | 作用 | 对应要点 |
|---|---|---|
search_kb(query, filter_noise=False) | 知识库检索,拼成上下文 | RAG 检索链路(Demo A/B/C) |
recall_memory(scope, project) | 读 user/project 记忆 | 记忆分层、按需加载(Demo D) |
remember(scope, key, value, project) | 写记忆 | 写入纪律、持久化(Demo E) |
Memory 类提供 write / recall / save / load,精确事实走 KV,文件持久化演示跨会话。
【思考题】
- 既然长期记忆”不调出来就等于不存在”,为什么不全塞进短期记忆(窗口)?你后端”内存 vs 磁盘”的直觉在 agent 这儿为什么不能直接套用?
- 本 lab Demo B 里,模型被一条”过时资料”带偏答错。如果要给检索加一道”防污染过滤”,除了 Demo C 的”新鲜度过滤”,你还能想到哪几种角度(各一句话即可)?
- “禁用 SQLite”这种精确事实,本 lab 用结构化 KV 存;“为什么禁用 SQLite 的复盘笔记”该存哪?两者该合并还是分开?
【参考答案】
- 不能全塞,因为窗口是稀缺且昂贵且会反噬的:按 token 计费、越长越慢,更致命的是塞太多无关内容会稀释模型注意力(lost in the middle),反而更傻。“内存 vs 磁盘”的直觉坑在这里:你那套里内存取回零代价、不影响正确性,只是占点 RAM;agent 里”取回”是要占窗口、且占多了会变傻的。所以长期记忆的价值不是”省得调”,恰恰是”不占窗口、按需精准调回”。宁肯多一次检索 IO,也不肯多占一寸桌面。
- 至少三种:①相关性过滤——拿召回片段和问题再过一道”这段是否真能回答此问题”,只留判通过的;②来源/范围约束——检索时带当前项目/模块的 metadata filter,不让跨模块噪声进来;③长度与 top_k 控制——宁可少召回几段精准的,不堆一堆稀释注意力。本质都是把”检索”从”自动采信”降级为”候选+需校验”。
- 分开存、用途分流:“禁用 SQLite”这个精确事实走 KV/固定规则文件,每次必读、绝不走向量(保证铁律);“为什么禁用”的复盘笔记走非结构化文档+向量,问”为啥不能 SQLite”时语义检索调回。两者访问模式和可靠性要求完全不同——一个是”要强制执行的约束”,一个是”要理解背景的知识”。合并进向量库只会让约束被语义检索的概率性带偏。工程上即:规则归规则、知识归知识,别让知识库的噪声污染规则的执行。