langgraph教程系列-03-状态如何在图里流动-reducer机制

本文是「LangGraph 教程系列」第 3 篇。写作时基于 langgraph 1.2.10、langchain 1.3.14、langchain-openai 1.4.1、Python 3.12+。配套代码仓库 https://github.com/wxj006007/deep-research-assistant ,本篇对应 tag v0.2

上一篇结尾埋了颗雷,「第二次检索的结果会把第一次的覆盖掉」。这一篇我们先把雷踩响,再用一行代码拆掉它。

还记得第 1 篇立的约定吗,节点不修改 state,只返回要更新的字段,合并交给框架。当时说第 3 篇它是主角,今天兑现。「合并」这一步在 LangGraph 里有个正式名字,叫 reducer。

一、v0.1 撞的墙,后写的把先写的冲掉

到 v0.1 为止,助手答题全靠模型脑子里的存货。研究助手嘛,总得查资料。v0.2 给它加上检索,思路很直接,先把问题拆成 2 个角度不同的子查询,分头检索,最后只根据查到的资料作答。图结构长这样
#mermaid-svg-Xu9rQ1l99d7PswB7{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-Xu9rQ1l99d7PswB7 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-Xu9rQ1l99d7PswB7 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-Xu9rQ1l99d7PswB7 .error-icon{fill:#552222;}#mermaid-svg-Xu9rQ1l99d7PswB7 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-Xu9rQ1l99d7PswB7 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-Xu9rQ1l99d7PswB7 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-Xu9rQ1l99d7PswB7 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-Xu9rQ1l99d7PswB7 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-Xu9rQ1l99d7PswB7 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-Xu9rQ1l99d7PswB7 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-Xu9rQ1l99d7PswB7 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-Xu9rQ1l99d7PswB7 .marker.cross{stroke:#333333;}#mermaid-svg-Xu9rQ1l99d7PswB7 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-Xu9rQ1l99d7PswB7 p{margin:0;}#mermaid-svg-Xu9rQ1l99d7PswB7 .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-Xu9rQ1l99d7PswB7 .cluster-label text{fill:#333;}#mermaid-svg-Xu9rQ1l99d7PswB7 .cluster-label span{color:#333;}#mermaid-svg-Xu9rQ1l99d7PswB7 .cluster-label span p{background-color:transparent;}#mermaid-svg-Xu9rQ1l99d7PswB7 .label text,#mermaid-svg-Xu9rQ1l99d7PswB7 span{fill:#333;color:#333;}#mermaid-svg-Xu9rQ1l99d7PswB7 .node rect,#mermaid-svg-Xu9rQ1l99d7PswB7 .node circle,#mermaid-svg-Xu9rQ1l99d7PswB7 .node ellipse,#mermaid-svg-Xu9rQ1l99d7PswB7 .node polygon,#mermaid-svg-Xu9rQ1l99d7PswB7 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-Xu9rQ1l99d7PswB7 .rough-node .label text,#mermaid-svg-Xu9rQ1l99d7PswB7 .node .label text,#mermaid-svg-Xu9rQ1l99d7PswB7 .image-shape .label,#mermaid-svg-Xu9rQ1l99d7PswB7 .icon-shape .label{text-anchor:middle;}#mermaid-svg-Xu9rQ1l99d7PswB7 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-Xu9rQ1l99d7PswB7 .rough-node .label,#mermaid-svg-Xu9rQ1l99d7PswB7 .node .label,#mermaid-svg-Xu9rQ1l99d7PswB7 .image-shape .label,#mermaid-svg-Xu9rQ1l99d7PswB7 .icon-shape .label{text-align:center;}#mermaid-svg-Xu9rQ1l99d7PswB7 .node.clickable{cursor:pointer;}#mermaid-svg-Xu9rQ1l99d7PswB7 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-Xu9rQ1l99d7PswB7 .arrowheadPath{fill:#333333;}#mermaid-svg-Xu9rQ1l99d7PswB7 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-Xu9rQ1l99d7PswB7 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-Xu9rQ1l99d7PswB7 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-Xu9rQ1l99d7PswB7 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-Xu9rQ1l99d7PswB7 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-Xu9rQ1l99d7PswB7 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-Xu9rQ1l99d7PswB7 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-Xu9rQ1l99d7PswB7 .cluster text{fill:#333;}#mermaid-svg-Xu9rQ1l99d7PswB7 .cluster span{color:#333;}#mermaid-svg-Xu9rQ1l99d7PswB7 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-Xu9rQ1l99d7PswB7 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-Xu9rQ1l99d7PswB7 rect.text{fill:none;stroke-width:0;}#mermaid-svg-Xu9rQ1l99d7PswB7 .icon-shape,#mermaid-svg-Xu9rQ1l99d7PswB7 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-Xu9rQ1l99d7PswB7 .icon-shape p,#mermaid-svg-Xu9rQ1l99d7PswB7 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-Xu9rQ1l99d7PswB7 .icon-shape .label rect,#mermaid-svg-Xu9rQ1l99d7PswB7 .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-Xu9rQ1l99d7PswB7 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-Xu9rQ1l99d7PswB7 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-Xu9rQ1l99d7PswB7 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} START
plan

