大模型实战营week7

【LangGraph 学习周记 · W7】从 StateGraph 到 Agentic RAG:五天搞懂"用图编排智能体"

本周主题:LangGraph

本周交付:day1_state.pyday2_node_edge.pyday3_llm_router.pyday4_cycle.pyday5_agentic_rag.py

一句话收获:LangGraph 的本质是:把一个 Agent 的执行流程画成一张"流程图",节点干活、边管方向、State 负责传话。


写在前面:为什么要学 LangGraph?

上周(W6)我手写过 ReAct 循环和 Plan-Execute,大概长这样:

python 复制代码
for step in range(1, max_steps + 1):
    prompt = REACT_PROMPT.format(...)
    response = llm.invoke([...])
    if "Final Answer:" in response.content:
        return ...
    action = parse_action(response.content)
    result = call_tool(...)
    history += ...

能跑,但痛点很明显:

  • 流程全写死在 while 里,想加一个"检查答案对不对"的步骤,就得改循环内部;
  • 状态靠字符串拼接history += ...),越跑越乱,调试只能靠 print;
  • 没法自检、没法重试,检索失败就只能硬答"无法回答"。

LangGraph 解决的就是这件事:把"流程控制"从代码里抽出来,变成一张可声明、可观察、可循环的图。

本周 5 天的学习路线,其实就是 4 个核心概念的逐步组合:

Day 主题 核心概念组合
Day 1 StateGraph 入门 State + Node + Edge 基础
Day 2 Node + Edge 实战 加 LLM 节点 + 工具节点
Day 3 条件分支 / LLM Router 边不再固定,按 State 决定
Day 4 循环 边指回前面的节点(重试)
Day 5 Agentic RAG 多节点 + 3 条件分支 + 1 循环

第一天:LangGraph 入门------先把四个概念钉死

1. 我学了什么

LangGraph 是什么?

LangChain 团队开发的开源框架,用"有向图"的方式编排大模型应用。和传统 DAG 工作流(只能从前往后走)最大的不同是:原生支持循环、条件分支和状态管理

四大核心概念:

概念 一句话 我的类比
State 所有节点共享的数据(本质是 dict) 会议记录本,谁都能看、谁都能写
Node 一个函数:读 State → 返回要更新的部分 流程图里的"处理步骤"
Edge 节点之间的连接,决定执行顺序 流程图里的箭头
StateGraph 把节点和边组装成可执行的图 流程图编辑器

另外还有两个特殊节点:START(虚拟起点)和 END(流程终止)。

2. 我的理解

① State 是 LangGraph 的灵魂。

以前写函数调用链,是 a = f1(x); b = f2(a); c = f3(b),参数要一层层手动传。LangGraph 里节点之间不直接传参 ,大家都去读写同一个 State。这有点像前端 React 的 state 或 Vue 的 data,区别是 LangGraph 的 State 是跨节点共享的

② 节点只返回"差异",不返回整个 State。

一开始我很自然地想这样写:

python 复制代码
def step1(state):
    state["steps"] = state["steps"] + ["xxx"]
    return state   # ❌ 不推荐

正确写法是只返回想改的字段:

python 复制代码
def step1(state):
    return {"steps": state["steps"] + ["xxx"]}   # ✅

LangGraph 会自动把返回的 dict 合并进全局 State,相当于做了一次 state.update(...)。这样每个节点"只对自己负责的字段负责",职责非常清楚。

③ TypedDict 只是"类型说明书"。

TypedDict 用类型注解描述 dict 里有哪些字段、各是什么类型。它只给 IDE 和 LangGraph 做类型提示,运行时不做校验,所以比 Pydantic 更轻量。

3. 核心代码

python 复制代码
import os
from typing import TypedDict
from langchain_openai import ChatOpenAI
from langchain_core.messages import HumanMessage
from langgraph.graph import StateGraph, START, END


