LangChain源码学习路线:第 16 篇 Agentic RAG 与 Retrieval Runtime源码解剖
核心问题: LangChain 如何把检索从一个前置步骤升级为 Agent 可规划、可调用、可重试、可观测的运行时能力?
源码主线:
BaseRetriever.invoke()→Document→create_retriever_tool()→Agent tool call→context injection / grounded answer前置文章: 第 4 篇
Tool Calling、第 15 篇Middleware / Guardrails依赖基线:
langgraph==1.2.7、langchain==1.3.11、langchain-core==1.4.8阅读边界: 本文覆盖 BaseRetriever、retriever tool、query rewrite、rerank、grounding 和 Agentic RAG 编排;不展开具体 embedding 模型、向量库索引算法和知识库治理流程。
0. 本篇在源码学习主线中的位置
前面的文章已经解释了底层 Runnable、Tool、StateGraph、Checkpoint 和 Streaming。本篇把这些能力放到一个更接近生产 Agent 的问题里:Agentic RAG / Retrieval Runtime。
Tool Calling / Middleware ↓Agentic RAG / Retrieval Runtime ↓Multi-Agent / Handoffs本篇只解决:
- 解释 retriever 为什么是 Runnable。
- 解释 retriever 如何包装成 tool 给 Agent 调用。
- 解释 query rewrite、rerank、grounding 的运行位置。
本篇不展开:
- 不讲向量数据库底层 ANN 算法。
- 不讲文档清洗全流程。
1. 本篇问题、学习目标与能力边界
核心问题
LangChain 如何把检索从一个前置步骤升级为 Agent 可规划、可调用、可重试、可观测的运行时能力?
学习目标
完成本篇后,读者必须能够:
- 画出Agentic RAG / Retrieval Runtime的构建期对象关系。
- 解释一次运行时调用如何进入主链。
- 说明关键分支、异常和停止条件。
- 区分公共 API、扩展接口和内部实现。
- 根据旅行规划助手场景做工程选型。
能力边界
| 能力 | 本篇是否覆盖 | 说明 |
|---|---|---|
| 构建期对象组装 | 是 | 解释公开参数如何变成运行时对象 |
| 运行时主链 | 是 | 解释 invoke/stream/tool/task 等主路径 |
| 分支与异常 | 是 | 解释失败、降级、重试、终止边界 |
| 扩展协作 | 是 | 解释与相邻框架组件如何组合 |
| 底层供应商实现 | 否 | 不展开模型、数据库或平台内部实现 |
2. 核心概念与最小心智模型
一句话定义
Agentic RAG 是把检索动作交给 Agent 决策和运行时调度的 RAG 形态,负责让模型在需要时查询知识,不负责保证知识库本身一定完整正确。
最小心智模型
Tool Calling / Middleware ↓Agentic RAG / Retrieval Runtime ↓Multi-Agent / Handoffs ↓工程化 Agent 能力核心术语
| 术语 | 源码对象 | 语义 | 不要误解为 |
|---|---|---|---|
| Retriever | BaseRetriever | 根据 query 返回 Document 列表 | 向量数据库本身 |
| Retriever tool | create_retriever_tool | 把 retriever 包成 Agent tool | 普通搜索字符串函数 |
| Query rewrite | Runnable / model node | 把用户问题改写为检索 query | 最终回答 |
| Grounding | prompt/context policy | 要求答案基于文档证据 | 事实自动正确 |
与相邻抽象的边界
| 对象 | 负责什么 | 不负责什么 | 与本篇对象的关系 |
|---|---|---|---|
Runnable | 统一 invoke / stream / batch 协议 | 不决定业务策略 | 提供可组合执行底座 |
StateGraph | 编排状态、节点和边 | 不实现所有外部系统 | 承载复杂 Agent 工作流 |
RunnableConfig | 传递 config、metadata、callbacks | 不保存业务状态 | 让观测和配置沿调用链传播 |
3. 完整执行链路
本篇的主线不是“先检索再问模型”,而是让检索成为 Agent 运行时可以选择的一条动作分支。也就是说,检索不是固定前置步骤,而是从 messages 中被模型判断、以 tool_call 表达、由工具运行时执行,再把结果以 ToolMessage 交还给下一轮模型调用。
从用户问题到检索证据的对象流转
用户问题 ↓HumanMessage(content="大阪 4 天行程,想确认交通和餐厅营业信息") ↓Agent state["messages"] ↓绑定了 retriever tool 的 ChatModel ↓AIMessage(tool_calls=[{"name": "travel_knowledge_search", "args": {"query": "..."}}]) ↓ToolNode / BaseTool.invoke(tool_args) ↓create_retriever_tool 包装出来的工具函数 ↓BaseRetriever.invoke(query) ↓list[Document(page_content=..., metadata={"source": ..., "score": ...})] ↓Document formatter / rerank / compression ↓ToolMessage(content="格式化后的证据文本", tool_call_id="call_xxx") ↓Agent state["messages"] 追加 ToolMessage ↓ChatModel 再次生成 grounded answer ↓AIMessage(content="带证据约束的最终回答")这里有两个关键对象变化。第一,用户输入不会直接变成检索 query,而是先进入消息状态,让模型决定是否需要工具。第二,BaseRetriever 返回的是 Document 列表,但 Agent 下一轮看到的通常不是原始 Document 对象,而是被格式化后的 ToolMessage.content;原始文档对象可以作为 artifact、trace 或 state 字段保存,用于调试和引用。
这也是 Agentic RAG 与传统 RAG 的差别:传统 RAG 的控制权在应用代码,应用代码总是先查再答;Agentic RAG 的控制权先给模型,模型通过 AIMessage.tool_calls 表达“现在需要检索”。框架的职责是把这个意图安全地转成工具调用,再把工具结果以消息协议交还给模型。
伪代码:Agent 何时决定检索
以下为保留关键控制流的简化伪代码,不是源码逐字复制:
from langchain_core.messages import HumanMessage, ToolMessage
def agentic_rag_turn(user_input: str, state: dict, config: dict) -> dict: messages = [*state.get("messages", []), HumanMessage(content=user_input)]
ai_message = model_with_retriever_tool.invoke(messages, config=config)
if not ai_message.tool_calls: return { "messages": [*messages, ai_message], "final_answer": ai_message.content, "retrieved_documents": state.get("retrieved_documents", []), }
tool_messages = [] retrieved_documents = []
for tool_call in ai_message.tool_calls: if tool_call["name"] != "travel_knowledge_search": raise ValueError(f"unknown tool: {tool_call['name']}")
query = tool_call["args"]["query"] docs = retriever.invoke(query, config=config) ranked_docs = rerank(query=query, documents=docs, top_k=5) tool_messages.append(format_as_tool_message(tool_call, ranked_docs)) retrieved_documents.extend(ranked_docs)
grounded_messages = [*messages, ai_message, *tool_messages] final_message = grounding_model.invoke(grounded_messages, config=config)
return { "messages": [*grounded_messages, final_message], "retrieved_documents": retrieved_documents, "final_answer": final_message.content, }这段伪代码的输入是用户文本、已有 Agent state 和 RunnableConfig。输出不是单纯字符串,而是一个状态增量:新的 messages、可审计的 retrieved_documents,以及面向调用方的 final_answer。对象形态经历了 str → HumanMessage → AIMessage.tool_calls → list[Document] → ToolMessage → AIMessage 的变化。
源码观察点在于 AIMessage.tool_calls 和 ToolMessage.tool_call_id 的配对。模型提出工具请求时,工具调用还没有发生;只有工具运行时消费 tool_calls 后,才会产生带 tool_call_id 的 ToolMessage。如果多个检索工具并发执行,这个 id 是把请求和结果对齐的关键。
框架这样设计,是为了把“模型决策”和“工具副作用”拆开。模型只能提出结构化请求,真正访问检索后端的是工具运行时;工具结果再以消息形式进入下一轮模型。这让重试、trace、权限检查、缓存和人工中断都能插在清晰边界上。
非工具型 RAG graph 的链路
同样的对象链也可以显式写成 LangGraph 节点,而不是让模型通过 tool call 自主决定:
class RagState(TypedDict): question: str rewritten_query: str | None documents: list[Document] evidence: str answer: str | None
def rewrite_node(state: RagState) -> dict: query = rewrite_chain.invoke({ "question": state["question"], "previous_docs": summarize_docs(state.get("documents", [])), }) return {"rewritten_query": query}
def retrieve_node(state: RagState) -> dict: docs = retriever.invoke(state["rewritten_query"] or state["question"]) return {"documents": docs}
def rerank_node(state: RagState) -> dict: selected = reranker.compress_documents( documents=state["documents"], query=state["rewritten_query"] or state["question"], ) return {"documents": selected, "evidence": format_evidence(selected)}
def answer_node(state: RagState) -> dict: answer = grounded_answer_chain.invoke({ "question": state["question"], "evidence": state["evidence"], }) return {"answer": answer}这条链路的输入输出更显式:每个节点只返回状态增量,Document 在 documents 字段中保留对象形态,只有进入 answer_node 前才被格式化为 evidence 字符串。这样做适合强可控 RAG:例如必须先改写 query、必须 rerank、必须引用证据时,显式图比完全交给 Agent 决策更容易审计。
源码观察点是 LangGraph 的节点返回值语义:节点通常不需要返回完整 state,而是返回要合并的字段。documents、evidence 和 answer 是不同层级的对象,不应该全部塞进 prompt;只有 evidence 是准备给模型看的上下文,documents 更适合保留在 state 或 trace 中。
框架允许两种写法并存,是因为 Agentic RAG 有不同控制强度。工具型写法让模型决定“何时检索”;图节点写法让应用决定“必须如何检索”。生产系统通常会混合:低风险问答交给工具型 Agent,高风险答案生成前走显式 rerank 与 grounding 节点。
正常结束条件
一次 Agentic RAG 正常结束,至少要满足三件事:如果模型发起了检索,所有 tool_call_id 都有对应 ToolMessage;进入最终回答的证据已经过格式化、筛选或压缩;最终回答没有绕过 grounding 约束直接编造不可追溯结论。
如果模型没有发起检索,也不一定是错误。真正要观察的是决策理由:问题是否可以直接回答、系统提示是否要求遇到外部事实必须检索、middleware 是否拦截了工具调用,以及 trace 中是否记录了“未检索”的原因。
4. 源码地图、关键文件与阅读顺序
核心目录
langgraph/├── graph/├── pregel/├── types.py└── func/
langchain_core/├── runnables/├── tools/├── retrievers.py└── messages/关键文件
| 优先级 | 文件 | 核心对象 | 阅读目的 |
|---|---|---|---|
| 1 | libs/core/langchain_core/retrievers.py | BaseRetriever | 理解 retriever Runnable 协议 |
| 2 | libs/core/langchain_core/tools/retriever.py | create_retriever_tool | 理解检索工具包装 |
| 3 | libs/core/langchain_core/tools/base.py | BaseTool | 理解工具执行边界 |
| 4 | libs/langgraph/langgraph/graph/state.py | StateGraph | 理解 RAG 工作流编排 |
推荐阅读顺序
1. 先读官方概念文档,确认公共契约。2. 再读公开 API 或装饰器入口。3. 顺着构建期对象进入 runtime。4. 追踪一次 invoke / stream / tool call / task call。5. 单独检查异常、重试、恢复和扩展点。6. 最后回到旅行规划助手做工程判断。不建议的阅读顺序
不建议直接从最底层 private helper 开始读。源码学习的第一目标是建立调用链,而不是收集函数名。先找公开入口,再沿参数和返回值追下去,才不会把内部实现误当成稳定 API。
5. 对象模型、继承关系与协议边界
核心对象关系
Public API / Decorator / Tool Protocol ↓Runtime wrapper ↓State / Config / Context ↓Storage / Model / Tool / Graph Runtime对象职责
| 对象 | 生命周期 | 输入 | 输出 | 核心职责 |
|---|---|---|---|---|
| 公开入口 | 构建期或调用期 | 用户参数 | runtime object | 提供稳定 API |
| runtime context | 单次调用 | config/state | 下游上下文 | 传递配置、状态和观测信息 |
| 协议对象 | 单步执行 | 上游对象 | 下游可消费结果 | 维持模块边界 |
| 扩展点 | 构建期注册、运行时触发 | request/response | 修改或观察后的结果 | 插入业务控制逻辑 |
协议边界
公共 API 负责:给业务代码稳定入口。 不负责:暴露所有内部调度细节。
运行时协议 负责:让状态、配置、工具、模型和持久化协作。 不负责:替业务判断所有策略。
扩展接口 负责:允许业务插入可维护的定制逻辑。 不负责:保证错误扩展仍然安全。稳定接口与内部实现
| 类型 | 对象 | 文章中的使用原则 |
|---|---|---|
| 公共 API | 官方文档列出的函数、类和装饰器 | 可以用于工程示例 |
| 扩展接口 | middleware、store、retriever、task、Command 等协议 | 说明契约和约束 |
| 内部实现 | private helper、runner、loop 细节 | 只用于解释,不建议业务依赖 |
6. 源码阅读策略与证据标准
本篇阅读策略
先找公开入口 ↓确认输入输出类型 ↓沿调用方追到核心实现 ↓记录状态与对象变化 ↓检查分支、异常和结束条件 ↓回到设计目的证据等级
| 标记 | 含义 | 写作要求 |
|---|---|---|
| 源码事实 | 当前正式版源码可以证明 | 附源码链接 |
| 官方契约 | 官方文档或 API Reference 明确说明 | 附官方链接 |
| 简化伪代码 | 压缩真实控制流 | 标注不是源码逐字复制 |
| 作者推断 | 根据调用链得出的理解 | 明确使用“从调用关系可以推断” |
| 工程建议 | 面向项目实践的建议 | 说明适用条件 |
本篇证据清单
| 结论 | 证据类型 | 文件或文档 | 定位 |
|---|---|---|---|
| 公共入口存在稳定契约 | 官方契约 | 官方 docs / reference | 第 14 章链接 |
| 核心协议对象存在源码定义 | 源码事实 | 官方源码仓库 | 第 4 章关键文件 |
| 运行时分支需要按协议处理 | 作者推断 | 调用链和源码结构 | 第 9 章 |
| 工程选型依赖场景边界 | 工程建议 | 旅行规划助手场景 | 第 11 章 |
7. 构建期源码解剖
本章回答:retriever、retriever tool、RAG graph 在构建期如何形成可运行对象?重点不是背 API,而是看清每个对象被装配后会在运行时承担哪一种协议角色。
构建期职责
| 输入 | 归一化动作 | 构建结果 |
|---|---|---|
BaseRetriever 子类或实例 | 确认输入是 query,输出是 list[Document] | 可被 invoke/batch/ainvoke 调用的 Runnable |
create_retriever_tool(...) 参数 | 绑定工具名、描述、文档格式化器 | Agent 可调用的 BaseTool |
| RAG state schema | 声明 query、documents、evidence、answer 字段 | 图运行时可合并的状态协议 |
| rerank / grounding 策略 | 作为普通 Runnable、节点或工具插入 | 检索后处理链 |
BaseRetriever 协议
真实源码签名
class BaseRetriever(RunnableSerializable[str, list[Document]]): ...细粒度伪代码
以下为保留关键控制流的简化伪代码,不是源码逐字复制:
from langchain_core.documents import Documentfrom langchain_core.retrievers import BaseRetriever
class TravelPolicyRetriever(BaseRetriever): index: TravelIndex city: str
def _get_relevant_documents(self, query: str, *, run_manager) -> list[Document]: raw_hits = self.index.search(query=query, filter={"city": self.city}, limit=20)
documents: list[Document] = [] for hit in raw_hits: if hit.score < 0.35: continue documents.append(Document( page_content=hit.text, metadata={ "source": hit.url, "title": hit.title, "score": hit.score, "city": self.city, }, ))
return documents
retriever = TravelPolicyRetriever(index=travel_index, city="Osaka")这段伪代码的输入是一个 query 字符串,输出是 list[Document]。对象形态不是 list[str],也不是数据库原始 hit,而是 LangChain 标准 Document:正文进入 page_content,可追踪信息进入 metadata。后续 rerank、格式化、引用和 trace 都依赖这个形态。
源码观察点是 BaseRetriever 继承 RunnableSerializable[str, list[Document]]。这意味着业务只实现检索核心方法,就可以获得统一的 invoke、ainvoke、batch、config 传递和 callback 生命周期。不要把 retriever 写成普通函数后到处手动调用,否则会绕开框架的可观测性与组合能力。
框架这样设计,是为了把“检索后端差异”关在 retriever 内部。向量库、关键词搜索、远程 API 可以完全不同,但上游只看到 query -> list[Document]。这也是后续把 retriever 包成 tool 或图节点的前提。
retriever tool 包装
真实源码签名
create_retriever_tool(retriever, name, description)细粒度伪代码
以下为保留关键控制流的简化伪代码,不是源码逐字复制:
from langchain_core.tools import create_retriever_tool
retrieval_tool = create_retriever_tool( retriever=retriever, name="travel_knowledge_search", description=( "Search verified travel documents. Use this when the answer depends " "on current opening hours, transportation rules, ticket policy, or local constraints." ), document_prompt=document_prompt, document_separator="\n\n---\n\n",)def simplified_retriever_tool(query: str) -> str: documents = retriever.invoke(query) rendered_documents = []
for doc in documents: rendered_documents.append(document_prompt.format( page_content=doc.page_content, source=doc.metadata.get("source", "unknown"), title=doc.metadata.get("title", "untitled"), ))
return document_separator.join(rendered_documents)这里的输入输出发生了关键转换:构建期输入是 BaseRetriever,构建期输出是 Agent 可见的工具;运行时工具输入是 query 字符串,工具输出通常是格式化后的字符串。Document 对象不会自动原样暴露给模型,而是经过 document_prompt 和 document_separator 转成可读证据。
源码观察点是工具名和描述。Agent 决定是否检索时,主要看到的是 tool schema、name 和 description,而不是 retriever 的内部实现。如果描述写得太泛,模型可能在不需要时乱查;如果描述写得太窄,模型可能在需要外部事实时不查。
框架把 retriever 包成 tool,是为了复用 Tool Calling 的运行时:AIMessage.tool_calls、ToolMessage.tool_call_id、ToolNode、callback、错误处理都不需要为检索单独发明一套协议。检索因此从“应用代码中的函数调用”变成“Agent 可决策的动作”。
RAG graph 构建
细粒度伪代码
以下为保留关键控制流的简化伪代码,不是源码逐字复制:
from typing import TypedDictfrom langgraph.graph import StateGraph, START, END
class TravelRagState(TypedDict): question: str rewritten_query: str | None documents: list[Document] evidence: str answer: str | None retrieval_attempts: int
builder = StateGraph(TravelRagState)builder.add_node("rewrite_query", rewrite_query_node)builder.add_node("retrieve", retrieve_node)builder.add_node("rerank", rerank_node)builder.add_node("answer", grounded_answer_node)
builder.add_edge(START, "rewrite_query")builder.add_edge("rewrite_query", "retrieve")builder.add_edge("retrieve", "rerank")builder.add_conditional_edges("rerank", route_after_rerank, { "answer": "answer", "rewrite_again": "rewrite_query", "refuse": END,})builder.add_edge("answer", END)
rag_graph = builder.compile()这段伪代码的输入是状态 schema 和节点函数,输出是编译后的 graph。对象形态从“Python 函数集合”变成“运行时可以调度的图对象”;每个节点只负责返回自己的状态增量,例如 {"documents": docs} 或 {"evidence": text}。
源码观察点是 add_conditional_edges。Agentic RAG 不只是线性链路,低质量检索结果可能回到 query rewrite,证据不足可能直接拒答,上下文过长可能进入 rerank 或 compression。条件边是把这些分支写成可观测运行时结构的地方。
框架这样设计,是为了让检索链路可恢复、可暂停、可审计。相比在一个大函数里 if/else 到底,图结构能明确显示每一步的输入输出,也便于 checkpoint 保存到“已经检索但尚未回答”的位置。
构建期产物
| 产物 | 保存的信息 | 运行时用途 |
|---|---|---|
BaseRetriever | 检索后端、过滤条件、返回 Document 的契约 | 执行 query 到文档对象的转换 |
BaseTool | 名称、描述、args schema、文档格式化方式 | 让 Agent 通过 tool call 触发检索 |
StateGraph | 状态 schema、节点、边、条件分支 | 显式编排 rewrite/retrieve/rerank/answer |
| rerank / grounding chain | 排序、压缩、证据注入策略 | 控制哪些文档进入最终 prompt |
8. 运行时主链源码解剖
本章回答:构建完成后,一次调用如何进入检索、rerank、grounding,并产生可追踪结果?这里要把 query、Document、ToolMessage、AIMessage 的对象边界分清。
运行时入口
| 调用方式 | 公开入口 | 核心运行对象 | 返回类型 |
|---|---|---|---|
| Agent 调用 | agent.invoke({"messages": [...]}) | model + tools + ToolNode | state 或最终消息 |
| retriever 调用 | retriever.invoke(query) | BaseRetriever | list[Document] |
| 显式图调用 | rag_graph.invoke(state) | StateGraph runtime | 合并后的 state |
| 流式观测 | stream() / astream() | graph / runnable events | chunk / event |
检索主链
细粒度伪代码
以下为保留关键控制流的简化伪代码,不是源码逐字复制:
def retrieve_node(state: TravelRagState, config: RunnableConfig) -> dict: query = state["rewritten_query"] or state["question"]
documents = retriever.invoke( query, config={ **config, "metadata": { **config.get("metadata", {}), "rag_stage": "retrieve", "query": query, }, }, )
return { "documents": documents, "retrieval_attempts": state.get("retrieval_attempts", 0) + 1, }输入是图状态和 config,输出是状态增量。query 在这里已经是检索表达,不一定等于用户原问题;documents 是 Document 对象列表,不应该在这个节点直接拼成 prompt 字符串。对象形态保留下来,后续才能基于 metadata.score、source、title 做 rerank、引用和过滤。
源码观察点是 config 传递。LangChain 的 Runnable 协议允许 metadata、tags、callbacks 沿调用链下传;检索节点把 rag_stage 和 query 写进 metadata,可以让 trace 明确显示哪次检索来自哪次改写。
框架这样设计,是为了让检索成为可组合 Runnable,而不是隐藏在 prompt 构造里的副作用。只要 retriever 保持 str -> list[Document] 契约,它就能放进 tool、graph node、middleware 或测试替身里。
query rewrite 运行时
细粒度伪代码
以下为保留关键控制流的简化伪代码,不是源码逐字复制:
def rewrite_query_node(state: TravelRagState) -> dict: rewrite_input = { "question": state["question"], "failed_query": state.get("rewritten_query"), "retrieval_attempts": state.get("retrieval_attempts", 0), "known_sources": [doc.metadata.get("source") for doc in state.get("documents", [])], }
rewritten = rewrite_chain.invoke(rewrite_input)
if not rewritten.query.strip(): return {"rewritten_query": state["question"]}
return { "rewritten_query": rewritten.query, "query_reason": rewritten.reason, }这段伪代码的输入不是完整 state,而是筛选后的 query rewrite 上下文:原问题、失败 query、尝试次数、已知来源。输出是新的 rewritten_query,不是答案。对象形态从业务 state 变成结构化 query 对象,再回写为 state 字段。
源码观察点是 query rewrite 与 final answer 的边界。rewrite chain 可以用模型,但它的任务只是生成可检索表达,例如补齐城市、时间、实体别名;它不应该引用不存在的证据,也不应该提前替用户下结论。
框架允许把 rewrite 写成 Runnable,是因为它可以被重试、替换或单测。低召回时回到 rewrite,比盲目扩大 top_k 更可控;但必须保留原问题和失败 query,否则后续 trace 很难解释为什么检索结果变化。
Document 转换、rerank 与 compression
细粒度伪代码
以下为保留关键控制流的简化伪代码,不是源码逐字复制:
def rerank_node(state: TravelRagState) -> dict: query = state["rewritten_query"] or state["question"] documents = state.get("documents", [])
scored = [] for doc in documents: score = reranker.score(query=query, text=doc.page_content) scored.append((score, doc))
selected = [] for score, doc in sorted(scored, key=lambda item: item[0], reverse=True): if score < 0.45: continue selected.append(Document( page_content=compress(doc.page_content, query=query), metadata={**doc.metadata, "rerank_score": score}, )) if token_count(selected) >= 1800: break
evidence = format_evidence(selected) return {"documents": selected, "evidence": evidence}输入是原始 Document 列表,输出仍然保留 Document 列表,同时额外生成 evidence 字符串。对象形态没有被过早压扁:documents 用于机器处理和追踪,evidence 用于 prompt 注入。把两者分开,能避免“模型看到的证据”和“系统记录的原始文档”混淆。
源码观察点是 rerank 后的 metadata。rerank_score、原始 source、title 都应继续保留,否则最终引用和错误分析会断链。很多 RAG 问题不是 retrieve 失败,而是 retrieve 成功后在压缩、排序或格式化阶段丢了来源。
框架本身不强制你使用某个 reranker,但通过 Document 协议让 rerank 成为普通后处理步骤。这样设计的好处是:你可以把 rerank 放在 retriever 内部、单独节点、middleware 或 tool wrapper 里,但上游和下游看到的仍是统一对象。
grounding 生成链
细粒度伪代码
以下为保留关键控制流的简化伪代码,不是源码逐字复制:
def grounded_answer_node(state: TravelRagState) -> dict: if not state.get("evidence"): return {"answer": "我没有检索到足够可靠的资料,不能基于证据回答。"}
prompt_value = grounded_prompt.invoke({ "question": state["question"], "evidence": state["evidence"], "citation_policy": "Every factual claim must cite a source id from evidence.", })
ai_message = model.invoke(prompt_value)
if not citations_are_supported(ai_message.content, state["documents"]): return {"answer": repair_or_refuse(ai_message.content, state["documents"])}
return {"answer": ai_message.content}输入是用户问题和格式化后的 evidence,输出是最终 answer。这里 PromptValue 和 AIMessage 是模型链路对象,而 Document 仍然用于校验引用是否被证据支持。不要把 grounding 理解成“模型一定正确”,它只是把生成约束和校验点显式化。
源码观察点是 prompt 注入边界。只有 evidence 进入模型上下文,documents 的完整 metadata 不一定全部进入 prompt。这样可以控制上下文预算,也减少敏感 metadata 暴露给模型的风险。
框架这样设计,是为了让 grounding 成为可替换策略:可以是严格 citation 检查、低置信拒答、二次模型验证,也可以是人工审核。关键是最终答案必须能回到 Document.metadata.source,否则 Agentic RAG 只是在工具调用外面包了一层故事。
Retry、Fallback 与恢复边界
| 机制 | 适用条件 | 不适用条件 | 幂等要求 |
|---|---|---|---|
| Retry | 临时网络、模型或检索后端失败 | 已产生不可重复副作用 | retriever / reranker 需要可重复执行 |
| Fallback | 主索引不可用或召回为空 | 安全要求不能降低 | fallback 数据源仍需来源标注 |
| Repair | 引用格式错误或答案遗漏证据 | 证据本身不足 | 只修复表达,不伪造证据 |
| Human review | 高风险建议或低置信答案 | 低风险自动问答 | 保留 query、docs、draft answer |
9. 关键分支、异常与边界
Agentic RAG 的分支重点不是“有没有异常”,而是检索质量、上下文预算、证据可信度和 Agent 决策是否符合策略。每个分支都应该留下可解释状态,而不是静默走到最终回答。
Agent 不检索分支
细粒度伪代码
以下为保留关键控制流的简化伪代码,不是源码逐字复制:
def route_after_model(ai_message: AIMessage, state: dict) -> str: if ai_message.tool_calls: return "run_tools"
if requires_external_facts(state["question"]): return "force_rewrite_then_retrieve"
return "final"输入是模型第一轮输出和当前 state,输出是路由标签。AIMessage 没有 tool_calls 只说明模型没有主动请求工具,不等于系统必须接受直接回答。业务策略仍然可以判断“这个问题涉及营业时间、交通规则、票务政策,必须检索”。
源码观察点是模型决策与应用策略的边界。Tool Calling 给了模型选择工具的能力,但 middleware、router 或 graph edge 仍然可以覆盖决策。生产系统常常需要这种双层控制:模型负责语义判断,应用负责风险边界。
空结果与低相关性分支
细粒度伪代码
以下为保留关键控制流的简化伪代码,不是源码逐字复制:
def route_after_retrieve(state: TravelRagState) -> str: documents = state.get("documents", []) attempts = state.get("retrieval_attempts", 0)
if not documents and attempts < 2: return "rewrite_query"
if not documents: return "refuse"
best_score = max(doc.metadata.get("score", 0.0) for doc in documents) if best_score < 0.35 and attempts < 2: return "rewrite_query"
if best_score < 0.35: return "refuse"
return "rerank"输入是检索后的 state,输出是下一步节点。对象形态没有变化,但状态语义发生变化:retrieval_attempts 用来阻止无限改写,score 用来判断召回质量,documents 为空时不应该直接让模型自由回答。
源码观察点是停止条件。Agentic RAG 容易陷入“查不到就换个 query 再查”的循环,所以必须有 attempt 上限、score 阈值和拒答路径。LangGraph 的条件边适合表达这些保护边界。
框架这样设计的价值在于失败可解释。用户看到的是“没有足够证据”,trace 里能看到原 query、改写 query、尝试次数、最高分和数据源。比起让模型编一个看似完整的答案,这种失败更容易被产品和数据团队修复。
上下文过长分支
细粒度伪代码
以下为保留关键控制流的简化伪代码,不是源码逐字复制:
def prepare_evidence(documents: list[Document], query: str, budget: int) -> tuple[list[Document], str]: ranked = rerank(query=query, documents=documents) selected: list[Document] = []
for doc in ranked: compressed = compress(doc.page_content, query=query) candidate = Document(page_content=compressed, metadata=doc.metadata)
if token_count(format_evidence([*selected, candidate])) > budget: continue
selected.append(candidate)
return selected, format_evidence(selected)输入是文档对象列表和上下文预算,输出是筛选后的文档对象及 evidence 字符串。这里的关键不是简单截断,而是在保留 metadata 的前提下压缩正文;否则最终答案可能引用不到来源。
源码观察点是 token budget 与 citation 的张力。压缩过度会丢证据,保留过多会超过上下文窗口。框架不替你决定取舍,但 Document 协议让你可以在压缩后继续保存来源、分数和标题。
工具错误与格式错误分支
| 条件 | 行为 | 状态结果 |
|---|---|---|
tool args 缺少 query | 返回工具错误或让模型修复参数 | 追加错误 ToolMessage,不访问后端 |
| retriever 后端超时 | retry 或 fallback 到备用索引 | 记录 retrieval_error 与数据源 |
返回对象不是 Document | 在 retriever 边界抛错 | 防止坏对象进入 rerank |
ToolMessage.tool_call_id 缺失 | 拒绝合并工具结果 | 防止多工具结果错配 |
| grounding 引用不在 evidence 中 | repair 或 refuse | 不输出无来源事实 |
能力边界
| 容易误判的能力 | 实际提供者 | 本篇对象的真实职责 |
|---|---|---|
| 自动保证答案正确 | 评测、grounding、人工审核 | 提供证据进入和校验的运行时边界 |
| 自动保证检索足够全面 | 索引质量、query rewrite、rerank | 提供可重试和可观测链路 |
| 自动保证安全合规 | middleware、权限系统、数据治理 | 提供工具调用与证据注入插入点 |
10. 扩展机制与框架协作
本章回答:业务代码应该在哪里扩展 Agentic RAG,而不是改内部 runtime?核心原则是保留协议边界:retriever 负责返回 Document,tool wrapper 负责 Agent 调用,rerank/grounding 负责证据筛选和回答约束。
扩展点总览
| 扩展点 | 扩展方式 | 执行时机 | 可修改内容 | 约束 |
|---|---|---|---|---|
| 自定义 retriever | 继承 BaseRetriever | 检索时 | query 到 Document 的转换 | 不直接返回字符串列表 |
| query rewrite | Runnable / graph node | 检索前或低召回后 | query、过滤条件、原因 | 不生成最终答案 |
| rerank / compression | Runnable / node / wrapper | 检索后、生成前 | 文档顺序、正文长度、分数 | 保留 source metadata |
| grounding policy | prompt + validator | 生成前后 | evidence、引用、拒答策略 | 不把无证据内容包装成事实 |
| middleware / guardrail | hook / policy | 工具调用前后 | 允许的数据源、敏感信息过滤 | 保持幂等和可追踪 |
自定义 retriever
细粒度伪代码
以下为保留关键控制流的简化伪代码,不是源码逐字复制:
class TenantAwareRetriever(BaseRetriever): index: TravelIndex
def _get_relevant_documents(self, query: str, *, run_manager) -> list[Document]: tenant_id = current_config()["metadata"]["tenant_id"] allowed_sources = load_allowed_sources(tenant_id)
hits = self.index.search(query=query, sources=allowed_sources)
return [ Document( page_content=hit.text, metadata={ "source": hit.source, "tenant_id": tenant_id, "score": hit.score, }, ) for hit in hits if hit.source in allowed_sources ]输入是 query,输出仍然是 list[Document],但检索范围由 tenant、权限或业务规则控制。对象形态不变,这一点很重要:扩展业务策略不应该破坏上游和下游对 retriever 的统一预期。
源码观察点是不要把权限过滤写在 prompt 里。Prompt 只能影响模型行为,不能可靠限制后端数据访问;真正的数据源限制应该在 retriever、middleware 或后端查询层完成。
与 Memory / Middleware 协作
细粒度伪代码
以下为保留关键控制流的简化伪代码,不是源码逐字复制:
def build_retrieval_query(question: str, memory: dict, policy: dict) -> dict: query = rewrite_chain.invoke({ "question": question, "user_preferences": memory.get("travel_preferences", []), "locale": memory.get("locale", "zh-CN"), })
return { "query": query.text, "filters": { "city": query.city, "language": policy.get("allowed_languages", ["zh", "en"]), "source_type": policy.get("allowed_source_types", ["official", "partner"]), }, }输入是问题、记忆和策略,输出是检索请求对象。这里的 memory 只影响 query 和 filter,不等于把长期记忆全文塞进检索 prompt。对象形态从用户偏好变成检索约束,边界更清楚。
源码观察点是 middleware 的位置。适合在工具调用前检查数据源权限,在工具调用后过滤敏感文档,在最终回答前检查引用。不要把这些逻辑全部写进 retriever,否则 retriever 会同时承担权限、审计、生成策略,边界会变得很难维护。
Observability 与评测协作
细粒度伪代码
以下为保留关键控制流的简化伪代码,不是源码逐字复制:
def with_rag_trace(config: RunnableConfig, state: TravelRagState) -> RunnableConfig: return { **config, "tags": [*config.get("tags", []), "agentic-rag"], "metadata": { **config.get("metadata", {}), "question": state["question"], "query": state.get("rewritten_query"), "retrieval_attempts": state.get("retrieval_attempts", 0), "document_sources": [doc.metadata.get("source") for doc in state.get("documents", [])], }, }输入是 config 和 state,输出是增强后的 config。对象形态没有进入模型上下文,而是进入 trace metadata。这样既能复盘检索质量,又避免把内部诊断信息暴露给模型或用户。
框架这样设计,是为了让观测数据沿 Runnable 调用链传播。评测系统可以统计 query rewrite 命中率、空召回率、rerank 后引用率、grounding 失败率,而不需要侵入每个节点的内部实现。
选择扩展还是重写流程
| 条件 | 选择扩展点 | 选择显式图流程 |
|---|---|---|
| 只替换检索后端 | 是 | 否 |
| 需要强制 rewrite → retrieve → rerank → answer | 否 | 是 |
| 需要 Agent 自主决定何时检索 | 是 | 视策略而定 |
| 需要高风险拒答和人工审核 | 部分 | 是 |
| 需要严格引用与评测闭环 | 部分 | 是 |
11. 工程决策与适用场景
适用场景
| 场景 | 是否推荐 | 原因 |
|---|---|---|
| 旅行规划助手生产化 | 是 | 需要状态、工具、检索、观测和恢复闭环 |
| 一次性脚本问答 | 否 | 直接调用模型更简单 |
| 高风险工具调用 | 是 | 可以加入 guardrail、interrupt、trace |
| 大规模知识问答 | 是 | 可以把 retrieval、rerank、grounding 拆清楚 |
| 简单静态 FAQ | 否 | 复杂运行时收益不高 |
工程决策表
| 决策点 | 推荐选择 | 前提 | 风险 |
|---|---|---|---|
| 是否抽象成独立模块 | 有稳定职责时抽象 | 边界清楚 | 过早拆分增加复杂度 |
| 是否持久化 | 需要恢复或复盘时持久化 | 有 thread_id / user_id | 隐私和清理成本 |
| 是否加入 guardrail | 涉及外部工具或敏感数据时加入 | 有明确策略 | 误杀正常请求 |
| 是否流式观测 | 用户等待时间长时加入 | 前端能消费事件 | 泄露内部状态 |
性能、可靠性与安全边界
性能:额外抽象会增加序列化、检索、trace 和存储成本。可靠性:可恢复机制要求状态 schema、幂等工具和错误分类清楚。安全:metadata、state、tool args、retrieved docs 都可能包含敏感信息。可观测性:至少记录 thread_id、node/tool、策略命中、错误和耗时。12. 常见误区与源码纠正
误区:把公共 API 当成全部源码机制
错误原因:
公开 API 很短,看起来像全部逻辑都在这一层。
源码事实:
公开 API 通常只负责归一化和装配;真正的运行语义在 runtime、协议对象和扩展点之间流转。工程影响:
只读公开 API 会误判能力边界,导致扩展时依赖错误位置。
误区:把所有中间结果都写进模型上下文
错误原因:
Agent 运行时的 state、memory、documents、trace 看起来都像“上下文”。
源码事实:
state、store、Document、metadata、trace、prompt messages 是不同协议;只有经过明确注入的内容才进入模型上下文。工程影响:
如果不区分这些协议,轻则上下文膨胀,重则泄露敏感数据或污染长期记忆。
误区:异常都应该在框架层吞掉
错误原因:
Agent 产品希望“永远给用户一个答案”。
源码事实:
可恢复异常可以转成 retry、fallback、interrupt;不可恢复异常必须上抛并进入 trace。工程影响:
吞掉异常会让错误状态继续进入后续节点,调试成本比显式失败高得多。
13. 最终心智模型与掌握检查
构建期心智模型
Public Config ↓Normalize ↓Build Runtime Object ↓Attach Protocol / Extension / Storage运行时心智模型
Input ↓Runtime Entry ↓Context / State / Config ↓Core Execute ↓State / Event / Output分支与异常心智模型
正常路径 → 返回协议对象并更新必要状态分支路径 → 路由、降级、拒答、人工确认或恢复可恢复异常 → retry / fallback / repair / resume不可恢复异常 → trace + raise保护上限 → 停止循环、预算或高风险动作一句话总结
Agentic RAG / Retrieval Runtime通过构建期协议装配形成可运行对象,运行时沿公开入口进入核心执行链,使用分支机制处理边界,并通过扩展点与 LangChain / LangGraph 的状态、工具、模型、存储和观测能力协作。
掌握检查
- 能说清公开入口与真实执行入口的区别。
- 能画出构建期对象关系。
- 能画出运行时对象流转。
- 能解释至少一个核心函数的伪代码。
- 能指出同步、异步、流式或批量路径的边界。
- 能说明异常在哪里抛出、在哪里处理。
- 能说明正常结束和保护性终止的区别。
- 能区分公共 API、扩展接口和内部实现。
- 能根据旅行规划助手场景判断是否应该使用该抽象。
14. 参考资料与下一篇衔接
官方概念文档
-
LangChain Retrieval
https://docs.langchain.com/oss/python/langchain/retrieval -
LangChain Tools
https://docs.langchain.com/oss/python/langchain/tools -
Thinking in LangGraph
https://docs.langchain.com/oss/python/langgraph/thinking-in-langgraph
官方 API Reference
-
BaseRetriever
https://reference.langchain.com/python/langchain-core/retrievers/ -
create_retriever_tool
https://reference.langchain.com/python/langchain-core/tools/
官方源码
-
libs/core/langchain_core/retrievers.py
https://github.com/langchain-ai/langchain/blob/langchain-core==1.4.8/libs/core/langchain_core/retrievers.py -
libs/core/langchain_core/tools/retriever.py
https://github.com/langchain-ai/langchain/blob/langchain-core==1.4.8/libs/core/langchain_core/tools/retriever.py -
libs/core/langchain_core/tools/base.py
https://github.com/langchain-ai/langchain/blob/langchain-core==1.4.8/libs/core/langchain_core/tools/base.py
下一篇衔接
下一篇进入:
第 17 篇:Multi-Agent、Subagents 与 Handoffs 源码解剖需要继续回答:
这项能力在旅行规划助手中应该放在哪一层?它和前一篇能力如何协作?哪些边界需要通过源码证据确认?