拆 2 个子查询
search_a

查第 1 个
search_b

查第 2 个
synthesize

汇总作答
END

纯直线,零条件边,四个节点排排站,跑完就散场。

问题出在 search_a 和 search_b 都要写 docs 这个字段。LangGraph 对 state 字段的默认更新语义是覆盖,节点返回什么,旧值就被替换成什么。questionanswer 这种只写一次的字段感觉不到,docs 这种要写两次的字段立刻出事,search_b 一提交,search_a 查到的资料原地蒸发。

这个坑我自己第一次用 LangGraph 时也结结实实踩过,最气人的是它不报错。图正常跑完,答案正常生成,只是资料悄悄少了一半,你不打印中间状态,根本发现不了。

二、reducer,给字段声明一条合并规则

先给现象背后的机制。state 里的每个字段可以理解成一条独立的通道,节点返回的 dict 是提交给通道的更新。默认规则是「新值替换旧值」。reducer 做的事,是让你给某条通道换一套规则,挂一个接收两个参数的函数上去,LangGraph 收到更新时就不再替换,而是调用这个函数,把旧值和新值合成一个。

写法上用的都是标准库的东西。typing.Annotated 负责给类型附加元数据,operator.add 充当 reducer,对两个 list 做加法就是拼接。于是 Annotated[list[Doc], operator.add] 读出来就是一句话,这个字段是 Doc 列表,更新方式是旧列表加新列表。