def get_llm(temperature: float = 0) -> ChatOpenAI:
    """DeepSeek LLM 工厂(OpenAI 兼容接口)"""
    return ChatOpenAI(
        model="deepseek-chat",
        temperature=temperature,
        openai_api_key=os.getenv("DEEPSEEK_API_KEY"),
        openai_api_base="https://api.deepseek.com/v1",
    )


class MyState(TypedDict):
    question: str        # 用户问题
    steps: list[str]     # 累积的步骤历史
    answer: str          # 最终答案


def step1(state: MyState) -> dict:
    return {"steps": state["steps"] + [f"step1 收到问题: {state['question']}"]}


def step2(state: MyState) -> dict:
    history = "\n".join(f"- {s}" for s in state["steps"])
    prompt = f"请根据以下处理步骤,简洁回答用户问题。\n\n{history}\n\n问题:{state['question']}"
    response = get_llm().invoke([HumanMessage(content=prompt)])
    return {
        "answer": response.content,
        "steps": state["steps"] + ["step2 调用 LLM 生成了最终答案"],
    }


def build_simple_graph():
    workflow = StateGraph(MyState)            # 1. 建图,传入 State 类型
    workflow.add_node("step1", step1)         # 2. 注册节点(名字必须唯一)
    workflow.add_node("step2", step2)
    workflow.add_edge(START, "step1")         # 3. 连边
    workflow.add_edge("step1", "step2")
    workflow.add_edge("step2", END)
    return workflow.compile()                 # 4. 编译后才能 invoke


if __name__ == "__main__":
    graph = build_simple_graph()
    result = graph.invoke({"question": "LangGraph 是什么?", "steps": [], "answer": ""})
    print(result["steps"])
    print(result["answer"])

State 在图里的流转过程:

text 复制代码
START
  │  question='LangGraph 是什么?', steps=[], answer=''
  ▼
step1 ── 把问题记进 steps
  │  steps=['step1 收到问题: ...']
  ▼
step2 ── 调 LLM 生成答案 + 追加 steps
  │  steps=['step1 ...', 'step2 ...'], answer='...'
  ▼
END

4. 关于 graph.invoke()

invoke()同步 执行入口:给输入,阻塞到整张图跑完,一次性返回最终 State,中间过程不会吐出来(想看中间过程要用 stream(),后面再学)。

  • input:初始 State,字典格式;
  • config:配置项。如果编译时挂了 checkpointer(如 MemorySaver),必须传 thread_id,用来标识一次会话,支持记忆、断点续跑、人机中断:
python 复制代码
  config = {"configurable": {"thread_id": "t1001"}}

注意 thread_id 放在 config 里,不会并进 State。

5. 踩坑 & 延伸思考

  • 讲义代码里 get_llm 用到了 osstep2 用到了 HumanMessage记得 import,否则直接报 NameError。
  • "默认是覆盖"这句话很关键。 所以列表累加时要手动写 state["steps"] + [...]。LangGraph 其实提供了更优雅的写法------reducer
python 复制代码
  from typing import Annotated
  import operator

  class MyState(TypedDict):
      question: str
      steps: Annotated[list[str], operator.add]   # 声明:这个字段用"相加"合并
      answer: str

  def step1(state):
      return {"steps": ["step1 收到问题"]}          # 只返回新增的那一条

用了 reducer 之后,如果你再"返回整个 state",旧的 steps 会被重复追加一遍------这也从另一个角度说明了为什么只返回差异才是正确姿势。


第二天:Node + Edge 实战------节点的三种形态

1. 我学了什么

Day 1 的节点都是纯函数,实战里节点分三类:

类型 内部做什么 例子
纯函数 Node 只对 State 做计算,不调 LLM、不调工具 清洗问题、截断答案、计数
LLM Node 调用大模型做决策 / 生成 / 改写 判断用户问题属于哪一类
工具 Node 调用外部工具(非 LLM) 计算器、查当前时间

