LangGraph 入门教程:构建 AI 工作流 [ 案例三 ]

【案例三】基于 LangGraph 实现的代理式 RAG(检索增强生成)系统


案例介绍

接下来我们开始讲解第三个案例。这个案例的核心,就是把我们之前用 LangChain 实现的普通 RAG 检索增强生成系统进行全面升级,改用 LangGraph 来搭建一套复杂版、智能闭环的 RAG 系统

在正式动手编码实现之前,我们先简单回顾一下 LangChain 篇章里我们做过的基础 RAG 架构,相信大家对这套基础流程已经不陌生了。

传统 RAG 整体可以拆分为两大核心环节:离线数据存储在线实时检索 。首先是离线数据存储:我们手头会有一批本地文档库或是线上知识库,第一步要做的就是把这些文档加载到程序内存中;加载完成后对长文档进行文本分割,再通过嵌入模型把分割后的文本向量化,最终持久化存入向量数据库。整个离线流程只需要执行一次,后续新增文档时再增量更新即可。

离线向量库准备好之后,就进入在线检索环节 ,这是高频反复执行的过程。用户每提一个问题,就要基于向量数据库做一次检索,有多少个问题就会触发多少次检索。在 LangChain 中,框架已经帮我们封装好了向量数据库和检索器能力,我们可以直接基于向量库创建检索器,借助检索器就能完成知识库的相似度查询。

但大家有没有发现传统 LangChain RAG 存在明显短板? 我们当时测试时,只用和文档高度相关的特定专属问题去提问,检索结果才会准确;可如果输入无关问题,比如 "你好""1+1 等于几" 这类和知识库毫无关联的内容,传统 RAG 依旧会强行去向量库检索,最终返回杂乱、不准确的无效结果。

这种面对外部无关输入、极易输出错误结果的现象,专业上叫做系统鲁棒性差

还记得我们在 LangChain 篇章中,编写过 LCEL 链式的 RAG 系统。整个系统就是一条直线:

它适合初学者,因为:

  • 概念少:只需要理解 Runnable, StrOutputParser 等基础概念
  • 逻辑简单:线性流程,易于理解和调试
  • 代码直观:一眼就能看懂数据流动
  • 快速上手:几分钟就能跑通整个流程

而在 LangGraph 篇章中,我们将会构建一个智能的文档问答系统(复杂版),它能够根据检索结果的质量动态调整查询策略。逻辑如下图所示:

举个例子:这里我们提供了计算机相关宣传文档,作为知识库。

bash 复制代码
C++开发方向.md
Java开发方向.md
测试开发方向.md
企业介绍.md

可以支持我们询问:

  • "比特提供了哪些知识体系"
  • "Java 开发方向的课程安排"
  • "测试开发方向的主线课程有哪些"
  • "C++ 开发方向的项目列表"
  • "Redis 课程内容是什么"
  • ......

当用户询问 "比特提供了哪些课程" 时,根据要求,会经历:

  1. 模型决定调用检索工具,搜索 "比特课程"
  2. 检索到相关文档后进行评估
  3. 如果文档相关,直接生成答案
  4. 如果文档不相关,重写问题后重新检索

这个设计确保了即使初次检索不成功,系统也能通过重写查询来改进结果质量,比传统 RAG 系统更加鲁棒。

这里跟大家厘清两个容易混淆的概念:鲁棒性稳定性

  • 稳定性:侧重系统出错之后的恢复能力,系统即便出现异常、报错,也能通过回滚、异常兜底等策略快速恢复正常运行;
  • 鲁棒性 :侧重系统抵御外部干扰的能力,在杂乱输入、无关请求、异常场景下,尽量从源头避免出错,不用强行做无效检索和错误应答。

简单总结: 稳定性是出错后能快速救回来 ,鲁棒性是不容易被干扰出错,一套成熟的智能系统,这两个特性都缺一不可。

而我们这次用 LangGraph 升级 RAG,核心目标就是提升系统鲁棒性 ,把原本简单线性的链式 RAG,改造成具备智能决策、结果校验、问题重写、循环检索能力的闭环架构。

因此,相较于 LangChain 篇章中链式的 RAG:"怎么让 RAG 跑起来",该案例将学习 "怎么让 RAG 跑得更好、更智能"!


编码思路

接下来我们先拆解本次升级版 RAG 的核心业务流程,理解清楚流程和架构图之后,后续编码会非常顺畅。

整套系统依旧是用户输入问题开始、模型输出答案结束 ,但中间增加了多层智能判断逻辑,专门适配两类用户提问场景:第一类:无关性问题 (如闲聊、数学计算题等);第二类:知识库相关特定问题

首先,用户输入任意问题后,第一步先交给大模型智能决策 :判断当前问题是否需要调用知识库检索。如果是闲聊、无关问题,无需检索向量库,模型直接给出回答即可,避免无效检索,这就从源头提升了鲁棒性;这一步的逻辑,和我们上一个案例中 "大模型判断是否需要调用搜索工具" 的思路完全一致。

如果模型判定问题和知识库相关、需要检索,就进入文档检索流程,调用提前封装好的检索器,从向量数据库中匹配相关文档。

检索出文档后,我们不再直接生成答案,而是增加检索结果质量筛查 环节:由大模型评估检索到的文档和用户问题是否匹配、信息是否充足有效。

  • 文档质量合格、信息匹配:直接基于检索文档整合信息,生成标准答案返回给用户;
  • 文档质量不合格、信息不匹配 :触发问题重写策略,让大模型基于原始问题重新优化措辞、梳理语义,生成更精准的新问题;重写后的新问题,会重新回到最开始的决策节点,再次走一遍「决策 --- 检索 --- 质检」的完整流程,直到检索出合格文档为止。

