# LangGraph 并行与 reducer：菜单为什么变成了 24 个菜

配套代码：

- `04_langgraph_paramlizm_demo.py` —— 错误写法的现场，就是本文要解释的那 24 个菜
- `05_langgraph_paramlizm_demo2.py` —— 追踪版，把 menu 的每一次变化打出来（见第六节）

本文所有输出都在 langgraph 1.2.1 上实测过。

## 一句话结论

`menu` 字段挂了 reducer（`Annotated[List[str], add]`），**有 reducer 的字段，节点返回的值是"增量"，会被追加到通道上，而不是覆盖它**。原代码既 `append` 改了列表本身，又 `return state` 把改完的整份列表当增量交回去，等于每个节点都写了两遍，逐层翻倍到 24。

## 现象

三个厨师各做一道菜，期望 3 条，实际：

```
🍽️ 所有菜都准备好了！菜单如下：   ← serve 里打印出 12 条
 - 鱼香肉丝
 - 番茄炒蛋
 ...（共 12 条）

最终结果: {'menu': [...24 条...], 'all_ready': True}
```

注意 `serve` 打印的是 12 条，而 `invoke` 返回的是 24 条——**这是两级不同的翻倍**，后面会拆开看。

## 一、reducer 是什么

先说 LangGraph 里 state 的真实模型。你写的 `MyState` 不是一个普通字典，它的每个字段都是一个 **通道（channel）**，通道有自己保存的值，也有自己的"合并规则"。

节点函数拿到的 `state` 是通道当前值的一份快照，节点的返回值是**要对通道做的更新**。至于这份更新怎么落到通道上，就由这个字段的合并规则决定——这个规则就是 **reducer**。

reducer 就是一个两参数的函数：

```python
def reducer(通道现在的值, 节点返回的值) -> 通道的新值
```

字段声明成 `Annotated[类型, 函数]` 时，那个函数就是 reducer：

```python
class MyState(TypedDict):
    menu: Annotated[List[str], add]   # add 就是 reducer，来自 operator
    all_ready: bool                   # 没写 Annotated，就没有 reducer
```

`operator.add` 作用在两个 list 上就是拼接，所以 `add(["番茄炒蛋"], ["宫保鸡丁"])` 得到 `["番茄炒蛋", "宫保鸡丁"]`。

**没有 reducer 的字段，默认行为是覆盖**，节点返回什么，通道就变成什么：

```python
class S(TypedDict):
    menu: List[str]          # 无 reducer

def a(s): return {"menu": ["番茄炒蛋"]}
def b(s): return {"menu": ["宫保鸡丁"]}   # a 之后串行执行

# 实测输出: {'menu': ['宫保鸡丁']}     ← 番茄炒蛋被覆盖掉了
```

这就是两类字段的根本差别，也是后面所有坑的来源：

| 字段 | 声明 | 节点返回值的含义 |
|---|---|---|
| `all_ready` | `bool` | 新值，**覆盖** |
| `menu` | `Annotated[List[str], add]` | 增量，**追加** |

## 二、为什么并行必须有 reducer

顺手回答一个问题：既然 reducer 这么容易踩坑，能不能不写？并行的时候不行。

把 `menu` 的 `Annotated` 去掉，让 a、b 两个节点并行写它：

```python
class S1(TypedDict):
    menu: List[str]          # 故意不加 reducer

def a(s): return {"menu": ["A"]}
def b(s): return {"menu": ["B"]}   # 与 a 并行
```

实测直接报错：

```
InvalidUpdateError At key 'menu': Can receive only one value per step.
Use an Annotated key to handle multiple values.
```

道理很直白：同一步里 a 和 b 各交回一个值，覆盖语义下 LangGraph 无法决定该听谁的——它不猜，直接报错让你给个规则。**reducer 就是你给的那个规则**，告诉它"两份都要，拼起来"。

所以 `menu` 上那个 `add` 不是可选的装饰，是并行汇聚的必要条件。它同时也意味着你必须按增量的方式写节点。

## 三、reducer 是被逐个调用的

这一点最影响直觉，所以单独验证。把 `add` 换成一个会打印的版本：

```python
def traced_add(old, new):
    print(f"[reducer] old={old}  new={new}")
    return add(old, new)
```

正确写法（各节点只返回自己的增量）下的实测输出：

```
start: 收到 state = {'menu': []}
a: 收到 state = {'menu': []}
b: 收到 state = {'menu': []}
   [reducer] old=[]              new=['番茄炒蛋']
   [reducer] old=['番茄炒蛋']     new=['宫保鸡丁']
serve: 收到 state = {'menu': ['番茄炒蛋', '宫保鸡丁']}
```