State 也从 3 个字段扩到 4 个:

python 复制代码
class GraphState(TypedDict):
    question: str       # 用户原始问题
    decision: str       # LLM 决策:"calculator" / "current_time" / "direct"
    tool_result: str    # 工具执行结果
    final_answer: str   # 最终答案

2. 我的理解

① 三种节点的"接口"其实是一模一样的:输入 State,输出 dict。

区别只在函数内部干了什么。这是 LangGraph 设计很漂亮的地方------不管里面是一行 strip() 还是一次 LLM 调用,对图来说都是一个"黑盒步骤",可以随便替换、组合。

② State 字段的命名 = 节点分工表。

一个好习惯:每个字段只由一个节点负责写 。看到 decision 就知道是 LLM 决策节点写的,看到 tool_result 就知道是工具节点写的。图变大以后,这种规范能省下大量调试时间。

③ LLM 的输出永远不可全信,必须容错。

让 LLM "只输出选项名",它还是可能回你一句"好的,我选 calculator。"所以解析时用关键词包含判断,并且一定要有 fallback

python 复制代码
raw = response.content.strip().lower()
if "calculator" in raw:
    decision = "calculator"
elif "current_time" in raw or "time" in raw:
    decision = "current_time"
else:
    decision = "direct"   # 兜底,保证路由永远有路可走

3. 核心代码:第一次见到条件边

路由函数读 State,返回下一个节点名:

python 复制代码
def route_decision(state: GraphState) -> str:
    if state["decision"] == "calculator":
        return "use_calculator"
    elif state["decision"] == "current_time":
        return "use_current_time"
    return "direct_answer"

组装图:

python 复制代码
def build_graph():
    workflow = StateGraph(GraphState)
    workflow.add_node("llm_decide", llm_decide)
    workflow.add_node("use_calculator", use_calculator)
    workflow.add_node("use_current_time", use_current_time)
    workflow.add_node("direct_answer", direct_answer)

    workflow.add_edge(START, "llm_decide")
    workflow.add_conditional_edges(
        "llm_decide", route_decision,
        {
            "use_calculator": "use_calculator",
            "use_current_time": "use_current_time",
            "direct_answer": "direct_answer",
        },
    )
    workflow.add_edge("use_calculator", END)
    workflow.add_edge("use_current_time", END)
    workflow.add_edge("direct_answer", END)
    return workflow.compile()

图的形状:

text 复制代码
            ┌──> use_calculator ──┐
START ──> llm_decide ──> use_current_time ──> END
            └──> direct_answer ───┘

注意: path_map 里的 key 必须和路由函数的返回值一字不差

4. 踩坑 & 延伸思考

  • 讲义里的 use_calculator 写死了 calculator("5+3"),所以不管你问什么算式,答案都是 8。这只是为了演示图的结构。真实场景应该再加一个 LLM 节点把算式从问题里抽取出来,或者直接用 Function Calling 让模型生成参数。
  • 这里的 "time" in raw 判断比较宽松,理论上任何含 time 的输出都会被判到时间分支。选项少的时候问题不大,选项多了要小心误匹配。

第三天:条件分支与 LLM Router------让大模型做"选择题"

1. 我学了什么

LLM Router 的定义:一次独立的 LLM 调用,把用户输入映射到预先定义好的有限选项之一,再交给对应的下游节点处理。

今天的路由选项扩成 4 条:paper_search(论文检索)、calculatortimedirect

三种路由方式对比:

模式 谁做决策 LLM 调用 维护成本 适合场景
if-else 代码 0 次 高,加分支要改代码 分支固定、关键词明确
LLM Router LLM 独立 1 次决策 低,主要改 prompt 分支固定、需要可控可观测
Function Calling LLM 嵌在生成过程中 中,要写 tool schema 工具多、让模型自己决定调不调

2. 我的理解