基于这套流程,我们就能梳理出 LangGraph 架构里需要设计的四大核心节点两条条件分支边

  1. 决策节点:绑定检索工具,接收用户问题,由大模型判断是否需要调用检索工具,输出带或不带工具调用标识的消息;
  2. 检索工具节点:专门执行检索动作,调用检索器从向量库获取匹配文档;
  3. 答案生成节点:针对质检合格的检索文档,整合信息生成最终标准回答;
  4. 问题重写节点:针对质检不合格的检索结果,重新优化用户提问话术。

两条条件边负责流程路由:

**第一条条件边:**根据决策节点输出的消息,判断是否带有工具调用标识,无标识则直接结束流程、回复用户;有标识则进入检索节点;

第二条条件边: 检索完成后,对文档做质量校验,合格则走向答案生成节点 ,不合格则走向问题重写节点,重写后回流到决策节点形成闭环。

同时我们遵循 LangGraph 的设计思想:每个节点只做单一独立任务 ,职责拆分清晰。决策只负责判断、检索只负责查文档、重写只负责优化问题、生成只负责整合答案,不把多个逻辑塞进同一个节点。这样架构可读性更强、后续功能扩展和维护也更方便。

梳理完架构和流程,再看 LangGraph 效果图就能完全对应上:

**实线是固定流转边,虚线是智能条件分支边;**从入口开始,经过决策分支分流,无关问题直接终止,相关问题进入检索,再经质检分支分流,合格生成答案、不合格重写回流,形成完整闭环。

架构流程理清后,我们接下来就进入编码环节。整体编码分为五步:

第一步:准备工作,加载本地业务文档、完成文档分割、嵌入向量化,搭建好向量知识库,这部分复用 LangChain 离线存储代码,无需大幅改动;

第二步:定义全局状态,维护流程中的对话消息上下文;

第三步:拆分并编写四大业务节点函数,每个节点实现专属逻辑;

第四步:创建图结构,添加固定边和两条条件分支边,搭建完整工作流;

第五步:编译图并运行测试,验证无关问题、相关问题、检索不合格重写等各类场景效果。

以上就是我们本次 LangGraph 复杂版 RAG 案例的完整背景、设计思路、流程拆解和编码规划,接下来我们正式开始逐行编码落地。


其实简单来说:构建一个智能文档问答系统,我们要将复杂流程分解为离散步骤,且通过共享状态连接各个节点,核心设计思路如下:

  • 模块化设计:每个节点只做一件事,职责清晰
  • 质量闭环:检索 → 检查 → 优化 → 再检索,确保答案质量
  • 智能路由:AI 自主决定下一步行动,无需人工干预

因此,我们需要:

第一步:准备 "知识库"(数据加载与处理)

第二步:创建 "检索工具"

第三步:设计 "工作流程节点"

  • 节点 1:决策节点 generate_query_or_respond
  • 节点 2:检索器工具节点 retrieve
  • 节点 3:问题优化节点 rewrite_question
  • 节点 4:答案生成节点 generate_answer

第四步:组装 "工作流水线"

  • 条件边 1:LLM 决策是否需要进行知识库检索
  • 条件边 2:检测【检索到的文档】是否与【问题】相关

代码实现


接下来我们就按照之前设计的流程,一步步落地编码实现 LangGraph 复杂版 RAG 系统。整个编码分为两大块:准备工作(搭建向量库与检索工具)LangGraph 核心流程编码(状态、节点、图结构、编译运行),我们逐个模块拆解讲解。

步骤一:准备 "知识库" 并创建 "检索工具"

首先要完成基础准备 ------ 将本地 4 个相关文档(企业介绍、C++/Java/ 测试方向课程文档)转化为可检索的向量库,并封装成检索工具。这部分复用 LangChain 离线存储逻辑,我们快速过一遍核心步骤。

  1. 文档加载与向量化:先通过 Markdown 加载器读取本地文档,用文本分割器按设定的 chunk 大小拆分长文本,再通过嵌入模型将分割后的文本向量化,最终存入内存级向量数据库(仅用于演示,实际可替换为 Pinecone、Milvus 等持久化数据库)。

  2. 创建检索器与工具封装 :向量库搭建完成后,直接调用向量数据库的 as_retriever() 方法生成基础检索器。但 LangGraph 中节点需要绑定「工具」而非原始检索器,因此需要将检索器封装为标准工具 ------ 这里用到 LangChain 1.x 版本新增的 create_retriever_tool() 方法。

    这里要说明一个版本差异:我们之前学的是 LangChain 0.3.x 版本,而 LangChain 1.x 为了整合 LangGraph 生态,将部分老版本能力迁移到了 langchain_community.tools 等新包中,create_retriever_tool() 就是迁移后的工具创建方法,作用是给检索器添加名称、描述,标准化为 LangGraph 可调用的工具。

  3. 工具测试验证 :封装完成后,我们可以通过 invoke() 方法测试检索工具,比如查询「C++ 方向有哪些课程」「比特提供了哪些课程」,打印检索结果(仅显示前 100 字符避免冗余),验证向量库是否成功存储文档、检索工具是否正常工作。测试通过后,准备工作就完成了。

