首页/LangGraph 框架指南/第 9 章 复用与封装:子图
LangGraph 框架指南 · 第 09 章

第 9 章 复用与封装:子图

本章目标:把一张图变成另一个节点——掌握子图的两种嵌入方式(状态共享与状态转换),理解"编译产物是 Runnable"这句话在架构层面的真正威力。第 2 章埋下的伏笔,本章兑现

约 16 分钟6,538 字4 个代码示例

本章目标:把一张图变成另一个节点——掌握子图的两种嵌入方式(状态共享与状态转换),理解"编译产物是 Runnable"这句话在架构层面的真正威力。第 2 章埋下的伏笔,本章兑现。

9.1 当同样的流程要用第二次

系统演进到现在,evaluate(单份评估)已经是一张五脏俱全的小图:解析、工具循环、打分。新的需求来了:

「深度评估」要复用整套评估流程;「快速预筛」要用它的前半段;下个季度上线的「人才库盘点」还要用它。

你面临三个选择:

  1. 把逻辑抽成普通函数——但函数进不了图:无法单独观测、无法单独持久化、无法参与 Send 扇出
  2. 复制粘贴一份改改——从此两个版本各自腐化
  3. 把整张图打包成一个节点——LangGraph 原生支持

第 3 个选择就是子图。它的可行性来自第 2 章的一个事实:编译产物实现了 Runnable 协议,而节点可以接收任何 Runnable。所以"把一张图注册为另一张图的节点"不需要任何新机制——add_node("evaluate", evaluate_graph) 就够了。

但这只是最表层的用法。子图真正的核心议题是:外图和子图的状态怎么对接? 两种模式,语义截然不同。

9.2 两种嵌入模式总览

flowchart TB subgraph 模式A[模式一:状态共享] PS[父图状态] ---|同一个状态对象| SG1[子图直接读写] end subgraph 模式B[模式二:状态转换] PS2[父图状态] -->|入参映射| SG2[子图独立状态] SG2 -->|返回值映射| PS2 end
  • 模式一(状态共享):子图与父图使用同一个状态 schema(或子图 schema 是父图 schema 的子集)。子图节点直接读写父图状态——父子之间不传参,共享一块黑板。
  • 模式二(状态转换):子图有自己独立的状态 schema。它作为节点被调用时接收父图状态,运行完把自己的最终状态返回给父图合并。需要写胶水代码做字段映射。

选择标准一句话:想要"透明协作"选模式一(父子共谋一块状态);想要"独立封装"选模式二(子图是黑盒组件)。

9.3 模式一:状态共享子图

需求:把"报告生成"做成可复用子图,供主流程在多处调用。父子共用同一个状态 schema:

from typing import Annotated, TypedDict
import operator
from langgraph.graph import StateGraph, START, END

# ---- 共享的状态 schema:父图与子图都用它 ----
class ScreenState(TypedDict):
    resume_text: str
    candidate: dict
    score: Annotated[int, operator.add]
    breakdown: Annotated[list[str], operator.add]
    report: str                       # 由报告子图填写

# ---- 子图:报告生成(复用单元) ----
def outline(state: ScreenState) -> dict:
    return {"breakdown": ["报告大纲:总分 + 明细 + 建议"]}

def compose(state: ScreenState) -> dict:
    report = (
        f"候选人 {state['candidate']['name']} 总分 {state['score']}\n"
        + "\n".join(state["breakdown"])
    )
    return {"report": report}

report_builder = StateGraph(ScreenState)
report_builder.add_node("outline", outline)
report_builder.add_node("compose", compose)
report_builder.add_edge(START, "outline")
report_builder.add_edge("outline", "compose")
report_builder.add_edge("compose", END)
report_graph = report_builder.compile()      # 子图:编译一次,处处复用

# ---- 父图:主评估流程 ----
def evaluate(state: ScreenState) -> dict:
    c = state["candidate"]
    score = min(100, 20 + len(c["skills"]) * 10 + c["years"] * 3)
    return {"score": score, "breakdown": ["技能匹配良好"]}

builder = StateGraph(ScreenState)
builder.add_node("evaluate", evaluate)
builder.add_node("generate_report", report_graph)   # 直接把子图当节点注册!
builder.add_edge(START, "evaluate")
builder.add_edge("evaluate", "generate_report")
builder.add_edge("generate_report", END)
main_graph = builder.compile()