LLM Router 的定位是"半放权":

  • 选项由我定(就这 4 条路,不能多也不能少);
  • 选择题由 LLM 做(它比关键词匹配更懂语义,"10 乘以 5 等于多少"没有"+"号它也能认出是计算);
  • 边界由我守(解析容错 + 兜底分支)。

我的理解是:if-else 太死,Function Calling 太"放飞",LLM Router 刚好在中间,决策结果是一个明确写进 State 的字段,随时可以打印、可以统计、可以审计。这就是"决策可观测"的意思。

3. 三个核心设计点

设计点 1:路由 Prompt 的 4 要素

python 复制代码
ROUTER_PROMPT = """你是一个问题路由器。根据用户的问题,决定走哪条路径。

可选路径:
- paper_search:研究/学术问题("论文"、"研究"、"原理"、"实现")
- calculator:数学计算("多少"、"计算"、"+")
- time:时间("现在"、"几点")
- direct:通用问题("是什么"、"怎么用")

**只输出路径名**(paper_search / calculator / time / direct):

问题:{question}
路径:"""
要素 作用 缺了会怎样
明确列出可选路径 让 LLM 知道只有这几个选项 模型自由发挥,输出没法解析
每条路径配关键词 给 LLM 判断标准 分类不稳定
强调"只输出路径名" 防止模型说废话 输出"好的,路径是 calculator"
结尾用"路径:"引导 让模型直接在后面填答案 解析边界不清

还有一条经验:各路径的描述要能互相区分。如果两条路径的关键词都含"算法",模型就会左右为难。

设计点 2:容错解析 3 段式

python 复制代码
def parse_route(llm_output: str) -> str:
    cleaned = llm_output.strip().lower()
    options = ["paper_search", "calculator", "time", "direct"]
    # 第 1 段:精确匹配
    if cleaned in options:
        return cleaned
    # 第 2 段:包含匹配
    for kw in options:
        if kw in cleaned:
            return kw
    # 第 3 段:兜底
    return "direct"

核心原则:永远不要相信 LLM 的输出。 兜底必须是一个显式的分支(direct),而不是悄悄吞掉错误。

设计点 3:两个函数的边界要分清

这是今天最容易混淆的地方。Router 其实由两个函数组成:

函数 身份 做什么 是否调 LLM
llm_route 节点(add_node 注册) 调 LLM 做决策,把结果写进 state["route"]
route_decision 路由函数(传给 add_conditional_edges) state["route"],映射成节点名 否,纯函数
python 复制代码
def llm_route(state: RouterState) -> dict:
    prompt = ROUTER_PROMPT.format(question=state["question"])
    response = get_llm().invoke([HumanMessage(content=prompt)])
    route = parse_route(response.content)
    return {"route": route, "path": state["path"] + ["llm_route"]}


def route_decision(state: RouterState) -> Literal[
    "node_paper_search", "node_calculator", "node_time", "node_direct"
]:
    short_to_full = {
        "paper_search": "node_paper_search",
        "calculator": "node_calculator",
        "time": "node_time",
        "direct": "node_direct",
    }
    return short_to_full.get(state["route"], "node_direct")

为什么要拆成两个? 我的理解是:路由函数不能修改 State(它只返回一个字符串),所以"调 LLM 并记录决策"这件事必须放在节点里完成;路由函数只负责"看一眼 State,指个方向"。一个负责想,一个负责指路。

为什么要短名 → 全名映射?

LLM 输出短名更简单、更稳;节点用全名更可读;以后改节点名,只改 mapping,不用动 prompt。

Literal[...] 返回类型能让 IDE 帮你检查返回值只能是这 4 个之一,拼错节点名立刻标红。

4. add_conditional_edges 的执行流程

