第 8 章 RAGAgentic RAGLangGraph

第 8 章:第七周(上):用 LangGraph 把 RAG 变成会决策的 Agent

第 8 章:第七周(上):用 LangGraph 把 RAG 变成会决策的 Agent

到第 6 周为止,系统的形状是固定的:查询进来,先查 Redis cache,未命中就走 Hybrid Search 取回一批 chunk,拼进 prompt 交给 Ollama。链路上没有任何一个环节会问一句"这一步真的有必要吗"。第七周拆掉的就是这个前提——把写死的流水线换成一张会在运行时决定下一步走哪里的状态图。这一章看的是决策怎么被拆成节点、节点之间怎么连、以及决策过程怎么暴露给人看。

传统 RAG 与 Agentic RAG:差别在谁做决定

源材料用两段伪代码把差别压得很清楚:

Traditional RAG (Weeks 5-6):
Query → Always Retrieve → Generate Answer
Agentic RAG (Week 7):
Query → Agent Decides:
  ├─ Simple question? → Respond directly (faster!)
  └─ Research needed? → Retrieve
       ├─ Relevant docs? → Generate answer
       └─ Not relevant? → Rewrite query → Try again

三处变化:检索从"必经"变成"可选",检索结果从"直接塞进 prompt"变成"先打分再决定用不用",一次失败从"就这样答吧"变成"改写查询再试一轮"。Week 7 README 给了一张逐维度对比表:

Feature Traditional RAG Agentic RAG
Retrieval Always retrieves Decides when needed
Relevance Check None Grades documents
Query Refinement None Rewrites if needed
Iterations Single pass Multiple attempts
Transparency Black box Shows reasoning
Simple Questions ~15-20s ~2-5s (no retrieval)
Complex Questions Single attempt Iterative refinement

表里的时间数字是课程本地环境(Docker Compose + 本地 Ollama)下的观察值,不是通用 benchmark,读的时候要注意口径。其中 ~15-20s 与 Week 7 README 性能基准表里的 First Query 15-20s 是同一口径;~2-5s 那一格省掉的不是某一次搜索的耗时,而是整条 retrieve + grade + generate 链路。

代价同样直接:Single pass 变成 Multiple attempts 之后,单次请求的延迟不再是可以预估的常数,它取决于 Agent 决定绕几圈。生产上这意味着超时、并发和成本估算都要按最坏情况留余量。

LangGraph 状态图:节点、边与条件路由

Week 7 的编排用 LangGraph,源材料给出的流程图如下(节点目录为 src/services/agents/):

LangGraph Agentic RAG 工作流:从 START 进入 generate_query_or_respond,按需路由到 retrieve 检索节点,再经 grade_documents 打分,相关则 generate_answer,不相关则 rewrite_query 回到检索,形成自适应闭环

LangGraph Workflow:

START
  ↓
generate_query_or_respond
  ├─ No retrieval needed → END (direct response)
  └─ Needs retrieval → retrieve (ToolNode)
       ↓
     grade_documents
       ├─ Relevant → generate_answer → END
       └─ Not relevant → rewrite_query → (loop back)

图里有两种边。普通边是确定的下一步;从 generate_query_or_respond 和从 grade_documents 出去的两条是条件边,读当前 state 再决定往哪走。前者决定"要不要检索",后者决定"检索结果能不能用"——Agent 的决策能力就落在这两个判断点上,其余都是管道。

README 强调实现遵循 2025 年的 LangGraph 写法:MessagesState 作状态容器,检索封装成 ToolNode,路由判断用 tools_condition。用框架自带的原语,条件路由和消息累加就不用自己手写,节点才能压到很短。

这里有一个源材料内部的口径差异值得记下来:顶层 README 在 Agentic RAG 基础设施清单里,把节点目录 src/services/agents/nodes/ 描述为包含 Guardrail、retrieve、grade、rewrite、generate;而 Week 7 README 的 "What We Built" 把 nodes.py 写成 "4 graph nodes (query, grade, rewrite, generate)"。两处对节点集合的说法并不一致,读代码时以 agentic_rag.py 里实际注册进图的对象为准,不要照抄任一份清单。

各类节点的职责

每个节点只做一件事,这是源材料列出的设计原则里 KISS 那条的具体体现——README 明确写了节点保持在 30 行以内。

  • guardrail:查询校验与域边界检测,判断问题是否落在 arXiv 论文这个域内,域外的在进入检索之前就被拦下。这是最便宜的一道防幻觉闸门:与其让 LLM 在没有证据时硬编答案,不如在入口承认"这不在我负责的范围"。
  • retrieve:tools.py 把 OpenSearch 包成 retriever tool,由图以 ToolNode 调用。Week 3 到 Week 5 攒下的 BM25、Hybrid Search 能力在这里被复用,检索逻辑没有重写。
  • grade:对取回的文档做语义相关性打分,输出"这些结果够不够用"的判断。它是整张图的枢纽,决定直接生成还是走回退。
  • rewrite:查询重写,针对的是"检索没命中"这个失败模式——把过于宽泛的问法改写成更能命中索引的查询,而不是原样重试。
  • generate:拿着通过打分的文档生成最终答案。

代码组织如下,~750 LOC 是 README 自己给出的规模口径:

src/services/agents/
├── tools.py            # Retriever tool wrapping OpenSearch
├── nodes.py            # 4 graph nodes (query, grade, rewrite, generate)
├── agentic_rag.py      # LangGraph workflow + service
├── prompts.py          # LLM prompt templates
└── factory.py          # Dependency injection

src/routers/
└── agentic_ask.py      # FastAPI endpoint