两种语义的差别,一张图看完
#mermaid-svg-ryOWAe5iyMbz7Lu1{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-ryOWAe5iyMbz7Lu1 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-ryOWAe5iyMbz7Lu1 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-ryOWAe5iyMbz7Lu1 .error-icon{fill:#552222;}#mermaid-svg-ryOWAe5iyMbz7Lu1 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-ryOWAe5iyMbz7Lu1 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-ryOWAe5iyMbz7Lu1 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-ryOWAe5iyMbz7Lu1 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-ryOWAe5iyMbz7Lu1 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-ryOWAe5iyMbz7Lu1 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-ryOWAe5iyMbz7Lu1 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-ryOWAe5iyMbz7Lu1 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-ryOWAe5iyMbz7Lu1 .marker.cross{stroke:#333333;}#mermaid-svg-ryOWAe5iyMbz7Lu1 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-ryOWAe5iyMbz7Lu1 p{margin:0;}#mermaid-svg-ryOWAe5iyMbz7Lu1 .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-ryOWAe5iyMbz7Lu1 .cluster-label text{fill:#333;}#mermaid-svg-ryOWAe5iyMbz7Lu1 .cluster-label span{color:#333;}#mermaid-svg-ryOWAe5iyMbz7Lu1 .cluster-label span p{background-color:transparent;}#mermaid-svg-ryOWAe5iyMbz7Lu1 .label text,#mermaid-svg-ryOWAe5iyMbz7Lu1 span{fill:#333;color:#333;}#mermaid-svg-ryOWAe5iyMbz7Lu1 .node rect,#mermaid-svg-ryOWAe5iyMbz7Lu1 .node circle,#mermaid-svg-ryOWAe5iyMbz7Lu1 .node ellipse,#mermaid-svg-ryOWAe5iyMbz7Lu1 .node polygon,#mermaid-svg-ryOWAe5iyMbz7Lu1 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-ryOWAe5iyMbz7Lu1 .rough-node .label text,#mermaid-svg-ryOWAe5iyMbz7Lu1 .node .label text,#mermaid-svg-ryOWAe5iyMbz7Lu1 .image-shape .label,#mermaid-svg-ryOWAe5iyMbz7Lu1 .icon-shape .label{text-anchor:middle;}#mermaid-svg-ryOWAe5iyMbz7Lu1 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-ryOWAe5iyMbz7Lu1 .rough-node .label,#mermaid-svg-ryOWAe5iyMbz7Lu1 .node .label,#mermaid-svg-ryOWAe5iyMbz7Lu1 .image-shape .label,#mermaid-svg-ryOWAe5iyMbz7Lu1 .icon-shape .label{text-align:center;}#mermaid-svg-ryOWAe5iyMbz7Lu1 .node.clickable{cursor:pointer;}#mermaid-svg-ryOWAe5iyMbz7Lu1 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-ryOWAe5iyMbz7Lu1 .arrowheadPath{fill:#333333;}#mermaid-svg-ryOWAe5iyMbz7Lu1 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-ryOWAe5iyMbz7Lu1 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-ryOWAe5iyMbz7Lu1 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-ryOWAe5iyMbz7Lu1 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-ryOWAe5iyMbz7Lu1 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-ryOWAe5iyMbz7Lu1 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-ryOWAe5iyMbz7Lu1 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-ryOWAe5iyMbz7Lu1 .cluster text{fill:#333;}#mermaid-svg-ryOWAe5iyMbz7Lu1 .cluster span{color:#333;}#mermaid-svg-ryOWAe5iyMbz7Lu1 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-ryOWAe5iyMbz7Lu1 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-ryOWAe5iyMbz7Lu1 rect.text{fill:none;stroke-width:0;}#mermaid-svg-ryOWAe5iyMbz7Lu1 .icon-shape,#mermaid-svg-ryOWAe5iyMbz7Lu1 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-ryOWAe5iyMbz7Lu1 .icon-shape p,#mermaid-svg-ryOWAe5iyMbz7Lu1 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-ryOWAe5iyMbz7Lu1 .icon-shape .label rect,#mermaid-svg-ryOWAe5iyMbz7Lu1 .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-ryOWAe5iyMbz7Lu1 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-ryOWAe5iyMbz7Lu1 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-ryOWAe5iyMbz7Lu1 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} operator.add,累积
再写 2 条
docs 有 2 条
docs 变 4 条
默认语义,覆盖
再写 2 条
docs 有 2 条
docs 剩 2 条

这也解释了第 1 篇那条约定为什么重要。要是节点直接改 state,自己动手 append,合并逻辑就散落在各个节点的肚子里,谁也说不清一个字段到底被谁怎么改过。节点只交增量,合并规则集中声明在 State 上,一行 Annotated 改的是整张图的行为。检索资料是研究的台账,而台账的规矩从账房先生的年代起就是只许追加、不许涂改,reducer 就是把这条规矩写进了字段的类型声明里。

三、动手实现

3.1 两个 State,一行之差

v0.2 里刻意准备了两个 State。累积版是主角

python 复制代码
import operator
from typing import Annotated, TypedDict
python 复制代码
class Doc(TypedDict):
    query: str    # 由哪个查询检索到的
    title: str    # 语料条目标题
    content: str  # 语料正文


# ---------- State:累积版(主角) ----------
class ResearchState(TypedDict):
    question: str
    # sub_queries 故意不加 reducer:它只在 plan 节点写入一次,之后没人再写。
    # 只写一次的字段用默认的"覆盖"语义就够了------不是所有 list 都需要 add。
    sub_queries: list[str]
    # 本篇主角:挂上 operator.add 之后,节点只返回"本次新增"的 docs,
    # LangGraph 负责 旧列表 + 新列表 的合并。多次检索的结果就这样攒起来。
    docs: Annotated[list[Doc], operator.add]
    answer: str

覆盖版是对照组,除了那一行 Annotated,逐字相同

python 复制代码
# ---------- State:覆盖版(对照组) ----------
class OverwriteState(TypedDict):
    question: str
    sub_queries: list[str]
    # 唯一的差异就这一行:docs 不带 Annotated[..., operator.add],
    # 每次节点写 docs 都是整体覆盖------后一次检索会冲掉前一次的结果。
    docs: list[Doc]
    answer: str