python 复制代码
workflow.add_conditional_edges(source, path, path_map=None)
  1. source 节点执行完毕,输出合并进 State;
  2. 调用 path(state),拿到返回值(路由函数看到的是 source 执行完之后的最新 State);
  3. path_map 就查表得到目标节点,没有就把返回值直接当节点名;
  4. 跳到目标节点;如果目标是 END,图结束。

补充:路由函数也可以返回一个列表,实现多个节点并行扇出(fan-out)。

5. 新增的 path 字段:让路由"看得见"

python 复制代码
class RouterState(TypedDict):
    question: str
    route: str
    path: list[str]      # 新增:记录走过哪些节点
    tool_result: str
    final_answer: str

每个节点返回时把自己的名字追加进 path,跑完直接打印:

text 复制代码
问题:10 乘以 5 等于多少?
决策:calculator
路径:llm_route → node_calculator

这个小技巧调试时特别好用,建议以后所有图都加上。


第四天:循环(Cycle)------一行代码实现"失败重试"

1. 我学了什么

LangGraph 的循环非常简单:加一条边指回前面已经执行过的节点,就是循环。

python 复制代码
workflow.add_edge("node_rewrite", "node_retrieve")   # ← 这一行就是循环

今天实现的是一个"检索 → 自检 → 改写 → 再检索"的图:

text 复制代码
START ──> Retrieve ──> Grade ──(relevant)──> Finalize ──> END
             ▲           │
             │     (irrelevant 且还能重试)
             │           ▼
             └─────── Rewrite

2. W6 手写循环 vs W7 声明式循环

维度 W6 手写 ReAct W7 LangGraph
实现 20+ 行 while 1 行 add_edge
状态 字符串累积 TypedDict State
可视化 可用 LangGraph Studio 看图
加新逻辑 改 while 内部 加节点 + 加边

3. 我的理解

① 循环 = 条件边 + 回边。

单有一条回边,图会无限转圈;必须配合条件边,在某个节点判断"要不要继续转"。所以一个可用的循环一定是两部分:往回走的边 + 决定是否往回走的路由函数

② 循环必须至少有两个出口:一个正常出口,一个兜底出口。

python 复制代码
def route_after_grade(state: LoopState) -> Literal["node_finalize", "node_rewrite"]:
    if state["relevance"] == "relevant":
        return "node_finalize"                      # 正常出口:找到了
    if state.get("retry_count", 0) >= state.get("max_retries", 2):
        return "node_finalize"                      # 兜底出口:试够了
    return "node_rewrite"                           # 继续循环
状态 去哪 原因
relevant finalize 找到答案了
irrelevant 且 retry < max rewrite 还能再试
irrelevant 且 retry >= max finalize 防止死循环

max_retries 就是循环的安全阀。LLM 改写器是不可控的,没有上限它可能永远觉得"还不够好"。

③ 计数器应该由"转圈的那个节点"负责加一。

这里是 node_rewrite 每执行一次 retry_count + 1,路由函数只负责读。写和读分开,逻辑不会乱。

4. 核心代码

State 新增 3 类字段:

python 复制代码
class LoopState(TypedDict):
    question: str
    rewritten_question: str   # 改写后的 query(rewrite 节点写)
    docs: list[str]           # 检索到的文档(retrieve 节点写)
    relevance: str            # 自检结果(grade 节点写)
    retry_count: int          # 已重试次数(rewrite 节点累加)
    max_retries: int          # 最大重试次数(invoke 时设定)
    final_answer: str

检索节点优先用改写后的问题:

python 复制代码
def node_retrieve(state: LoopState) -> dict:
    q = state.get("rewritten_question") or state["question"]
    result = rag_search_with_citation(q, top_k=3)      # 复用 W6 的 RAG
    return {
        "docs": [d.page_content for d in result["docs"]],
        "final_answer": result["answer"],
    }

改写节点:

