LangGraph State、节点与动态路由学习笔记

一、TypedDict:给字典定义结构

TypedDict 用来定义一种固定字段和字段类型的字典结构。

复制代码
from typing import TypedDict

class ChatState(TypedDict):
    user_message: str
    intent: str
    response: str

虽然语法使用:

复制代码
class ChatState(TypedDict):

但实际数据仍然是字典:

复制代码
state = {
    "user_message": "我要请假",
    "intent": "submit_leave",
    "response": ""
}

访问方式也是:

复制代码
state["intent"]

而不是:

复制代码
state.intent

可以记成:

TypedDict 本质上是给字典规定"里面应该有什么字段、字段是什么类型",只是定义时使用了 class 语法。


二、dataclass、BaseModel、TypedDict 的区别

TypedDict

主要用于:

描述字典结构。

复制代码
class State(TypedDict):
    intent: str

使用:

复制代码
state["intent"]

dataclass

主要用于:

方便定义一个以保存数据为主的普通类。

复制代码
from dataclasses import dataclass

@dataclass
class User:
    name: str
    age: int

使用:

复制代码
user.name

它会自动生成 __init__、__repr__、比较方法等。


Pydantic BaseModel

主要用于:

数据对象 + 运行时校验 + 数据转换。

复制代码
from pydantic import BaseModel

class User(BaseModel):
    name: str
    age: int

特别适合:

复制代码
FastAPI 请求
配置文件
LLM 结构化输出
外部数据

简单记:

复制代码
TypedDict   → 给字典规定结构
dataclass   → 快速创建数据类
BaseModel   → 数据类 + 数据校验

三、Node 和节点函数不是同一个概念

之前容易误解的一句话:

Node 是 LangGraph 中的具体执行单元,通常实现为一个函数。

这句话不够严谨,容易理解成:

复制代码
函数 = 节点

更准确应该是:

Node 是图中的执行单元;节点绑定一个可调用对象,通常是函数,函数负责实现这个节点的逻辑。

例如:

复制代码
def recognize(state):
    ...

builder.add_node("intent_recognition", recognize)

这里:

复制代码
"intent_recognition"
        ↓
节点名称

recognize
        ↓
节点执行的函数逻辑

甚至可以:

复制代码
builder.add_node("node_a", recognize)
builder.add_node("node_b", recognize)

这时候:

复制代码
node_a ──→ recognize()
node_b ──→ recognize()

图中是两个不同的节点,但是使用同一个函数逻辑。

所以记住:

节点是"点",函数是这个点执行的具体逻辑。


四、State 是节点之间通信的核心

LangGraph 的 StateGraph 本质上就是:

节点通过读取和写入共享 State 来通信。

官方对节点的典型描述就是:

复制代码
State → Partial<State>

也就是:

复制代码
读取当前 State
      ↓
执行节点逻辑
      ↓
返回部分 State 更新

节点之间并不是:

复制代码
node_b(node_a的返回值)

而是:

复制代码
node_a
   ↓
return {...}
   ↓
更新 Graph State
   ↓
node_b
   ↓
从 Graph State 读取

所以一句话:

节点之间不是直接传参数,而是通过共享 State 通信。


五、全局 State 的作用

例如:

复制代码
class OverallState(TypedDict):
    user_message: str
    intent: str
    response: str

builder = StateGraph(OverallState)

可以理解成:

OverallState 定义这个 Graph 最基本、最核心的共享状态结构。

例如:

复制代码
user_message
intent
response

这些就是整个图主要维护的数据。

但是需要注意:

全局 State 不应该简单理解成"整个运行时状态空间的唯一可能结构"。

如果节点声明了额外的、图能够识别的内部状态 schema,那么图内部还可以有用于节点间通信的状态字段。

所以我们后来区分出了:

复制代码
全局 State
≠
脑子里所理解的所有运行时状态的唯一集合

更好的理解是:

复制代码
全局 State
→ 图最核心的共享状态 schema

内部/私有 State
→ 特定节点之间需要使用的额外状态 schema

六、状态空间与全局 State 不要混为一谈

这是这两天一个非常关键的认识。

最开始容易认为:

复制代码
状态空间 = OverallState

于是就会产生疑问:

OverallState 里没有 temp_result,那节点怎么可能传递 temp_result?

更合适的心智模型是:

复制代码
Graph 运行过程中涉及的状态 channels
│
├── 核心 State 字段
│   ├── user_message
│   ├── intent
│   └── response
│
└── 图已经认识的内部状态字段
    ├── temp_result
    └── ...

