- 引言
- 环境准备
- LangGraph 是什么
- 核心概念:三要素与运行机制
- Graph API vs Functional API
- 第一个图:定义、编译、调用
- 状态管理
- 控制流
- 边的系统总结
- 节点执行与容错机制
- 最佳实践
- 常见问题
- 总结
引言
当我们用大模型构建 Agent 应用时,很快会遇到一类问题:流程不再是”一次提问、一次回答”,而是包含分支、循环、并行、人工介入和长时间运行的复杂工作流。用普通的 Python 函数调用来组织这些流程,代码会迅速变得难以维护,而且一旦进程崩溃,中间状态就全部丢失。
LangGraph 就是为解决这类问题而生的编排框架。它把工作流建模为一张状态图:节点负责计算,边负责流转,状态负责在节点之间传递数据。更重要的是,它的运行时天然支持持久化、流式输出和容错恢复。
本文是一篇教程型整合文章,覆盖从零开始的完整路径:
- 环境准备:Miniconda、依赖安装、API Key 配置;
- 核心概念:State / Node / Edge 三要素与 Superstep 执行模型;
- 状态管理:Schema 定义、Reducer 合并机制、Multi Schema;
- 控制流:顺序、分支、扇入扇出、循环与递归限制;
- 容错机制:重试、超时、错误处理与节点缓存。
所有代码基于 langgraph 1.1.x + Python 3.13 验证通过,模型示例使用 DeepSeek(可替换为任意 LangChain 支持的模型)。
环境准备
安装 Miniconda 并创建环境
Conda 是包管理和环境管理工具,Miniconda 是它的精简版,体积小、安装快。从官方页面下载对应系统的安装包:https://docs.anaconda.com/miniconda/
| 操作系统 | 推荐安装包 |
|---|---|
| Windows | Miniconda3 Windows 64-bit(.exe) |
| macOS | Apple 芯片选 macOS Apple M1 64-bit,Intel 芯片选 macOS Intel x86 64-bit |
| Linux | Miniconda3 Linux 64-bit(.sh) |
Windows 安装时务必勾选 “Add Miniconda3 to my PATH environment variable”;macOS / Linux 在终端执行安装脚本并按提示输入 yes 完成 conda init。
安装完成后验证并创建课程环境:
1 | conda --version |
常用的 Conda 命令:
| 命令 | 作用 |
|---|---|
conda info --envs |
列出所有虚拟环境 |
conda activate 环境名 |
激活(进入)某个环境 |
conda deactivate |
退出当前环境 |
conda remove -n 环境名 --all |
删除某个环境 |
虚拟环境是一个隔离的 Python 运行空间,不同环境之间互不影响——这是管理多项目依赖最省心的方式。
安装依赖与配置 API Key
中国大陆用户建议先配置 pip 镜像源,大幅提升下载速度:
1 | pip config set global.index-url https://pypi.tuna.tsinghua.edu.cn/simple |
安装 LangGraph 及相关依赖(可按需精简):
1 | pip install langgraph langchain langchain-core langchain-openai \ |
核心包说明:
| 包名 | 用途 |
|---|---|
langgraph |
LangGraph 核心库,构建状态图 |
langgraph-checkpoint |
检查点(Checkpoint)机制 |
langgraph-prebuilt |
预置的 Agent 组件 |
langchain-core |
LangChain 基础抽象(消息、Runnable 等) |
langchain-deepseek / langchain-openai / langchain-anthropic |
各模型服务商集成 |
python-dotenv |
读取 .env 环境变量文件 |
接下来配置 API Key。以 DeepSeek 为例(新用户注册即送额度),在 platform.deepseek.com 创建 API Key 后,在项目根目录新建 .env 文件:
1 | # 必填:示例默认使用 DeepSeek 模型 |
代码中统一用两行加载,SDK 会自动从环境变量读取密钥,不要在代码中硬编码:
1 | from dotenv import load_dotenv |
.env应加入.gitignore,确保密钥不会被提交到 Git。
环境验证与检查清单
用一个脚本验证环境是否就绪:
1 | # test_env.py |
开始学习前,逐项确认:
- Miniconda 已安装,
conda --version正常输出 -
langgraph环境已创建(Python 3.13)并激活 - 核心依赖已安装,
pip show langgraph有输出 -
.env已创建且 API Key 有可用额度 - 验证脚本全部通过
LangGraph 是什么
LangChain 与 LangGraph 的定位
LangChain 1.x 的定位发生了明显变化:旧式 Chain、Retriever、Memory 等组件被迁移至 langchain-classic;LangChain 现在聚焦于 Agent 开发,核心入口是 create_agent,围绕 Agent 提供模型、工具、消息、结构化输出、Middleware 和记忆管理等高层抽象。
LangGraph 则是更底层的编排框架与 Agent Runtime,负责复杂工作流和有状态 Agent 的执行,提供持久化、流式输出、Durable Execution、Human-in-the-loop 等运行时能力。create_agent 底层正是基于 LangGraph 实现的。
| 对比维度 | LangChain | LangGraph |
|---|---|---|
| 定位 | Agent 高层开发框架 | 底层编排框架 & Agent Runtime |
| 核心入口 | create_agent |
StateGraph / @entrypoint |
| 适用场景 | 结构直接的 Agent 应用 | 复杂工作流、持久化状态、长时间运行、人工介入 |
| 流程控制 | Agent 循环自动管理 | 精细控制节点、边、条件分支 |
| 学习成本 | 较低 | 较高 |
一句话总结:LangChain 提供易用的 Agent 高层抽象,LangGraph 提供可靠、可持久化且可精细控制的底层执行能力。 大多数 Agent 项目从 create_agent 开始即可;当需要复杂工作流编排、确定性步骤与 Agent 步骤混合、或底层状态控制时,再引入 LangGraph。
LangGraph 运行时底层基于自研的 Pregel 运行时,核心思想借鉴了 Google Pregel 计算模型,用于组织和执行复杂的图计算流程。
核心概念:三要素与运行机制
State、Node、Edge
LangGraph 运行时由三个基本要素构成:
- State(状态):图运行过程中的共享数据结构,表示应用在某一时刻的状态快照。它承载上下文信息、中间结果和后续节点需要读取的数据,是节点之间传递信息的核心载体。
- Node(节点):具体执行单元,通常实现为一个函数。节点读取当前 State,执行业务逻辑,返回对 State 的局部更新。节点本身不直接修改全局状态,状态的合并与提交由运行时统一完成。
- Edge(边):定义节点之间的流转关系,决定一个节点执行完成后下一步进入哪个节点。边可以是固定流转,也可以根据当前 State 做条件判断,从而实现分支、循环等复杂控制流。
一个最简单的运行图只有两个节点:
1 | START --> node_1 --> node_2 --> END |
Superstep 执行模型
LangGraph 的图运行过程基于 Superstep(超步) 来组织和推进。Superstep 可以理解为图运行过程中的一次”单步循环”,一次完整的图运行就是由一系列连续的 Superstep 串联而成。
每个 Superstep 分为三个阶段:
1 | ┌─────────────────────── Superstep N ───────────────────────┐ |
理解这个模型有两个关键点:
- 并行节点在同一个 Superstep 中执行,各自读取的是同一份状态快照,互不干扰;
- 状态更新在 Superstep 结束时统一提交,这就是为什么并行写同一个字段时需要 Reducer(下文详述)。
Checkpoint 快照序列
在每个 Superstep 边界,LangGraph 可以生成一个 Checkpoint(检查点)快照,记录当前的 metadata.step、next(下一步待执行的任务)与 State。以 START → node_1 → node_2 → END 为例,一次运行会产生如下快照序列:
| 快照 | step | next | 说明 |
|---|---|---|---|
| Input Checkpoint | -1 | __start__ |
由 graph.invoke 的入参创建,尚未进入用户节点 |
| Checkpoint 0 | 0 | node_1 |
输入已写入 State |
| Checkpoint 1 | 1 | node_2 |
node_1 的结果已提交(记录在 tasks.result 中) |
| Checkpoint 2 | 2 | () |
node_2 完成,无后续任务,运行结束 |
注意 END 是终止边界,不会产生额外的节点执行快照。Checkpoint 是持久化、时间旅行调试和 Human-in-the-loop 的基础,本文先建立概念,后续文章再展开。
Graph API vs Functional API
LangGraph 提供两种 API 来构建运行图,二者共享相同的底层运行时,可以在同一应用中协同使用。
Graph API 采用声明式方式:显式定义 State、Node 和 Edge,将业务流程组织成可视化的图结构。适合复杂分支、多节点共享状态、并行执行与结果汇聚,以及需要图结构辅助调试和团队协作的场景。
Functional API 采用命令式方式:用 @entrypoint 定义工作流入口,用 @task 定义可被检查点记录的任务,然后在函数内部用普通的 if/else、循环和函数调用组织流程。适合已有过程式代码最小改造、线性流程、简单分支和快速原型验证。
| 对比项 | Graph API | Functional API |
|---|---|---|
| 编程风格 | 声明式图结构 | 命令式函数流程 |
| 核心抽象 | State、Node、Edge | entrypoint、task |
| 状态管理 | 显式定义全局 State | 更多依赖函数参数和返回值 |
| 流程表达 | 通过节点和边表达 | 通过普通 Python 控制流表达 |
| 可视化能力 | 强,天然适合画图和调试 | 弱,更像普通代码流程 |
| 适合场景 | 复杂工作流、多分支、多节点协作 | 简单流程、快速原型、已有代码改造 |
| 学习成本 | 相对更高 | 相对更低 |
选型建议:从零构建或流程结构复杂,用 Graph API;现有代码改造、快速原型或流程逻辑简单,用 Functional API。学习 LangGraph 建议优先掌握 Graph API,因为它更能体现 LangGraph 的核心思想——通过 State、Node、Edge 显式描述一个可执行的计算图。本文后续内容均基于 Graph API。
第一个图:定义、编译、调用
三步构建状态图
图的构建与运行分为三个阶段:定义状态图 → 编译状态图 → 调用状态图。
第一步,定义全局状态、节点函数,并把它们组装进 StateGraph:
1 | from langgraph.graph import StateGraph, START, END |
运行结果:
1 | {'logs': ['node_1 运行完毕', 'node_2 运行完毕'], 'cur_id': 'start, node_1, node_2'} |
注意 logs 字段:两个节点各自返回了一个单元素列表,但最终结果把它们拼接了起来——这就是 Annotated[list[str], add] 中 Reducer 的作用。而 cur_id 没有 Reducer,节点在返回值中手动读取旧值再拼接新值。
图结构可视化
LangGraph 支持用 mermaid(一种文本标记语言,可直接渲染为流程图)表达图结构:
1 | raw_mermaid = graph.get_graph().draw_mermaid() |
输出中从 graph TD; 开始的部分就是 mermaid 源码:
1 | graph TD; |
也可以直接渲染为 PNG 图片字节流:
1 | png_bytes = graph.get_graph().draw_mermaid_png() |
draw_mermaid_png()底层会调用在线渲染服务(默认 https://mermaid.ink),网络不稳定时可能失败,通常重试即可;也可以通过base_url参数替换为可用的渲染服务。
在 Jupyter 环境下还有更快捷的方式:
1 | from IPython.display import display |
状态管理
三种 Schema 定义方式
状态定义实际上是在声明状态的 Schema(状态字段的完整描述)。官方推荐三种方式:TypedDict、dataclass、Pydantic。三者构建的图行为完全一致,差别只在字段访问方式和校验行为。
TypedDict——字段用 state["字段名"] 访问:
1 | from typing import TypedDict, Annotated |
dataclass——字段用 state.字段名 访问:
1 | from dataclasses import dataclass |
Pydantic——字段访问方式与 dataclass 相同:
1 | from pydantic import BaseModel |
校验行为对比
三种方式都要求字段名称完全一致,只是处理方式不同:
| 场景 | TypedDict | dataclass | Pydantic |
|---|---|---|---|
| 输入字段不匹配 | 抛出 KeyError |
抛出 TypeError |
抛出 ValidationError |
| 节点返回字段不匹配 | 更新被忽略 | 更新被忽略 | 更新被忽略 |
推荐优先使用 TypedDict,大多数官方案例也采用这种方式。理由:
- 相比普通
dict,提供更明确的字段约束和类型提示; - 相比
dataclass,更贴近 LangGraph 的状态更新方式——节点返回的是”部分状态更新”字典,而不是完整对象; - 相比
Pydantic BaseModel,更轻量,没有额外的数据校验开销。
State Reducer:状态合并机制
Reducer 是 LangGraph 中用于合并状态更新的核心机制。它定义了当多个节点对同一状态键写入更新时,如何将这些更新合并为一个最终结果。
Reducer 的核心特征:
- 函数签名:
(Value, Value) -> Value,接收当前值和更新值,返回合并后的新值; - 注解定义:通过
Annotated[Type, reducer_function]为状态键指定 Reducer; - 默认行为:未指定 Reducer 的状态键使用覆盖策略(Last-Write-Wins)。
自定义一个 Reducer 就是写一个二元合并函数:
1 | def my_reducer(left: list[str], right: list[str]) -> list[str]: |
其中 left 是当前已累计的状态值,right 是本次待合并的新值。再通过 Annotated 与状态字段关联:
1 | class OverAllState(TypedDict): |
Annotated[] 是 Python 的类型注解扩展机制:第一个参数是原始类型,后续参数是附加元数据。Annotated 本身不规定元数据的含义,由框架自行解析——LangGraph 用它把”字段类型”和”合并规则”同时声明在字段定义中。
两个最常用的内置 Reducer:
**1. operator.add**:Python 内置加法函数,等价于 a + b,可用于数字累加、列表拼接:
1 | from operator import add |
2. add_messages:专用于合并对话消息列表的 Reducer,维护 BaseMessage 对象序列(HumanMessage、AIMessage、SystemMessage、ToolMessage 等)。它不是简单的列表拼接,而是基于消息 id 的合并:
- 若新消息的
id在已有列表中不存在,则追加到末尾; - 若
id相同,则用新消息替换旧消息。
1 | from langgraph.graph.message import add_messages |
合并结果保留了 id='1' 的系统消息,id='2'、id='3' 被替换为新内容,id='4'、id='5' 被追加。这个”追加 + 按 id 覆盖”的语义,正是构建多轮对话和 Agent 消息流的基础。
默认覆盖与 Overwrite
默认行为:没有定义 Reducer 的字段,后一次更新直接覆盖旧值:
1 | class OverAllState(TypedDict): |
节点更新 State 的核心规则可以概括为:
节点只返回需要更新的字段;未返回的字段保持不变;返回的字段根据是否配置 Reducer 决定是合并还是覆盖。
Overwrite 绕过 Reducer:如果字段定义了 Reducer,但某一次更新希望直接覆盖旧值,可以用 Overwrite 包裹:
1 | from langgraph.types import Overwrite |
以 START -> node_a -> node_b -> node_c 为例(logs 绑定了 add):
- 没有
Overwrite时,结果为['START', 'node_a', 'node_b', 'node_c']; node_b使用Overwrite后,结果为['node_b', 'node_c']。
Overwrite 只影响当前这一次更新,不会修改字段本身的 Reducer 定义——node_c 的正常返回仍然走追加合并。
Multi Schema:输入、输出与私有状态
LangGraph 支持在一个图中使用多个状态 Schema,区分图的边界和内部数据流:
| 状态类型 | 声明位置 | 作用 |
|---|---|---|
| 全局状态 / 内部状态 | StateGraph(state_schema=...) |
图内部主要使用的状态 |
| 输入状态 | input_schema=... |
约束调用图时允许传入哪些字段 |
| 输出状态 | output_schema=... |
约束图运行结束后只返回哪些字段 |
| 私有状态 | 节点函数的入参类型注解 | 节点之间传递的临时状态,不作为图的输入输出 |
输入/输出状态面向图的边界,全局/私有状态面向图内部的数据传递。
完整案例——三个节点分别演示全局状态、私有状态和输出状态:
1 | from typing import TypedDict |
输出:
1 | { "graph_output": "Dear 小黄, 早上好~ 很高兴认识你!" } |
虽然图内部同时存在 username、nickname、greeting 等字段,但最终结果只返回 graph_output——因为输出会按照 output_schema 裁剪。
背后的机制:LangGraph 的状态底层被拆分为多个 Channel(每个状态字段对应一个)。创建 StateGraph 时,state_schema、input_schema、output_schema 中的字段被记录;add_node() 时,节点入参类型注解中的新字段(如私有状态)也会被记录。运行时:
- 调用图时,输入按
input_schema裁剪; - 节点接收到的状态按其入参类型裁剪;
- 节点返回的是部分更新,按字段名写入对应 Channel;
- 最终输出按
output_schema裁剪。
设计规范(工程最佳实践):
- 输入/输出状态通常应是全局状态的子集;
- 私有状态与全局状态应避免字段重名;
- 节点函数应明确声明入参和返回的状态类型;
- 节点中不要访问入参类型中不存在的字段(会抛
KeyError); - 节点返回的字典尽量与返回类型注解保持一致。
预定义状态:MessagesState 与 AgentState
MessagesState 是官方为 LLM 场景预定义的状态类型,源码只有一行:
1 | class MessagesState(TypedDict): |
它预定义好了 messages 字段及其 add_messages 合并规则。构建聊天机器人、Agent、工具调用流程时,直接继承并扩展自定义字段即可:
1 | from langgraph.graph.message import MessagesState |
执行时,node_a 返回的 HumanMessage 和 llm_node 返回的 AIMessage 都会经由 add_messages 合并进 messages,最终状态中包含完整的消息列表。
AgentState(langchain.agents.middleware.types.AgentState)是 LangChain Agent 内部使用的状态类型,包含三个字段:
messages:消息列表,同样使用add_messages;jump_to:Agent 中间件体系内部的控制字段,表示流程跳转意图。注意它不是通用跳转机制——自定义StateGraph中即使定义了该字段也不会自动跳转,普通运行图控制流向应使用Command(goto=...);structured_response:Agent 结构化输出的存放字段,OmitFromInput标记表示它不作为外部输入暴露。
AgentState 专为 LangChain Agent 运行时设计,普通自定义 LangGraph 项目不建议直接基于它扩展。
控制流
顺序结构
add_edge 在两个节点之间添加一条有向边,是最基础的控制流构建方式:
1 | builder.add_edge(START, "node_a") # 图从 node_a 开始执行 |
如果要构建一组按顺序执行的节点,也可以用 add_sequence 简化:
1 | builder.add_sequence([node_a, node_b]) |
默认函数名被用作节点名,相邻节点之间自动加边,适合简单的线性流程。
指向 END 的边可以省略吗? 可以,但不建议。从运行机制看,LangGraph 在每个 Superstep 开始时根据发生更新的 Channel 计算本轮任务列表,如果没有新节点被激活,图运行自然结束——END 并不是运行时真正执行的节点,它只表达”终止语义”。但显式的 END 边让图结构更完整、可视化渲染更清晰,在分支和循环场景中尤其利于阅读维护。
与 END 不同,**START 通常不能省略**:它告诉 LangGraph 图从哪些节点开始执行,没有从 START 出发的边,运行时就无法确定初始执行节点。
分支结构
分支分为两类,核心判断标准:
编译期就知道下游候选集合 → 静态分支;运行时才决定目标或任务数量 → 动态分支。
并行节点
并行节点是最简单的静态分支:多个节点从同一个上游触发,在同一个超步中被激活:
1 | builder.add_edge(START, "node_a") |
需要注意的是:
- 这里的”并行”主要指调度语义上的并行,两个节点之间没有先后依赖;
- 它们的输出在当前超步执行完成后统一合并到状态中;
- 如果两个节点写入同一个状态字段,该字段通常需要配置 Reducer,否则会抛出
InvalidUpdateError。
条件分支:add_conditional_edges
add_conditional_edges 从一个上游节点出发,根据运行时状态选择下游节点,核心参数有三个:
- **
source**:条件分支的起始节点; - **
path**:路由规则(通常是函数),返回值表示跳转目标——可以是单个节点名 /END,也可以是多个目标组成的序列; - **
path_map**:路由返回值到真实节点名的映射,可省略。
不使用 path_map 时,路由函数必须直接返回合法的节点名:
1 | def router(state: OverAllState) -> Literal["node_a", "node_b"]: |
使用 path_map 时,路由函数可以返回业务语义更强的标签,再映射到节点:
1 | def router(state: OverAllState) -> Literal["a", "b"]: |
好处:路由函数返回业务含义清晰的标签,节点名保持工程化命名,且渲染图时边上会显示路由标签,图更容易理解。在可视化结果中,条件边渲染为虚线(固定边是实线)。
路由到多个节点:路由函数返回序列即可同时触发多个下游:
1 | def router(state) -> Sequence[Literal["node_a", "node_b", "node_c"]]: |
此时建议显式传入 path_map=["node_a", "node_b", "node_c"](列表形式等价于恒等映射字典)。否则渲染器无法从返回值推断映射关系,画出的图中节点会”游离”在结构之外。
延迟节点:defer
如果希望某些节点(日志、审计、结果汇总、收尾清理)在所有常规任务执行完毕后再运行,可以在添加节点时设置 defer=True:
1 | builder.add_node("audit_node", audit_node, defer=True) |
audit_node 虽然也由 START 触发,但不会立即执行,而是等常规流程结束后,在额外的超步中执行——因此它能读取到所有任务节点已提交的状态:
1 | def audit_node(state: OverAllState) -> OverAllState: |
底层机制(了解即可):编译时 LangGraph 为 defer=True 节点的入边创建特殊 Channel 类型 LastValueAfterFinish(普通节点是 EphemeralValue)。常规运行阶段该通道被写入但不触发下游;当没有新的普通节点需要执行时,运行时调用所有 Channel 的 finish() 方法,特殊通道随即变为可用,延迟节点在额外超步中被触发。
动态扇出:Send
扇出(Fan-out)指一个上游节点像扇子一样向外分发多个下游任务。与静态并行不同,Send 允许运行时决定任务数量和每个任务的输入——比如根据一个列表,为每个元素动态启动一个处理任务。
路由函数返回一个 Send 实例序列,每个实例描述一次独立的任务分发:
1 | from langgraph.types import Send |
完整示例——根据主题动态生成诗、词、笑话三个任务,全部派发给同一个 worker_node:
1 | CONTENT_TYPES = ["poem", "ci_poem", "joke"] |
运行时动态创建三个指向 worker_node 的任务,它们执行同一个节点函数,但接收到的私有状态不同,因此分别生成诗、词和笑话,三个任务在同一个超步中并行执行。
两个要点:
path_map=["worker_node"]必须显式声明——Send的目标和数量是运行时才确定的,渲染器无法提前推断,声明后图结构才能正确展示条件边;- 如果多个并行任务写入同一个状态字段,必须为该字段定义 Reducer 来合并输出。本例三个任务分别写
poem、ci_poem、joke三个不同字段,所以不存在并发合并问题。
动态跳转:Command(goto)
Command 是 LangGraph 中控制图执行的多功能原语,构造器接受四个参数:
| 参数 | 作用 |
|---|---|
update |
更新图状态,等同于节点直接返回状态更新字典 |
goto |
指定节点执行完成后的跳转目标,实现运行时条件分支 |
graph |
存在子图时,指定跳转发生在哪一层图中 |
resume |
恢复被中断的图执行(human-in-the-loop 场景) |
当一个节点既要更新状态、又要决定下一跳时,Command 比条件边更合适:
1 | from langgraph.types import Command |
三个注意点:
- 返回值类型注解
Command[Literal[...]]不参与运行时校验,它的作用是让 LangGraph 和类型检查工具知道该节点可能跳转到哪些目标——对图结构渲染非常重要,不声明的话渲染出的图无法表达潜在跳转关系; - 使用
Command(goto=...)的节点,不要再添加普通下游边,否则普通边和Command跳转可能同时生效,触发多个下游节点; Send与Command的目标节点都必须提前注册,”动态”指的是运行时决定任务数量、任务输入或跳转路径,而不是运行时创建新节点。
Send 与 Command 的分工:
| 场景 | 典型 API | 一句话 |
|---|---|---|
| 动态扇出 | Send + add_conditional_edges() |
运行时要启动多少个任务实例 |
| 动态跳转 | Command(goto=...) |
当前节点执行完后要去哪里 |
多分支汇聚:Fan-in
与扇出相对,扇入(Fan-in)指多个上游分支汇聚到同一个下游节点。根据触发条件分为两种:
- “与”触发:所有上游分支全部到达才触发下游节点;
- “或”触发:任意一个分支到达即触发下游节点。
(”与/或”只是帮助理解的类比,不是官方术语。)
静态扇入:”与”触发 vs “或”触发
这两种语义的写法看起来相似,行为完全不同:
1 | # “与”触发:node_e 等待 node_c 和 node_d 全部完成后,触发一次 |
用一个五节点的图验证(START → node_a → {node_b, node_c},node_b → node_d)。节点通过 config 参数读取当前超步编号:
1 | from langchain_core.runnables import RunnableConfig |
“与”触发(add_edge(["node_c", "node_d"], "node_e"))的运行日志:
1 | cur_step: 1, node_a 被触发 |
node_c 在第 2 步就完成了,但 node_e 等到第 4 步(node_d 完成后)才执行。
“或”触发(两条独立边)的运行日志:
1 | cur_step: 1, node_a 被触发 |
node_e 被触发了两次。这是并行汇聚场景中最容易踩的坑之一,务必根据业务语义选择正确的写法。
动态扇入:MapReduce 结构
用 Send 动态扇出多个子任务,子任务的中间结果通过 Reducer 合并(扇入),再由一个归约节点汇总——这就构成了经典的 MapReduce 结构:
1 | 输入数据 Map: 映射为中间键值对 |
实现一个词频统计任务:
1 | class OverAllState(TypedDict): |
输出:
1 | { |
关键机制:三个 mapper_node 任务实例并行执行,各自返回 entries;由于该字段绑定了 add Reducer,三份中间结果被合并到同一个状态字段;mapper_node → reducer_node 的边是”与”触发,reducer_node 等所有 mapper 完成后执行一次,完成分组和求和。
display(graph)展示的是节点级计算图(只有一个mapper_node),而实际运行时Send会按输入数据动态创建多个任务实例。
循环结构:ReAct
LangChain Agent 底层的运行图架构正是 ReAct(Reason + Action,”推理 + 行动”)循环:
- Reason:大模型根据当前消息状态推理,判断是否需要调用工具;
- Action:需要则生成工具调用请求;
- Observation:工具执行结果以
ToolMessage形式返回给大模型; - Loop:大模型基于新观察继续推理,决定是否继续调用工具;
- Final Answer:不再发起工具调用时,生成最终回答,流程结束。
本质是一个 “LLM → Tool → LLM → Tool → … → LLM” 的循环:
1 | START |
静态实现与动态实现
同一个 ReAct 循环可以用两种方式实现,本质区别不在能否循环,而在路由逻辑写在哪里。
静态实现——路由逻辑在独立的 router() 函数中,通过 add_conditional_edges() 显式声明条件边。”静态”指下游候选集合在编译期已确定,运行时只是从中选择:
1 | def llm_node(state: OverAllState) -> OverAllState: |
动态实现——路由逻辑内聚在节点返回值中,llm_node 用 Command 同时完成”状态更新 + 下一跳控制”,不再需要独立的 router() 和条件边:
1 | def llm_node(state: OverAllState) -> Command[Literal["tool_node", "output_node"]]: |
| 静态实现 | 动态实现 | |
|---|---|---|
| 路由位置 | 独立的 router() 函数 |
节点返回值中 |
| 图结构表达 | 显式条件边,更直观 | 控制逻辑内聚,代码更紧凑 |
| 适合场景 | 路由规则独立、图结构清晰优先 | 节点执行结果直接决定下一跳 |
工具调用失败的重试行为值得注意:示例中靠系统提示词 "如果工具调用失败,必须重新调用直至成功" 驱动模型重试——是否重试由大模型决定,而不是程序逻辑强制保证。如果模型在多次失败后选择放弃并生成回答,运行图不会阻止它。需要硬性保证时,应在 tool_node 或路由逻辑中用代码实现重试。
递归限制与优雅退出
循环结构如果没有合理的停止条件,图可能无限执行。LangGraph 提供递归限制机制:recursion_limit 限制单次运行允许的最大 Superstep 数量,耗尽时抛出 GraphRecursionError:
1 | graph.invoke(input, config={"recursion_limit": 10}) |
默认值不可靠:它随版本和运行环境变化——
langchain_core.runnables.config中默认 25(LangChain Agent 生效)、langgraph._internal._config中默认 10000(自定义图生效)、LangGraph API 服务侧默认 10011。**最佳实践是显式传入recursion_limit**。
处理递归限制有两种方法:
主动方法(Proactive)——借助 RemainingSteps 托管值在图内部提前判断剩余步数,优雅退出:
1 | from langgraph.managed import RemainingSteps |
当前超步序号与剩余步数之和等于 recursion_limit。剩余步数不足时路由到 END,图正常结束,不抛异常,还能把中间状态保存到检查点、返回部分结果。
被动方法(Reactive)——在图外部捕获异常:
1 | from langgraph.errors import GraphRecursionError |
图已被异常中断,但进程不会崩溃,可以集中处理错误。
| 方法 | 超限检测 | 处理方式 | 控制流 |
|---|---|---|---|
主动(RemainingSteps) |
达到限制之前 | 图内部条件路由 | 图继续运行,正常结束 |
被动(捕获 GraphRecursionError) |
超出限制之后 | 图外部 try/except |
图运行被异常中断 |
选择建议:循环本身是业务逻辑的一部分(ReAct 循环、多轮反思、自我修正、工具重试)时,优先用主动方法在图内优雅降级;被动方法作为最后一道防线保留在最外层。
边的系统总结
把前文的边用法做一次系统梳理:
| 边类型 | 写法 | 语义 |
|---|---|---|
| 普通边 | add_edge("a", "b") |
a 完成后直接执行 b |
| 条件边 | add_conditional_edges("a", router) |
a 完成后由路由函数决定进入哪个/哪些节点 |
| 入口点 | add_edge(START, "a") 或 set_entry_point("a") |
图开始运行时首先执行 a(二者等价) |
| 条件入口点 | add_conditional_edges(START, router) |
以 START 为起点的条件边 |
| 终止点 | add_edge("a", END) 或 set_finish_point("a") |
a 完成后流程结束(二者等价) |
| 条件终止点 | 路由函数返回 END |
根据状态决定继续执行还是终止 |
三点注意:
- 一个节点可以有多个出边,目标节点会在下一个超步中并行执行;
- 条件边也可以通过节点返回
Command(goto=...)实现,方式不同但性质和作用完全一致; - 同一个节点尽量只用一种路由机制——不要混用普通边和条件边/
Command,否则多条路径可能同时生效,导致运行逻辑混乱。
另外,END 不是普通业务节点,而是特殊终止标记:编译后的运行图中,终止边和 END 没有对应的 Channel 和节点。终止边可以省略(运行时在无活跃节点时自然终止),但为了可读性和可视化渲染正常,最好显式声明。
节点执行与容错机制
节点执行失败时若不做处理,可能导致整个图运行失败。LangGraph 针对四类问题提供对应机制:
| 问题/场景 | 对应机制 | 类型 | 版本要求 |
|---|---|---|---|
| 临时故障(网络抖动、接口偶发失败) | 重试 RetryPolicy |
容错 | 当前版本可用 |
| 节点执行时间过长 | 超时 TimeoutPolicy |
容错 | langgraph≥1.2 |
| 重试耗尽后需要兜底 | 错误处理 error_handler |
容错 | langgraph≥1.2 |
| 相同输入重复触发耗时节点 | 缓存 CachePolicy |
性能优化 | 当前版本可用 |
失败处理的整体顺序:
1 | 节点执行失败 |
重试机制:RetryPolicy
重试是最常用、最基础的容错能力,在 add_node 时通过 retry_policy= 配置:
1 | from langgraph.types import RetryPolicy |
运行日志显示节点被调用了 3 次(间隔约 0.5s、1s,指数退避),全部失败后异常向外抛出,可在外层 try/except 捕获。
RetryPolicy 主要参数:
| 参数 | 默认值 | 描述 |
|---|---|---|
max_attempts |
3 |
最大尝试次数(包括首次执行) |
initial_interval |
0.5 |
第一次重试前的等待秒数 |
backoff_factor |
2.0 |
每次重试后等待时间的放大倍数 |
max_interval |
128.0 |
相邻两次重试的最大等待秒数 |
jitter |
True |
是否为重试间隔添加随机抖动 |
retry_on |
default_retry_on |
哪些异常触发重试 |
理解要点:
max_attempts=3表示总共执行 3 次(首次 + 2 次重试),不是失败后再重试 3 次;- 指数退避:
initial_interval=0.5, backoff_factor=2.0时等待时间约为0.5s → 1s → 2s → 4s...,且不超过max_interval; - jitter(抖动):并发场景下大量任务同时失败后若按固定间隔同时重试,可能把服务再次打爆;加入随机抖动可打散重试时间、削平瞬时流量峰值。生产环境建议保留默认
jitter=True; retry_on支持三种写法:单个异常类型retry_on=HTTPError、多个异常类型retry_on=(HTTPError, ConnectionError)、自定义判断函数retry_on=lambda exc: isinstance(exc, HTTPError)。
默认重试规则:default_retry_on
不指定 retry_on 时使用默认的 default_retry_on,其核心逻辑:
1 | def default_retry_on(exc: Exception) -> bool: |
设计逻辑:
- 4xx 不重试:客户端请求错误(参数错误、鉴权失败、资源不存在),重试没有意义;
- 5xx 重试:服务端临时异常(服务不可用、网关错误),适合通过重试恢复;
ValueError、TypeError、SyntaxError等通常是代码逻辑错误,单纯重试无用。
超时控制与错误处理
以下两个特性要求 langgraph>=1.2,这里只做概念说明。
超时控制(TimeoutPolicy):限制单次节点执行的最大耗时,避免节点因外部服务无响应而长时间阻塞:
1 | from langgraph.types import TimeoutPolicy |
超过 60 秒抛出 NodeTimeoutError,交给 RetryPolicy 判断是否重试,重试耗尽才进入错误处理。常见参数:run_timeout(单次运行最长时间)、idle_timeout(无可观察进展时的最长空闲时间)、refresh_on(空闲时间的刷新方式)。
注意:节点级超时仅适用于异步节点(
async def)。同步节点一旦执行就会阻塞线程,Python 缺少安全的外部强杀机制。同步节点应在内部使用具体库自带的timeout参数(HTTP 客户端、数据库驱动等)。
错误处理(error_handler):节点最终失败后执行兜底逻辑:
1 | builder.add_node( |
handle_api_error 可以返回状态更新,也可以通过 Command 路由到其他节点。适用场景:API 失败后返回兜底结果、切换到备用服务、执行补偿逻辑、避免单个节点失败导致整图终止。
节点缓存:CachePolicy
节点缓存指:将节点的历史运行结果保存下来,后续收到相同输入时不再重复执行,直接返回缓存结果——用于减少重复计算、降低延迟和成本。
适用场景需同时满足:
- 节点函数具有确定性:相同输入产生相同输出;
- 执行成本较高:大模型调用、API 请求、复杂计算等;
- 相同输入会重复出现:多次调试、重复流程、多分支复用中间结果;
- 输入能被缓存键函数稳定处理:默认用
pickle序列化生成缓存 Key,建议输入是简单可序列化结构(str/int/dict/list等);包含文件句柄、数据库连接、模型实例等复杂对象时应自定义key_func。
配置需要两步,缺一不可:
1 | from langgraph.cache.memory import InMemoryCache |
只配 cache_policy 不传 cache=,缓存不会生效。CachePolicy 只有两个参数:
| 参数 | 描述 |
|---|---|
key_func |
根据节点输入生成缓存 Key 的函数,默认 default_cache_key(pickle 序列化) |
ttl |
缓存存活秒数;None 表示永不过期 |
用一个模拟 3 秒耗时操作的节点验证缓存行为:
1 | def node_a(state: EmptyState) -> EmptyState: |
| 调用 | 输入 | 是否命中缓存 | 现象 |
|---|---|---|---|
| 第 1 次 | user=小明 |
未命中 | 执行节点(3s),结果写入缓存 |
| 第 2 次 | user=小明(相同) |
命中 | 不执行节点,直接返回缓存结果 |
| 第 3 次 | user=小花(不同) |
未命中 | 执行节点,写入新缓存 |
| ttl 过期后 | user=小明 |
未命中 | 重新执行,并重新写入缓存 |
ttl 设置提醒:如果节点依赖外部状态(当前时间、远程 API 数据、数据库查询、提示词/模型版本等),不要设置永久缓存——应根据业务设置合适的
ttl,或把这些变量纳入缓存 Key 的计算逻辑。
此外,langgraph>=1.2 还提供全图默认配置 set_node_defaults,可一次性为所有节点设置默认的 retry_policy、timeout、error_handler、cache_policy,避免在每个 add_node 中重复相同配置。
最佳实践
环境与配置
- API Key 一律放
.env并加入.gitignore,代码用load_dotenv()加载,绝不硬编码; - 用独立的 conda 虚拟环境隔离项目依赖。
状态设计
- 优先用
TypedDict定义 State Schema; - 会被多个节点(尤其并行节点)写入的字段,必须配置 Reducer,否则
InvalidUpdateError; - 用
input_schema/output_schema约束图的边界,私有状态字段避免与全局状态重名; - 节点只返回需要更新的字段,返回字典与返回类型注解保持一致。
图结构
- 显式声明指向
END的边,图结构更完整、可视化更清晰; - 同一个节点只用一种路由机制,不要混用普通边与条件边/
Command; - 使用
Command(goto=...)或Send时,用类型注解 /path_map显式声明可能的目标节点,保证图渲染正确; - 路由逻辑独立、图结构清晰优先 → 条件边;节点结果直接决定下一跳、代码内聚优先 →
Command。
循环与容错
- **显式传入
recursion_limit**,不要依赖随版本变化的默认值; - 业务性循环(ReAct、反思、重试)用
RemainingSteps主动优雅退出,外层再捕获GraphRecursionError兜底; - 生产环境重试保留
jitter=True,用retry_on限定可重试的异常类型; - 确定性 + 高成本 + 输入重复的节点才启用缓存,并设置合理的
ttl。
常见问题
Q1:conda 命令找不到?
Windows:确认安装时勾选了 “Add Miniconda3 to my PATH”,或直接从开始菜单打开 Anaconda Prompt。macOS / Linux:执行 ~/miniconda3/bin/conda init 后重启终端。
Q2:import langgraph 报 ModuleNotFoundError?
包没有安装到当前环境。conda activate langgraph 后用 pip show langgraph 确认,未安装则重新 pip install。
Q3:模型调用报 AuthenticationError?
依次检查:.env 中 Key 是否正确、.env 是否在项目根目录、代码开头是否执行了 load_dotenv(override=True)。
Q4:并行节点写同一个字段报 InvalidUpdateError?
同一超步内多个更新写同一字段且没有 Reducer 时会冲突。为字段配置合适的 Reducer(如 operator.add、add_messages),或让各节点写不同字段。
Q5:add_edge(["a", "b"], "c") 和分别写两条边到 c 有什么区别?
前者是”与”触发:c 等 a、b 全部完成后执行一次;后者是”或”触发:a、b 各自完成后分别触发 c(可能执行两次)。二者不等价,按业务语义选择。
Q6:条件边渲染出的图中,节点”游离”在结构之外?
路由函数返回序列或 Send 时,渲染器无法推断映射关系。显式传入 path_map 声明候选下游节点即可。
Q7:图一直循环最后报 GraphRecursionError?
循环没有可达的停止条件,或 recursion_limit 太小。检查路由逻辑,显式设置 recursion_limit,并考虑用 RemainingSteps 实现优雅退出。
Q8:配置了 CachePolicy 但缓存不生效?
缓存需要两步:节点上配 cache_policy= 且 编译时传 cache=InMemoryCache(),缺一不可。另外确认两次输入完全相同、缓存未超过 ttl。
Q9:draw_mermaid_png() 渲染失败?
底层调用在线服务(默认 mermaid.ink),网络问题可能失败,通常重试即可;也可通过 base_url 参数替换为可用的渲染服务。
Q10:jump_to 字段能用来控制自定义图的跳转吗?
不能。它是 LangChain Agent 中间件体系的内部字段,普通 StateGraph 不会因它自动跳转。自定义图控制流向请使用 Command(goto=...)。
总结
本文从零开始整合了 LangGraph 入门所需的核心内容:
- 环境:Miniconda + Python 3.13 +
.env管理 API Key,验证脚本确保环境就绪; - 定位:LangChain 提供 Agent 高层抽象,LangGraph 提供底层编排与运行时能力,
create_agent底层即基于 LangGraph; - 核心模型:State / Node / Edge 三要素,Superstep 三阶段(路由 → 执行 → 提交)驱动图运行,Checkpoint 在超步边界记录快照;
- 状态管理:TypedDict 是首选 Schema;Reducer 决定合并还是覆盖;Multi Schema 划分图的边界与内部数据流;
MessagesState为 LLM 场景开箱即用; - 控制流:顺序、并行、条件分支、
defer收尾、Send动态扇出、Command动态跳转、Fan-in 汇聚、MapReduce、ReAct 循环,以及recursion_limit下的主动/被动退出策略; - 容错:
RetryPolicy(指数退避 + jitter + 异常过滤)和节点缓存(两步配置)当前版本可用,超时与错误处理需langgraph>=1.2。
掌握了这些,你已经能用 LangGraph 构建结构清晰、可持久化、可容错的工作流。后续可以继续深入持久化与记忆管理、中断与 Human-in-the-loop、以及 LangGraph 部署等主题。