- 🍨 本文为🔗365天深度学习训练营 中的学习记录博客
- 🍖 原作者:K同学啊
【LangGraph 学习周记 · W7】从 StateGraph 到 Agentic RAG:五天搞懂"用图编排智能体"
本周主题:LangGraph
本周交付:
day1_state.py→day2_node_edge.py→day3_llm_router.py→day4_cycle.py→day5_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用到了os,step2用到了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(论文检索)、calculator、time、direct。
三种路由方式对比:
| 模式 | 谁做决策 | 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)
source节点执行完毕,输出合并进 State;- 调用
path(state),拿到返回值(路由函数看到的是 source 执行完之后的最新 State); - 有
path_map就查表得到目标节点,没有就把返回值直接当节点名; - 跳到目标节点;如果目标是
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_citation、calculator、current_time、get_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 只写了 docs 和 tool_result,final_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_generator,route_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 条心得
- State 设计先行。 写代码之前先想清楚:有哪些字段、每个字段由谁写、由谁读。State 设计好了,图基本就出来了。
- 节点只做一件事,只返回自己负责的字段。 这样图再复杂,出了问题也能快速定位到节点。
- 所有 LLM 输出都要容错解析,并且要有显式兜底。 尤其注意关键词之间的包含关系(relevant / irrelevant、grounded / ungrounded)。
- 有循环就必须有兜底出口。 业务层用
max_retries,框架层还有recursion_limit。 - 画完图要"人肉跑一遍"。 拿着 State 沿每条边走,检查每个节点读的字段此时是否已经有值。这一步帮我发现了 Day 4 和 Day 5 的两个问题。
下周想继续探索的
stream()流式输出,观察每个节点的中间状态;- Checkpointer(MemorySaver / SqliteSaver)+
thread_id,实现多轮记忆和断点续跑; interrupt_before/interrupt_after,实现人机协同(Human-in-the-loop);- 用 LangGraph Studio 把本周的图可视化出来。
如果这篇周记对你有帮助,欢迎点赞收藏~文中如有理解不到位的地方,也欢迎在评论区指正交流。