但这里要注意一个严谨点:

不能理解成节点随便 return****一个从未在任何 schema 中声明的字段,LangGraph 就一定永久保存它。

内部字段也应该通过图所认识的状态 schema 定义。


七、私有 State

私有 State 可以理解成:

只在图内部节点之间使用、不需要作为整个 Graph 对外输入或最终输出暴露的状态。

例如:

复制代码
class PrivateState(TypedDict):
    temp_result: str

某个节点写入:

复制代码
return {
    "temp_result": "abc"
}

后续需要这个内部字段的节点可以读取:

复制代码
def node_b(state: PrivateState):
    print(state["temp_result"])

这里的"私有"不是 Python 的:

复制代码
__xxx

这种私有属性。

而是:

这是工作流内部使用的数据,不是 Graph 对外接口最关心的数据。


八、私有 State 生命周期

私有字段不是:

复制代码
节点执行完
↓
马上消失

而是在本次工作流执行过程中,作为状态 channel 供后续节点使用。

后续节点如果再次更新同一个字段:

复制代码
return {
    "temp_result": "new"
}

那么就会按照这个字段对应的 reducer/update 规则更新。

是否能够跨多次运行保存,则涉及:

复制代码
checkpointer
thread
持久化

这是另外一层机制。


九、节点输入 State:决定这个节点看到什么

例如:

复制代码
def node_a(state: OverallState):
    ...

这里的:

复制代码
state: OverallState

可以理解成这个节点的输入状态视图/schema。

节点从 Graph 当前状态里读取自己需要的数据:

复制代码
message = state["user_message"]

所以可以记:

节点输入 State 负责描述这个节点读取什么状态。


十、节点 return:返回的是 State 更新

例如:

复制代码
def node_a(state):
    return {
        "intent": "submit_leave"
    }

它不是说:

新 State 现在只有 intent。

而是在说:

我要更新当前 State 的 intent**。**

假设原来:

复制代码
{
    "user_message": "我要请假",
    "intent": "",
    "response": ""
}

节点返回:

复制代码
{
    "intent": "submit_leave"
}

之后可以理解成:

复制代码
{
    "user_message": "我要请假",
    "intent": "submit_leave",
    "response": ""
}

所以:

节点通常只 return 自己需要更新的那几个字段。

LangGraph 官方也把节点模型描述为读取 State、返回 Partial<State>。


十一、Reducer:决定同一字段如何更新

普通字段:

复制代码
intent: str

顺序更新时,一般后一次更新会取代前一次值。

但是如果多个并行节点在同一步更新同一个普通 channel,就可能产生冲突。

如果我们希望多个结果进行合并,就定义 reducer:

复制代码
from typing import Annotated
from operator import add

class State(TypedDict):
    logs: Annotated[list[str], add]

那么:

复制代码
{"logs": ["A"]}

和:

复制代码
{"logs": ["B"]}

可以通过 add 聚合为:

复制代码
["A", "B"]

官方 StateGraph 也明确说明:每个 State key 都可以配置 reducer,用来聚合多个节点对该字段的更新。

所以记:

复制代码
普通字段
→ 普通状态更新

Annotated[..., reducer]
→ 按 reducer 合并

十二、invoke() 是初始化 Graph State

例如:

复制代码
graph.invoke({
    "user_message": "我要请假"
})

不要把它理解成:

复制代码
把参数直接传给 START

也不是:

复制代码
直接调用 node_a({"user_message": ...})

更好的理解:

复制代码
invoke(...)
    ↓
为本次 Graph 运行提供初始 State
    ↓
START
    ↓
第一个节点读取当前 State

所以:

**invoke()**给整个 Graph 提供初始状态,而不是给某一个节点直接传参。


十三、START 的作用

假设:

复制代码
START → A

调用:

复制代码
graph.invoke({
    "user_message": "我要请假"
})

流程:

复制代码
初始化 Graph State
      ↓
START
      ↓
A

START 本身更像一个特殊的入口标记。

真正第一个执行你代码逻辑的是:

复制代码
A

所以:

invoke() 初始化 State,START 指定工作流入口,START 后面的节点第一次真正读取 State。


十四、add_sequence()

例如:

复制代码
builder.add_sequence([
    node_a,
    node_b
])

表示:

复制代码
node_a
   ↓
node_b

LangGraph 会按照列表顺序添加并连接这些节点。

如果没有显式提供名称,节点名称会从函数/Callable 对象推断出来。官方 API 也支持:

复制代码
[(name, node), ...]

这种明确指定名字的方式。

所以:

复制代码
builder.add_sequence([node_a, node_b])

节点仍然有名字,只是没有手动写。


十五、add_edge:固定跳转

例如:

复制代码
builder.add_edge("A", "B")

表示:

复制代码
A
↓
B

A 执行结束以后,固定执行 B。

这是:

静态路由。


十六、add_conditional_edges:条件动态路由

例如:

复制代码
def route(state):
    if state["intent"] == "submit_leave":
        return "submit_node"
    else:
        return "chat_node"

然后:

复制代码
builder.add_conditional_edges(
    "recognize",
    route
)

执行:

复制代码
recognize
    ↓
route(state)
    ↓
根据当前 State
    ↓
submit_node / chat_node

所以:

**add_conditional_edges()**是图结构层面的动态分支。

路由函数的输入也是当前 Graph State。

官方定义中,path 函数负责返回一个或多个下一节点;如果没有 path_map,返回值应该直接对应节点名。


十七、path_map

情况 1:直接返回节点名

复制代码
def route(state):
    return "node_a"

那么:

复制代码
builder.add_conditional_edges(
    "start",
    route
)

不一定需要 path_map。

因为:

复制代码
"node_a"

本身就是节点名。


情况 2:返回别名

例如:

复制代码
def route(state):
    return "a"

那么可以:

复制代码
path_map = {
    "a": "node_a",
    "b": "node_b"
}

于是:

复制代码
route 返回 "a"
      ↓
path_map
      ↓
node_a

所以:

字典形式的 path_map 可以完成"路由标签 → 节点名"的映射。


情况 3:path_map 列表

例如:

复制代码
path_map=["worker_node"]

这里没有:

复制代码
别名 → 节点

这种映射关系。

它更多是在明确:

这个条件路径可能到达哪些节点。

如果没有 path_map 或返回类型提示,LangGraph 在图可视化等场景可能只能保守地认为它可能跳到很多节点;显式的 path_map 能帮助描述可能的目标范围。


十八、为什么多个 Send,path_map 可能只有一个值

例如:

复制代码
return [
    Send("worker_node", {"task": "A"}),
    Send("worker_node", {"task": "B"}),
    Send("worker_node", {"task": "C"})
]

虽然有 3 个 Send,但目标节点种类只有:

复制代码
worker_node

只是这个节点被运行了 3 个实例:

复制代码
worker_node(task=A)
worker_node(task=B)
worker_node(task=C)

所以:

复制代码
path_map=["worker_node"]

完全合理。

记住:

Send 数量表示运行多少任务实例;path_map 描述可能涉及哪些节点名称。


十九、Command(goto=...)

Command 是另外一种动态控制流程的方法。

例如:

复制代码
from langgraph.types import Command

def node_a(state):
    if state["intent"] == "submit_leave":
        return Command(goto="submit_node")

    return Command(goto="chat_node")

可以理解成:

复制代码
node_a
   ↓
节点自己决定下一步
   ↓
submit_node / chat_node

和:

复制代码
builder.add_conditional_edges(...)

相比:

复制代码
add_conditional_edges
→ 图结构外部定义路由函数

Command(goto=...)
→ 节点内部直接决定下一步

二十、Command 可以同时更新 State

例如:

复制代码
return Command(
    update={
        "intent": "submit_leave"
    },
    goto="submit_node"
)

相当于同时:

复制代码
1. 更新 State
2. 决定下一站

即:

复制代码
intent = "submit_leave"
      +
goto submit_node

官方 Command 当前支持 update 和 goto,其中 goto 可以是节点名、多个节点名、Send 或多个 Send。


二十一、add_conditional_edges 和 Command 的关系

这两者都是:

动态控制"下一步去哪"。

但是控制位置不同。

add_conditional_edges

复制代码
节点执行完
    ↓
额外调用 route(state)
    ↓
route 决定下一步

Command

复制代码
节点函数执行过程中
    ↓
自己 return Command(...)
    ↓
直接决定下一步

因此可以记:

add_conditional_edges**= 图层面的动态路由。**
Command(goto=...)****= 节点内部的动态跳转。

整个 Graph 当然可以同时使用这两种机制。

但同一个路由决策通常没有必要重复设计两套逻辑。


二十二、Send 是什么

Send 最核心的理解:

动态执行某个节点,并给这一次节点执行一份自定义输入。

语法:

复制代码
Send(
    "节点名",
    这一次节点的输入
)