python 复制代码
# 导入初始化聊天模型的函数(用于配置和创建聊天模型)
from langchain.chat_models import init_chat_model
# 导入 OpenAI 的嵌入模型(用于将文本转换为向量)
from langchain_openai import OpenAIEmbeddings
# 导入消息类型(用于定义与模型交互的消息格式)
from langchain_core.messages import HumanMessage
# 导入 Markdown 文档加载器(用于读取 Markdown 格式的文件)
from langchain_community.document_loaders import UnstructuredMarkdownLoader
# 导入文本分割器(用于将长文本切分成小块)
from langchain_text_splitters import RecursiveCharacterTextSplitter
# 导入内存向量存储(用于存储和检索文本向量)
from langchain_core.vectorstores import InMemoryVectorStore
# 导入创建检索器工具的函数(将检索器包装成工具供 Agent 使用)
from langchain_community.tools.retriever import create_retriever_tool


# ========== 1. 初始化模型 ==========

# 初始化聊天模型(使用 GPT-4o-mini,性价比高的模型)
# - "gpt-4o-mini": OpenAI 的轻量级模型,适合大多数对话和检索任务
model = init_chat_model("gpt-4o-mini")

# 初始化 OpenAI 嵌入模型
# - model="text-embedding-3-large": OpenAI 最新的嵌入模型,生成的向量维度更高,语义理解更准确
embeddings = OpenAIEmbeddings(model="text-embedding-3-large")


# ========== 2. 加载文档 ==========

# 定义需要加载的文档路径列表(比特就业课的相关介绍文档)
paths = [
    "../bit/企业介绍.md",        # 企业介绍文档
    "../bit/C++开发方向.md",     # C++ 开发方向介绍
    "../bit/Java开发方向.md",    # Java 开发方向介绍
    "../bit/测试开发方向.md",     # 测试开发方向介绍
]

# 使用 UnstructuredMarkdownLoader 加载每个 Markdown 文档
# - UnstructuredMarkdownLoader: 专门用于解析 Markdown 格式的文档加载器
# - .load(): 执行加载操作,返回 Document 对象列表
docs = [UnstructuredMarkdownLoader(path).load() for path in paths]

# 将嵌套列表展平(每个 .load() 返回一个列表,需要合并成一个列表)
# - 外层列表 doc_splits 中的每个元素是 Document 对象
docs_list = [item for sublist in docs for item in sublist]


# ========== 3. 分割文档 ==========

# 创建文本分割器(使用 tiktoken 编码器)
# - tiktoken_encoder: OpenAI 的分词器,确保按 token 数分割,而不是字符数
# - encoding_name="clk100k_base": GPT-4/3.5 系列使用的编码方式
# - chunk_size=1000: 每个块最多 1000 个 token
# - chunk_overlap=50: 块与块之间重叠 50 个 token,保持上下文连贯性
text_splitter = RecursiveCharacterTextSplitter.from_tiktoken_encoder(
    encoding_name="cl100k_base",
    chunk_size=1000,
    chunk_overlap=50
)

# 执行分割:将长文档切分成多个小块
# - 每个小块是独立的 Document 对象,保留元数据(如来源文件)
doc_splits = text_splitter.split_documents(docs_list)


# ========== 4. 创建向量存储 ==========

# 使用 InMemoryVectorStore(内存向量存储)存储文档块
# - 适合数据量不大的场景(几个文档),快速且无需外部数据库
# - from_documents: 将文档列表转换为向量并存储
# - documents=doc_splits: 分割后的小块文档
# - embedding=embeddings: 使用的嵌入模型
vectorstore = InMemoryVectorStore.from_documents(
    documents=doc_splits,
    embedding=embeddings
)


# ========== 5. 创建检索器工具 ==========

# 从向量存储创建检索器
# - as_retriever(): 将向量存储转换为检索器接口
# - search_kwargs={"k": 2}: 每次检索返回最相关的 2 个文档块
retriever = vectorstore.as_retriever(search_kwargs={"k": 2})

# 将检索器包装成 Tool(工具),供 LangChain Agent 调用
# - retriever: 检索器实例
# - "retrieve_bit": 工具的名称,Agent 通过这个名字调用工具
# - "搜索并返回有关比特就业课的信息。": 工具的描述,Agent 根据描述判断何时使用这个工具
retriever_tool = create_retriever_tool(
    retriever,
    "retrieve_bit",
    "搜索并返回有关比特就业课的信息。",
)

其中,create_retriever_tool() 用于创建一个基于文档检索的工具,参数说明:

  • retriever: BaseRetriever - 检索器实例,负责实际的文档检索
  • name: str - 工具名称,传递给语言模型,需要唯一且具有描述性
  • description: str - 工具描述,帮助语言模型理解何时使用该工具

该方法返回 Tool 对象,其继承了 BaseToolBaseTool 是所有 LangChain 工具的基础类。执行工具时可使用 invoke() 方法,而执行参数则是检索器执行参数。

python 复制代码
return Tool(
    name=name,
    description=description,
    func=func,
    coroutine=afunc,
    args_schema=RetrieverInput,
    response_format=response_format,
)

测试:

python 复制代码
# 测试
test_queries = [
    "比特提供了哪些课程",
    "Java开发方向的课程安排",
    "测试开发方向的课程安排有哪些",
    "C++开发方向的主线项目列表",
    "Redis课程内容是什么"
]

for query in test_queries:
    print("-" * 50)
    print(f"查询: {query}\n")
    result = retriever_tool.invoke({"query": query})
    # 只显示前100个字符,避免输出过长
    content_preview = result[:100] + "..." if len(result) > 100 else result
    print(f"结果预览: {content_preview}")
    print(f"结果长度: {len(result)} 字符")

步骤二:设计 "工作流程节点"