result = main_graph.invoke({
    "resume_text": "...",
    "candidate": {"name": "李思远", "skills": ["Python", "FastAPI", "PostgreSQL"], "years": 6},
})
print(result["report"])
# 候选人 李思远 总分 68
# 技能匹配良好
# 报告大纲:总分 + 明细 + 建议

注意子图内部的微妙之处:outline 向 breakdown(reducer 字段)追加了一条;compose 读到的 breakdown 已包含父图里 evaluate 写入的内容——子图节点看到的就是父图的实时状态。没有入参出参、没有字段映射,一块黑板大家共用。

代价也在这里:子图与父图的状态 schema 耦死了。父图哪天重构字段名,子图跟着改;子图也没法复用到状态 schema 不同的图里。模式一是用耦合换简洁。

9.4 模式二:状态转换子图(黑盒封装)

需求变了:报告子图要沉淀为团队公共组件,谁的状态 schema 都不能假设。这时子图定义自己的状态,由一个包装函数负责父子之间的翻译:

from typing import Annotated, TypedDict
import operator
from langgraph.graph import StateGraph, START, END

# ---- 子图:完全独立的状态 schema(黑盒) ----
class ReportInput(TypedDict):
    name: str
    score: int
    highlights: list[str]

class ReportState(TypedDict):        # 子图内部状态
    name: str
    score: int
    highlights: list[str]
    report: str

def compose(state: ReportState) -> dict:
    report = f"{state['name']} 总分 {state['score']}:" + ";".join(state["highlights"])
    return {"report": report}

report_builder = StateGraph(ReportState)
report_builder.add_node("compose", compose)
report_builder.add_edge(START, "compose")
report_builder.add_edge("compose", END)
report_graph = report_builder.compile()

# ---- 包装函数:父子状态翻译层 ----
def generate_report(state: ScreenState) -> dict:
    # 父图状态 → 子图输入
    sub_input = {
        "name": state["candidate"]["name"],
        "score": state["score"],
        "highlights": state.get("breakdown", []),
    }
    sub_result = report_graph.invoke(sub_input)     # 子图作为普通 Runnable 调用
    # 子图输出 → 父图补丁
    return {"report": sub_result["report"]}

# ---- 父图:与 9.3 相同的组装方式 ----
builder = StateGraph(ScreenState)
builder.add_node("evaluate", evaluate)              # evaluate 与 9.3 相同
builder.add_node("generate_report", generate_report)  # 注册的是包装函数
builder.add_edge(START, "evaluate")
builder.add_edge("evaluate", "generate_report")
builder.add_edge("generate_report", END)
main_graph = builder.compile()

result = main_graph.invoke({
    "resume_text": "...",
    "candidate": {"name": "李思远", "skills": ["Python", "FastAPI", "PostgreSQL"], "years": 6},
})
print(result["report"])
# 李思远 总分 68:技能匹配良好

包装函数内部就是一次普通的 invoke——子图从头到尾只是一个 Runnable。两个方向的映射(父→子的入参构造、子→父的补丁构造)都显式写在包装函数里,一眼可见。

这个模式的威力在于子图彻底解耦:ReportState 是子图的私事,父图根本不知道 highlights 的存在。团队可以把报告子图发布成独立包,任何项目拿来就嵌。

两种模式的取舍总结:

维度 模式一:状态共享 模式二:状态转换
状态关系 同一块黑板 各自独立 + 翻译层
胶水代码 零 包装函数两个方向的映射
耦合度 高(schema 绑定) 低(黑盒)
复用范围 同 schema 的图 任意项目
适用 流程内部分阶段 沉淀公共组件

9.5 子图与持久化:一层一个账本

先埋一个伏笔(第 12 章展开):启用 Checkpointer 后,父图与子图各自维护自己的检查点历史。子图作为节点运行时,父图记录"子图节点完成",子图内部则有自己的逐节点记录。这带来一个实际问题:父图状态里看不到子图内部的中间字段(模式二下尤其如此——子图状态根本不进父图)。

