第 9 章 复用与封装:子图
本章目标:把一张图变成另一个节点——掌握子图的两种嵌入方式(状态共享与状态转换),理解"编译产物是 Runnable"这句话在架构层面的真正威力。第 2 章埋下的伏笔,本章兑现
本章目标:把一张图变成另一个节点——掌握子图的两种嵌入方式(状态共享与状态转换),理解"编译产物是 Runnable"这句话在架构层面的真正威力。第 2 章埋下的伏笔,本章兑现。
9.1 当同样的流程要用第二次
系统演进到现在,evaluate(单份评估)已经是一张五脏俱全的小图:解析、工具循环、打分。新的需求来了:
「深度评估」要复用整套评估流程;「快速预筛」要用它的前半段;下个季度上线的「人才库盘点」还要用它。
你面临三个选择:
- 把逻辑抽成普通函数——但函数进不了图:无法单独观测、无法单独持久化、无法参与 Send 扇出
- 复制粘贴一份改改——从此两个版本各自腐化
- 把整张图打包成一个节点——LangGraph 原生支持
第 3 个选择就是子图。它的可行性来自第 2 章的一个事实:编译产物实现了 Runnable 协议,而节点可以接收任何 Runnable。所以"把一张图注册为另一张图的节点"不需要任何新机制——add_node("evaluate", evaluate_graph) 就够了。
但这只是最表层的用法。子图真正的核心议题是:外图和子图的状态怎么对接? 两种模式,语义截然不同。
9.2 两种嵌入模式总览
- 模式一(状态共享):子图与父图使用同一个状态 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 代码里出现它,要知道它在解决什么问题——层级间的控制权移交。
常见坑与排错
-
模式一忘了共享 schema 的前提。子图 schema 与父图不一致却按模式一嵌入(无包装函数),运行时会因为字段缺失而炸。症状:子图节点里
KeyError,而且父图状态里明明有这个字段——那是父图 schema 里有、子图 schema 里没有。模式一的前提是"子图 schema ⊆ 父图 schema"。 -
模式二里把子图直接 add_node 而忘了包装。子图有自己的 schema,直接注册会导致父图状态原样传给子图——字段对不上,炸在子图入口。独立 schema 的子图必须经过包装函数翻译。
-
子图里改了 reducer 字段,父图却没感知。模式一下这其实是正常特性(共享黑板),但如果你以为子图是黑盒、没意识到 breakdown 被追加了,排查时会困惑"这个字段怎么多了一条"。共享模式下,子图的每次写入都是父图可见的全局副作用——这既是特性也是风险。
-
子图编译产物每次调用重新 compile。包装函数里写
StateGraph(...).compile()(每次 invoke 都编译)是性能黑洞。正确姿势:模块级编译一次(9.4 的report_graph定义在函数外),包装函数只做映射和调用。 -
子图与 Send 组合时的状态想象。Send 的载荷是"私有输入",如果 Send 的目标是子图节点,子图拿到的是载荷(而非全局状态)——模式一的共享黑板前提被 Send 打破。Send + 子图几乎总是应当走模式二(子图本来就是输入/输出式的黑盒,天然适配 Send 的载荷语义)。
-
调试时忘记子图有独立边界。
stream的事件默认以"父图节点"粒度吐出,子图内部像被折叠了。要看子图内部明细,用subgraphs=True(第 11 章会讲到)。
动手练习
-
把第 8 章的 ReAct 评估图整体改造成子图,嵌入"批次评估"主图(第 7 章的结构:prepare → Send 扇出 → 汇总)。子图用哪种模式?写出你的选择理由再动手(提示:Send 载荷语义)。
-
用模式一实现"复核子图":内部两个节点(抽查关键字段、二次打分),被主图在
evaluate之后调用。复核节点向breakdown追加"复核记录"。运行后检查父图最终状态里 breakdown 的完整内容,体会共享黑板的副作用。 -
把 9.4 的报告子图改成异步版:包装函数
async def,内部await report_graph.ainvoke(...)。验证父图整体用ainvoke调用时行为一致。思考:异步包装给第 5 章的"物理并行"带来了什么机会?
本章速查
| API / 概念 | 一句话 |
|---|---|
| 子图的本质 | 编译产物是 Runnable,因此可直接 add_node 注册 |
| 模式一:状态共享 | 父子共用一块状态黑板,零胶水、高耦合 |
| 模式一前提 | 子图 schema ⊆ 父图 schema |
| 模式二:状态转换 | 子图独立 schema + 包装函数做双向映射,黑盒低耦合 |
| 包装函数职责 | 父状态→子输入的构造;子输出→父补丁的构造 |
| 持久化边界 | 父子各自记账,父图状态只放子图最终产物 |
Command(graph=Command.PARENT) |
子图节点直接改写父图流向(层级控制权移交) |
| 选型口诀 | 透明协作选共享,独立封装选转换 |