8795 字
44 分钟
LangChain 源码深潜:Agentic RAG 与 Retrieval Runtime 机制解剖

LangChain源码学习路线:第 16 篇 Agentic RAG 与 Retrieval Runtime源码解剖#

核心问题: LangChain 如何把检索从一个前置步骤升级为 Agent 可规划、可调用、可重试、可观测的运行时能力?

源码主线: BaseRetriever.invoke()Documentcreate_retriever_tool()Agent tool callcontext injection / grounded answer

前置文章: 第 4 篇 Tool Calling、第 15 篇 Middleware / Guardrails

依赖基线: langgraph==1.2.7langchain==1.3.11langchain-core==1.4.8

源码基线: https://github.com/langchain-ai/langchain/tree/langchain==1.3.11、https://github.com/langchain-ai/langchain/tree/langchain-core==1.4.8、https://github.com/langchain-ai/langgraph/tree/1.2.7

阅读边界: 本文覆盖 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 可规划、可调用、可重试、可观测的运行时能力?

学习目标#

完成本篇后,读者必须能够:

  1. 画出Agentic RAG / Retrieval Runtime的构建期对象关系。
  2. 解释一次运行时调用如何进入主链。
  3. 说明关键分支、异常和停止条件。
  4. 区分公共 API、扩展接口和内部实现。
  5. 根据旅行规划助手场景做工程选型。

能力边界#

能力本篇是否覆盖说明
构建期对象组装解释公开参数如何变成运行时对象
运行时主链解释 invoke/stream/tool/task 等主路径
分支与异常解释失败、降级、重试、终止边界
扩展协作解释与相邻框架组件如何组合
底层供应商实现不展开模型、数据库或平台内部实现

2. 核心概念与最小心智模型#

一句话定义#

Agentic RAG 是把检索动作交给 Agent 决策和运行时调度的 RAG 形态,负责让模型在需要时查询知识,不负责保证知识库本身一定完整正确。

最小心智模型#

Tool Calling / Middleware
Agentic RAG / Retrieval Runtime
Multi-Agent / Handoffs
工程化 Agent 能力

核心术语#

术语源码对象语义不要误解为
RetrieverBaseRetriever根据 query 返回 Document 列表向量数据库本身
Retriever toolcreate_retriever_tool把 retriever 包成 Agent tool普通搜索字符串函数
Query rewriteRunnable / model node把用户问题改写为检索 query最终回答
Groundingprompt/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_callsToolMessage.tool_call_id 的配对。模型提出工具请求时,工具调用还没有发生;只有工具运行时消费 tool_calls 后,才会产生带 tool_call_idToolMessage。如果多个检索工具并发执行,这个 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}

这条链路的输入输出更显式:每个节点只返回状态增量,Documentdocuments 字段中保留对象形态,只有进入 answer_node 前才被格式化为 evidence 字符串。这样做适合强可控 RAG:例如必须先改写 query、必须 rerank、必须引用证据时,显式图比完全交给 Agent 决策更容易审计。

源码观察点是 LangGraph 的节点返回值语义:节点通常不需要返回完整 state,而是返回要合并的字段。documentsevidenceanswer 是不同层级的对象,不应该全部塞进 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/

关键文件#

优先级文件核心对象阅读目的
1libs/core/langchain_core/retrievers.pyBaseRetriever理解 retriever Runnable 协议
2libs/core/langchain_core/tools/retriever.pycreate_retriever_tool理解检索工具包装
3libs/core/langchain_core/tools/base.pyBaseTool理解工具执行边界
4libs/langgraph/langgraph/graph/state.pyStateGraph理解 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 Document
from 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]]。这意味着业务只实现检索核心方法,就可以获得统一的 invokeainvokebatch、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_promptdocument_separator 转成可读证据。

源码观察点是工具名和描述。Agent 决定是否检索时,主要看到的是 tool schema、name 和 description,而不是 retriever 的内部实现。如果描述写得太泛,模型可能在不需要时乱查;如果描述写得太窄,模型可能在需要外部事实时不查。

框架把 retriever 包成 tool,是为了复用 Tool Calling 的运行时:AIMessage.tool_callsToolMessage.tool_call_id、ToolNode、callback、错误处理都不需要为检索单独发明一套协议。检索因此从“应用代码中的函数调用”变成“Agent 可决策的动作”。

RAG graph 构建#

细粒度伪代码#

以下为保留关键控制流的简化伪代码,不是源码逐字复制:

from typing import TypedDict
from 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,并产生可追踪结果?这里要把 queryDocumentToolMessageAIMessage 的对象边界分清。

运行时入口#

调用方式公开入口核心运行对象返回类型
Agent 调用agent.invoke({"messages": [...]})model + tools + ToolNodestate 或最终消息
retriever 调用retriever.invoke(query)BaseRetrieverlist[Document]
显式图调用rag_graph.invoke(state)StateGraph runtime合并后的 state
流式观测stream() / astream()graph / runnable eventschunk / 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 在这里已经是检索表达,不一定等于用户原问题;documentsDocument 对象列表,不应该在这个节点直接拼成 prompt 字符串。对象形态保留下来,后续才能基于 metadata.scoresourcetitle 做 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、原始 sourcetitle 都应继续保留,否则最终引用和错误分析会断链。很多 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。这里 PromptValueAIMessage 是模型链路对象,而 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 rewriteRunnable / graph node检索前或低召回后query、过滤条件、原因不生成最终答案
rerank / compressionRunnable / node / wrapper检索后、生成前文档顺序、正文长度、分数保留 source metadata
grounding policyprompt + validator生成前后evidence、引用、拒答策略不把无证据内容包装成事实
middleware / guardrailhook / 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. 参考资料与下一篇衔接#

官方概念文档#

  1. LangChain Retrieval
    https://docs.langchain.com/oss/python/langchain/retrieval

  2. LangChain Tools
    https://docs.langchain.com/oss/python/langchain/tools

  3. Thinking in LangGraph
    https://docs.langchain.com/oss/python/langgraph/thinking-in-langgraph

官方 API Reference#

  1. BaseRetriever
    https://reference.langchain.com/python/langchain-core/retrievers/

  2. create_retriever_tool
    https://reference.langchain.com/python/langchain-core/tools/

官方源码#

  1. libs/core/langchain_core/retrievers.py
    https://github.com/langchain-ai/langchain/blob/langchain-core==1.4.8/libs/core/langchain_core/retrievers.py

  2. 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

  3. 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 源码解剖

需要继续回答:

这项能力在旅行规划助手中应该放在哪一层?
它和前一篇能力如何协作?
哪些边界需要通过源码证据确认?
LangChain 源码深潜:Agentic RAG 与 Retrieval Runtime 机制解剖
https://jupiter-ws.cn/posts/agent-frameworks/16_langchain_agentic_rag_retrieval_runtime_source_deep_dive/
作者
Jupiter
发布于
2026-03-20
许可协议
CC BY-NC-SA 4.0