留着对照组不是为了凑字数,待会儿要用同一组节点、同一个图结构把两个版本各跑一遍,现场看资料被冲掉。

3.2 检索先用假的

可能有小伙伴纳闷,说好的检索呢,怎么连不上网。坦率的讲,这是刻意的。本篇的主角是 reducer,真联网搜索会引入不确定性,每次跑出来的结果都不一样,教学就没法复现了。所以 v0.2 用一个内置的 FAKE_CORPUS(7 条 LangGraph 主题的小语料,内容其实是本系列后面几篇的剧透)加一个确定性的关键词匹配函数顶替,不联网、不要 key、跑一万遍结果一样。真搜索是 v1.1 的事,接口已经按真实检索工具的形状设计好了,届时只换实现、不换调用方。

python 复制代码
def fake_search(query: str, already_have: list[Doc], top_k: int = 2) -> list[Doc]:
    """确定性的模拟检索:纯 Python,无随机、无 LLM。

    打分规则:query 小写后统计每条语料 keywords 的命中数,
    按(命中数降序,语料原始顺序)排序取 top_k;命中 0 的不返回。
    增量去重:already_have 里已有的 title 先排除------真实检索也需要这一步,
    否则 reducer 累积出来的是一堆重复文档。
    """
    query_lower = query.lower()
    have_titles = {doc["title"] for doc in already_have}

    scored: list[tuple[int, int, dict]] = []
    for index, entry in enumerate(FAKE_CORPUS):
        if entry["title"] in have_titles:
            continue
        hits = sum(1 for kw in entry["keywords"] if kw.lower() in query_lower)
        if hits > 0:
            scored.append((hits, index, entry))

    # 命中数降序;命中数相同按语料原始顺序,保证结果完全确定
    scored.sort(key=lambda item: (-item[0], item[1]))
    return [
        Doc(query=query, title=entry["title"], content=entry["content"])
        for _, _, entry in scored[:top_k]
    ]

注意 already_have 这个参数,它承担增量去重。挂了 operator.add 之后每次写入都是追加,检索方要是不排除已有的文档,攒出来的就是一堆重复资料。这一步在真实检索里同样躲不掉。

3.3 节点保持 schema 无感

先看拆查询的 plan 节点,函数签名有个容易被忽略的细节

python 复制代码
# ---------- Node 1:拆解子查询 ----------
# 注意:节点参数标注为 dict 而不是 ResearchState------LangGraph 会从函数注解
# 推断输入 schema,若写死 ResearchState,同一组节点就无法再喂给 OverwriteState
# (docs 通道类型冲突)。节点保持 schema 无感,才能"一组节点、两个 State"复用。
def plan_node(state: dict) -> dict:
    """把问题拆成恰好 2 个角度不同的检索查询,一次性写入 sub_queries。"""
    llm = get_llm()
    response = llm.invoke(
        [
            (
                "system",
                "你是一个研究规划助手。把用户的问题拆解成恰好 2 个"
                "角度不同的检索查询,用于分别检索资料。\n"
                "每行一个,只输出查询本身,不要编号、不要解释。",
            ),
            ("human", state["question"]),
        ]
    )
    # 兜底:按行切分、去空行取前 2 条;不足 2 条用原问题补齐,
    # 保证下游 sub_queries[0] / sub_queries[1] 永不越界。
    lines = [line.strip() for line in response.content.splitlines() if line.strip()]
    sub_queries = lines[:2]
    while len(sub_queries) < 2:
        sub_queries.append(state["question"])
    return {"sub_queries": sub_queries}

前两篇的节点都标注了具体的 ResearchState,这里改标 dict。原因写在注释里了,LangGraph 会从函数注解推断输入 schema,写死了累积版的 State,这组节点就没法再喂给覆盖版。节点只管读字段、返回增量,不关心字段挂没挂 reducer,这才复用得起来。

然后是检索节点。两次检索逻辑一样,只是用的子查询不同,所以用一个工厂函数造两个实例

