# 使用LangChain+LangGraph开发AI应用（8） - LangGraph 基础


![](https://static.xiongneng.me/langchain-langgraph-cover-20261017184837.png)

## 学习进度

LangGraph 是 agent 脚下的引擎。你在 `create_agent` 里用到的循环、限轮数、钩子时序，全是这张图上长出来的东西。这一篇把引擎拆开看，状态 Schema 定字段，节点干活，边定走向，Reducers 管并发写入怎么合并，这是图语言的全部词汇，学会了，中断恢复、人机协同、多智能体编排才有地基。

示例在 `source/hello_graph_deep.py`，九个 demo，全部纯函数不依赖模型。完整案例是一个文本体检流水线 `source/textcheck.py`，清洗、分诊、统计、汇总，一条能看见每一步的图。

## 前言用途

写流程有三个层次。最朴素的是顺序脚本，从头跑到尾，遇不到分叉。往上一点是工作流引擎，有分支有循环，但每一步写死在代码里。LangGraph 走的是第三条路，把流程声明成一张图，节点是函数，边是走向，状态是所有节点共享的一块数据，执行交给引擎。你描述图，引擎管调度，并行、重试、断点恢复、状态持久化这些脏活都不用你写。

这带来三个直接的好处。画出来的流程可审查，评审的人看图说话，不用读代码脑补控制流。节点之间只通过状态通信，任何一个节点换成新实现，别的代码不动。引擎知道每个节点的边界，哪些节点能并行、哪一步该存档，都由图结构推出来，后续篇章的中断恢复、人机协同全建立在这上面。

什么时候需要直接写图。流程里有分支或循环，或者你想让某几步并行跑，或者要做中途等人确认的交互，这些场景下手写图比套 agent 模板更可控。单轮问答这种直线流程，用 create_agent 就够，不必为它画图。

## 原理概念

LangGraph 把工作流建模成三样东西。State 是共享数据结构，全图只有一份，记录流程此刻的快照。Node 是函数，读状态、干活、返回更新。Edge 决定下一个跑哪个节点。官方文档的说法很省字，nodes do the work, edges tell what to do next。

![](https://static.xiongneng.me/graph-exec-20261017184649.png)

执行引擎的算法来自 Google 的 Pregel，按离散的 super-step 推进。每个 super-step 里，所有被激活的节点并行执行，各自把更新交回状态，然后引擎按边激活下一批节点，直到没有节点被激活。demo 4 里一个节点两条出边、两个目标并行跑，就是这个机制在工作。这个模型还解释了一个设计约束，同一个 super-step 里多个节点写同一个字段，引擎必须知道怎么合并，这就轮到 Reducer 出场。

节点函数的参数是可选的，写几个框架注几个。最常见是只收 state。加一个 config 参数能拿到 RunnableConfig，thread_id、tags 这些运行时配置都在里面。再加一个 runtime 参数拿到 Runtime 对象，context、store、stream_writer 都从这来。探针实测过 `(state, config)` 双参写法，`config["configurable"]` 里传什么节点就收到什么。参数是按声明注入的，不声明就不给，函数保持好测的纯函数长相。

State 的 schema 支持 TypedDict、dataclass 和 Pydantic 三种，官方主要推荐 TypedDict，需要默认值用 dataclass，需要递归校验才上 Pydantic，注意 langchain 的 create_agent 不支持 Pydantic 状态。每个字段可以挂一个 Reducer，它是个二元函数，签名固定，左边是字段当前值，右边是节点返回的新值，返回合并结果。`Annotated[list[str], operator.add]` 的意思就是这个字段用列表相加来合并。没挂 Reducer 的字段走覆盖语义，节点返回什么就是什么。节点只返回更新的那几个字段，不用带全量状态。

图还支持多套 schema 分工。定义一个只含输入字段的 InputState 和只含输出字段的 OutputState，构造时传 `StateGraph(OverallState, input_schema=InputState, output_schema=OutputState)`，对外看到的输入输出就收窄了，内部节点之间随便传中间字段。官方管这种中间字段叫私有 channel，专门给节点内部通信用。有个实测过的细节要留意，私有字段在流式输出时不会被隐藏，`stream_mode="values"` 会把所有 channel 都吐出来，要藏就用 output_keys 限制，或者改看 updates 模式只看每步的更新。

图编译后才能跑，`compile()` 会做结构检查。入口和出口是两个常量，START 和 END，从 langgraph.graph 导入，`add_edge(START, "a")` 定入口，`add_edge("b", END)` 定出口。

## 核心用法

九个用法，示例在 `source/hello_graph_deep.py`，全部纯函数图，不依赖任何模型，python 直接跑。

### 1. 静态边

最基础的图，两个节点一条直线。

```python
from langgraph.graph import END, START, StateGraph
from typing import TypedDict

class State(TypedDict):
    text: str

def upper(state: State):
    return {"text": state["text"].upper()}

def wrap(state: State):
    return {"text": f"[{state['text']}]"}

g = StateGraph(State)
g.add_node("upper", upper)
g.add_node("wrap", wrap)
g.add_edge(START, "upper")
g.add_edge("upper", "wrap")
g.add_edge("wrap", END)
result = g.compile().invoke({"text": "hello"})
```

```text
== 静态边 ==
  [HELLO]
```

节点返回的是部分更新，`{"text": ...}` 一个字段就够，引擎负责合并进状态。静态边是无条件的，走到这就一定走那条。

### 2. Reducer 两种语义

同一个图里两种字段，看合并和覆盖怎么各干各的。

```python
from operator import add
from typing import Annotated

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

def node_a(state: State):
    return {"log": ["a"], "last": "a"}

def node_b(state: State):
    return {"log": ["b"], "last": "b"}
```

```text
== Reducer ==
  log 合并成 ['a', 'b']，last 被覆盖成 b
```

log 挂了 `add`，两个节点的写入相加成 `['a', 'b']`。last 没挂，后写的 b 把 a 盖掉。什么时候用哪个，判断标准就一条，这个字段会不会被多个节点写，会就挂 reducer，只被一个节点写就覆盖。

`operator.add` 之外，reducer 就是个普通函数，自己写也行。官方的示例叫 append_strings，签名两个位置参数 left 和 right，返回 `left + right`，效果跟 `operator.add` 一样。想做去重、排序、截断，都在这个函数里做。

```python
def merge_unique(left: list[str], right: list[str]) -> list[str]:
    return list(dict.fromkeys(left + right))

class State(TypedDict):
    tags: Annotated[list[str], merge_unique]
```

### 3. 条件路由

`add_conditional_edges` 按状态决定下一跳。

```python
from typing import Literal

def check(state: State) -> Literal["even", "odd"]:
    return "even" if state["n"] % 2 == 0 else "odd"

g.add_conditional_edges("check", check)
```

```text
== 条件路由 ==
  n=4 走 even
  n=7 走 odd
```

路由函数读状态返回下一个节点名。返回值可以直接是节点名，也可以配映射字典 `{True: "node_b", False: "node_c"}` 让可读性更好。路由函数的类型注解写 `Literal[...]`，图可视化时靠它画分支。

### 4. 多出边并行

一个节点画多条出边，目标节点并行执行。

```python
g.add_edge("fan", "x1")
g.add_edge("fan", "x2")
```

```text
== 多出边并行 ==
  执行了 ['fan', 'x1', 'x2']
```

fan 的两条出边让 x1、x2 落进同一个 super-step，引擎并行跑完再汇合。两个节点都往 log 写，所以 log 挂了 reducer，没挂的话这里直接抛 `InvalidUpdateError`。

### 5. Overwrite 绕过 reducer

合并型字段想清空，返回空列表是没用的。

```python
from langgraph.types import Overwrite

def node_clear(state: State):
    return {"errors": Overwrite([])}
```

```text
== Overwrite ==
  清空后 []
```

挂了 `add` 的字段，节点返回 `[]` 会被合并进旧值，等于什么都没做。`Overwrite([])` 绕过 reducer，直接把字段替换成空列表。这个类型专治「我想覆盖一个带 reducer 的字段」。

### 6. add_messages 按 id 更新

消息列表的专属 reducer，按消息 id 对账。

```python
from langgraph.graph.message import add_messages

class State(TypedDict):
    messages: Annotated[list, add_messages]

def bot(state: State):
    return {"messages": [AIMessage(id="m1", content="revised answer")]}
```

```text
== add_messages ==
  同 id 更新后: revised answer
```

新 id 追加，同 id 覆盖，输入的第 m1 条被原地改写而不是追加一条。这个语义是第 9 篇人机协同的基础，人工修订一条消息就是用同 id 重发。第 1 篇用过的 MessagesState 就是预置了这套 reducer 的现成 schema，自己加字段继承它就行。

```python
from langgraph.graph import MessagesState

class State(MessagesState):
    documents: list[str]
```

messages 字段不用再声明，子类里只写新增的 documents。

### 7. Command 节点内路由

条件边的路由逻辑挂在边上，Command 把它收回节点内部。

```python
from langgraph.types import Command

def judge(state: State) -> Command[Literal["pass", "fail"]]:
    if state["score"] >= 60:
        return Command(update={"where": "pass"}, goto="pass")
    return Command(update={"where": "fail"}, goto="fail")
```

```text
== Command ==
  score=90 -> pass
  score=50 -> fail
```

update 和 goto 打包在一个返回值里，状态更新和跳转天然同步，不会出现改了 A 字段却跳去不认 A 的节点。官方的选型建议很清楚，只路由用条件边，要同时更新状态和路由用 Command。类型注解 `Command[Literal[...]]` 必须写，图渲染靠它。

### 8. Send map-reduce

任务条数运行时才知道，固定边画不了，Send 把每个子任务连各自的 state 发出去。

```python
from langgraph.types import Send

def dispatch(state: State):
    return [Send("make", {"subject": s}) for s in state["subjects"]]

g.add_conditional_edges(START, dispatch, ["make"])
```

```text
== Send map-reduce ==
  ['关于狗的笑话', '关于猫的笑话', '关于鸟的笑话']
```

dispatch 返回 Send 列表，几个条目就发几份，每份带独立的输入 state，三个统计节点并行执行，结果经 reducer 汇总。处理文章列表、批量文档这类 map-reduce 场景，这就是标准姿势。

### 9. 递归上限

循环图必须有出口，引擎的兜底是 recursion_limit。

```python
app.invoke({"n": 0}, config={"recursion_limit": 5})
```

```text
== 递归上限 ==
  循环到 3 正常结束: n=3
  死循环被 recursion_limit=5 叫停
```

每个 super-step 计一步，默认上限一千，超了抛 GraphRecursionError。demo 里循环三次正常结束，死循环图被 limit=5 叫停。调试循环图时把这个值调小，炸得快好定位。

## 完整案例

九个词都认识了。收个尾，用它们搭一条真的流水线，`textcheck.py`，文本体检。输入一段文字，清洗空白，判断中英文，按语言走不同的统计分支，最后汇总一份体检报告。

```bash
uv run python textcheck.py "LangGraph 让工作流变成一张能看见的图，节点做活，边定走向，条件边让流程学会分叉。" --trace
```

```text
执行轨迹:
  [clean] cleaned=LangGraph 让工作流变成一张能看见的图，节点做活，边定走向...
  [detect] lang=zh
  [zh_count]
      + stats: 中文字数 31
      + stats: 字频前五 让x2、流x2、边x2
      + stats: 句子数 1
  [summary] report=文本体检报告

文本体检报告
  · 语言判定 zh
  · 中文字数 31
  · 字频前五 让x2、流x2、边x2
  · 句子数 1
```

--trace 用 updates 流把每一步打印出来，哪个节点跑了、改了哪个字段一目了然，这就是「可审查」的直观长相。图的结构是 clean 到 detect，detect 条件路由到 zh_count 或 en_count，两个统计节点都写 `stats` 这个 `Annotated[list[str], add]` 字段，最后汇进 summary。

```bash
uv run python textcheck.py "StateGraph turns a workflow into a visible graph. Nodes do the work."
```

```text
文本体检报告
  · 语言判定 en
  · english words 12
  · top words ax2, stategraphx1, turnsx1, workflowx1, intox1
  · sentences 2
```

英文输入自动换分支，词频、词数、句子数按英文规则统计。空输入还有一条特殊路径，summary 节点里判断 cleaned 为空就 `Command(goto=END)` 提前收工，报告写「空文本，没什么可体检的」，一个节点同时干完更新和跳转。

```bash
echo "" | uv run python textcheck.py
```

```text
空文本，没什么可体检的。
```

整个案例没有一个模型调用，每一步都是确定性函数，跑一百遍结果一致。测试在 `tests/test_hello_graph_deep.py` 九条加 `tests/test_textcheck.py` 八条，链式顺序、两种 reducer 语义、条件路由、并行分支、Overwrite、同 id 更新、Command 路由、Send 发散、递归兜底、空文本短路都钉住了。加上前七篇的，全仓一百三十三条，全绿。

## 源码

[https://github.com/yidao620c/xneng-langchain-langgraph](https://github.com/yidao620c/xneng-langchain-langgraph)

## 参考

- [LangGraph 官方文档 - Graph API](https://docs.langchain.com/oss/python/langgraph/graph-api)
- [LangGraph 官方文档 - State & Reducers](https://docs.langchain.com/oss/python/langgraph/graph-api#reducers)
- [LangGraph 官方文档 - Send & Command](https://docs.langchain.com/oss/python/langgraph/graph-api#send)


---

> 作者: XiongNeng  
> URL: https://xiongneng.me/posts/ai/langchain/langchain-langgraph-08-langgraph/  

