简介:Agentic RAG即代理式检索增强生成,本资源以可运行源码形式系统解析其核心原理与工程实现,适合已掌握基础RAG概念、希望进阶学习AI Agent与检索系统结合的开发者。内容重点对比传统RAG的单一知识源与一次性检索局限,展示如何借助智能代理实现动态信息源选择。资源共3个文件、压缩包仅8KB,核心为HTML交互演示页面及InsCode在线运行配置,另附gitignore便于工程化部署。读者可借助源码直观理解单代理与多代理架构差异,并掌握基于函数调用语言模型和代理框架(如DSPy、LangChain)的两条实施路径,对构建企业级智能知识检索系统具有直接参考价值。现有147人学习下载,适合作为Agentic RAG入门到实战的轻量配套资料,兼顾原理讲解与动手验证需求。
1. Agentic RAG解析:当普通RAG在多跳问答上翻车时,这个架构把检索权交给了Agent
先从一个经常让人摸不着头脑的线上问题说起:用户问“某企业去年收购的子公司,其研发总部在哪个城市”,传统 RAG 拿整句去做一次性向量检索,往往夹带着大量无关信息,最后模型只凭直觉作答。Agentic RAG 的切入点就是把“查一次”改成“查多次且边查边判断”——由 Agent 自主决定检索子问题、检索时机和终止条件,直到信息足够再生成答案。对做知识库问答、多文档复杂推理的人来说,这个方向非常值得投入;对刚入门的人,它也可以直接在源码层面跑通,从工具接口、循环调度到观察压缩一步步看明白。这篇笔记就照着一份可运行源码的脉络,把实现与排坑一次讲清楚。
2. 拆开Agentic RAG的执行循环:规划、检索、反思三个环节为什么缺一不可
2.1 传统RAG的一次性检索,在多跳问题面前为什么失效
我在这类项目的开场例子里经常遇到那个问题:用户连续问两个相关实体之间的关系,传统 RAG 的管道是 query → embedding → 一次向量检索 → prompt 拼接 → LLM 生成。这个最短路径在设计上就没有“回头看”的能力。多跳问题意味着答案必须来自两段或更多段不同文档,且第二次检索的关键词要从第一次检索的结果里推断出来。
有同学问我:把 top_k 从 5 调到 20,把更多文档召回进来,不就覆盖了吗?我实测过这个思路,准确率不升反降。原因在于相似度检索基于 embedding 的距离,距离近不代表组合起来能支持推理。多余的文档带着高度的表面相关性进入上下文,反而把真正有用的信息稀释掉。模型拿到 20 段内容,更倾向于从中找和自己已有知识一致的部分来答,而不是做严格的证据链验证。
换句话说,多跳问题卡住的地方不是“检索器不够强”,而是“缺少一个控制层来判断信息是否充足,以及下一步要检索什么”。把控制层做出来,这就是 Agentic RAG 的价值所在。
2.2 走查Plan-Act-Reflect:一个迁移自规划-执行-反思的工作循环
刚开始接触 Agentic RAG 时,别急着研究各种酷炫的框架。先把它的循环拆成三个动作:规划(Plan)、执行(Act)、反思(Reflect)。我用一张非常朴素的流程来对应源码里的函数调用:
# 伪码:一个Agent循环的三种动作映射到代码 state = {"question": user_question, "history": []} # 循环开始 while not state["finished"]: plan = llm_plan(state) # 规划:下一步是继续检索还是直接回答 if plan["action"] == "answer": # 反思后认为信息足够 break docs = retriever.run(plan["query"]) # 执行:调用检索工具 state["history"].append({"role":"observation", "content": docs}) state["finished"] = check_loop_termination(state) # 反思:判断是否终止在这个循环里,关键的隐藏角色是check_loop_termination。它绝不能只调一次 LLM 让它回答“是/否”,因为 LLM 在长上下文里对“信息是否足够”的判断很不稳定。成熟的做法是把这个判断逻辑拆成几个可观测的指标:遍历历史里已有的子问题,计算与当前 query 的相似度,如果重复则强制终止;统计历史 token,超过预算就强制生成。
我在复现时观察到一个容易被忽略的细节:Reflect 的执行质量严重影响整体准确率。如果你只叫 LLM“请反思一下还有什么缺的”,模型会给一个似是而非的回答;但如果你给它一张清单,让它逐项核验“是否找到答案涉及的实体”、“两段文档是否在时间轴上一致”、“是否存在未验证的冲突信息”,输出立刻就稳定很多。这个体验让我坚信:Agentic RAG 难的不是 Agent 或者 RAG,而是把反思这个模糊动作工程化成结构化检查。
2.3 为什么必须对着可运行源码来理解,而不是只看流程图
Agentic RAG 的架构图看起来并不复杂:用户问题进 Agent,Agent 调检索工具,结果反馈回 Agent,循环结束后输出。但真到自己动手,你会发现架构图上的每条连线都要回答三个工程问题:数据格式是什么(纯文本还是 JSON 结构化)?放在 prompt 的哪个位置(system 还是 user)?截断还是完整保留?
可运行源码就是这些工程问题的标准答案。它能让你看清四类信息的拼接顺序:系统提示词、用户原始问题、历史对话轨迹、本轮检索观察。顺序错了,模型会把“检索到的文本”当成“用户指令”,行为立刻异常。另外,可运行源码能省掉你造轮子的时间。Agent 编排要处理工具注册、参数校验、流式输出,这些基础设施在成熟的源码里都已就位,你需要动手改造的通常是检索器和决策 prompt 这两处。
我在最开始实现方案时,尝试过从零搭一个精简循环,结果卡在“工具的继承关系”和“上下文 message 类型一致性”这些环节上,白白耗掉几天。后来换成跑通一份现有开源骨架,再往里面添加自己的反思清单和混合检索,整个过程顺利得多。这也是我把这篇笔记定位为“跟着源码拆”的原因——直接对照代码理解边界条件,效率远高于对照文档理解概念。
这里我补一个对比表格,把不同项目里常见的方案放在一起看一眼:
| 实现方式 | 循环控制 | 工具调用 | 上下文维护 | 适用场景 |
|---|---|---|---|---|
| 从零编写循环 | 自己写 while 循环 | 自己解析 JSON | 手动拼接 messages | 学习原理、极简演示 |
| 基于 Agent 编排框架 | 框架内置(图状态机或链式) | 函数注册表 | 框架自动维护 | 业务原型、生产系统 |
| 基于业务平台加脚本 | 平台工作流触发 | 脚本封装 HTTP 调用 | 存储于外部缓存 | 多系统集成、非技术团队 |
这个表格不是让你照抄,而是提醒你选择源码时先看它的循环和工具调用是怎么实现的。不要被“架构图里画了 Agent”迷惑,很多源码骨架过滤得特别厉害,只是把 RAG 结果映射到一个固定模板的答案上。一旦遇到需要动态决定是否检索的场景,这种“假 Agent”就会露出原形。
3. 复现Agentic RAG的最小工程:代码骨架、循环控制与检索器接口
3.1 最小工程骨架:三个文件拆分与依赖声明
任何 Agentic RAG 工程落到代码层面,都需要三类东西:入口、Agent 循环、工具层。我把最小骨架拆成三个文件。三个文件的职责边界很清晰:main.py 只负责读配置、初始化对象、启动循环;agent.py 只负责控制逻辑,不关心检索和文档解析的细节;tools.py 暴露统一的run(query)接口,屏蔽内部使用了什么检索库。
project/ ├── main.py # 入口:读取配置并启动 Agent 循环 ├── agent.py # Agent循环:决策、检索、反思、终止调度 ├── tools.py # 工具层:暴露出统一的检索与融合接口 └── requirements.txt依赖文件里的包不需要贪多。核心依赖一般集中在三块:大模型 SDK 或 OpenAI 兼容客户端、一个轻量向量库、以及可选的文档解析库。受限于篇幅,我把requirements.txt的示意放在下面:
openai>=1.30.0 chromadb>=0.5.0 pydantic>=2.0.0 langchain-text-splitters>=0.2.0这个清单的细节不用在意,重要的是你要理解每一条对应哪个职责:大模型 SDK 管对话;向量库管文档索引;pydantic 管输出的数据校验;文本分割器管把长文档切成适合召回的片段。如果某个环节你已经有了别的工具,替换即可,没有必须全套使用一说。
配置参数的决定权经常被低估。我在一个项目里见过整套循环只依赖 LLM 判断是否继续,把max_steps设成 1,却希望系统能多轮检索——这相当于没给 Agent 留够发挥的空间。起步阶段建议控制层参数先保守:max_steps=3,top_k=5,model_temperature=0.1。温度这个参数一定要压到低位,Agent 决策阶段如果温度偏高,模型会不自觉地改变检索策略和输出格式,稳定性很差。等跑通以后再按评估结果逐步放开。
3.2 核心循环代码:100行以内跑通的Agent调度
下面是核心循环代码。这段代码我刻意保持最小化,便于你直接对照阅读——理解它的结构与参数,比把它直接用于生产更重要。
# agent.py 核心片段 # 依赖:支持JSON输出格式的大模型客户端,或OpenAI兼容接口 from typing import List, Dict import json class AgenticRAG: def __init__(self, llm, retriever_tool, max_steps: int = 3): self.llm = llm # 大模型客户端,暴露 chat(messages) 方法 self.retriever_tool = retriever_tool # 实现了 run(query) -> List[Dict] 的工具 self.max_steps = max_steps # 最大循环步数,推荐 3~5 def run(self, question: str) -> str: history: List[Dict] = [] for step in range(self.max_steps): decision = self._decide(question, history) if decision["type"] == "finish": return decision["answer"] query = decision["query"] docs = self.retriever_tool.run(query) # 观察结果以结构化内容进入历史,供下一轮决策 history.append({"role": "observation", "content": json.dumps(docs, ensure_ascii=False)}) return self._final_answer(question, history) def _decide(self, question: str, history: List[Dict]) -> Dict: prompt = self._build_decision_prompt(question, history) # 要求模型输出严格JSON,否则跳过解析错误处理时容易翻车 resp = self.llm.chat(messages=prompt, response_format={"type": "json_object"}) return json.loads(resp) def _build_decision_prompt(self, question: str, history: List[Dict]): return [ {"role": "system", "content": "你是检索决策助手。根据已有信息判断是否还需要检索。" "如需检索,输出 {\"type\":\"search\",\"query\":\"子问题\"}; " "如信息已足够,输出 {\"type\":\"finish\",\"answer\":\"最终答案\"}"}, {"role": "user", "content": f"原始问题:{question}\n当前历史:\n{json.dumps(history, ensure_ascii=False)}"} ]逻辑说明:核心是把每轮检索后的观察结果追加进history,而不是只保留最新一轮。为什么这么设计?因为第 2 轮的决策既需要第 1 轮检索到的事实,也需要第 1 轮已经查过哪些 query,避免重复劳动。很多自实现代码只在history里保存最后一轮消息,导致 Agent 前查后忘,不停重复同一个子问题。
参数说明:max_steps默认 3,推荐在 3~5 之间取值。设置过大的值并不会带来更高质量的答案,反而会让成本线性上升,并且模型在长历史中容易产生前后不一致的内容。我在一些业务场景里试过将 max_steps 拉到 7,观察结果是:第 5 轮以后的检索结果几乎不再影响最终答案,因为它自己已经忘了第 1 轮在查什么。
这里还要顺手做一层兜底:如果decision里既没有"type"也没有"query",解析就会直接抛异常。可运行源码里通常会包一层 try/catch,并加一次重试机会。我习惯在重试的 prompt 后面追加一句“你上一次没有输出合法 JSON,请仔细检查花括号和引号是否闭合”。这个细节看着玄学,实战里能救回不少偶发请求。
3.3 检索器混合召回与参数设置:向量库加关键词库,并用RRF合并
只有纯向量检索的 Agentic RAG 在真实业务里常遇到一个问题:专名、编号、缩写,embedding 匹配效果不稳定。针对技术文档类的知识库,行业常见做法是混合检索:一路向量召回语义相似,一路关键词召回精确匹配专名,最后用 RRF(Reciprocal Rank Fusion,倒数排名融合)合并两个列表。
# tools.py 核心片段:混合检索的统一工具接口 class RetrieverTool: def __init__(self, vector_index, keyword_index, top_k: int = 5, alpha: float = 0.5): self.vector_index = vector_index # 向量索引,提供 search(query, top_k) self.keyword_index = keyword_index # 关键词索引,提供 search(query, top_k) self.top_k = top_k self.alpha = alpha # 向量与关键词结果的加权系数,默认 0.5 对半 def run(self, query: str) -> List[Dict]: vec_hits = self.vector_index.search(query, top_k=self.top_k * 2) kw_hits = self.keyword_index.search(query, top_k=self.top_k * 2) # RRF 融合:分别计算倒数排名得分,再用 alpha 加权 fused = self._rrf_fusion(vec_hits, kw_hits, alpha=self.alpha, k=60) return fused[: self.top_k] def _rrf_fusion(self, vec_hits, kw_hits, alpha: float, k: int): # 标准RRF公式:score += 1 / (k + rank),rank从1开始 ...这里有一个值得注意的参数取舍:向量检测的召回量我设成top_k * 2,而不是与top_k相等,目的是给 RRF 融合前的“漏召回”留一定余量。alpha的调法依赖业务数据:如果知识库里专业术语很多,关键词召回往往更可靠,alpha 应加大关键词权重到 0.6~0.7;如果问题是自然语言口语化表达居多,向量语义更占优。唯一能做主的是开好评估集,两组参数各自跑一遍再选。这个“跑一遍再选”的习惯,比凭感觉调 alpha 可靠得多。
最后一个常被忽略的参数是文本切分的块大小(chunk size)。Agentic RAG 的循环里,每一轮检索都同时面对多轮历史,所以单块文档不宜太长。我见过比较稳定的设置是:普通企业知识库用 300~500 字符切块,重叠 50 字符;代码文档或强结构化文档可压缩到 200~300 字符。块越大,召回相关段越粗,循环需要更多轮次才能补足信息;块太碎,则又容易丢失上下文连贯性。这个参数没有绝对标准,但一定和 top_k、max_steps 联合调整,它们三个共同决定了每轮能往循环里带多少信息。
4. 深入可运行源码:工具调用解析、上下文压缩与循环退出条件
4.1 工具调用与参数解析:LLM 说“检索”,系统如何翻译成函数调用
可运行源码里常见的工具调用有两种:一种是“输出 JSON 然后解析”,另一种是“原生 function call”。后者在当前主流 LLM 上已经比较普及,但前者更通用,尤其对接本地模型时,我给你展示一种兼容性更好的方式。
# 工具描述:让 LLM 知道这个工具接受什么参数 tool_schema = { "name": "search_docs", "description": "从企业知识库检索与输入查询相关的文档片段", "parameters": { "type": "object", "properties": { "query": {"type": "string", "description": "子问题,应该拆到单个具体实体"}, "top_k": {"type": "integer", "description": "召回数量,默认3"}, }, "required": ["query"], }, }这一步有个特别容易被坑到的地方:LLM 经常把原问题整个塞进query参数,导致检索子问题退化成原问题复制。原因倒不神秘——用户的问题本身就长,模型倾向于直接抄写。我通常会在工具描述里补一句“query 应该是一个不包含关系型连词的单实体问题,例如‘某公司的总部在哪’而不是‘某公司收购的另一家公司总部在哪’”。这不算什么高深技巧,但对召回质量的影响是立竿见影的。
工具调用的解析层永远需要做一次兜底校验:LLM 输出的 JSON 可能出现多余字段、缺少必填字段,甚至整体不是合法 JSON。成熟的做法是解析失败时 catch 异常并让 LLM 重试一次,而不是直接崩溃。重试次数建议 1~2 次,多了会浪费 token 也没意义。兜底逻辑通常这样实现:
# 工具调用兜底:解析失败时重试一次,仍失败则强制走最终答案生成 def safe_parse(resp_text, max_retries=1): for i in range(max_retries + 1): try: return json.loads(resp_text) except json.JSONDecodeError: if i < max_retries: resp_text = llm.chat(messages=[{ "role": "user", "content": f"解析失败,重新输出严格JSON。原文:{resp_text}" }]) else: return {"type": "finish", "answer": "抱歉,暂时无法解析工具输出。"}注意这个兜底的默认回答很保守,宁可让用户看到“无法解析”,也不能让循环崩掉或者静默生成一个错误答案。这也是生产系统跟教学 Demo 的分水岭——教学代码挂了就挂了,生产代码要能自愈。
4.2 上下文压缩:多轮循环的检索结果,不能全塞进第二轮的 prompt
首次把 Agentic RAG 接上真实 LLM 的人,八成会遇到同一个问题:第二轮开始,prompt 长度开始起飞,延迟明显增加,更麻烦的是模型开始“遗忘”原始问题。这是因为把每一轮检索回来的完整文档直接拼接进历史。
可运行源码里常驻的优化是上下文压缩(Context Compression),有几件事可以做:
- 对检索文档做重排(Rerank),只保留 top 2 进历史;
- 对文档做摘要压缩,把每段压成一两句话存入历史;
- 丢弃与当前子问题无关的历史轮次,只保留最终的观察结论。
# context.py:一层轻量压缩,防止历史轨迹撑爆 prompt from typing import List, Dict import json def compress_history(history: List[Dict], max_docs_per_step: int = 2) -> List[Dict]: new_history = [] for msg in history: if msg["role"] != "observation": new_history.append(msg) continue docs = json.loads(msg["content"]) # 假设 content 是 JSON 数组 # 按 score 降序取前 max_docs_per_step 段 docs = sorted(docs, key=lambda x: x.get("score", 0), reverse=True)[:max_docs_per_step] # 摘要压缩:用 LLM 把 docs 压缩成一段话 summary = summarize_with_llm(json.dumps(docs, ensure_ascii=False)) new_history.append({"role": "observation", "content": summary}) return new_history压缩的代价也要说清楚:每做一次摘要,就多一次 LLM 调用。压缩策略不适合每一步都执行,常见做法是在第二步以后做——也就是在历史里有至少两轮 observation 时,才触发一次整体压缩。这是 token 成本与上下文清晰度的权衡点。你要是算力充足,也可以把max_docs_per_step提高到 3;但我在实际压测中的感受是,2 段基本能覆盖一个子问题的答案,再多就会稀释后续决策的注意力。
还有一种更省的压缩方式,不需要额外调 LLM:直接截断长文档,保留首尾。对部分结构化文档,首尾往往包含实体名和结论,中间是论证过程。这个技巧虽然粗暴,但在强约束 token 预算的场景下很管用。等预算充足后再切回 LLM 摘要也不迟。
4.3 循环退出条件:三种终止条件配合,防止Agent“自嗨”
源码阅读到循环控制时,你总会看到一个“N 选 1”的简单实现——比如只靠 LLM 判断是否结束。我们前面的最小实现也用了这种方式。但在真实场景下,只靠 LLM 自判结束是危险的,它可能会一直认为自己信息不够,无限检索下去。于是我在这类实现中添加了三重退出机制:
- 最大步数截止:硬性停止,不管 LLM 是否认为信息足够。
- 重复查询截止:记录每轮检索的 query,与历史 query 做文本相似度,重复则中断。
- 上下文长度截止:设定历史 token 上限,达到八成时强制进入最终答案生成。
第一条是调度侧的保险,第三条是资源侧的保护,第二条则是提高回答质量的——因为重复检索不仅浪费钱,还说明 Agent 已经在原地打转了。重复检测可以实现得很轻,比如用一个列表存前几轮的 query,再做一轮 jaccard 距离比较。
# 重复查询检测:相似度超过阈值则中断循环 def is_repetitive(new_query: str, past_queries: List[str], threshold: float = 0.85) -> bool: for q in past_queries: if jaccard_similarity(new_query, q) > threshold: return True return False def jaccard_similarity(a: str, b: str) -> float: set_a, set_b = set(a.split()), set(b.split()) if not set_a or not set_b: return 0.0 return len(set_a & set_b) / len(set_a | set_b)这个重复阈值 0.85 是不是固定死的?不一定。业务问题如果整体句子长度偏短,阈值可以下调到 0.8;如果偏长(包含很多限定语),就该上调到 0.9 左右。我在实际项目里见过一种更稳的做法:不只在 query 层面做相似度,还把每轮返回的 doc id 列表也记下来,如果两轮返回的文档 id 集合高度重合,也判定为原地打转。
这个参数是典型的需要在评测集上微调的参数,不存在一个万能值。同时要注意,终止条件触发后,不能让用户等太久,直接输出“经过搜索仍未找到足够信息”也是一个合法答案。知道什么时候收手,比硬撑到底更能赢得用户信任。
5. Agentic RAG 常见问题与避坑记录:无限循环、上下文爆炸、噪声传导怎么破
5.1 坑一:Agent 陷入无限循环,反复检索同一个子问题
现象:循环跑了七八步还在检索,每轮 query 几乎一样,日志里全是同一个子问题,成本直线上升。
原因:LLM 的信息评估过于自信,或者反思环节的提示词不够严格,模型认为“再检索一次会更确定”。另一个隐蔽原因:历史轨迹里的观察结果没有让模型感知到“已经查过”,它不知道第 2 轮和第 1 轮是同一个子问题。
解决:加重复查询检测,用上一章介绍的 jaccard 相似度方法,阈值设在 0.8~0.9。同时把历史观察结果摘要化,让模型在决策时明确看到“已经查过什么,结论是什么”。这样能大幅度减少重复检索。还有一个止血操作:在循环计数器上加硬上限,我的习惯是max_steps永远不超过 5,即使别的小团队为了稳妥会设到 8,我也不建议。
5.2 坑二:多轮迭代后上下文爆炸,推理延迟增加到不可用
现象:第二轮开始,请求耗时从 1 秒飙到 5 秒以上,用户端明显感知到卡顿;更麻烦的是问题 “追溯” 到一半,模型已经开始答非所问。
原因:检索文档全量进历史,且历史轨迹从未压缩。很多初版实现只做了简单的history.append,却没有控制每轮追加多少内容,导致对话越长,上下文越大,延迟呈线性增长。
解决:启用上下文压缩,observation 只保留 top 2 文档或摘要;同时把原始文档从历史里剥离——历史里只存“检索结论”,不存“原始文本”。有个小习惯很推荐:检索回来后立即让 LLM 生成一句 50 字左右的“观察笔记”,替代原文进历史。这一招能把 token 消耗砍掉 60% 以上,而且对决策环节的影响很小。
5.3 坑三:检索噪声被 Agent 接力放大,最终答案错得离谱
现象:检索结果明明是噪声,但 Agent 会把噪声前后自洽成一篇“看起来合理”的答案。特别是知识库本身质量不高时,这种问题极其隐蔽,没有评测集几乎发现不了。
原因:反思环节不设防。模型面对相互矛盾的 document 集合时,倾向于选择与已有观点更一致的部分,而不是主动验证冲突。这种“自洽性偏见”在多轮叠加后被放大,导致最终答案错得离谱。
解决:在决策 prompt 里增加冲突检测指令,要求模型遇到冲突时延后回答,而不是强行选定。或者提供多路独立评估,把检索文档切给两个不同的模型实例分别打分,取一致部分。还有一个工程习惯:在前端把检索到的文本高亮出来,让用户直接能看到哪些是噪声。这个 UI 层的小设计看着不起眼,但对线上准确率有很强的兜底价值。
5.4 坑四:LLM 输出 JSON 不稳定,工具调用偶发失败
现象:运行时报json.JSONDecodeError,系统直接中断,或者工具调用空跑,日志里没有任何模型输出。
原因:没有错误重试机制。实测经验:即使是大模型,JSON 输出稳定性也达不到 100%,面对长 prompt、复杂 query 时更容易带出多余文字或断行。
解决:解析失败时自动重试,重试次数一到就降级为直接回答(基于已有历史强制生成答案)。重试 prompt 里可以加一句“你上一次没有输出合法 JSON,请检查花括号闭合”。同时,在代码里把response_format强制设为json_object,能让模型从底层减少非 JSON 输出。如果用的是本地模型,没有这个参数,就需要把工具描述写得更严格,并显式声明“输出必须是可以被 json.loads 解析的字符串”。
5.5 坑五:把 max_steps 当“万能旋钮”,调大后答案质量反而下降
现象:为了提升多跳问题的表现,直接把max_steps从 3 调到 10,结果成本涨了 3 倍,答案准确率却更差了;甚至偶尔出现同一问题不同轮次答案互相矛盾。
原因:Agent 的上下文窗口是有限的,步数过多会让早期观察结果被后续内容稀释。模型面对越来越长的历史时,更容易“迷失在中间”,忘记原始问题约束。
解决:不要试图通过无限调大步数来覆盖所有场景。先把问题拆解成串行子任务,一个 Agent 负责一个环节,比一个 Agent 跑 10 步要可控得多。max_steps宁可保守,也不要激进一步:起步设为 3,评测集上发现确实有信息链更长的样本,再单独为这类样本设置高步数路由。
6. 验证与进阶:用评测集压测Agentic RAG,以及让它“听指挥”的三个技巧
6.1 建立一套评测集,答案正确性要靠人工标注
Agentic RAG 的验证不能只看 loss 或 rouge,因为它承担的是信息检索和推理的综合任务。我习惯的做法是准备一份包含 50~100 条多跳问题的评测集,每条问题都要标注“支持它的文档片段 ID”和“标准答案”。评测时记录三个指标:答案正确率、平均检索轮数、平均耗时。答案正确率靠人工复核,平均检索轮数靠循环日志统计。
有了这套评测集,你修改alpha、max_steps、top_k时才不会靠感觉。每次改完参数,全量跑一遍,对比指标变化,这是对这个方向最快但也最可靠的学习方法。
6.2 让它“听指挥”的三个进阶技巧
第一个技巧是工具粒度拆分。不要把全部文档塞进一个检索器,而是拆成“技术文档检索”和“业务规范检索”两个工具,让 Agent 先做领域选择再执行检索。这样既能提升召回精度,也方便后续针对不同工具调整不同的top_k。实测中,工具粒度变细以后,Agent 的规划行为明显稳定很多。
第二个技巧是检索时机控制。在系统提示词里明确写一句“如果你对问题有高置信度,可以不检索直接回答;如不确定,再进行检索”。许多人以为 Agentic RAG 就是要每轮都检索,事实恰恰相反,业务知识库中相当一部分问题是高频且简单的,强制检索会白白增加延迟和成本。
第三个技巧是路由阈值。混合检索的 RRF 融合结果会有一个融合分,当分数低于某个阈值时,最稳妥的做法不是继续让 Agent 强行生成,而是把“未找到足够信息”作为选项返回给用户。这个阈值需要你在评测集上观察无答案样本的分数分布来确定,通常取在 0.08~0.15 之间。
我自己的习惯是:每做完一个版本的 Agent 循环,先跑通最小评测集,再挑 10 条真实用户问题做人工核对,最后才决定要不要让这套代码进入业务。遇到过太多次“结构完美但答案经不起细问”的情况了,非常打击信心,也让我明白——循环结构只是基础,真正决定上限的是检索策略和反思工具的工程化程度。希望这篇笔记对你接下来的跟源码、排坑之路有帮助。
本文还有配套的精品资源,点击获取