python 复制代码
def node_rewrite(state: LoopState) -> dict:
    prompt = f"""你是一个 query 改写专家。原 query 检索结果不相关,请改写让它更可能命中。

原 query:{state['question']}
改写后:"""
    rewritten = get_llm().invoke([HumanMessage(content=prompt)]).content.strip()
    return {
        "rewritten_question": rewritten,
        "retry_count": state.get("retry_count", 0) + 1,
    }

组装图:

python 复制代码
def build_loop_graph():
    workflow = StateGraph(LoopState)
    workflow.add_node("node_retrieve", node_retrieve)
    workflow.add_node("node_grade", node_grade)
    workflow.add_node("node_rewrite", node_rewrite)
    workflow.add_node("node_finalize", node_finalize)

    workflow.add_edge(START, "node_retrieve")
    workflow.add_edge("node_retrieve", "node_grade")
    workflow.add_conditional_edges(
        "node_grade", route_after_grade,
        {"node_finalize": "node_finalize", "node_rewrite": "node_rewrite"},
    )
    workflow.add_edge("node_rewrite", "node_retrieve")   # 循环
    workflow.add_edge("node_finalize", END)
    return workflow.compile()

5. 踩坑:一个非常隐蔽的 bug ⚠️

讲义里自检节点的解析是这样写的:

python 复制代码
relevance = "relevant" if "relevant" in response.content.lower() else "irrelevant"

看起来没问题,但是:"relevant" in "irrelevant" 的结果是 True!

也就是说,LLM 就算老老实实回答了 irrelevant,这行代码也会把它判成 relevant循环永远不会被触发。我跑的时候一直是"重试次数: 0",排查了半天才发现问题在这里。

修复方法:先判断更长、会包含短词的那个

python 复制代码
text = response.content.strip().lower()
if "irrelevant" in text:
    relevance = "irrelevant"
elif "relevant" in text:
    relevance = "relevant"
else:
    relevance = "irrelevant"   # 解析失败按不相关处理,更保守

这个坑和 Day 3 的"容错解析"是同一个道理:关键词包含匹配时,要注意词和词之间的包含关系 。同理,Day 5 的 "grounded" in "ungrounded" 也是 True。

另一个延伸:除了自己写 max_retries,LangGraph 本身还有一个 recursion_limit(默认 25 步),超过会抛异常。它是框架层面的最后一道保险,可以这样调:

python 复制代码
graph.invoke(inputs, config={"recursion_limit": 50})

第五天:Phase 3 收官------Agentic RAG

1. 我学了什么

把前四天的东西全部组合起来:LLM Router(Day 3)+ 检索自检(Day 4)+ 循环重试(Day 4)+ 新增的幻觉检查,做成一个"会自检、会重试"的 RAG 智能体。

各节点职责:

节点 职责
Router 决定走哪条路径 question route
Retriever 调 W6 RAG 检索文档 question docs
Grader 检查"检索到的文档对不对" docs relevance
Rewriter 改写 query 准备重试 question question, retry_count
Hallucination Checker 检查"答案是否基于文档" docs, final_answer hallucination
Generator 基于文档生成最终答案 docs, question final_answer
Calculator / Time / Direct 非检索类问题的出口 question final_answer

2. 我的理解

① 两次自检,分工不同:

  • Grader 管"输入":检索回来的资料,到底和问题有没有关系?
  • Hallucination Checker 管"输出":模型写出来的答案,是不是真的从资料里来的,还是自己编的?

"幻觉(Hallucination)"就是 LLM 生成了 context 里没有的内容,看起来答了,其实是编的。在 RAG 场景里,这是最需要防的问题。

② W6 → W7 升级的本质:从"一条流水线"变成"带质检和返工的流水线"。

能力 W6 Plan-Execute W7 Agentic RAG
决策 硬编码 3 阶段 LLM Router 4 路径
自检 Grader + Hallucination Checker
重试 Rewriter 改写后重新检索
可视化 LangGraph 图

③ W6 代码 0 改动。