接下来是核心部分 ------ 用 LangGraph 搭建闭环工作流。遵循「定义状态 → 实现节点 → 构建图结构 → 编译运行」的固定流程,每个步骤都对应我们之前设计的架构图。

由于系统是对话式场景,核心需要维护「消息上下文列表」。LangGraph 已预定义 MessagesState 类(位于 langgraph.graph 中),内置 messages 列表用于存储对话历史(HumanMessage、AIMessage、ToolMessage),无需重复定义,直接复用即可,简化编码。

每个节点只负责单一独立任务,职责拆分清晰,便于维护和扩展。我们逐个实现并测试:

根据 Graph 示意图,设计出以下四个节点:


节点 1:决策节点 generate_query_or_respond

**功能:**绑定检索工具,让大模型判断用户问题是否需要检索。

实现逻辑: 将大模型与之前封装的检索工具绑定,接收状态中的 messages 上下文,调用模型生成响应。若问题需要检索(如「测试方向主线课程有哪些」),返回带 tool_calls 标识的 AIMessage;若为无关问题(如「你好」),直接返回无工具调用的 AIMessage。

测试验证: 直接调用节点函数,传入模拟的用户消息,观察返回结果是否正确包含 / 不包含 tool_calls,确保决策逻辑生效。

该节点核心设计:

  • 决定是直接回答还是检索文档
  • 可以使用 model.bind_tools([retriever_tool]) 让模型能够调用检索工具

代码如下:

python 复制代码
from langgraph.graph import MessagesState
def generate_query_or_respond(state: MessagesState):
    """调用模型以基于当前状态生成响应,或者简单地应用用户给定问题,它将决定使用检索工具检索。"""
    response = model.bind_tools([retriever_tool]).invoke(state["messages"])
    return {"messages": [response]}

其中 MessagesState 是 LangGraph 中给我们写好只包含 messages 的 State,可以直接使用。其源码如下:

python 复制代码
class MessagesState(TypedDict):
    messages: Annotated[list[AnyMessage], add_messages]

测试 generate_query_or_respond 方法返回值:

python 复制代码
# 测试 generate_query_or_respond方法返回值
input_messages = {
    "messages": [
        {"role": "user", "content": "比特提供了哪些课程?"}
    ]
}
generate_query_or_respond(input_messages)["messages"][-1].pretty_print()

# 打印:
# ============================== Ai Message ==============================
# Tool Calls:
#   retrieve_bit (call_hNvMsOUD3MFKEXwX1TzqahwI)
#  Call ID: call_hNvMsOUD3MFKEXwX1TzqahwI
#    args:
#      query: 比特课程

从结果看来,根据我们的输入:"比特提供了哪些课程?",LLM 决定继续检索文档,且设置好了初次的查询字符串!


节点 2:检索器工具节点 retrieve

**功能:**执行检索操作,从向量库获取相关文档。

实现逻辑: 无需手动写函数,直接使用 LangGraph 预定义的 ToolNode,将检索工具传入即可 ------ToolNode 会自动处理工具调用、结果封装,一行代码完成工具节点初始化,比案例 2 手动调用工具更简洁。

前面已经创建好了检索器工具。对于节点,可以使用 ToolNode 类来定义:

python 复制代码
from langgraph.prebuilt import ToolNode
retrieve = ToolNode([retriever_tool])

ToolNode 用于创建 LangGraph 工作流程中执行工具的节点。


节点 3:问题优化节点 rewrite_question

**功能:**当检索结果不合格时,优化用户原始问题,生成更精准的新问题。

实现逻辑:

① 从状态中提取原始用户问题(第 0 条 HumanMessage)、检索结果(ToolMessage);

② 构造提示词:让大模型基于原始问题和无效检索结果,推断语义意图,生成更具体的新问题(如将「比特课程」重写为「比特公司提供的就业课程有哪些?请说明各方向核心内容」);

③ **关键细节:**将大模型返回的 AIMessage(重写后的问题)转换为 HumanMessage,追加到上下文,确保后续流程能复用该新问题。

**测试验证:**模拟构造包含「原始问题 + 带工具调用的 AIMessage + 无效 ToolMessage」的状态,调用节点函数,观察是否生成符合要求的重写问题。

该节点主要负责当文档不相关时,重写问题以改进检索效果。代码如下:

python 复制代码
REWRITE_PROMPT = (
    "查看输入并尝试推断潜在的语义意图/含义。\n"
    "这是最初的问题:\n"
    "{question}\n"
    "\n"
    "提出一个改进后的问题:"
)

def rewrite_question(state: MessagesState):
    """重写原始用户问题"""
    messages = state["messages"]
    question = messages[0].content
    prompt = REWRITE_PROMPT.format(question=question)
    response = model.invoke([HumanMessage(content=prompt)])
    return {"messages": [{"role": "user", "content": response.content}]}

模拟【检索内容与原本问题不相关】的情况,测试一下:

python 复制代码
from langchain_core.messages import convert_to_messages
input_messages = convert_to_messages(
    [
        {
            "role": "user",
            "content": "比特提供了哪些课程?",
        },
        {
            "role": "assistant",
            "content": "",
            "tool_calls": [
                {
                    "id": "1",
                    "name": "retrieve_bit",
                    "args": {"query": "比特课程"},
                }
            ],
        },
        {
            "role": "tool",
            "content": "你好",
            "tool_call_id": "1",
        },
    ]
)
response = rewrite_question({"messages": input_messages})
print(response["messages"][-1]["content"])
# 改进后的问题可以是:"我想了解比特所提供的课程有哪些,包括课程内容、目标和学习时长。"

节点 4:答案生成节点 generate_answer

