第 1 章 从一个失控的脚本说起
本章不写一行 LangGraph 代码,只回答一个问题:当 LLM 应用长到一定程度,为什么必然会走向"图"?
本章不写一行 LangGraph 代码,只回答一个问题:当 LLM 应用长到一定程度,为什么必然会走向"图"?
1.1 一切从一个朴素需求开始
假设你在一家招聘 SaaS 公司任职。产品同学提了个看起来不大的需求:
用户上传一份简历,系统读取后输出一个 0~100 的匹配分,并给出三条理由。
用 LangChain 写,十来行就搞定:
from pydantic import BaseModel, Field
from langchain_openai import ChatOpenAI
llm = ChatOpenAI(model="gpt-4o-mini", temperature=0)
class Score(BaseModel):
score: int = Field(ge=0, le=100)
reasons: list[str] = Field(max_items=3)
grader = llm.with_structured_output(Score)
def grade(resume: str, job: str) -> Score:
prompt = f"岗位要求:{job}\n候选人简历:{resume}\n请给出匹配分和理由。"
return grader.invoke(prompt)
干净、直接、能用。可是第二周产品又来了:
评分之前先抽干简历结构化字段,再对照岗位要求逐条比对,最后综合出分;另外简历里如果有公司名,去查一下公司背景,作为加分参考。
于是你在 grade 里加判断、加 if、加临时变量。一个月后,这个函数变成了这样:
def grade(resume: str, job: str) -> dict:
parsed = parse_resume(resume) # LLM 抽取结构化字段
if needs_company_lookup(parsed): # 有公司名?
company = lookup_company(parsed.company) # 查公司背景
parsed.company_info = company
hard_ok = check_hard(parsed, job) # 硬性条件
soft_ok = check_soft(parsed, job) # 软性条件
if not hard_ok:
parsed.rejection_reason = "硬性条件不满足"
return parsed
score = summarize(parsed, hard_ok, soft_ok) # 综合评分
if score < 60 and can_retry(parsed): # 重试一次
parsed.retry_note = "已重试"
return grade(resume, job)
return score
代码还在跑,但你已经不敢动了。每一个 if 都对应一条产品规则,每一处临时变量都是隐含的状态。这类代码有个共同特征——控制流和数据流纠缠在一起。
1.2 三类必然而来的工程痛点
把上面这段"失控脚本"拆开看,它暴露的是 LLM 应用规模化后的三类共性问题。LangGraph 的所有设计,几乎都是对着这三类痛点来的。
痛点一:控制流无法稳定扩展
朴素的写法靠函数嵌套和 if/else 表达"先做 A 再做 B,若失败则做 C"。一旦规则到十几条,控制流就成了意大利面:
- 想插入一步"查学历真伪",要改三个地方
- 想把"软性条件"和"硬性条件"并行跑,得重写大段逻辑
- 新人接手时,没人能一眼说清"这个函数到底走了哪几步"
根因:控制流隐没在代码里,没有显式的、可观察的结构。
痛点二:中间状态无法保存与恢复
面试官中途想看"评分到哪一步了",你做不到——因为状态散落在局部变量里,函数返回就消失。更现实的场景:
- 评估跑到一半调外部 API 失败,想从失败那一步重跑,而不是从头来
- 用户离开后回来,希望继续上一次的评估
- 高风险简历评分到一半,要等 HR 确认一个信息再继续
根因:执行过程是"黑盒推进",中间状态既不可见、也不可恢复。
痛点三:并发与扇出无章可循
产品需求继续演进:
现在用户一次上传 5 份简历,系统要并行评估,最后出一份横向比较报告。
你想到用 asyncio.gather,可每个评估内部又有子步骤、有重试、有共享的岗位上下文。手撸并发控制很快又会失控:
- 哪些步骤可以并行、哪些必须串行,没有统一表达
- 并行结果怎么汇回到统一的状态里,各自赋值容易覆盖
- 想动态决定扇出几份(用户上传几份就跑几份),没有现成的抽象
根因:并发语义散落在调用代码里,没有一等公民式的抽象。
1.3 走向"图"是自然收敛
把上面三类痛点的解法抽象出来,会得到三件事:
| 痛点 | 解法 | LangGraph 中的对应物 |
|---|---|---|
| 控制流不可扩展 | 把步骤显式画成节点,把流转关系画成边 | StateGraph |
| 状态不可保存 | 每一步结束把状态快照下来,按"线程"组织 | Checkpointer / Thread |
| 并发无章法 | 用"超步"组织并行,用 Send 动态扇出 | Superstep / Send |
这三件事合起来,就是"有向状态转换图"——一种把"做什么、按什么顺序做、做到什么程度"全部显式化的结构。它不是新发明:2010 年 Google 的 Pregel 论文就用类似的模型跑大规模图计算,LangGraph 把这个模型搬到了 LLM 工作流编排上。
1.4 什么是 LangGraph
一句话定义:LangGraph 是一个把 LLM 工作流表达为有向状态转换图、并为之提供运行时与持久化的编排引擎。
它来自 LangChain 团队,但定位不同于 LangChain 本身:
- LangChain 关注"单次能力":怎么调模型、怎么绑定工具、怎么构造消息
- LangGraph 关注"多次组合":怎么把多次模型调用、工具调用、人工审核串成一个可控、可恢复、可观测的流程
LangGraph 不是要替代 LangChain,而是站在它肩膀上:图里的每一个节点,仍然是一个 LangChain Runnable 或一个普通可调用对象。LangGraph 负责把这些"能力单元"组织成一条可控的流水线。
1.5 本书会怎么带你走完全程
本书不会一章一个孤立知识点,而是围绕一个业务——智能简历筛选系统——逐章长出来。每一章解决上一章暴露出来的工程问题:
到全书结尾,你会得到一个结构清晰、可恢复、可人工介入、可记忆、可容错的多 Agent 简历筛选系统——并且你清楚每一块为什么在那里。
常见坑与排错
本章不涉及代码,但有几个认知层面的坑值得提前点出:
-
"图"不是 DAG。新手常以为工作流图必须是"无环有向图"。LangGraph 的图允许循环,甚至鼓励循环(ReAct 模式、重试、多轮审核本质上都是循环)。循环的终止靠条件边,而不是禁止出现环。
-
别把图当链表写。从单链式 if/else 迁移过来的人,容易把图画成一条直线
A→B→C→D。这浪费了图的表达力——真正发挥图价值的是分支、合并、并行、循环这些"非线性"结构。 -
节点是"纯函数"而非"状态机"。节点接收状态、返回更新,不要在节点里持有外部可变状态。这是后面持久化、时间旅行、并发正确性的共同前提。
-
状态是图级共享的,不是节点私有的。节点之间不直接传参,而是读写同一个图状态。理解这一点,是理解后面 reducer、checkpointer、子图状态共享的基础。
动手练习
-
回想你最近写的一个 LLM 应用,画出它的实际控制流(哪些步骤、哪些分支、哪些循环)。看看它是否已经具备"图"的雏形。
-
在 1.1 节那段
grade函数里,找出三类痛点的具体证据:哪一行代码体现"控制流纠缠"?哪一处体现"状态散落"?哪一处如果要做并行会很难改? -
思考题:如果让你自己设计一个"工作流引擎",你会给它哪些一等公民概念?和 LangGraph 的节点/边/状态/超步对照一下。
本章速查
| 概念 | 一句话 |
|---|---|
| LangGraph | 把 LLM 工作流表达为有向状态转换图的编排引擎 |
| 它解决的问题 | 控制流不可扩展、中间状态不可恢复、并发无章法 |
| 与 LangChain 的关系 | LangChain 提供"单次能力",LangGraph 负责"多次组合" |
| 本书的演进线索 | 单节点 → 状态 → 条件循环 → 并行 → Command → 动态扇出 → 工具 → 子图 → 多 Agent → 生产化 |