第 11 章

子图

图中嵌图——用模块化方式构建复杂智能体系统

一个节点 = 一个完整的图,组合拼装成更大的系统

图越来越复杂

单个图有 30 个节点、20 条边——没人能看懂,也没人敢改。

维护困难

一个巨大的图里塞了研究、写作、审核、发布四个流程。改审核逻辑时误伤了写作流程。

无法复用

团队 A 写了一个"代码审查"图,团队 B 也需要一个。只能复制粘贴,各自维护。

协作冲突

三个团队在同一个图文件上开发。每天都在解决合并冲突,Git 状态一团糟。

子图就是为这个设计的:把大图拆成小图,每个小图独立开发、独立测试、独立部署。然后在父图里像拼积木一样组合起来。

开始之前

前置知识

  • Ch04-10 的所有核心概念
  • 特别是 持久化(checkpointer 传播)
  • 理解 StateSchema 和状态共享

学完后你应该能

  • 区分两种子图通信方式
  • 在节点内调用子图(状态不同)
  • 将子图直接作为节点添加(状态共享)
  • 理解子图的持久化模式

什么是子图

子图就是作为另一个图中节点使用的图

图:父图与子图的关系

适用场景

  • 多智能体系统:不同团队各负责一个子图
  • 复用组件:一组节点在多个图中重复使用
  • 分布式开发:不同团队独立开发、独立测试

两种通信

关键决策:父子图的状态是否共享?

模式适用场景做法
在节点内调用父子图有不同状态(无共享键)写包装函数映射状态
直接作为节点父子图共享状态键直接传入编译后的子图
图:两种子图通信方式对比
判断标准:如果子图有自己独特的状态字段(和父图不同),用节点内调用。如果子图用的状态字段父图也有,直接作为节点。

节点内调用

父子图状态不同时,在节点函数内调用子图并转换状态

思考题:主图的 State 是 input + output + stats,子图的 State 是 text + processedText + wordCount,两边的字段名完全不同,怎么让它们互相通信?
// 子图 — 有自己的状态 { bar }
const SubgraphState = new StateSchema({ bar: z.string() });

const subgraph = new StateGraph(SubgraphState)
  .addNode("subgraphNode1", (state) => {
    return { bar: "hi! " + state.bar };
  })
  .addEdge(START, "subgraphNode1")
  .compile();

// 父图 — 状态是 { foo },和子图的 { bar } 不同
const State = new StateSchema({ foo: z.string() });

const graph = new StateGraph(State)
  .addNode("node1", async (state) => {
    // 手动映射:foo → bar
    const output = await subgraph.invoke({ bar: state.foo });
    // 手动映射回:bar → foo
    return { foo: output.bar };
  })
  .addEdge(START, "node1")
  .compile();

📄 完整代码:11_call_inside_node.ts

模式:在节点函数内手动完成 foo → bar 的输入映射,调用子图,再把 bar → foo 映射回来。两个图完全独立,各自维护自己的状态结构。

直接作为节点

父子图共享状态键时,直接传入编译后的子图

思考题:主图和子图都用 messages 字段,每次都写 wrapper 节点手动映射太繁琐了,既然字段名一样,能不能让 LangGraph 自动处理 State 的传入传出?
// 父子图共享 { foo } 状态
const State = new StateSchema({ foo: z.string() });

const subgraph = new StateGraph(State)
  .addNode("subgraphNode1", (state) => {
    return { foo: "hi! " + state.foo };
  })
  .addEdge(START, "subgraphNode1")
  .compile();

// 直接传入子图作为节点 — 无需包装函数
const graph = new StateGraph(State)
  .addNode("node1", subgraph)  // 直接传!
  .addEdge(START, "node1")
  .compile();

📄 完整代码:11_direct_as_node.ts

简洁很多:不需要包装函数,不需要状态映射。子图直接读取和更新父图的状态。只要两边的 StateSchema 有相同的键就行。

子图持久化

子图的检查点行为取决于它的 checkpointer 配置

思考题:子图需要 checkpointer 吗?如果子图是纯计算(输入→输出),加了 checkpointer 反而浪费性能;如果需要记忆跨调用状态,不加又不行。三种模式该选哪个?
模式checkpointer行为
每次调用(默认)不指定每次调用从新开始,继承父 checkpointer 支持中断
每线程指定状态在同一线程的多次调用间累积
无状态false无检查点,像普通函数调用

功能对比

功能每次调用每线程无状态
中断 (HITL)✅✅❌
多轮记忆❌✅❌
多次调用(不同子图)✅⚠️✅
状态检查⚠️✅❌