**功能:**基于合格的检索结果,生成最终标准答案。

实现逻辑:

① 提取用户原始问题(确保最终回答针对用户初始提问)和最新检索结果(最后一条 ToolMessage);

② 构造提示词:要求大模型仅用 3 句话,基于检索上下文回答问题,未知则回复「不知道」;

③ 调用大模型生成无工具调用的 AIMessage,作为最终结果追加到上下文。

该节点将基于相关文档生成最终答案,代码如下:

python 复制代码
GENERATE_PROMPT = (
    "你是负责回答问题的助手。\n"
    "你使用以下回答的上的片段来回答问题。\n"
    "如果你不知道答案,就说你不知道。\n"
    "最多用三句话回答,答案要简明扼要。\n"
    "Question: {question} \n"
    "Context: {context} \n"
)

def generate_answer(state: MessagesState):
    """生成答案"""
    # 最原始问题
    question = state["messages"][0].content
    # 问题的检索结果(保证参考答案)
    context = state["messages"][-1].content
    prompt = GENERATE_PROMPT.format(question=question, context=context)
    response = model.invoke([HumanMessage(content=prompt)])
    return {"messages": [response]}

步骤三:组装 "工作流水线"

根据下图,开始组装 "工作流水线"。


图结构直接对应我们设计的架构,包含「2 条固定边 + 2 条条件边」,用 StateGraph 类搭建:

固定边添加

  • 起始边:START → generate_query_or_respond(用户问题先进入决策节点);
  • 结束边:generate_answer → END(生成答案后流程结束);
  • 回流边:rewrite_question → generate_query_or_respond(重写后的问题重新进入决策节点,形成闭环)。
添加节点与入口点
python 复制代码
# 组装Graph
from langgraph.graph import StateGraph, START, END
from langgraph.prebuilt import ToolNode, tools_condition

workflow = StateGraph(MessagesState)
workflow.add_node("generate_query_or_respond", generate_query_or_respond)
workflow.add_node("retrieve", ToolNode([retriever_tool]))
workflow.add_node(rewrite_question)
workflow.add_node(generate_answer)

workflow.add_edge(START, "generate_query_or_respond")

条件边添加(核心逻辑):条件边实现「智能路由」,根据前序节点结果决定下一步流向:

条件边 1:LLM 决策是否需要进行知识库检索

条件边 1(决策路由):

起始节点: generate_query_or_respond

**判断逻辑:**使用 LangGraph 预定义的 tools_condition() 方法,自动判断最后一条 AIMessage 是否包含 tool_calls;

路由规则: 有工具调用 → 流向 retrieve 节点;无工具调用 → 流向 END(直接结束)。

第一条分支用来判断是否需要调用工具,如下图所示:

"是否需要调用工具" 的判断,可以使用 tools_condition() 方法来决定工作流的路由(等价于手动写的 should_continue 方法),规则如下:

  • 如果最后一条 AI 消息包含工具调用,则路由到工具执行节点;
  • 否则,请结束工作流。

该方法返回类型为 Literal["tools", "__end__"],表示:

  • 当最后一条 AI 消息包含工具调用,则返回 "tools"
  • 否则返回 "__end__"

因此,代码如下所示:

python 复制代码
workflow.add_conditional_edges(
    "generate_query_or_respond",
    # 评估 LLM 决策
    tools_condition,
    {
        "tools": "retrieve",  # 将条件输出转换为图中的节点
        "__end__": END,
    },
)

条件边 2:检测【检索到的文档】是否与【问题】相关

条件边 2(结果质检路由):

起始节点: retrieve

判断逻辑: 自定义 grade_documents 方法,让大模型评估检索结果与用户最新问题的相关性(结构化输出「yes/no」);

路由规则: 相关(yes)→ 流向 generate_answer;不相关(no)→ 流向 rewrite_question

该条件边核心在于:

评估检索到的文档与问题的相关性

可以使用【结构化输出】模型返回二元评分

  • yes:表示检索到的文档与问题相关
  • no:表示检索到的文档与问题不相关

返回下一步路由决策:"generate_answer""rewrite_question"

代码如下:

python 复制代码
# 对文档进行评估
from pydantic import BaseModel, Field
from typing import Literal

GRADE_PROMPT = (
    "你是一个评分员,评估检索到的文档与用户问题的相关性。\n "
    "以下是检索到的文档: {context} \n\n"
    "以下是用户的问题: {question} \n\n"
    "如果文档包含与用户问题相关的关键字或语义,则将其评为相关。\n"
    "给出一个二元分数"yes"或"no",以表明该文档是否与问题相关。"
)

class GradeDocuments(BaseModel):
    """使用二进制评分进行相关性检查"""
    score: str = Field(
        description="相关性评分: 如果相关则为"yes",如果不相关则为"no""
    )

def grade_documents(state: MessagesState) -> Literal["generate_answer", "rewrite_question"]:
    """确定检索到的文档是否与问题相关"""
    question = state["messages"][0].content
    context = state["messages"][-1].content

    prompt = GRADE_PROMPT.format(question=question, context=context)
    response = model.with_structured_output(GradeDocuments).invoke(
        [{"role": "user", "content": prompt}]
    )

    score = response.score
    if score == "yes":
        return "generate_answer"
    else:
        return "rewrite_question"

workflow.add_conditional_edges(
    "retrieve",
    # 评估代理决策
    grade_documents,
    ["generate_answer", "rewrite_question"],
)

添加结束点并编译

编译图 :调用 workflow.compile() 生成可执行的图实例,LangGraph 会自动校验节点和边的合法性。