例如:

复制代码
Send(
    "worker",
    {"task": "A"}
)

表示:

复制代码
动态执行 worker 节点
        +
这次 worker 收到:
{"task": "A"}

注意:

复制代码
"worker"

是节点名,不是函数名。

假设:

复制代码
def worker_func(state):
    ...

注册:

复制代码
builder.add_node(
    "worker",
    worker_func
)

那么:

复制代码
Send("worker", {"task": "A"})

关系是:

复制代码
Send
 ↓
找到 "worker" 节点
 ↓
worker 节点绑定 worker_func
 ↓
用 {"task": "A"} 执行这次 worker

官方定义中,Send(node, arg) 的第一个参数就是目标节点名,第二个参数就是发送给这个节点的自定义 state/message。


二十三、Send 最大的特点:自定义目标节点输入

普通节点通常通过 Graph 当前 State 获得输入。

但:

复制代码
Send("worker", {"task": "A"})

这一次 worker 的输入就是:

复制代码
{"task": "A"}

官方明确说明:

Send 发出的 state 可以和 Graph 的核心 State 不同。

这也是它适合 map-reduce 和动态任务分发的原因。

如果 worker 还需要其他信息:

复制代码
Send(
    "worker",
    {
        "task": task,
        "user_message": state["user_message"]
    }
)

就自己一起传进去。


二十四、Send 执行完成以后

Send 改变的是:

这个 worker 这一次吃什么输入。

worker 返回的数据仍然会按照 Graph State 的规则产生更新。

例如:

复制代码
def worker(state):
    return {
        "results": [
            state["task"] + "完成"
        ]
    }

多个 worker:

复制代码
worker(A)
→ ["A完成"]

worker(B)
→ ["B完成"]

worker(C)
→ ["C完成"]

为了安全聚合结果,通常会使用 reducer:

复制代码
results: Annotated[list[str], add]

于是:

复制代码
[
    "A完成",
    "B完成",
    "C完成"
]

二十五、Send 的核心不是普通跳转,而是任务分发

虽然 Send 看起来像:

复制代码
突然跳到 worker

这个理解有助于入门。

但是更准确的语义是:

动态发送一个执行任务到某个节点。

特别是:

复制代码
[
    Send("worker", {"task": "A"}),
    Send("worker", {"task": "B"}),
    Send("worker", {"task": "C"})
]

这就非常明显:

复制代码
一个分发点
     ↓
 ┌───┼───┐
 ↓   ↓   ↓
A    B    C

它不是简单"跳转一次",而是:

动态产生多个节点执行实例。


二十六、Send 最经典的使用方式

最经典的是:

复制代码
def dispatch(state):
    return [
        Send("worker", {"task": task})
        for task in state["tasks"]
    ]

builder.add_conditional_edges(
    "dispatch_node",
    dispatch
)

流程:

复制代码
dispatch_node
     ↓
dispatch(state)
     ↓
产生多个 Send
     ↓
worker(A)
worker(B)
worker(C)

官方 Send 文档也把 conditional edges + map-reduce 作为典型场景。


二十七、Command 也可以配合 Send

Command.goto 当前也支持 Send。

例如:

复制代码
return Command(
    goto=Send(
        "worker",
        {"task": "A"}
    )
)

甚至:

复制代码
return Command(
    goto=[
        Send("worker", {"task": "A"}),
        Send("worker", {"task": "B"}),
        Send("worker", {"task": "C"})
    ]
)

意思就是:

复制代码
当前节点
   ↓
Command 控制下一步
   ↓
通过多个 Send
   ↓
动态派发三个任务

官方当前 Command.goto 类型明确支持一个 Send 或多个 Send。


二十八、三种机制最终怎么记

这是目前最重要的总结。

add_conditional_edges

重点:

动态选择路径。

复制代码
根据 State
   ↓
A / B / C

Command(goto=...)

重点:

当前节点主动控制下一步。

复制代码
当前节点
   ↓
我自己决定 goto 哪里

Send

重点:

动态分发任务,并可以给每个任务自定义输入。

复制代码
任务列表
   ↓
worker(A)
worker(B)
worker(C)

因此可以总结成:

跳转/路由主要看 add_conditional_edges****和 Command**;** Send****更强调任务分发。


二十九、静态 Fan-out / Fan-in

Fan-out:扇出

一个节点分成多个节点:

复制代码
        B
      ↗
A
      ↘
        C

也就是:

复制代码
一个 → 多个

Fan-in:扇入