rag_search_with_citationcalculatorcurrent_timeget_llm 全部原样复用,W7 只是在外面"包一层节点"。这让我真正体会到:LangGraph 管的是"流程",不管"能力",两者可以完全解耦。

3. 核心代码

State:

python 复制代码
class AgenticRAGState(TypedDict):
    question: str
    route: str            # Router 写
    docs: list[str]       # Retriever 写
    relevance: str        # Grader 写
    retry_count: int      # Rewriter 累加
    max_retries: int
    hallucination: str    # Hallucination Checker 写
    tool_result: str
    final_answer: str     # Generator / 工具节点写

三个路由函数,每个都有"正常出口 + 兜底"

python 复制代码
def route_after_router(state) -> str:
    return {
        "paper_search": "node_retriever",
        "calculator": "node_calculator",
        "time": "node_time",
        "direct": "node_direct",
    }.get(state["route"], "node_direct")


def route_after_grader(state) -> Literal["node_hallucination_checker", "node_rewriter"]:
    if state["relevance"] == "relevant":
        return "node_hallucination_checker"
    if state.get("retry_count", 0) >= state.get("max_retries", 2):
        return "node_hallucination_checker"   # 兜底
    return "node_rewriter"


def route_after_hallucination(state) -> Literal["node_generator", "node_rewriter"]:
    if state["hallucination"] == "grounded":
        return "node_generator"
    if state.get("retry_count", 0) >= state.get("max_retries", 2):
        return "node_generator"               # 兜底
    return "node_rewriter"

讲义中的整体结构:

text 复制代码
用户问题
  ↓
[Router] ──┬── calculator ──> END
           ├── time ────────> END
           ├── direct ──────> END
           └── paper_search
                  ↓
             [Retriever] <─────────────┐
                  ↓                    │
              [Grader]                 │
               ├─ relevant ──> [Hallucination Checker]
               │                  ├─ grounded ──> [Generator] ──> END
               │                  └─ hallucinated ──┐
               └─ irrelevant ──────────────────> [Rewriter]

4. 我的思考:讲义里的执行顺序有个逻辑问题

跑完 demo 以后我仔细推了一遍 State 的流转,发现一个问题:

Hallucination Checker 在 Generator 之前执行,但它要检查的是 state["final_answer"]

而在走到 Checker 的时候,Retriever 只写了 docstool_resultfinal_answer 还是初始的空字符串 ""。也就是说,Checker 其实是在检查一个空答案,这一步的判断结果没有意义。

我的理解是:要检查答案有没有幻觉,得先有答案。 所以更合理的顺序是:

text 复制代码
Retriever → Grader → Generator → Hallucination Checker
                                   ├─ grounded ──> END
                                   └─ hallucinated ──> 重新生成 / 改写重试

改动只涉及几条边:

python 复制代码
workflow.add_conditional_edges(
    "node_grader", route_after_grader,
    {"node_generator": "node_generator", "node_rewriter": "node_rewriter"},
)
workflow.add_edge("node_generator", "node_hallucination_checker")
workflow.add_conditional_edges(
    "node_hallucination_checker", route_after_hallucination,
    {END: END, "node_rewriter": "node_rewriter"},
)

(对应地,route_after_grader 的正常出口改成 node_generatorroute_after_hallucination 在 grounded 或超限时返回 END。)

另外两个细节:

  • Rewriter 直接覆盖了 question 。好处是 Retriever 不用改代码;坏处是原始问题丢了,后面 Grader 和 Generator 看到的都是改写后的问题。我觉得更稳妥的做法是像 Day 4 那样保留 question,另开一个 rewritten_question 字段
  • "答案有幻觉"不一定是"检索出了问题",也可能是生成时模型自己发挥过度。所以 hallucinated 之后,也可以选择先回到 Generator 重新生成(加更严格的 prompt),而不是直接改写 query 重新检索。

