💻 源码
"""
并行 + reducer 追踪版:把 menu 通道的每一次变化打出来。

04_langgraph_paramlizm_demo.py 是"错误写法"的现场,本文件是它的显微镜:
把 operator.add 换成一个会打印的 traced_add,就能看见 menu 每一步
从什么变成什么,从而搞清楚 3 个菜为什么会变成 24 个。

用法:
    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 --mode right
    python -X utf8 05_langgraph_paramlizm_demo2.py --stream    # 再用 stream 看增量/快照
    python -X utf8 05_langgraph_paramlizm_demo2.py --no-sleep  # 去掉 sleep,跑得快点

配套文档:04_并行与reducer说明.md
"""

import argparse
import time
from operator import add
from typing import Annotated, List, TypedDict

from langgraph.graph import END, START, StateGraph

# 是否真的 sleep(--no-sleep 关掉)
SLEEP = True
# reducer 被调用的次数,用来给日志编号
_reduce_calls = 0


def cook(seconds: float) -> None:
    """模拟做菜耗时。"""
    if SLEEP:
        time.sleep(seconds)


def traced_add(old: List[str], new: List[str]) -> List[str]:
    """带日志的 reducer。

    reducer 的签名永远是 (通道现在的值, 节点返回的值) -> 通道的新值。
    这里除了照常拼接,还把两边的内容和长度打出来 —— 这就是"追踪 menu"的关键。
    注意 reducer 是**每个节点的更新来一次**,不是一次收到所有并行结果。
    """
    global _reduce_calls
    _reduce_calls += 1
    result = add(old, new)
    print(
        f"   🔧 [reducer #{_reduce_calls}] "
        f"old({len(old)})={old}  +  new({len(new)})={new}  ->  长度 {len(result)}"
    )
    return result


class MyState(TypedDict):
    menu: Annotated[List[str], traced_add]  # 有 reducer:节点返回值是"增量",会被追加
    all_ready: bool  # 无 reducer:节点返回值是"新值",直接覆盖


def peek(node: str, state: MyState) -> None:
    """打印节点看到的 menu 快照,以及那个 list 对象的 id。

    并行节点打出来的 id 是同一个 —— 这说明 state 里的 list 是共享对象,
    所以 append 会直接改到通道里的数据上,绕过 reducer。
    """
    menu = state.get("menu", [])
    print(f"   👀 {node} 看到 menu({len(menu)}) = {menu}   id={id(menu)}")


# ======================================================================
# 写法一:错误写法(04 里的那份)—— append 改共享 list + return state
# ======================================================================
def wrong_start(state: MyState):
    print("👨‍🍳 [错误写法] 厨房准备开始!")
    peek("start", state)
    state["menu"] = []  # 多余:带 reducer 的通道本来就是 []
    return state


def wrong_chef_a(state: MyState):
    print("👩‍🍳 A 厨师在做:番茄炒蛋...")
    peek("chef_a", state)
    cook(2)
    state["menu"].append("番茄炒蛋")  # ① 就地改了三个节点共享的那个 list
    return state  # ② 又把改完的整份当增量交回去


def wrong_chef_b(state: MyState):
    print("👨‍🍳 B 厨师在做:宫保鸡丁...")
    peek("chef_b", state)
    cook(3)
    state["menu"].append("宫保鸡丁")
    return state


def wrong_chef_c(state: MyState):
    print("👩‍🍳 C 厨师在做:鱼香肉丝...")
    peek("chef_c", state)
    cook(1)
    state["menu"].append("鱼香肉丝")
    return state


def wrong_serve(state: MyState):
    print("\n🍽️ [错误写法] 上菜:")
    peek("serve", state)
    for dish in state["menu"]:
        print("   -", dish)
    state["all_ready"] = True
    return state  # 汇聚节点的 return state:把读到的整份 menu 再追加一遍


# ======================================================================
# 写法二:正确写法 —— 只返回自己新增的那一份,不碰 state 里的 list
# ======================================================================
def right_start(state: MyState):
    print("👨‍🍳 [正确写法] 厨房准备开始!")
    peek("start", state)
    return {}  # 什么都不写就返回空 dict


def right_chef_a(state: MyState):
    print("👩‍🍳 A 厨师在做:番茄炒蛋...")
    peek("chef_a", state)
    cook(2)
    return {"menu": ["番茄炒蛋"]}  # 只交增量,合并交给 reducer


def right_chef_b(state: MyState):
    print("👨‍🍳 B 厨师在做:宫保鸡丁...")
    peek("chef_b", state)
    cook(3)
    return {"menu": ["宫保鸡丁"]}


def right_chef_c(state: MyState):
    print("👩‍🍳 C 厨师在做:鱼香肉丝...")
    peek("chef_c", state)
    cook(1)
    return {"menu": ["鱼香肉丝"]}


def right_serve(state: MyState):
    print("\n🍽️ [正确写法] 上菜:")
    peek("serve", state)
    for dish in state["menu"]:
        print("   -", dish)
    return {"all_ready": True}  # 绝不带 menu,否则整份再追加一遍


