《LangGraph 从入门到实战》第 1 篇把心智模型立住了:节点是函数、边是控制流、State 是共享黑板,结尾还留了个作业——把你手头一个真实业务画成纸上的图。本篇是第 2 篇,开始动手兑现:把那张纸翻译成第一张能跑起来的 StateGraph,跑通之后再拆开看它内部到底发生了什么。为了把注意力全部留给图本身,节点里先不接真模型,返回值直接写死,等第 5、6 篇再把模型与工具装进来。
环境一步到位:langgraph 是独立发布的包,pip install langgraph 即可,要求 Python 3.10 及以上,不需要顺带安装 langchain,本篇全部代码只依赖这一个包。版本口径与全系列一致:写作时 PyPI 上是 1.2.x,安装细节见官方安装文档。全文按"五步搭图 → invoke 跑通 → 部分更新语义 → 图的可视化 → 执行顺序验证 → 报错排查"展开,结尾给两道动手练习。
1、搭图的五个步骤
先看全景。搭一张图固定五步,每步产物明确、顺序不乱,五步总览如下:
| 步骤 | 动作 | 产物 |
|---|---|---|
| 第一步 | 定义 State | 一个 TypedDict 类 |
| 第二步 | 写节点函数 | 若干普通函数 |
| 第三步 | 建图并登记节点 | builder 挂上节点 |
| 第四步 | 连边 | 节点之间的转移 |
| 第五步 | 编译 | 可运行的图对象 |
示例业务取一段极简写作流水线:给一个题目,先写提纲,再按提纲写成稿。三个黑板字段、两个节点、一条直线,刚好把五步走完,又不让细节淹掉主线。
1.1、定义 State
State 是全图共享的黑板,最小形态是一个 TypedDict:声明字段名与类型即可,不需要实例化,建图时把类本身传进去。示例如下:
from typing import TypedDict
from langgraph.graph import StateGraph, START, END
class State(TypedDict):
topic: str # 写作题目,初始输入
outline: str # 提纲,第一个节点写入
article: str # 成稿,第二个节点写入
解读:StateGraph、START、END 三个名字从 0.x 时代一路用到 1.2.x,是搭图的标准导入。三个字段就是黑板的全部——题目进来,提纲与成稿在流转中产生。类型先全用 str,够用就好;更讲究的定义方式(加 reducer、换 Pydantic)是第 3 篇的内容。
还有一个容易忽略的细节:StateGraph(State) 接收的是类本身,不是实例。建图时框架只关心黑板长什么样、有哪些键,不关心值——值要等到每次 invoke 时才存在。形状与数据分离,是后面第 7 篇能对 State 打快照、做持久化的前提。
1.2、写两个节点函数
节点就是普通函数:入参是当前的完整 State,返回值是要更新的字段。两个节点如下:
def make_outline(state: State) -> dict:
outline = f"{state['topic']}:现状、原理、风险"
return {"outline": outline}
def write_article(state: State) -> dict:
return {"article": f"按提纲[{state['outline']}]扩写 600 字"}
解读:注意两个函数的返回值都只有一个键。make_outline 读 topic 写 outline,write_article 读 outline 写 article——读全量、写增量,这是节点函数的基本契约。函数体里没有任何图相关的东西,想写单元测试直接对着函数调就行。
1.3、建图连边与编译
后三步一口气做完:实例化 builder、登记节点、连边、编译。代码如下:
builder = StateGraph(State)
builder.add_node("make_outline", make_outline) # 第三步:登记
builder.add_node("write_article", write_article)
builder.add_edge(START, "make_outline") # 第四步:连边
builder.add_edge("make_outline", "write_article")
builder.add_edge("write_article", END)
graph = builder.compile() # 第五步:编译
解读:START 与 END 是两个虚拟节点,标记图的入口和出口。add_node 只负责登记"这个名字对应这个函数",不决定执行顺序——顺序完全由边声明:从 START 到 make_outline,再到 write_article,最后到 END。compile() 把声明好的结构编译成可运行对象,之后对它的操作就只剩 invoke 与 stream。写到这里,第 1 篇纸上的图变成了代码里的一个值。
2、invoke 的输入与输出
跑一张图,就是喂给它初始 State。初始输入只需要给起跑字段 topic,后续字段由节点在流转中写入。运行示例如下:
result = graph.invoke({"topic": "LangGraph 入门"})
print(result)
输出如下:
{'topic': 'LangGraph 入门', 'outline': 'LangGraph 入门:现状、原理、风险', 'article': '按提纲[LangGraph 入门:现状、原理、风险]扩写 600 字'}
输入输出对照着读:进去一个键,出来三个键。outline 是 make_outline 写上去的,article 是 write_article 写上去的,topic 从头到尾没人动、原样躺在黑板上。invoke 返回的也不是某个节点的返回值,而是整张黑板跑完后的最终快照。
把这次执行翻译成时序更清楚:第一个超步,框架调度 make_outline,它读到 topic、写回 outline;第二个超步,调度 write_article,此时它读到的黑板上已经有 outline 了。两个节点之间没有任何直接调用,全部通信都走黑板——这就是"共享"二字的含义。
3、节点返回部分更新
新手最容易懵的点在这一节:节点返回的不是新 State,而是要更新的那部分字段,官方叫部分更新(partial state update)。规则一句话:返回的字段覆盖旧值,没返回的字段原样保留。第 2 节的输出已经验证了后半句——两个节点都没返回 topic,topic 安然无恙。
返回的是增量,不是全量;没写的键原样保留。黑板由框架合并,不由节点搬运。
为什么这样设计?因为合并是框架的活:节点只声明"我改了什么",框架负责把增量合进黑板。如果要求每个节点返回完整 State,任何一个字段的结构调整都会波及所有节点,改一处动全图。
坑有两个。第一个坑是顺手多带字段,比如写润色节点时手滑:
def polish(state: State) -> dict:
# 本意只改 article,却把 topic 也格式化了一遍
return {"topic": state["topic"].upper(),
"article": state["article"] + "(已润色)"}
这段代码能跑、不报错,但 topic 被悄悄改写——如果后面还有节点要拿原始题目做检索,bug 就埋下了。部分更新的约定逼着你把"要改什么"显式写进返回值,多带的每一个字段都是一次显式覆盖。
想确认自己吃透了,做个小实验:把 make_outline 的返回值改成同时带上 topic,值原样照抄,再跑一遍第 2 节——输出一个字不变;把值改成别的,黑板立刻跟着变。覆盖式合并没有魔法,一切以返回值为准。
第二个坑是默认语义是替换不是追加。字段没配 reducer 时,返回值直接顶掉旧值。对 str 字段无所谓;换成消息列表,"追加一条消息"就必须自己拼好整份列表再返回,或者给字段配上 add_messages 这类 reducer——那是第 3 篇的主角,Graph API 概览对整套合并规则有正式定义。再提醒一句:同一超步里两个节点写同一个无 reducer 字段,运行期会抛 InvalidUpdateError,并行场景留到第 4 篇展开。
4、图的可视化输出
图是显式数据结构,就该能直接看。graph.get_graph().draw_mermaid() 返回一段 mermaid 源码字符串,print 出来贴进任何支持 mermaid 的地方就能渲染;draw_mermaid_png 还能直接出图片,但要额外装依赖,日常用文本版足够。draw_mermaid 的输出里节点用注册名、入口出口叫 __start__ 与 __end__,等价的手绘中文版如下:
解读:方框是登记过的两个节点,圆角框是 START 与 END 虚拟节点,三条箭头与 add_edge 一一对应。这张图与第 1 篇的客服图唯一的差别是没有回边——回边要配上条件边才有意义,第 4 篇补上。图能打印、能存档、能贴进代码评审,这是结构显式化最直观的红利。
5、节点执行顺序验证
第 1 节说"顺序由边决定",口说无凭,用 stream 拆开看。stream_mode 取 updates 时,每经过一个节点吐一个增量片段,片段的键就是节点名。验证代码如下:
for chunk in graph.stream({"topic": "LangGraph 入门"},
stream_mode="updates"):
print(chunk)
输出如下:
{'make_outline': {'outline': 'LangGraph 入门:现状、原理、风险'}}
{'write_article': {'article': '按提纲[LangGraph 入门:现状、原理、风险]扩写 600 字'}}
解读:两个片段先后到达,顺序正是边声明的顺序;每个片段天然就是"哪个节点写了什么"的日志,连 print 都不用往节点里加。想做实验,把 1.3 节里两行 add_node 交换顺序再跑——输出一个字都不会变,因为 add_node 只登记不排序;反过来,动任何一条边,流程立刻跟着变,函数一个字不用改。顺带记一个术语:图的一轮推进叫一个超步(superstep),updates 模式下片段数就是超步数,第 7 篇讲的持久化快照正是按超步打的。
6、三个高频报错
第一张图跑不顺,九成栽在三处,汇总如下:
| 报错 | 触发场景 | 处理办法 |
|---|---|---|
| ValueError | 连边指向未注册的节点名,常见于拼写不一致 | 核对 add_node 与 add_edge 的名字 |
| InvalidUpdateError | 返回了 State 未声明的字段,或同超步多节点写同键 | 返回值只含已声明字段,并行写同键配 reducer |
| GraphRecursionError | 图带环且循环超过 recursion_limit(1.0.6 起默认 1000) |
检查条件边终止条件,或按需调高限额 |
前两条在建图和首次运行时就会暴露,改起来快;第三条是循环图的专属——第 1 篇说过"从每个循环自己数变成框架替你数",框架数满 25 步就抛错。排查心法也简单:建图期报错核对名字,运行期报错核对返回值,循环报错核对终止条件,三类错误正好对应三个动作。确有长循环需求时,调高限额的标准写法如下:
graph.invoke({"topic": "LangGraph 入门"},
config={"recursion_limit": 50})
解读:recursion_limit 走 config 传入,属于运行时配置而非图结构的一部分。调高之前先确认循环该停的时候真的会停,否则只是把崩溃往后推。
7、总结
五步搭图——定义 State、写节点、add_node 登记、add_edge 连边、compile 编译——再加一把 invoke,就是 LangGraph 入门的全部动作。三个机制值得带走:节点返回部分更新,返回的覆盖、没返回的保留;执行顺序由边决定,与登记顺序无关;图是显式数据结构,draw_mermaid 随时打印,stream 逐步回放。报错三件套 ValueError、InvalidUpdateError、GraphRecursionError,覆盖了新手期绝大多数翻车现场。
两道练习,难度递进。练习一:在提纲与成稿之间加一个润色节点,把图扩成三节点线性——只需一次 add_node 加两条 add_edge,把原来那条中间边拆成两段。练习二:让起标题与配插图两个节点都汇向一个组装节点,做出第一个汇聚图——两条边指向同一节点即是汇聚;提示一点,让两个分支写不同的字段,就不会撞上 InvalidUpdateError,汇聚与并行的完整玩法在第 4 篇。做完之后,建议把第 1 篇画在纸上的那张业务图也翻译过来跑一遍;对于想继续深入的读者,建议下一站直接进第 3 篇——reducer 把黑板从"能写"升级到"写得讲究"。

