上一篇文章的结尾我立了个 flag:搜索 Agent 的”搜索 → 抓取 → 基于内容回答”和 RAG 的”检索 → 取文档 → 基于文档回答”在逻辑上几乎同构,Agent Loop、trace、规则引擎应该都能直接复用。这两周(roadmap 的第四、五周)把 RAG 和记忆系统做完了,flag 基本成立。这篇文章分两部分:第一部分讲 agentic RAG 怎么落地,第二部分讲记忆系统的分层设计——后者是这两周真正的重头戏,会展开讲。照例,完整代码和实验数据在文末的仓库里。

先交代节奏:第四周 RAG 原计划单独一周,实际和第五周压进了同一周做。不是赶进度,而是动手前发现这两块不是并列关系,而是地基与上层

1
2
3
4
第四周 RAG   →   造出「向量检索器」retriever

▼ 直接复用
第五周记忆 → 长期事实的「语义召回」就是再调一次它

记忆系统里”按语义找回相关事实”这个能力,缺的部件恰好就是 RAG 的检索器。分开两周做反而要走回头路。

动手前还修正了两个认知偏差,简单记一下:

偏差一:”RAG 已经过时了”。 调研后发现退场的只是 2023 年那种”切块塞库、top-k 拼接”的朴素 RAG,RAG 本身演化成了 agentic RAG 并下沉为基础设施。对”X 已经过时了”这类判断要警惕——往往不是消失,而是换了形态。

偏差二:”我还没学过 RAG”。 厘清定义后发现,我第二、三周做的搜索 Agent——迭代循环 + 规则判断是否检索 + 纠正降级 + trace——本身就是一个完整的 agentic RAG,只是数据源是网页、检索方式是关键词。Agent 的能力是”循环 + 工具”的组合,换工具不换骨架。

RAG 的基本原理(分片、向量化、召回、重排、生成)我之前写过两篇:RAG(上)RAG(下),本文不再重复,直接进入工程部分。

第一部分:Agentic RAG

和朴素 RAG 的根本区别:谁拥有推理路径

朴素 RAG 是一条人写死的管线:来一个问题,无脑 retrieve-then-generate,检索结果再差也带着往下跑。agentic RAG 把检索降级成 Agent 循环里的一个工具——查不查、查几次、查完不满意要不要换关键词再查,由模型(加上规则引擎兜底)在循环里动态决定。推理路径的所有权从开发者转移给了系统本身,这和上一篇里 Workflow 与 Agent 的分界线是同一条。

说明:本周语料只有 6 篇本地技术笔记、切出来 100 个 chunk,这个体量直接塞长上下文完全装得下,根本”不需要” RAG。只有当语料大到塞不下、或需要权限控制、成本控制、可追溯引用时,检索才是必选项。所以这周做 RAG 的目的是学机制,不是因为非用不可——但小语料有个好处:每条查询我都能一眼判断召回对不对,评测不用猜。

chunking:被一组 A/B 数据钉死的第一道天花板

写正式代码前照例先做实验(第三周”json_schema 吃掉 tools”的教训之后,这已经是固定流程)。本周最值钱的一组数据来自 chunking 策略的 A/B:用 15 条标注查询(答案确实落在那 6 篇笔记里)评测两种切法:

切法 hit@1 top-1 平均相似度 chunk 数 / 平均尺寸
按 Markdown 标题切 87% (13/15) 0.764 100 个 / 395 字
按字数切(400 字滑窗 / 80 重叠) 53% (8/15) 0.683 94 个 / 389 字

注意第三列:两种切法的 chunk 数量和平均尺寸几乎一样,检索器、embedding、top_k 全没动,唯一变量是”切的时候有没有保住语义结构”。34 个百分点的召回差距纯粹来自这一件事。”检索质量是 RAG 的天花板”这句话我在 RAG(上)里就写过,但这次是第一次亲手把它跑成数字——chunking 不是预处理小事,而是 RAG 的第一道天花板,且关键变量是结构完整性,不是 chunk 大小。