📄 完整代码:11_persistence_modes.ts

查看子图状态

用 subgraphs: true 选项查看子图内部状态

思考题:主图把子图当节点用,运行完了想看子图内部的中间状态——比如 step1 输出了什么、step2 又改了什么。默认 getState 只能看到主图层面,子图是黑盒,怎么透视子图内部?
// 查看当前调用的子图状态
const subgraphState = (await graph.getState(config, { subgraphs: true }))
  .tasks[0].state;
// 流式输出子图结果
for await (const chunk of await graph.stream(
  { foo: "foo" },
  { subgraphs: true, streamMode: "updates" }
)) {
  console.log(chunk);
}

📄 完整代码:11_subgraph_state.ts

默认行为:父图把子图视为一个黑盒节点。加上 subgraphs: true 后,可以查看子图内部的执行细节——包括中间节点的状态和输出。

排错指南

状态键不匹配

症状:子图读不到父图的数据,或返回的数据父图不认识。

修复:检查父子图的 StateSchema。使用"直接作为节点"时,两者的键名必须完全一致。

checkpointer 没传播

症状:子图中的 interrupt 不暂停。

修复:确认在父图编译时传入了 checkpointer。默认情况下子图自动继承父图的 checkpointer。

忘记 compile

症状:传入子图时报类型错误。

修复:子图必须先 .compile() 才能作为节点使用。未编译的 StateGraph 不是可执行图。

动手练习

构建一个包含两个子图的父图

任务:构建"研究 + 写作"两阶段的文档生成系统。
  • 子图 A(研究):状态为 { topic, findings },包含"搜索"和"整理"两个节点
  • 子图 B(写作):状态为 { findings, article },包含"起草"和"润色"两个节点
  • 父图状态为 { topic, findings, article }
  • 用节点内调用模式连接两个子图

参考答案

思考题:你的系统有三层结构:主图调度、分析子图、文本处理子图。每层的 State 都不一样,需要逐层映射。多层嵌套子图怎么组织?有什么注意事项?
// 子图 A:研究
const ResearchState = new StateSchema({
  topic: z.string(),
  findings: z.array(z.string()).optional(),
});

const researchGraph = new StateGraph(ResearchState)
  .addNode("search", async (state) => {
    // 模拟搜索
    return { findings: [`Found info about ${state.topic}`] };
  })
  .addNode("organize", (state) => {
    return { findings: state.findings?.map(f => `Organized: ${f}`) };
  })
  .addEdge(START, "search")
  .addEdge("search", "organize")
  .compile();

// 子图 B:写作
const WritingState = new StateSchema({
  findings: z.array(z.string()).optional(),
  article: z.string().optional(),
});

const writingGraph = new StateGraph(WritingState)
  .addNode("draft", (state) => {
    return { article: `Draft based on ${state.findings?.join(", ")}` };
  })
  .addNode("polish", (state) => {
    return { article: `Polished: ${state.article}` };
  })
  .addEdge(START, "draft")
  .addEdge("draft", "polish")
  .compile();

// 父图 — 用节点内调用连接
const ParentState = new StateSchema({
  topic: z.string(),
  findings: z.array(z.string()).optional(),
  article: z.string().optional(),
});

const parentGraph = new StateGraph(ParentState)
  .addNode("research", async (state) => {
    const out = await researchGraph.invoke({ topic: state.topic });
    return { findings: out.findings };
  })
  .addNode("write", async (state) => {
    const out = await writingGraph.invoke({ findings: state.findings });
    return { article: out.article };
  })
  .addEdge(START, "research")
  .addEdge("research", "write")
  .addEdge("write", END)
  .compile();

📄 完整代码:11_multi_level_subgraph.ts

模式:每个子图有独立的状态(ResearchState vs WritingState)。父图在节点函数内做状态映射——把 topic 传给研究子图,把 findings 传给写作子图。

本章回顾

到这里,你已经能

  • 区分两种子图通信方式
  • 在节点内调用子图(状态不同时)
  • 将子图直接作为节点(状态共享时)
  • 理解子图的三种持久化模式
  • 查看和流式输出子图内部状态
核心结论:子图是构建复杂智能体系统的模块化方案。小图独立开发、独立测试,大图负责组合和编排。判断标准很简单——状态相同就直接传,状态不同就包装一下。

延伸阅读

进阶方向

  • LangSmith 调试 — 可视化追踪子图执行
  • 工具集成 — 网页搜索、数据库查询、API 调用
  • 重试逻辑 — 指数退避实现失败重试
  • 测试策略 — 单元测试和集成测试

官方资源