python 复制代码
# ---------- Node 2:检索节点工厂 ----------
def make_search_node(index: int):
    """造一个"用第 index 个子查询做检索"的节点。

    同一份逻辑挂成 search_a、search_b 两个节点实例------
    在没有循环的世界里,"多次检索"只能这样预先铺开。
    """

    def search_node(state: dict) -> dict:
        query = state["sub_queries"][index]
        # 覆盖版跑到 search_a 时 state 里还没有 docs 字段,用 get 兜底,
        # 让累积版和覆盖版共用同一份节点代码。
        already_have = state.get("docs", [])
        new_docs = fake_search(query, already_have)
        # 关键:只返回"本次新增"的 docs,不做任何合并------
        # 合并(或覆盖)是 State 上 reducer 的事,节点不该操心。
        return {"docs": new_docs}

    return search_node

最后那行 return 是全篇最要紧的一行代码。节点返回的是本次新增的 docs,不是攒好的全量列表。合并是 State 的事,节点不该操心。

收尾的 synthesize 节点这里就不贴全了,它把 state 里的 docs 拼成资料清单喂给模型,提示词刻意要求「仅根据以下资料回答」「资料未覆盖之处如实说明,不要编造」。不许模型拿自己的知识救场,资料丢没丢,答案里一眼就能看出来。

3.4 同一组节点,编译两遍

组装环节把 State schema 做成了参数

python 复制代码
# ---------- 组装:同一组节点,喂哪个 State 由调用方决定 ----------
def build_graph(state_schema: type):
    """按给定的 State schema 编译图。

    节点函数本身不感知 schema------它们只是读字段、返回增量。
    docs 是累积还是覆盖,完全由 State 声明里那一行 Annotated 决定。
    """
    builder = StateGraph(state_schema)
    builder.add_node("plan", plan_node)
    builder.add_node("search_a", make_search_node(0))
    builder.add_node("search_b", make_search_node(1))
    builder.add_node("synthesize", synthesize_node)

    # 纯直线 DAG:全部 add_edge,零条件边、零回边。
    # 想多查一次?只能再铺一个节点。这个憋屈感留给第 4 篇的循环来解。
    builder.add_edge(START, "plan")
    builder.add_edge("plan", "search_a")
    builder.add_edge("search_a", "search_b")
    builder.add_edge("search_b", "synthesize")
    builder.add_edge("synthesize", END)
    return builder.compile()


graph = build_graph(ResearchState)  # 累积版为正式导出

build_graph(ResearchState)build_graph(OverwriteState) 编译出来的两张图,节点一样、边一样,唯一的差别是 docs 通道的合并规则。这是个挺干净的实验设计,变量只有一个。

3.5 现场对比

python -m src.v02_reducer 会把两张图各跑一遍,问题就用「LangGraph 里的 reducer 和条件边分别解决什么问题?」,逐节点打印 docs 的数量。实际跑一遍,输出如下(两段回答较长,摘要展示)

复制代码
========== 第一遍:docs 带 reducer(累积版) ==========
[plan]      子查询: ['LangGraph reducer 状态合并机制的作用与解决的问题', 'LangGraph 条件边路由逻辑的作用与解决的问题']
[search_a]  state 中 docs 共 2 条: ['reducer 与状态累积', 'LangGraph 基础概念']
[search_b]  state 中 docs 共 3 条: ['reducer 与状态累积', 'LangGraph 基础概念', '条件边与路由']
[synthesize] 回答: ...Reducer 解决的是"状态覆盖"问题...条件边解决的是"流程分岔"问题...
用到的资料标题:【reducer 与状态累积】【条件边与路由】

========== 第二遍:docs 不带 reducer(覆盖版),同样的节点、同样的图 ==========
[plan]      子查询: ['LangGraph reducer 状态聚合机制与作用', 'LangGraph 条件边 动态路由与分支控制']
[search_a]  state 中 docs 共 2 条: ['reducer 与状态累积', 'LangGraph 基础概念']
[search_b]  state 中 docs 共 1 条: ['条件边与路由']  ← 第一次的结果被覆盖丢了!
[synthesize] 回答: ...条件边解决的是"流程分岔"问题...
reducer:提供的资料中未包含关于 reducer 的信息,因此无法说明其解决的问题。
用到的资料标题:【条件边与路由】