多个节点汇聚到一个节点:

复制代码
B ──┐
    ├──→ D
C ──┘

也就是:

复制代码
多个 → 一个

三十、静态扇入

如果:

复制代码
builder.add_edge(
    ["B", "C"],
    "D"
)

表示:

复制代码
B 完成 ──┐
         ├──→ D
C 完成 ──┘

这种多起点边会等待所有指定的上游节点完成后再执行下游节点。

所以它是一种明显的:

复制代码
AND 汇合

即:

B 和 C 都完成,再进入 D。

之所以叫"静态",是因为:

复制代码
B
C
D

这些节点关系在建 Graph 时就已经确定。


三十一、动态扇出

使用 Send:

复制代码
return [
    Send("worker", {"task": task})
    for task in state["tasks"]
]

如果:

复制代码
tasks = ["A", "B", "C"]

就是:

复制代码
dispatch
    ↓
worker(A)
worker(B)
worker(C)

如果 tasks 有 100 个:

复制代码
worker × 100

运行之前可能不知道具体有多少个。

这就是:

动态 Fan-out。


三十二、动态扇入

多个动态 worker 返回:

复制代码
{"results": [...]}

通过:

复制代码
results: Annotated[list[str], add]

聚合:

复制代码
worker(A) ──┐
worker(B) ──┼──→ results
worker(C) ──┘

后面的节点再读取:

复制代码
state["results"]

就是典型的:

动态 fan-out → reducer 聚合 → 后续继续处理。

这也是 Send 官方文档提到的典型 map-reduce 模式。


三十三、这两天形成的最终心智模型

整个 LangGraph 可以先理解成:

复制代码
              Graph State
         ┌────────────────┐
         │ user_message   │
         │ intent         │
         │ task_result    │
         │ results        │
         │ ...            │
         └────────────────┘
            ↑          ↓
            写          读
            ↑          ↓
          Node A     Node B

节点:

复制代码
读取 State
   ↓
执行逻辑
   ↓
返回 State 更新

边:

复制代码
决定接下来执行谁

其中:

复制代码
add_edge
→ 静态路径

add_conditional_edges
→ 图层面的动态路径

Command(goto=...)
→ 节点内部动态控制路径

Send
→ 动态任务分发 + 自定义目标节点输入

Reducer
→ 决定多个 State 更新如何聚合

三十四、最值得记住的 10 句话

  1. 节点是图里的点,函数是节点执行的逻辑。

  2. LangGraph 节点之间不是直接传参数,而是通过 State 通信。

  3. **invoke()**提供的是整个 Graph 的初始状态。

  4. 节点输入 State 决定它读取什么数据。

  5. 节点 return****的通常是部分 State 更新,而不是一份新的完整 State。

  6. 全局 State 描述图最核心的共享状态结构;不要简单把它和所有内部状态概念完全画等号。

  7. Reducer 决定同一个状态字段的多个更新怎么合并。

  8. add_conditional_edges****是图层面的动态路由, Command****是节点内部的动态路由。

  9. **Send("节点名", 输入)**的核心是动态任务分发,并允许这次节点执行使用自定义输入。

  10. Command**/** add_conditional_edges****更关注"流程往哪走", Send****更关注"任务怎么分出去"。

相关推荐
用户019027581611 小时前
A 股代码后缀 .SH/.SZ/.BJ 怎么区分?Python 怎么统一处理沪深京港美股代码?
python
迅猛龙办公室1 小时前
python实现简单进度条
java·前端·python
郝学胜_神的一滴1 小时前
AI 编程智能体 06:用Anaconda搞定Python多环境,彻底告别版本兼容灾难
人工智能·python
清水白石0081 小时前
Python 对象模型深度解析:从“一切皆对象”到 id、type、isinstance 底层机制与小整数缓存原理
开发语言·python·缓存
for_ever_love__1 小时前
Python 进阶——迭代器、生成器、装饰器与 asyncio
python·装饰器·生成器·大模型开发
5G微创业1 小时前
去水印API如何实现技术变现
python·sdk·短视频
for_ever_love__2 小时前
模块与包——import 机制与 requirements 管理依赖
python·模块·大模型开发·依赖管理
郝学胜-神的一滴2 小时前
AI 编程智能体 05:拆解智能体分级体系、类型与全行业落地场景
开发语言·人工智能·python·程序人生·pycharm
小小张说故事2 小时前
Python 装饰器把函数"搞丢"了?5 个必踩的坑与完整避坑对照表
后端·python