factory.py 做依赖注入,prompts.py 单独放 LLM prompt 模板。prompt 与节点逻辑分离,调 prompt 就不必动图结构;依赖构造抽出来,节点才能在不启动整套 Docker 服务的前提下被单独测试。README 把这几条归到 SOLID 与 Explicit(类型标注、docstring、命名清晰)下面,DRY 指复用已有的 OpenSearch、Ollama、Jina 服务,YAGNI 指只实现当下需要的节点。

自适应检索:首轮不足时的多轮与回退

自适应检索的完整语义是:grade_documents 判定不相关时,控制流不回 retrieve,而是先到 rewrite_query,改写后的查询再触发一次检索,然后重新打分。回退不是"重试同一个请求",是"换一个问法再试"。

响应体里的 retrieval_attempts 字段就是给这段循环计数的——调用方据此知道这次答案是在第一轮就命中,还是绕了几圈。这个计数在生产里的作用比它看起来重要:它是区分"自适应检索正常工作"和"循环没有出口"的唯一外部信号。

源材料的流程图只画了回环,没有画终止判断,停止条件是要自己补的护栏。要小心两件事:改写后的查询可能与首轮高度相似,图会在两个节点之间空转;每一轮回退都是一次完整检索加一次打分,延迟和算力按轮数增长。可靠的做法是把 retrieval_attempts 当预算用,到上限就带着已知结果降级回答。

把推理过程暴露出来

源材料把 "Reasoning Transparency" 列为 Week 7 的核心特征之一:把 Agent 的决策步骤显式返回,用于调试和建立信任。API 响应里的 reasoning_steps 是一个自然语言字符串数组,例如:

"reasoning_steps": [
  "Decided to retrieve relevant papers",
  "Retrieved documents from database",
  "Generated answer from relevant documents"
]

直接返回给最终用户是次要的用法,真正有用的是排障:答案不对时,reasoning_steps 能把"检索没找到"、"找到了但打分判错"、"文档没问题但生成跑偏"三种失败区分开——这三种情况在传统 RAG 的响应里长得完全一样,都是一个看起来自信的错误答案。第 6 章的 Langfuse trace 给的是延迟与成本视角的分层信息,reasoning_steps 给的是决策时间线,两者互补。

新的 agentic 端点

Week 7 新增 POST /api/v1/ask-agentic,路径上与第 5 章的 /api/v1/ask、/api/v1/stream 并存,传统链路没有被替换掉。请求体:

{
  "query": "What are transformers in ML?",
  "top_k": 3,
  "use_hybrid": true
}

响应体:

{
  "query": "What are transformers in ML?",
  "answer": "Transformers are neural network architectures...",
  "sources": ["https://arxiv.org/pdf/1706.03762.pdf"],
  "chunks_used": 3,
  "search_mode": "hybrid",
  "reasoning_steps": [
    "Decided to retrieve relevant papers",
    "Retrieved documents from database",
    "Generated answer from relevant documents"
  ],
  "retrieval_attempts": 1
}

请求参数与第 4 章的 Hybrid Search、第 5 章的 ask 端点保持一致(top_k、use_hybrid),响应则多了 reasoning_steps 和 retrieval_attempts 两组与决策相关的字段,search_mode 回显实际走的是 hybrid 还是 BM25。直接回答、不检索的那条路径同样返回这个结构,只是 sources 和 chunks_used 为空。

从命令行验证时,README 给的两个例子刚好覆盖两条路径:

# Simple question (should respond directly)
curl -X POST http://localhost:8000/api/v1/ask-agentic \
  -H "Content-Type: application/json" \
  -d '{
    "query": "What is 2+2?",
    "top_k": 3,
    "use_hybrid": true
  }'

# Research question (should retrieve papers)
curl -X POST http://localhost:8000/api/v1/ask-agentic \
  -H "Content-Type: application/json" \
  -d '{
    "query": "What are attention mechanisms?",
    "top_k": 3,
    "use_hybrid": true
  }'

交互式验证走 notebook:

jupyter notebook notebooks/week7/week7_agentic_rag.ipynb

典型测试场景

源材料列了三个场景,每个场景都指定了期望行为和期望的 reasoning_steps。这种写法的好处是断言对象不是答案文本(答案文本本来就会漂),而是决策路径:

场景 Query Expected Reasoning
Scenario 1: Direct Response (No Retrieval) "What is 5 + 7?" Agent responds "12" without retrieving papers "Responded directly without retrieval"
Scenario 2: Successful Retrieval "What are transformers in machine learning?" Agent retrieves papers, grades as relevant, generates answer "Decided to retrieve" → "Retrieved documents" → "Generated answer"
Scenario 3: Query Rewriting "Tell me about ML stuff" (vague) Agent retrieves, grades as not relevant, rewrites query, tries again "Retrieved" → "Not relevant" → "Rewritten query" → "Retrieved again" → "Generated answer"

场景 1 验证最短路径能走通,也是延迟收益的落点。场景 2 验证正常闭环。场景 3 最有价值,因为只有真的发生了回退它才会通过——用故意含糊的查询触发 grade 判负,再看 retrieval_attempts 是否大于 1。

参考链接

release 链接出自源材料原文,源 README 里的仓库名写作 jamwithai/arxiv-paper-curator,与课程主仓库名 production-agentic-rag-course 不同,取代码时按 release tag week7.0 克隆即可复现本章的代码状态。

第七周这一半的改动,本质是把检索从一个步骤提升成一个决策,代价是延迟不再可预测。图结构本身很薄——两个条件分支、几个职责单一的节点——难点不在 LangGraph 的 API,而在两个判断点的 prompt、回退的终止预算,以及答案出错时能不能从 reasoning_steps 里看出是谁的锅。下一章接 Telegram Bot,看这套会决策的系统怎么被搬到手机上可用的入口。