# 自定义 reducer：`return b` 为什么是个陷阱

配套代码：

- `06_langgraph_paramlizm_demo3.py` —— 本文分析的对象，跑出来"看着对了"的那份
- `04_并行与reducer说明.md` —— 先读这篇，reducer 是什么、`add` 版为什么翻倍到 24
- `05_langgraph_paramlizm_demo2.py` —— 追踪工具，本文的日志都是同样手法打出来的

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

## 一句话结论

`06` 把 reducer 从 `add` 换成 `my_merge(a, b): return b`，输出从 24 条变回 3 条 —— **但这不是修好了 bug，是换了个 reducer 把症状盖住了。** `my_merge` 在这份代码里是个空操作，那 3 个菜完全是 `append` 副作用的产物。代价是：顺序变得不确定、节点里埋了竞态、而且一旦有人把节点改成规范写法就立刻静默丢数据。

## 现象：它确实输出了 3 条

```python
def my_merge(a, b):
    # 这里定义合并逻辑
    return b

class MyState(TypedDict):
    menu: Annotated[List[str], my_merge]
    all_ready: bool
```

节点还是 `04` 那份错误写法（`append` 改列表 + `return state`），实跑：

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

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

数量对了。但**顺序不对** —— 鱼香肉丝排在最前面，而它是 `chef_c` 做的、声明在最后。这是第一个信号，说明结果不是按合并规则算出来的。

## 一、`my_merge` 是个空操作

reducer 的契约是 `(通道现在的值, 节点返回的值) -> 通道的新值`。`return b` 意思是"丢掉 old，只要 new"，也就是**覆盖** —— 语义上等价于根本不写 reducer（无 reducer 字段的默认行为就是覆盖）。

但这份代码里连覆盖都没发生。给 `my_merge` 加上日志，把两个参数的 `id()` 也打出来：

```python
def my_merge(a, b):
    print(f"[my_merge] old={a} (id={id(a)})  new={b} (id={id(b)})")
    return b
```

实测：

```
[my_merge] old=[] (id=1259944948544)  new=[] (id=1259940762688)
a  id(menu)= 1259940762688
b  id(menu)= 1259940762688
c  id(menu)= 1259940762688
[my_merge] old=['鱼香肉丝','番茄炒蛋','宫保鸡丁'] (id=1259940762688)
           new=['鱼香肉丝','番茄炒蛋','宫保鸡丁'] (id=1259940762688)
[my_merge] old=[同上] (id=1259940762688)  new=[同上] (id=1259940762688)
[my_merge] old=[同上] (id=1259940762688)  new=[同上] (id=1259940762688)
[my_merge] old=[同上] (id=1259940762688)  new=[同上] (id=1259940762688)
```

**`old` 和 `new` 的 id 完全相同 —— 它们是同一个对象。** 原因是节点 `append` 改的就是通道里那个 list，然后 `return state` 又把这个同一个 list 原样交回来。于是 `a is b` 成立，`return b` 返回的就是通道自己已经有的东西。

推论：`return b` 和 `return a` 应该完全等效。实测确认了：

| reducer | 结果 |
|---|---|
| `lambda a, b: b` | 3 条 `['鱼香肉丝','番茄炒蛋','宫保鸡丁']` |
| `lambda a, b: a` | 3 条 `['鱼香肉丝','番茄炒蛋','宫保鸡丁']` |

`return a` 是连"新值"都不看的 reducer，居然也能得到 3 个菜。**这就证明了那 3 个菜跟 reducer 无关，纯粹是 `append` 的副作用。**

## 二、和 `04` 的关系：盖住症状，不是修复

`04` 的错误写法犯了两件事：

1. `state["menu"].append(...)` —— 就地改了三个并行节点共享的那个 list
2. `return state` —— 又把改完的整份列表当增量交回去

`add` 作为 reducer 时，错误 ② 导致累加，逐级翻倍到 24。换成 `my_merge` 后，**只是让错误 ② 不再造成翻倍**（覆盖不累加），错误 ① 一个字都没动。

数字上确实从 24 变成 3，但 3 这个正确结果是错误 ① 凑出来的，不是任何一条合并规则算出来的。

## 三、四×二对照表

把 reducer 和节点写法两个维度交叉跑一遍（实测，sleep 缩短为 0.1~0.3 秒）：

| reducer | 节点写法 | 结果 | 评价 |
|---|---|---|---|
| `add` | append + return state | 12 条 | `04` 的 bug，翻倍 |
| `add` | 只返回增量 | **3 条 `['番茄炒蛋','宫保鸡丁','鱼香肉丝']`** | ✅ 唯一正确 |
| `my_merge`(`return b`) | append + return state | 3 条 `['鱼香肉丝',...]` | `06` —— 巧合，顺序不定 |
| `my_merge`(`return b`) | 只返回增量 | 1 条 `['鱼香肉丝']` | ❌ 静默丢数据 |
| `return a` | append + return state | 3 条 `['鱼香肉丝',...]` | reducer 完全没参与 |
| `return a` | 只返回增量 | 0 条 `[]` | ❌ 全丢 |

（上表 `add` + append 那行是 12 而非 24，因为测试里的 `serve` 用的是规范写法 `return {"all_ready": True}`，少了第二级翻倍。`04` 的 `serve` 也 `return state`，所以是 24。）

只有第二行是对的：**合并语义用 `add`，节点交增量。** 其他五行要么数量错，要么是靠副作用凑对的。

## 四、三个实际后果

### 1. 顺序变成不确定的

`06` 的输出是 `['鱼香肉丝', '番茄炒蛋', '宫保鸡丁']`，因为 `chef_c` 只 `sleep(1)` 最先跑完，它的 `append` 就落在最前面。**菜品顺序由线程完成的先后决定**，改一下 sleep 时长顺序就变。