按标题切听起来简单,实现里有几个不做就会翻车的细节:

1
2
3
4
5
6
7
8
m = HEADING_RE.match(line)        # ^#{1,6} 标题行
if m:
level = len(m.group(1))
# 弹出 ≥ 当前 level 的上级,保证栈里是严格递增的标题链
while heading_stack and heading_stack[-1][0] >= level:
heading_stack.pop()
heading_stack.append((level, title))
current_path = " > ".join(t for _, t in heading_stack)
  1. 用一个标题栈维护当前位置的完整层级路径,每个 chunk 的 section 字段形如 RAG-1 文档摄入 > Chunking 策略——这个路径既是检索时的上下文线索,也是后面引用约束的来源字段。
  2. 代码块内的 # 是注释不是标题,解析时要跳过 ``` 包裹的区域,否则技术笔记里一段 shell 代码就能把切分搅乱。
  3. 切完还有两道修正:太短的 section(孤立标题、一两行的段)向相邻段合并,避免产出一堆信息量为零的碎 chunk;超长的 section 再按段落滑窗切,相邻块带约 100 字重叠。
  4. 每个 chunk 的正文带上 # 标题路径 前缀再去做 embedding——让向量里编码进”这段话是讲什么的”的结构信息。

向量库:100 个 chunk 用不上 FAISS

embedding 用千问的 text-embedding-v3,动手前先验证链路:返回 1024 维向量,实测已经 L2 归一化(范数 = 1.000),这意味着余弦相似度直接算点积;单批 3 条延迟 592ms;一次传 23 条(超过单批上限 10)会自动切成 3 批正确返回。链路稳定,放心建库。

向量库没有上 FAISS 或 Chroma,就用 numpy:

1
2
3
4
scores = self.vectors @ query_vector              # 余弦相似度 = 点积
k = min(top_k, len(scores))
top_idx = np.argpartition(scores, -k)[-k:] # O(N) 无序取 top-k
top_idx = top_idx[np.argsort(-scores[top_idx])] # 只对这 k 个排序
  1. 语料只有 100 个 chunk,numpy 全内存检索完全够用,零外部依赖,持久化就是 .npy + .json 两个文件。学习阶段不要为了”像生产系统”而引入重型组件。
  2. 向量已归一化,余弦相似度退化为一次矩阵点积。
  3. argpartition 先以 O(N) 拿到无序的 top-k,再只对这 k 个排序,不对全量打分结果做完整排序。

这个存储类有一个为下文埋的设计:构造时接受一个 namespace 参数,不同 namespace 在同一目录下用不同文件前缀(docs_vectors.npy / memory_facts_vectors.npy)。当时是给记忆系统预留的——第二部分会看到它怎么被用上。会不会串库?实验里往文档库塞了一条”毒事实”探针,确认它不会出现在记忆侧的召回里,反之亦然,隔离成立。

接回 Agent Loop:检索是一个工具,路由是一条新支路

预期中最顺利的部分:retrieve_documents 只是工具注册表里多的一项,agent.py 主循环一行没动。但有个地方躲不掉——纠正机制要重新设计优先级。v2 只有一条规则(该联网没联网就纠正),v3 变成两级:

1
2
3
4
5
6
7
8
9
10
11
any_tool_called = has_searched or has_retrieved

# 检索纠正优先于搜索纠正
if (not any_tool_called
and not retrieval_correction_injected
and should_have_retrieved(user_message)):
inject(RETRIEVAL_CORRECTION_MESSAGE) # 先查本地库
elif (not any_tool_called
and not correction_injected
and should_have_searched(user_message)):
inject(CORRECTION_MESSAGE) # 再考虑联网
  1. 问题涉及本项目内容(”第三周的降级阈值是多少”)→ 优先纠正去查本地库;通用事实性问题 → 沿用 v2 的联网纠正。两个纠正各只注入一次。
  2. 前置条件从 v2 的 already_searched 升级成 any_tool_called——只要模型碰过任何工具,就尊重它 stop 的判断。否则会出现:模型已经查完本地库准备回答,规则又强行让它联网,多跑冤枉轮次。这是第三周假阳性修复在双工具下的推广。