========== 对比 ==========
累积版最终 docs: 3 条 | 覆盖版最终 docs: 1 条
唯一的代码差异:docs: Annotated[list[Doc], operator.add] vs docs: list[Doc]

累积版两次检索攒下 3 条资料。细心的小伙伴可能会问,search_b 怎么只新增了 1 条,top_k 不是 2 吗。回看 3.2 节的 already_have,第二个子查询的候选里有一条「LangGraph 基础概念」在 search_a 时已经拿到了,被增量去重拦在门外,这正是去重该干的活。覆盖版就惨了,search_b 一提交,state 里只剩它自己那 1 条,search_a 查到的 2 条资料没了。覆盖版的答案也随之变味,问题问的是 reducer 和条件边两件事,资料只剩条件边那一半,模型只好老老实实承认「提供的资料中未包含关于 reducer 的信息」。同样的节点,同样的图,一行类型声明的差距。

四、顺着上面的再聊聊

sub_queries 为什么不挂 reducer。你想想看,它只在 plan 节点写入一次,之后没人再碰,默认的覆盖语义刚刚好。反过来讲,覆盖不是什么设计缺陷,「新值替换旧值」对绝大多数字段正是想要的行为,answer 你就希望以最后一次生成的为准。别看完本篇就把所有 list 都挂上 add,挂了 add 的字段每次写入都是追加,你反而失去了「整个换掉」的能力。哪个字段用哪种语义,是设计决策,不是默认动作。

节点里手动合并是反模式。有人会想,那我在节点里 state["docs"] + new_docs 返回全量,不挂 reducer 不也一样吗。这想法在覆盖版里确实能跑通,但它把合并逻辑复制进了每个写 docs 的节点,改语义要挨个节点改。而且它跟 reducer 势不两立,要是字段已经挂了 add,节点又返回全量,旧值加全量,资料直接翻倍。所以纪律只有一条,节点永远只交增量。

reducer 也不是只有 operator.add 一种。它可以是任何接收(旧值,新值)返回合并结果的函数,按 id 去重、字典 merge、取最大值都行。LangGraph 官方还为消息列表提供了现成的 add_messages,等后面接上工具调用时我们会碰到它。

五、本篇小结

回到 reducer 这块,本篇其实就一句话,state 字段的默认更新语义是覆盖,给字段挂上 reducer 之后变成合并,Annotated[list[Doc], operator.add] 让多次检索的 docs 累积而不是互相冲掉。配套的纪律也顺手立住了,节点只返回增量,合并规则集中声明在 State 上,不是所有字段都该累积,覆盖常常正是你要的语义。

v0.2 能攒资料了,但那股憋屈感还在。图是条纯直线,想多查一次,只能预先在图里再铺一个检索节点,查三次就铺三个。更难受的是跑完就散场,synthesize 拿到资料发现不够回答,也没有任何机会回头补查,只能硬着头皮承认缺口。

流程需要能回头,回头了还得知道什么时候停。下一篇,循环与终止条件登场。

赞或收藏 关注 我们下次再见

相关推荐
喜欢的名字被抢了2 天前
langgraph教程系列-01-为什么需要图-从链到图
教程·langgraph
梦想的颜色2 天前
2026 AI Agent 工程师完整技术图谱|从面试题「什么是本体 Ontology」切入,附精选面试题库
面试·知识图谱·langgraph·aiagent·大模型面试·本体·2026 面试真题
一只小bit2 天前
Agent 动态调控:模型、工具、提示词、输出、流模式
机器学习·langchain·llm·人机交互·langgraph
喜欢的名字被抢了2 天前
langgraph教程系列-02-让流程分叉-条件边与路由
教程·langgraph
_itgo2 天前
LangGraph 主要3 种核心模式
ai·langchain·langgraph
糖果店的幽灵3 天前
人已经用 WorkBuddy 找工作拿了面试,你还在一份份手工改简历
人工智能·面试·职场和发展·langgraph
Esaka_Forever3 天前
为什么要学习 LangGraph,LangChain 与 LangGraph 的适用边界
langgraph
人间凡尔赛4 天前
2026多智能体系统深度解析:从GPT-5.6 Ultra到开源框架,构建你的Agent军团
ai·agent·多智能体·langgraph·crewai·gpt-5.6
Esaka_Forever4 天前
StateGraph —— LangGraph 的核心基石
langgraph