由此推出一条设计纪律:父子边界上放"最终产物",子图内部细节留在子图里。模式一的共享状态下,子图往共享状态写的每个字段都会漏到父图层面——如果这让父图状态变得嘈杂,说明该考虑模式二了。

9.6 子图的流向逃逸:Command(graph=Command.PARENT)(进阶)

第 6 章预告过 Command(graph=Command.PARENT),现在有了上下文可以讲清楚。

默认情况下,子图节点返回的 Command(goto=...) 只在子图内部跳转——子图的边界像一堵墙,goto 出不去。但某些架构(第 10 章多 Agent 的"子图抢过控制权"模式)需要子图直接改写父图的流向:

from langgraph.types import Command

def subgraph_node(state) -> Command:
    return Command(
        update={"report": "..."},
        goto="next_main_node",              # 目标是父图的节点名
        graph=Command.PARENT,               # 关键:跳转作用于父图层
    )

graph=Command.PARENT 声明"这条 goto 逃出当前层,作用于父图"。用得极少,但一旦读到别人的多 Agent 代码里出现它,要知道它在解决什么问题——层级间的控制权移交。

常见坑与排错

  1. 模式一忘了共享 schema 的前提。子图 schema 与父图不一致却按模式一嵌入(无包装函数),运行时会因为字段缺失而炸。症状:子图节点里 KeyError,而且父图状态里明明有这个字段——那是父图 schema 里有、子图 schema 里没有。模式一的前提是"子图 schema ⊆ 父图 schema"。

  2. 模式二里把子图直接 add_node 而忘了包装。子图有自己的 schema,直接注册会导致父图状态原样传给子图——字段对不上,炸在子图入口。独立 schema 的子图必须经过包装函数翻译。

  3. 子图里改了 reducer 字段,父图却没感知。模式一下这其实是正常特性(共享黑板),但如果你以为子图是黑盒、没意识到 breakdown 被追加了,排查时会困惑"这个字段怎么多了一条"。共享模式下,子图的每次写入都是父图可见的全局副作用——这既是特性也是风险。

  4. 子图编译产物每次调用重新 compile。包装函数里写 StateGraph(...).compile()(每次 invoke 都编译)是性能黑洞。正确姿势:模块级编译一次(9.4 的 report_graph 定义在函数外),包装函数只做映射和调用。

  5. 子图与 Send 组合时的状态想象。Send 的载荷是"私有输入",如果 Send 的目标是子图节点,子图拿到的是载荷(而非全局状态)——模式一的共享黑板前提被 Send 打破。Send + 子图几乎总是应当走模式二(子图本来就是输入/输出式的黑盒,天然适配 Send 的载荷语义)。

  6. 调试时忘记子图有独立边界。stream 的事件默认以"父图节点"粒度吐出,子图内部像被折叠了。要看子图内部明细,用 subgraphs=True(第 11 章会讲到)。

动手练习

  1. 把第 8 章的 ReAct 评估图整体改造成子图,嵌入"批次评估"主图(第 7 章的结构:prepare → Send 扇出 → 汇总)。子图用哪种模式?写出你的选择理由再动手(提示:Send 载荷语义)。

  2. 用模式一实现"复核子图":内部两个节点(抽查关键字段、二次打分),被主图在 evaluate 之后调用。复核节点向 breakdown 追加"复核记录"。运行后检查父图最终状态里 breakdown 的完整内容,体会共享黑板的副作用。

  3. 把 9.4 的报告子图改成异步版:包装函数 async def,内部 await report_graph.ainvoke(...)。验证父图整体用 ainvoke 调用时行为一致。思考:异步包装给第 5 章的"物理并行"带来了什么机会?

本章速查

API / 概念 一句话
子图的本质 编译产物是 Runnable,因此可直接 add_node 注册
模式一:状态共享 父子共用一块状态黑板,零胶水、高耦合
模式一前提 子图 schema ⊆ 父图 schema
模式二:状态转换 子图独立 schema + 包装函数做双向映射,黑盒低耦合
包装函数职责 父状态→子输入的构造;子输出→父补丁的构造
持久化边界 父子各自记账,父图状态只放子图最终产物
Command(graph=Command.PARENT) 子图节点直接改写父图流向(层级控制权移交)
选型口诀 透明协作选共享,独立封装选转换