多一个工具,就多一条”该不该用这个工具”的判定支路——这是工具数量的隐藏成本。 第二周读到”工具集臃肿是最常见失败模式”时只觉得是工具选择的问题,现在看,每加一个工具,路由规则、纠正逻辑、测试用例都要跟着配套,成本是结构性的。

引用约束:三个失败模式的对策,也是记忆系统的输入

roadmap 点名了 RAG 的三个经典失败模式,对策各有归属:

失败模式 表现 对策
召回不准 答案在库里但没召回 根因大多在 chunking(见上文 A/B),加 min_score 过滤低分噪音
引用错位 把 A chunk 的内容标成 B 的来源 评测时逐条核对引用的 chunk 是否真含该事实
过度总结 把多个 chunk 揉成流畅但失真的一段话 强制分点回答、每点对应来源,不允许自由叙述

引用约束的具体做法:system prompt 强制要求事实性陈述标注 [doc#section],来源字段由 chunk 元数据提供。实测 4 个 RAG 用例 3 个引用正确,翻车的那个见后面踩坑表。

但这周对引用约束的理解多了一层:它不只是给用户看的溯源标记。一句话带不带引用,是”模型的自由发挥”和”有依据的结论”之间最廉价的分类器——这个性质在记忆系统里直接变成了长期记忆的写入闸门。这是第二部分的事了。

第二部分:记忆系统

RAG 不是记忆,聊天历史也不是长期记忆

第五周开工前先把三个容易混淆的东西掰开:

  • RAG 召回的是离线预处理的文档库——它不随对话生长,昨天聊了什么它不知道;
  • 聊天历史是会话内的原始流水——会话一结束就没了,而且越长越塞不下上下文窗口;
  • 记忆系统要做的是:跨轮、跨会话地积累信息,并且在每轮调用前决定把什么喂给模型

读 Anthropic 的 context engineering 文章时有句话点醒了我:记忆系统的难点从来不在”怎么存”——存就是写 json、写向量库,没有任何技术含量——而在”每轮怎么挑”。把这个命题想清楚之后,整个系统的设计就顺了:所有模块都是在为”挑选”服务。

分层总览:一个门面、两层存储、一个挑选器

整个记忆系统收敛在一个 memory/ 包里,对 agent.py 只暴露两个方法——轮前读、轮后写:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
                     agent.py

┌─────────────┴─────────────┐
轮前调用 轮后调用
assemble_context() update_from_turn()
(读,挑选) (写,积累)
│ │
┌────────▼─────────────────────────▼────────┐
│ MemoryManager │
├────────────────────┬───────────────────────┤
│ 短期记忆 │ 长期记忆 │
│ 最近 K 轮完整对话 │ 偏好 key-value │
│ (轮次+字符双闸门) │ 事实 list+向量库 │
│ │ 主题 counter │
└──────────┬─────────┴───────────▲───────────┘
evict │ 超出 K 轮 │ 晋升
┌──────────▼─────────┐ │
│ 摘要器 │──────────┘
│ 高保真: 带引用的结论 ──┘(进长期事实)
│ 低保真: 其余压成一句话(≤80 字)
└────────────────────┘

分层的依据不是”短期/长期”这个时间标签本身,而是三个问题的不同答案:生命周期多长、怎么写入、每轮以什么方式进 prompt。 下面逐层拆。

短期记忆:双闸门,evict 不等于删除

短期记忆持有最近的完整对话轮次,两道闸门控制规模:轮次闸门(K=3 轮)和字符闸门(预算超限),先触发者先裁。但有两个设计点比”裁剪”本身更重要。

第一,工具的中间结果根本不进短期记忆。 每轮存的 ConversationTurn 只有 user 输入、assistant 最终回答和一行工具调用摘要(retrieve_documents(✓) 这种)——检索回来的 chunk 正文、抓取的网页正文统统不存。依据是 compaction 原则:优先丢”可重新获取的原材料”。检索 chunk 是最典型的可重获取数据,丢了随时能 re-retrieve;而刚刚发生的对话是不可重建的。裁剪优先级排下来是:

1
2
3
最该丢 ──────────────────────────────────► 最不该丢
旧检索 chunk → 旧网页正文 → 旧工具记录 → 旧模型回答 → 最近 K 轮
(可重新检索) (不可重建,不丢)

第二,evict 不是删除,是一次分层压缩。 超出 K 轮的旧轮次被裁掉前要过两道工序:

1
2
3
4
5
6
7
8
9
10
超出 K 轮的旧轮次

├── 高保真通道:带 [doc#section] 引用的结论
│ └──► 晋升为长期「已确认事实」(带来源、可语义召回,零信息损失)

└── 低保真通道:剩余的过程性对话
└──► 调一次模型压成 ≤80 字摘要,追加进累积摘要


旧轮次真正删除

值得注意的是成本控制:低保真摘要只在 evict 时调一次模型,不是每轮都调;累积摘要本身也设了 1200 字符上限,防止”摘要”自己膨胀成新的上下文负担。实测注入 5 轮对话后自动 evict 回 3 轮,产出一条 45 字摘要换掉了被裁的整轮原文——评测语料小、节省还不明显,但这个机制的价值要在几十轮的长对话里才会显现。

长期记忆:三类分治,写入是闸门设计

长期记忆没有做成一个大杂烩列表,而是按”性质”切成三类,分开存、分开写、分开进 prompt:

类型 性质 数据结构 写入时机 每轮进 prompt 方式
用户偏好 低频、强约束 key-value 显式信号触发(”请记住……”) 全量(很小)
已确认事实 中频、会话内高相关 列表 + 向量库 带引用的结论才晋升 语义召回 top-k
主题兴趣 累积、弱约束 计数器 每轮规则累加 暂不进(加权召回留了接口)

三类的写入策略合起来是一个”双通道”设计:规则抽取(零成本)+ 显式信号,唯独没有”每轮让模型抽取记忆”这个看起来最直觉的通道——成本、延迟之外,更重要的是第三周的教训:让模型做高频的判断类任务,假阳性会淹死你。

三类里最有意思的是已确认事实的晋升机制。什么样的内容配进长期记忆?我的答案是复用第一部分的引用约束:

1
2
3
4
5
sources = CITATION_RE.findall(unit)     # 找 [doc#section] 引用
if not sources:
continue # 没有引用 = 模型自由发挥,不要
fact_text = CITATION_RE.sub("", unit).strip()
candidates.append((fact_text, "; ".join(sources)))
  1. 逐句(或逐列表项)扫描模型的回答,只有带 [doc#section] 引用的句子才有资格晋升——引用即”已确认”,没引用的句子可能是幻觉,宁可漏掉也不污染记忆库。
  2. 晋升的事实存两份:json 里存元数据(fact / source / turn / 时间戳),向量库的 memory_facts namespace 里存对应向量——就是第一部分埋的那个伏笔,文档库和记忆库共用同一套 embedding 和存储实现,按 namespace 隔离。
  3. 一轮里抽出的多条事实批量 embed 一次,不逐条调 API。

召回侧就是再调一次 RAG 的检索器:用户当前问题做 query,在 memory_facts namespace 里查 top-k、过 min_score 卡掉低分噪音。四轮真实对话的实测:累计晋升 10 条事实,每轮稳定召回 3 条相关的;主题计数器同期记下 agent_loop×4、tool_calling×3、fallback×3、rag×2、chunking×2——这份兴趣画像本周只记不用上,加权召回推到下周。

上下文装配:所有”挑选”的最终出口

前面攒下的所有东西——偏好、摘要、事实、最近几轮——最终都要过同一道门:每轮调用前的上下文装配。装配器按固定顺序、分段预算组装 messages:

内容 预算(字符) 进出规则
1 System prompt 固定 永远在
2 用户偏好 很小 永远在
3 历史摘要 有则在
4 召回的长期事实 800 语义 top-k
5 最近 K 轮对话 4000 双闸门裁剪
6 当前问题 + 本轮检索 最大 永远在

两条排序逻辑都有实验支撑。纵向:越稳定、约束越强的越靠前横向:越靠后的内容对模型的影响越强——后者我专门验证过:构造”段 3 旧摘要说上限 500、段 6 本轮检索说 1500”的冲突场景,不加任何”以最新为准”的显式指令,3/3 跟随了本轮新检索。我本来担心第三周”json_schema 吃掉 tools”的优先级坑换个形态复现,结果没有——装配顺序本身就是一种优先级声明,把当前问题和新检索放最末段,模型自然偏向新信息。

超预算时的裁剪也分先后,而且各段手法不同:

1
2
3
4
5
6
7
超预算?

├─ 先裁段 5:从最旧轮开始,把 assistant 回答截到 200 字
│ (user 输入保全文——更短,且不可重生)
├─ 再裁段 3/4:摘要保尾部削头部;事实按整条削尾,不切坏半条

└─ 段 1/2/6 不可裁(系统约束、偏好、当前问题动谁都不行)

装配的动态性在四轮真实对话里肉眼可见:

装配出现的段 上下文字符 事实召回
1 system, current 1390 0
2 system, facts, recent, current 2908 3
3 system, preferences, facts, recent, current 3750 3
4 同上(满配) 4026 3

同一个 Agent,每轮喂进去的上下文构成都不一样——冷启动只有 2 段,第二轮长出事实召回和近期对话,第三轮偏好段加入后满配。平均装配 3018 字符(约 1887 token),全程在 8000 字符总预算内。这张表就是”记忆 = 每轮的挑选问题”的直接证据:装配器本质是一个”挑选 + 降维”器,而这件事换个名字,就叫上下文工程。

一轮对话里记忆的完整时序

把读写两侧合起来,一轮对话里记忆系统的完整动作是:

1
2
3
4
5
6
7
8
9
10
11
12
13
用户输入


[轮前] assemble_context() ◄── 读:偏好全量、摘要、事实语义召回、最近 K 轮


Agent Loop(模型决策 + 工具调用,循环若干次)


[轮后] update_from_turn() ──► 写:主题计数 → 偏好抽取 → 事实晋升
│ → 本轮入短期 → 必要时摘要 + evict

save() ──► 6 个 json + 2 个向量库全量落盘

这张时序图直接解释了实测踩到的一个坑:用户说”请记住,回答时先列结论”,当轮不生效,下一轮才生效——因为偏好在轮后才抽取写入,而装配发生在轮前,时序上天然滞后一轮。多数场景可接受(”轮后更新”语义本身是自洽的),但要当轮生效就得在装配前对当前输入做预扫描,属于”知道怎么修、暂时不值得修”的问题。

图里最后那行 save() 也值得一提:每轮结束全量落盘 6 个 json 加 2 个向量库,跨会话还要手动加载快照。这是整个实现里最琐碎、最容易出错的部分——不是设计难,是纯粹的脏活。这份体感对下周很重要,后面说。

踩坑记录

# 现象 原因 解法
1 “请记住:回答涉及本地文档时先列结论”这句纯偏好设置,触发了检索纠正,白白多跑一轮 规则引擎的本地指向词含”本地”,对只是提到”本地文档”的偏好语句也判了 True 偏好语句先过偏好抽取,命中就短路,不再进检索纠正
2 偏好设置当轮不生效,下一轮才出现在 prompt 里 轮后写入、轮前装配,时序滞后一轮(见上文时序图) 符合”轮后更新”语义,可接受;要当轮生效需预扫描
3 引用偶尔用文件全路径而不是文档名,破坏 [doc#section] 契约——还会连带污染事实晋升的 source 字段 工具返回里同时给了 docpath 两个字段,prompt 没强制只用 doc 工具结果隐藏 path,或 prompt 强制只用 doc 字段
4 问”MCP 协议是什么”(纯外部概念),模型先查了本地库再联网,多花一轮 26.5s “本地优先 + 不认识的名词宁可多查”在外部概念上的副作用 “本地优先”策略的过度检索成本,暂时接受

第 1 条值得多说一句:这是第三周纠正机制假阳性的轮回——规则引擎天生会假阳性,上次修在搜索纠正,这次换到检索纠正又来一遍。只要用无状态规则做有状态判断,假阳性就不是修一次就完的 bug,而是要在每条新规则上重新付一次的税。

v2.0 → v3.0 量化对比

指标 v2.0(第三周) v3.0(第五周)
数据源 网页(实时) 网页 + 本地文档库
检索方式 关键词搜索 关键词 + 向量相似度
记忆能力 短期裁剪 + 三类长期记忆 + 动态装配
引用粒度 snippet + URL chunk 级 [doc#section]
检索准确率 hit@1 87% / hit@3 93% / hit@5 100%
平均轮次(路由用例) ~2.5 1.88
平均装配上下文 未控制 3018 字符(8000 预算内)
实现 .py 文件数 / 总行数 6 / 1367 18 / 3270

几个核心认知

  1. 检索质量是可量化的天花板,且结构 > 大小。 召回不准时,prompt 写得再好、引用约束再严,也救不回压根没被召回的内容。检索和生成是两个独立的优化对象,chunking 的关键变量是语义结构的完整性(87% vs 53% 这组数字会让我记很久)。
  2. 记忆 = 每轮的挑选问题,本质是上下文工程。 存储没有技术含量,难的是短期裁剪、长期召回、摘要压缩、装配排序共同回答的那个问题:这一轮,什么该进 prompt、什么留在 store 待召回、什么直接丢。
  3. 写入闸门比召回算法更决定记忆库的质量。 “带引用才晋升””显式信号才记偏好””规则抽取不上模型”——这些看起来保守的闸门,挡住的是幻觉和噪音对记忆库的慢性污染。记忆库一旦脏了,召回越准,错得越自信。
  4. 给模型的输入,质量比数量更决定上限。 第二周的”工具集臃肿”、第三周的”上下文优先级”、本周的”切法定召回”和”装配即挑选”,其实是同一句话的四个侧面。
  5. 从”我觉得这样设计对”到”我有数据证明这样设计对”。 设计阶段的每个假设都列出来、实验逐条验证或修正:”业界不用 RAG 了”被修正、”需要显式指令压制旧摘要”被证伪、”分 namespace 不串库”被证实。口号和结论的区别,就是有没有一组可复现的数字。

下一步是 LangGraph。这次手写记忆系统给我攒了一份很具体的痛点清单:每轮手动 save 六个 json 加两个向量库、跨会话手动加载快照、”偏好轮后才写、当轮读不到”的时序坑——这些”状态在哪存、什么时候读写”的脏活,正是 LangGraph 的 checkpointer 和 store 声称要消除的。而装配那部分”挑选”逻辑,我反而想自己留着。框架到底接得住接不住,下篇见分晓。

本文的完整代码(含 experiments.py 九组实验和原始报告 experiment_report.json)在 ai-agent-lab 仓库的 week_4&5 目录。

(全文完)

附:引用