三个要点：

1. **reducer 不是一次收到所有并行结果**，而是每个节点的更新来一次，`old` 是累积到目前的通道值，`new` 只是那一个节点返回的东西。
2. **并行节点看到的 state 是同一份起始快照**（a 和 b 都看到 `{'menu': []}`），a 的结果对 b 不可见——所以别指望在并行节点里读到兄弟节点写的东西。
3. `menu` 有 reducer，所以通道初始值就是 `[]`，不需要 `start` 去初始化。原代码里的 `state["menu"] = []` 是多余的。

## 四、24 是怎么算出来的

原代码每个厨师节点干了两件事：

```python
def chef_a(state):
    state["menu"].append("番茄炒蛋")   # ① 就地改了列表
    return state                      # ② 又把整份列表当增量交回去
```

**错误 ①：三个并行节点改的是同一个列表对象。** 打印 `id(state["menu"])` 实测：

```
a   拿到 menu 的 id = 2635165391232
b   拿到 menu 的 id = 2635165391232
c   拿到 menu 的 id = 2635165391232      ← 同一个对象
```

快照是浅的，`state` 字典是各自的，但里面的 list 是同一个。三个 `append` 全落在这一个列表上，于是它变成 3 条。这一步绕过了 reducer，是直接篡改通道里的数据。

**错误 ②：`return state` 让这 3 条又被当作增量追加。** 节点结束时，LangGraph 拿返回值里的 `menu` 交给 reducer。此时那个列表已经是 3 条了，于是每个节点都贡献一次"3 条"的追加。

两个错误叠在一起，用带打印的 reducer 实测原代码：

```
   [reducer] len(old)=0  len(new)=0  -> len=0      ← start 返回空 menu
   [reducer] len(old)=3  len(new)=3  -> len=6      ← 三个 append 已让列表变 3 条
   [reducer] len(old)=6  len(new)=3  -> len=9
   [reducer] len(old)=9  len(new)=3  -> len=12
serve 看到的菜单条数: 12
   [reducer] len(old)=12 len(new)=12 -> len=24     ← serve 的 return state
final len: 24
```

逐行读这段就能把两级翻倍分清：

- 第一级，**3 → 12**：三个 `append` 把共享列表变成 3 条（这时 `old` 已经是 3 了，因为 append 改的就是通道里那个对象），再被三次 `return state` 各追加 3 条。这是 `serve` 打印 12 条的原因。
- 第二级，**12 → 24**：`serve` 自己也 `return state`，把它刚读到的 12 条整份又追加一遍。这是最终结果 24 条的原因。

第二级值得单独强调，因为它跟 `append` 无关。只犯 `return state` 这一个错就够翻倍了：

```python
def start(s): return {"menu": ["番茄炒蛋", "宫保鸡丁", "鱼香肉丝"]}
def serve(s):
    s["all_ready"] = True
    return s          # ← 唯一的错

# 实测: {'menu': [3 个菜 + 同样的 3 个菜], 'all_ready': True}   ← 6 条
```

**汇聚节点（fan-in）是这个坑的高发地。** 它只想改 `all_ready`，但 `return state` 顺带把读到的完整 `menu` 又交了一遍。

## 五、正确写法

规则一条：**节点只返回自己要写的那几个 key 组成的新 dict，不 `return state`，不改 `state` 里的可变对象。**

```python
def start_cooking(state: MyState):
    print("👨‍🍳 厨房准备开始！")
    return {}                        # 什么都不写，就返回空 dict

def chef_a(state: MyState):
    time.sleep(2)
    return {"menu": ["番茄炒蛋"]}     # 只交自己新增的那一份

def serve_dish(state: MyState):
    for dish in state["menu"]:       # 读是随便读的
        print(" -", dish)
    return {"all_ready": True}       # 千万不要带上 menu
```

实测输出：

```
🍽️ 所有菜都准备好了！菜单如下：
 - 番茄炒蛋
 - 宫保鸡丁
 - 鱼香肉丝

最终结果: {'menu': ['番茄炒蛋', '宫保鸡丁', '鱼香肉丝'], 'all_ready': True}
```

菜品顺序是 a、b、c 的声明顺序，不是完成时间顺序（c 只睡 1 秒但排最后）。`add` 按 LangGraph 处理节点更新的顺序拼接，跟谁先跑完无关——**并行汇聚的结果顺序是稳定的，但别理解成"先完成先入列"**。

## 六、自己动手看：`05_langgraph_paramlizm_demo2.py`

上面的日志都是这个脚本打出来的。它把 `add` 换成会打印的 `traced_add`，两种写法共用同一张图，只换节点函数：