python 复制代码
workflow.add_edge("generate_answer", END)
workflow.add_edge("rewrite_question", "generate_query_or_respond")

graph = workflow.compile()

运行 RAG
python 复制代码
# 运行 RAG
for chunk in graph.stream(
    {
        "messages": [HumanMessage(content="C++开发方向的项目列表")]
    }
):
    for node, update in chunk.items():
        print(f"由节点 {node} 更新消息:")
        if node != "rewrite_question":
            update["messages"][-1].pretty_print()
        print("\n\n")

结果如下:

python 复制代码
由节点 generate_query_or_respond 更新消息:
================================== Ai Message ==================================
Tool Calls:
  retrieve_bit (call_kHhk5pvL33lGheckV1VwMKKt)
 Call ID: call_kHhk5pvL33lGheckV1VwMKKt
  Args:
    query: C++开发方向的项目列表

由节点 retrieve 更新消息:
================================== Tool Message ==================================
Name: retrieve_bit
...(检索结果较长,省略显示)

由节点 generate_answer 更新消息:
================================== Ai Message ==================================
C++开发方向的项目包括多个层次的挑战项目、标准项目和极简项目。挑战项目如微服务的即时通讯系统和视频点播系统,涵盖复杂功能和技术组件;标准项目如仿QQ音乐的播放器客户端等,适合快速掌握;极简项目则如贪吃蛇游戏,适合基础薄弱的同学,总共有15个不同规格的项目可供选择。

完整代码:

python 复制代码
# ============================================================
# 导入模块(按类型分组)
# ============================================================

# ---------- 标准库 ----------
import os

# ---------- 第三方库 ----------
from dotenv import load_dotenv

# ---------- LangChain 嵌入模型 ----------
# 本地 HuggingFace 嵌入模型(免费,无需 API key)
from langchain_community.embeddings import HuggingFaceEmbeddings
# OpenAI 风格的嵌入模型(用于 OpenRouter)
from langchain_openai import OpenAIEmbeddings

# ---------- LangChain 聊天模型 ----------
from langchain_openai import ChatOpenAI

# ---------- LangChain 核心消息 ----------
from langchain_core.messages import HumanMessage, convert_to_messages

# ---------- LangChain 工具 ----------
from langchain_core.tools import create_retriever_tool

# ---------- 文档处理 ----------
from langchain_community.document_loaders import UnstructuredMarkdownLoader
from langchain_text_splitters import RecursiveCharacterTextSplitter
from langchain_core.vectorstores import InMemoryVectorStore

# ---------- LangGraph 图构建 ----------
from langgraph.graph import StateGraph, START, END
from langgraph.graph import MessagesState
from langgraph.prebuilt import ToolNode, tools_condition

# ---------- Pydantic 数据验证 ----------
from pydantic import BaseModel, Field
from typing import Literal

# ============================================================
# 环境配置
# ============================================================

# 加载 .env 文件中的环境变量(API key 等)
load_dotenv()

# ============================================================
# 1. 初始化模型
# ============================================================

# ---------- 聊天模型 ----------
# 使用 OpenRouter 聚合 API,自动选择可用的免费模型
# - base_url: OpenRouter 的 API 端点
# - api_key: 从环境变量获取
# - model: "openrouter/free" 表示自动选择免费模型
# - temperature: 0 表示确定性输出(每次结果相同)
model = ChatOpenAI(
    base_url="https://openrouter.ai/api/v1",
    api_key=os.environ["OPENROUTER_API_KEY"],
    model="openrouter/free",
    temperature=0,
)

# ---------- 嵌入模型 ----------
# 使用本地 HuggingFace 模型,无需 API key
# - model_name: 轻量级句子嵌入模型(384维)
# - device: 'cpu' 使用 CPU 运行
# - normalize_embeddings: 归一化向量,便于余弦相似度计算
embeddings = HuggingFaceEmbeddings(
    model_name="sentence-transformers/all-MiniLM-L6-v2",
    model_kwargs={'device': 'cpu'},
    encode_kwargs={'normalize_embeddings': True}
)

# 备选方案:使用 OpenRouter 的云端嵌入模型(需要测试兼容性)
# embeddings = OpenAIEmbeddings(
#     base_url="https://openrouter.ai/api/v1",
#     api_key=os.environ["OPENROUTER_API_KEY"],
#     model="nvidia/llama-nemotron-embed-vl-1b-v2:free"
# )


# ============================================================
# 2. 加载和分割文档
# ============================================================

# 定义需要加载的 Markdown 文件路径
paths = [
    "./Linux/线程的同步与互斥 [ 上 ].md",
    "./Linux/线程的同步与互斥 [ 中 ].md",
    "./Linux/线程的同步与互斥 [ 下 ].md",
]

# 加载每个文档(.load() 返回列表)
docs = [UnstructuredMarkdownLoader(path).load() for path in paths]

# 展平二维列表:将多个列表合并成一个
# 原结构:[[doc1], [doc2], [doc3]] → 新结构:[doc1, doc2, doc3]
docs_list = [item for sublist in docs for item in sublist]

# 创建文本分割器(使用 tiktoken 编码器,按 token 数分割)
# - encoding_name="cl100k_base": GPT-4/3.5 使用的编码方式
# - chunk_size=1000: 每个块最多 1000 个 token
# - chunk_overlap=50: 块之间重叠 50 个 token,保持上下文连贯
text_splitter = RecursiveCharacterTextSplitter.from_tiktoken_encoder(
    encoding_name="cl100k_base",
    chunk_size=1000,
    chunk_overlap=50
)

# 执行分割,将长文档切成小块
doc_splits = text_splitter.split_documents(docs_list)