# --- 构建 LangGraph(两种写法共用同一张图,只换节点函数)---
NODES = {
    "wrong": (wrong_start, wrong_chef_a, wrong_chef_b, wrong_chef_c, wrong_serve),
    "right": (right_start, right_chef_a, right_chef_b, right_chef_c, right_serve),
}


def build_graph(mode: str):
    start, a, b, c, serve = NODES[mode]

    graph_builder = StateGraph(MyState)
    graph_builder.add_node("start", start)
    graph_builder.add_node("chef_a", a)
    graph_builder.add_node("chef_b", b)
    graph_builder.add_node("chef_c", c)
    graph_builder.add_node("serve", serve)

    # 启动 → 并行三个厨师
    graph_builder.add_edge(START, "start")
    graph_builder.add_edge("start", "chef_a")
    graph_builder.add_edge("start", "chef_b")
    graph_builder.add_edge("start", "chef_c")

    # 三个厨师汇总到 serve(fan-in)
    graph_builder.add_edge(["chef_a", "chef_b", "chef_c"], "serve")
    graph_builder.add_edge("serve", END)

    return graph_builder.compile()


# --- 两种查看方式 ---
def run_invoke(mode: str) -> List[str]:
    """普通 invoke:靠 reducer 日志看 menu 的每一次合并。"""
    global _reduce_calls
    _reduce_calls = 0

    title = "错误写法:append + return state" if mode == "wrong" else "正确写法:只返回增量"
    print("\n" + "=" * 70)
    print(f"  {title}   (mode={mode})")
    print("=" * 70)

    result = build_graph(mode).invoke({})
    menu = result["menu"]
    print(f"\n📊 reducer 共被调用 {_reduce_calls} 次,最终 menu 长度 = {len(menu)}(期望 3)")
    print(f"   最终结果: {result}")
    return menu


def run_stream(mode: str) -> None:
    """stream 方式:把"节点交了什么"和"通道变成了什么"分开看。

    stream_mode="updates"(默认):每个节点跑完 yield 一次 {节点名: 该节点的返回值}。
        看到的是**增量**,也就是交给 reducer 的那个 new。
        错误写法在这里最扎眼 —— chef_a 交回来的不是 1 个菜而是一整份。
    stream_mode="values":通道值变化后 yield 一次完整快照。
        看到的是**合并后的结果**,也就是 reducer 返回的 old。
        并行的三个厨师合并完才给一份,所以快照数比节点数少。
    """
    global _reduce_calls

    print("\n" + "=" * 70)
    print(f"  stream_mode='updates':每个节点交回的增量 (mode={mode})")
    print("=" * 70)
    _reduce_calls = 0
    for i, chunk in enumerate(build_graph(mode).stream({}), start=1):
        for node_name, update in chunk.items():
            menu = update.get("menu") if isinstance(update, dict) else None
            n = len(menu) if isinstance(menu, list) else "-"
            print(f"\n── 更新 {i} ── {node_name} 交回 menu({n}) = {menu}")

    print("\n" + "=" * 70)
    print(f"  stream_mode='values':合并后的通道快照 (mode={mode})")
    print("=" * 70)
    _reduce_calls = 0
    for i, snap in enumerate(build_graph(mode).stream({}, stream_mode="values")):
        menu = snap.get("menu", [])
        print(f"\n── 快照 {i} ── menu({len(menu)}) = {menu}  all_ready={snap.get('all_ready')}")


# --- 运行 ---
def main() -> None:
    global SLEEP

    ap = argparse.ArgumentParser(description="LangGraph 并行 + reducer 追踪 demo")
    ap.add_argument(
        "--mode",
        choices=["wrong", "right", "both"],
        default="both",
        help="跑哪种写法,默认两种都跑做对比",
    )
    ap.add_argument("--stream", action="store_true", help="额外用 stream 看增量和快照")
    ap.add_argument("--no-sleep", action="store_true", help="去掉模拟耗时,跑得快点")
    args = ap.parse_args()

    SLEEP = not args.no_sleep
    modes = ["wrong", "right"] if args.mode == "both" else [args.mode]

    menus = {m: run_invoke(m) for m in modes}

    if args.stream:
        for m in modes:
            run_stream(m)

    if args.mode == "both":
        print("\n" + "=" * 70)
        print("  对比")
        print("=" * 70)
        print(f"  错误写法 menu 长度 = {len(menus['wrong'])}   (3 个菜被写了 8 遍)")
        print(f"  正确写法 menu 长度 = {len(menus['right'])}   ✅")
        print("\n  两级翻倍:")
        print("    3 → 12  三次 append 改共享 list,又被三次 return state 各追加 3 条")
        print("    12 → 24 serve 的 return state 把读到的 12 条整份再追加一遍")
        print("\n  正确写法的 menu 顺序是 a/b/c 的声明顺序,不是完成时间顺序")
        print("  (c 只睡 1 秒却排最后)—— add 按 LangGraph 处理更新的顺序拼接。")


if __name__ == "__main__":
    main()