规范写法不受这个影响 —— `add` 按 LangGraph 处理节点更新的顺序拼接，与谁先跑完无关，稳定是声明顺序 `['番茄炒蛋','宫保鸡丁','鱼香肉丝']`。`05_langgraph_paramlizm_demo2.py` 加 `--no-sleep` 前后对比就能看到这个差别：错误写法的顺序会变，规范写法不会。

### 2. 节点里埋了竞态

三个线程同时改一个 list，现在侥幸没丢数据，只是因为 CPython 的 `list.append` 恰好是原子操作。节点里只要换成下面任何一种写法，就是标准的 read-modify-write 竞态：

```python
state["menu"] = state["menu"] + ["番茄炒蛋"]   # 读 → 建新 list → 写回
state["menu"] += ["番茄炒蛋"]
current = state["menu"]; ...; current.append(x)  # 中间隔了别的操作
```

这类 bug 不报错，只是偶尔少一个菜，且随机器负载变化。**依赖 `append` 的原子性来保证正确性，是不该写进教学代码的假设。**

### 3. 换成规范写法立刻丢数据 —— 最危险的一点

`my_merge` 和"节点交增量"这个正确习惯是**互斥**的。同一个 `my_merge`，节点改成规范写法：

```
[my_merge] old=[]            new=['番茄炒蛋']  -> ['番茄炒蛋']
[my_merge] old=['番茄炒蛋']   new=['宫保鸡丁']  -> ['宫保鸡丁']    ← 番茄炒蛋没了
[my_merge] old=['宫保鸡丁']   new=['鱼香肉丝']  -> ['鱼香肉丝']

结果: {'menu': ['鱼香肉丝']}
```

这次 `old` 和 `new` 是不同对象了，覆盖语义真正生效，于是每个厨师都把前一个的菜盖掉，只剩最后一个。**不报错，只是少了两个菜。**

这就是它最坏的地方：把一个坏习惯（改共享对象）变成了正确性的**前提**。后来的人按规范改代码，反而把功能改坏了 —— 而且没有任何报错提示他。

## 五、规范写法

并行追加就用 `add`，节点交增量：

```python
from operator import add

class MyState(TypedDict):
    menu: Annotated[List[str], add]      # 合并语义：拼接
    all_ready: bool                      # 无 reducer：覆盖

def chef_a(state: MyState):
    return {"menu": ["番茄炒蛋"]}         # 不 append，不 return state
```

## 六、自定义 reducer 什么时候是对的

自定义 reducer 完全正当，只要它**真的在写合并逻辑**。几个实测可用的例子：

```python
def merge_unique(old: List[str], new: List[str]) -> List[str]:
    """拼接并去重，保留首次出现的顺序。"""
    seen = set(old)
    return old + [x for x in new if not (x in seen or seen.add(x))]

def keep_max(old: int, new: int) -> int:
    """并行打分，取最高的那个。"""
    return max(old, new)

def merge_dict(old: dict, new: dict) -> dict:
    """浅合并，后来的覆盖同名 key。"""
    return {**old, **new}
```

三个节点各交回一份，实测：

```python
def a(s): return {"menu": ["番茄炒蛋", "米饭"], "best": 3, "tags": {"a": 1}}
def b(s): return {"menu": ["宫保鸡丁", "米饭"], "best": 7, "tags": {"b": 2}}
def c(s): return {"menu": ["米饭"],            "best": 5, "tags": {"c": 3}}

merge_unique -> ['番茄炒蛋', '米饭', '宫保鸡丁']    # 三份里的"米饭"合成一个
keep_max     -> 7
merge_dict   -> {'a': 1, 'b': 2, 'c': 3}
```

顺带一个好消息：**不需要给带 reducer 的字段准备初始值。** LangGraph 会按类型注解推出一个空值当通道初值，`keep_max` 第一次被调用时 `old` 就是 `0`（`int` → `0`，`str` → `''`，`List` → `[]`）：

```
[keep_max] old=0 (type=int)  new=3
[keep_max] old=3             new=7
结果: {'best': 7}
```

所以 `invoke({})` 就够了，也不需要在起始节点里写 `state["menu"] = []`。

## 七、判断标准

一条就够用：

> **reducer 里如果没同时用到 `old` 和 `new` 两个参数，就该怀疑它写错了。**

- `return b` —— 没用 `old`，那是覆盖，不是合并。这种情况下 reducer 可以直接删掉，效果一样（而且删掉之后并行写同一字段会正常报 `InvalidUpdateError`，比静默丢数据好）
- `return a` —— 没用 `new`，节点的输出被整个扔掉
- `return old + new` / `max(old, new)` / `{**old, **new}` —— 两个都用了，这才是合并

配套的自查（详见 `04_并行与reducer说明.md` 第七节）：节点里不出现 `state[...].append(...)`，不 `return state`，只返回自己输出 key 组成的 dict。

## 八、三份 demo 的定位

| 文件 | reducer | 节点写法 | 结果 | 用途 |
|---|---|---|---|---|
| `04_langgraph_paramlizm_demo.py` | `add` | append + return state | 24 条 | 反例：翻倍 |
| `05_langgraph_paramlizm_demo2.py` | `traced_add` | 两种都有，`--mode` 切换 | 24 / 3 | 追踪工具 + 正例 |
| `06_langgraph_paramlizm_demo3.py` | `my_merge` | append + return state | 3 条 | 反例：靠副作用凑对 |

`06` 比 `04` 更值得警惕：`04` 数字明显不对，一眼能看出有问题；`06` 数字是对的，藏着顺序不定、竞态、以及"改成规范写法就丢数据"三个隐患。**测试通过不等于写法正确。**