# ============================================================
# 3. 创建向量存储和检索工具
# ============================================================

# 创建内存向量存储
# - 将文档块转换为向量并存储
# - 适合小规模数据,无需外部数据库
vectorstore = InMemoryVectorStore.from_documents(
    documents=doc_splits,
    embedding=embeddings
)

# 创建检索器
# - search_kwargs={"k": 2}: 每次检索返回最相关的 2 个文档块
retriever = vectorstore.as_retriever(search_kwargs={"k": 2})

# 将检索器包装成 LangChain 工具
# - retriever: 检索器实例
# - "retrieve_bit": 工具名称(Agent 通过此名称调用)
# - "搜索返回相关的线程同步与互斥的知识": 工具描述(帮助 LLM 决定何时使用)
retriever_tool = create_retriever_tool(
    retriever,
    "retrieve_bit",
    "搜索返回相关的线程同步与互斥的知识",
)


# ============================================================
# 4. 定义 LangGraph 工作流的各个节点函数
# ============================================================

def generate_query_or_respond(state: MessagesState):
    """
    节点1: 生成查询或响应

    作用:
    - 调用 LLM 决定是否需要检索
    - 如果 LLM 决定使用工具,会返回 tool_calls
    - 如果 LLM 认为可以直接回答,则直接返回响应

    参数:
        state: 包含消息列表的状态对象

    返回:
        更新后的消息(可能包含 tool_calls)
    """
    # 将检索工具绑定到模型,让模型知道可以调用这个工具
    # .invoke(state["messages"]) 调用模型并传入历史消息
    response = model.bind_tools([retriever_tool]).invoke(state["messages"])
    return {"messages": [response]}


def rewrite_question(state: MessagesState):
    """
    节点3: 重写问题

    作用:
    - 当检索到的文档不相关时触发
    - 根据原始问题推断语义意图,生成改进后的问题
    - 用于下一轮检索

    参数:
        state: 包含消息列表的状态对象

    返回:
        重写后的问题(作为用户消息)
    """
    # 获取原始问题(第一条消息的内容)
    messages = state["messages"]
    question = messages[0].content

    # 构建提示词,要求 LLM 重写问题
    prompt = REWRITE_PROMPT.format(question=question)

    # 调用模型生成改进后的问题
    response = model.invoke([HumanMessage(content=prompt)])

    # 返回重写后的问题(作为用户消息)
    return {"messages": [{"role": "user", "content": response.content}]}


def grade_documents(state: MessagesState) -> Literal["generate_answer", "rewrite_question"]:
    """
    决策点: 评估文档相关性

    作用:
    - 判断检索到的文档是否与用户问题相关
    - 相关 → 进入生成答案节点
    - 不相关 → 进入重写问题节点(重新检索)

    参数:
        state: 包含消息列表的状态对象

    返回:
        下一个节点的名称
    """
    # 原始问题(第一条消息)
    question = state["messages"][0].content

    # 检索到的文档内容(最后一条消息)
    context = state["messages"][-1].content

    # 构建评估提示词
    prompt = GRADE_PROMPT.format(question=question, context=context)

    # 使用结构化输出,强制模型返回 GradeDocuments 格式
    response = model.with_structured_output(GradeDocuments).invoke(
        [{"role": "user", "content": prompt}]
    )

    # 根据评分决定下一步
    score = response.score
    if score == "yes":
        return "generate_answer"  # 文档相关,生成答案
    else:
        return "rewrite_question"  # 文档不相关,重写问题重新检索


def generate_answer(state: MessagesState):
    """
    节点4: 生成最终答案

    作用:
    - 使用检索到的文档作为上下文
    - 调用 LLM 生成准确、简明的答案

    参数:
        state: 包含消息列表的状态对象

    返回:
        生成的答案(作为助手消息)
    """
    # 原始问题(第一条消息)
    question = state["messages"][0].content

    # 检索到的文档内容(最后一条消息,作为上下文)
    context = state["messages"][-1].content

    # 构建生成答案的提示词
    prompt = GENERATE_PROMPT.format(question=question, context=context)

    # 调用模型生成答案
    response = model.invoke([HumanMessage(content=prompt)])

    return {"messages": [response]}


# ============================================================
# 5. 定义提示词模板
# ============================================================

# 重写问题的提示词模板
# - 要求 LLM 分析原始问题的语义意图
# - 生成更精确、更利于检索的问题
REWRITE_PROMPT = (
    "查看输入并尝试推断潜在的语义意图/含义。\n"
    "这是最初的问题:\n"
    "{question}\n"
    "\n"
    "提出一个改进后的问题:"
)

# 评估文档相关性的提示词模板
# - 要求 LLM 判断文档是否与问题相关
# - 输出二元评分:"yes" 或 "no"
GRADE_PROMPT = (
    "你是一个评分员,评估检索到的文档与用户问题的相关性。\n "
    "以下是检索到的文档: {context} \n\n"
    "以下是用户的问题: {question} \n\n"
    "如果文档包含与用户问题相关的关键字或语义,则将其评为相关。\n"
    "给出一个二元分数"yes"或"no",以表明该文档是否与问题相关。"
)

# 生成答案的提示词模板
# - 使用检索到的上下文回答问题
# - 要求回答简明扼要(三句话以内)
# - 不知道就说不知道(避免幻觉)
GENERATE_PROMPT = (
    "你是负责回答问题的助手。\n"
    "你使用以下回答的上的片段来回答问题。\n"
    "如果你不知道答案,就说你不知道。\n"
    "最多用三句话回答,答案要简明扼要。\n"
    "Question: {question} \n"
    "Context: {context} \n"
)