这一步让我对 Day 3 讲义里那个目标"完整手动推导一遍数据在代码中的流通路径 "有了切身体会:画完图不算完,要拿着 State 沿着每条边走一遍,看每个节点读的字段在那个时刻是否已经被写过。

5. 运行

python 复制代码
if __name__ == "__main__":
    graph = build_agentic_rag()
    for q in ["What optimizer did the paper use?", "10 乘以 5 等于多少?",
              "现在几点?", "Python 是什么语言?"]:
        result = graph.invoke({
            "question": q, "route": "", "docs": [], "relevance": "",
            "retry_count": 0, "max_retries": 2, "hallucination": "",
            "tool_result": "", "final_answer": "",
        })
        print(f"[{result['route']}] 重试 {result['retry_count']} 次 → {result['final_answer'][:200]}")

本周总结

一张表回顾一周

文件 核心能力 一句话
day1_state.py StateGraph 入门 节点读 State、返回差异,边决定顺序
day2_node_edge.py 三类节点 + 条件边雏形 三种节点接口统一,区别只在内部
day3_llm_router.py LLM Router LLM 做选择题,选项和兜底由我定
day4_cycle.py 循环重试 回边 + 条件边 + max_retries
day5_agentic_rag.py Agentic RAG 路由 + 双重自检 + 循环重试

我提炼出的 5 条心得

  1. State 设计先行。 写代码之前先想清楚:有哪些字段、每个字段由谁写、由谁读。State 设计好了,图基本就出来了。
  2. 节点只做一件事,只返回自己负责的字段。 这样图再复杂,出了问题也能快速定位到节点。
  3. 所有 LLM 输出都要容错解析,并且要有显式兜底。 尤其注意关键词之间的包含关系(relevant / irrelevant、grounded / ungrounded)。
  4. 有循环就必须有兜底出口。 业务层用 max_retries,框架层还有 recursion_limit
  5. 画完图要"人肉跑一遍"。 拿着 State 沿每条边走,检查每个节点读的字段此时是否已经有值。这一步帮我发现了 Day 4 和 Day 5 的两个问题。

下周想继续探索的

  • stream() 流式输出,观察每个节点的中间状态;
  • Checkpointer(MemorySaver / SqliteSaver)+ thread_id,实现多轮记忆和断点续跑;
  • interrupt_before / interrupt_after,实现人机协同(Human-in-the-loop);
  • 用 LangGraph Studio 把本周的图可视化出来。

如果这篇周记对你有帮助,欢迎点赞收藏~文中如有理解不到位的地方,也欢迎在评论区指正交流。

相关推荐
adinnet20261 小时前
Neo4j 凭什么叫原生图数据库?Cypher 查询能做什么
大数据·人工智能
小猿君1 小时前
Claude 入口砍到只剩一个,GPT-6 已经能自己连干 24 小时
人工智能·chatgpt·agi
goujunwe1 小时前
从效果衰减到稳定增长:GEO 常态化运维实战指南
人工智能
朗锐智科_机器视觉1 小时前
光电传感器:机器视觉的“触发与到位“
人工智能·机器视觉·传感器·工控机·图像采集卡
武子康1 小时前
TP、PP、DP、EP 如何组合:八张卡,怎样淘汰不合适的配置
人工智能·llm·agent
蓝速科技1 小时前
桌面双屏翻译机量产调试:三类场景兼容性破局方案丨蓝速科技
运维·数据库·人工智能·科技·技术分享
见闻小天地1 小时前
脱硫脱硝塔选变频器避坑指南:四方DL500变频器使用体验分享
大数据·运维·人工智能·业界资讯
打工仔折腾 AI1 小时前
普通摄像头接入AI识别:绿联NAS部署Frigate监控实战
人工智能·后端·python·性能优化·ai agent 实战
2601_957638242 小时前
常佐网络做的裂变小程序会不会被封:实践与思考
人工智能·常佐网络做的裂变小程序会