```bash
python -X utf8 05_langgraph_paramlizm_demo2.py              # 错误/正确 对比跑
python -X utf8 05_langgraph_paramlizm_demo2.py --mode wrong # 只看错误写法
python -X utf8 05_langgraph_paramlizm_demo2.py --stream     # 再用 stream 看增量/快照
python -X utf8 05_langgraph_paramlizm_demo2.py --no-sleep   # 去掉 sleep，秒出
```

三个观察点：

**1. `🔧 [reducer #n]` 日志** —— 每行是一次合并，`old` 是通道现值，`new` 是某个节点交回来的东西。错误写法 5 次调用（`new` 全是 3 条或 12 条），正确写法 3 次（`new` 都是 1 条）。

**2. `👀` 行的 `id=`** —— 错误写法里三个厨师的 `id` 相同，正确写法里也相同（快照是共享的），差别在于正确写法**不去改它**。这解释了为什么"共享"本身不是问题，`append` 才是。

**3. `--stream` 的两种模式**，正好对应 reducer 的两个参数：

```
stream_mode="updates"  → 节点交回的增量，就是 reducer 的 new
stream_mode="values"   → 合并后的通道快照，就是 reducer 的 old
```

错误写法在 `updates` 下最扎眼——`chef_a` 交回来的不是 1 个菜而是一整份：

```
── 更新 2 ── chef_a 交回 menu(3) = ['番茄炒蛋', '宫保鸡丁', '鱼香肉丝']
── 更新 3 ── chef_b 交回 menu(3) = ['番茄炒蛋', '宫保鸡丁', '鱼香肉丝']
── 更新 4 ── chef_c 交回 menu(3) = ['番茄炒蛋', '宫保鸡丁', '鱼香肉丝']
── 更新 5 ── serve  交回 menu(12) = [...12 条...]
```

`values` 下只有 3 个快照（0 条 → 12 条 → 24 条），比节点数少——**并行的三个厨师合并完才 yield 一次**，这是它和 `updates` 的本质区别。

### 顺手看到的两个副作用

跑 `--mode wrong` 时，`append` 的执行顺序就是**完成时间顺序**：带 sleep 时 c 只睡 1 秒，菜单变成 `['鱼香肉丝', '番茄炒蛋', '宫保鸡丁']`；加 `--no-sleep` 又变回 a/b/c。而正确写法两种情况下都稳定是声明顺序 `['番茄炒蛋', '宫保鸡丁', '鱼香肉丝']`。**就地修改让结果依赖时序，返回增量不会。**

`--no-sleep` 跑错误写法还能看到更难查的一幕：

```
👀 chef_a 看到 menu(0) = []
👀 chef_b 看到 menu(1) = ['番茄炒蛋']      ← b 看见了 a 写的东西
👀 chef_c 看到 menu(2) = ['番茄炒蛋', '宫保鸡丁']
```

按 LangGraph 的语义，并行节点应该都看到同一份起始快照（正确写法里三个都是 `menu(0)=[]`）。这里之所以互相可见，正是因为 `append` 改的是共享对象。**并行节点之间出现了本不该存在的可见性，而且这种可见性随时序变化**——比数量翻倍更难排查。

## 七、检查清单

写并行图时按这几条自查：

- [ ] 会被多个并行节点写的字段，加了 `Annotated[..., reducer]` 吗？（不加会 `InvalidUpdateError`）
- [ ] 节点里有没有 `state[...].append(...)` / `.update(...)` / `+=` 这类就地修改？有就是 bug，并行节点共享同一个对象。
- [ ] 有没有 `return state`？带 reducer 的字段一旦这么写就翻倍。全部改成返回只含自己输出 key 的 dict。
- [ ] 汇聚节点单独看一遍：它读了带 reducer 的字段，返回值里绝不能再出现那个字段。
- [ ] 有没有在初始化节点里给带 reducer 的 list 字段赋 `[]`？多余，通道本来就是空的。

## 八、和本项目 `_004_` 的关系

`_004_langgraph_more_nodes/agent_state.py` 里的 `AgentState` **目前所有字段都没有 reducer**，节点也都是串行的，所以现在的 `def x_node(state) -> AgentState: ...; return state` 写法是安全的——覆盖语义下 `return state` 交回原值等于没改。

但如果以后要并行（比如六类实体的匹配同时跑，或者多路 Cypher 并发执行），给某个字段加上 reducer 的那一刻，**这个字段上所有 `return state` 的节点都会立刻开始翻倍**，而且不报错，只是结果变多——比报错难查。届时的改法就是本文第五节：那条链上的节点全部改成返回增量 dict。