# ============================================================
# 6. 定义 Pydantic 数据模型(用于结构化输出)
# ============================================================

class GradeDocuments(BaseModel):
    """
    文档相关性评分的数据模型

    用于强制 LLM 输出结构化的 JSON,便于程序解析
    """
    score: str = Field(
        description="相关性评分: 如果相关则为"yes",如果不相关则为"no""
    )


# ============================================================
# 7. 构建 LangGraph 工作流图
# ============================================================

# 创建状态图,使用预定义的 MessagesState
# MessagesState 包含 messages 字段,存储对话历史
workflow = StateGraph(MessagesState)

# ---------- 添加节点 ----------
# 节点1: 查询生成/响应决策
workflow.add_node("generate_query_or_respond", generate_query_or_respond)

# 节点2: 检索工具节点(执行文档检索)
workflow.add_node("retrieve", ToolNode([retriever_tool]))

# 节点3: 重写问题
workflow.add_node("rewrite_question", rewrite_question)

# 节点4: 生成答案
workflow.add_node("generate_answer", generate_answer)

# ---------- 添加边(定义执行顺序)----------
# 从 START 开始,进入 generate_query_or_respond
workflow.add_edge(START, "generate_query_or_respond")

# 条件边1: 根据 generate_query_or_respond 的决策分支
# - tools_condition: 检查是否调用了工具
# - 如果调用了工具 → 进入 "retrieve"
# - 如果没有调用工具 → 结束(END)
workflow.add_conditional_edges(
    "generate_query_or_respond",
    tools_condition,  # 决策函数
    {
        "tools": "retrieve",  # 有 tool_calls 时
        "__end__": END,  # 无 tool_calls 时
    },
)

# 条件边2: 评估检索到的文档是否相关
# - 相关 → generate_answer
# - 不相关 → rewrite_question
workflow.add_conditional_edges(
    "retrieve",
    grade_documents,  # 决策函数
    ["generate_answer", "rewrite_question"],
)

# 生成答案后结束
workflow.add_edge("generate_answer", END)

# 重写问题后回到 generate_query_or_respond(重新开始流程)
workflow.add_edge("rewrite_question", "generate_query_or_respond")

# ---------- 编译图 ----------
# 将图编译成可执行的应用
graph = workflow.compile()

# ============================================================
# 8. 运行测试
# ============================================================

if __name__ == "__main__":
    # 运行 RAG 工作流
    # 输入问题,流式输出每个节点的执行结果
    for chunk in graph.stream(
            {
                "messages": [HumanMessage(content="线程同步的相关系统调用")]
            }
    ):
        for node, update in chunk.items():
            print(f"由节点 {node} 更新消息:")
            # rewrite_question 节点返回的是字典格式,不是消息对象
            if node != "rewrite_question":
                update["messages"][-1].pretty_print()
            print("\n\n")


#                     ┌─────────────────────────────┐
#                     │          START              │
#                     └─────────────┬───────────────┘
#                                   ↓
#                     ┌─────────────────────────────┐
#                     │  generate_query_or_respond  │
#                     │   (LLM 决定是否需要检索)     │
#                     └─────────────┬───────────────┘
#                                   ↓
#                          tools_condition 判断
#                                   │
#                     ┌─────────────┴─────────────┐
#                     ↓                           ↓
#               [调用了工具]                  [没有调用工具]
#                     ↓                           ↓
#           ┌─────────────────┐              ┌─────────┐
#           │    retrieve     │              │   END   │
#           │ (执行文档检索)   │              └─────────┘
#           └────────┬────────┘
#                    ↓
#               grade_documents 判断
#                    │
#          ┌─────────┴─────────┐
#          ↓                   ↓
#     [相关: yes]         [不相关: no]
#          ↓                   ↓
# ┌─────────────────┐  ┌─────────────────────┐
# │ generate_answer │  │  rewrite_question   │
# │  (生成最终答案) │  │   (重写问题)         │
# └────────┬────────┘  └──────────┬──────────┘
#          ↓                       ↓
#        ┌───┐              (返回 generate_query_or_respond)
#        │END│                    (重新开始流程)
#        └───┘
相关推荐
我有医保我先冲1 小时前
【无标题】
java·大数据·人工智能
辰尘_星启1 小时前
【ROS2】 Python 节点的开发流程
开发语言·python·机器人·系统·控制·ros2
scan7241 小时前
pydantic格式输出
服务器·前端·javascript
染指11101 小时前
2.AI大模型-链式思考TOC让AI实现思考-优秀提示词设计技巧
大数据·数据库·人工智能
Daydream.V1 小时前
从零搭建 AI Agent:LLM Agent+Function Calling+Dify 本地部署 + Coze 实战全攻略
人工智能·langchain·ollama·functioncalling·大模型部署
数据智研1 小时前
【数据分享】中国第三产业统计年鉴(1991-2025)(1993-1994、2001-2005末出版)
大数据·人工智能·信息可视化·数据分析
HyperAI超神经1 小时前
在线教程丨单卡即可爆改,面壁智能等开源MiniCPM-V-4.6,1.3B端侧模型支持图像理解/视频理解/OCR/多轮多模态对话
人工智能·ai·ocr
m0_624578591 小时前
SQL数据更新时如何减少锁表时间_合理控制事务边界与并发
jvm·数据库·python
曲幽1 小时前
让 FastAPI Agent 思考不阻塞:手把手教你实现异步任务与后台处理方案
redis·python·agent·fastapi·web·async·celery·ai agent·backgroundtask