深入浅出 LangGraph —— 第11章:子图:构建模块化Agent

📖 本章学习目标

  • ✅ 理解子图的概念与在大型系统中的价值
  • ✅ 掌握将子图作为节点嵌入父图的两种方式
  • ✅ 学会设计父子图之间的状态转换(State Transform)
  • ✅ 理解子图的检查点隔离与调试方法
  • ✅ 能够将复杂 Agent 拆解为可独立测试的模块化子图
  • ✅ 避免常见的子图设计陷阱

一、子图的价值:分而治之

1、大型 Agent 系统的复杂性挑战

当 Agent 系统越来越复杂,一个图里可能有几十个节点,状态字段多达几十个。这带来了严重问题:

  • 单一大图难以理解和维护
  • 不同功能模块混在一起,无法单独测试
  • 代码无法在不同项目间复用
  • 团队协作时出现大量冲突
  • 修改一处可能影响全局

子图就是用来解决单一大图的这些问题的。它有点类似微服务架构------每个服务独立开发、测试、部署。

2、子图带来的好处

以一个写文章的Agent为例:
S3
事实核查
语法检查
评分
S2
大纲生成
段落写作
格式化
S1
关键词提取
调用搜索API
结果排序
主图

(编排层)

子图的优势:

-每个子图独立开发、测试、部署

  • 子图可在多个父图中复用
  • 父图只关注"编排",不关注"实现"
  • 子图有自己独立的 State,不污染父图
  • 团队可以并行开发不同子图

二、创建和使用子图

1、最简单的子图

步骤1:定义子图State
typescript 复制代码
import * as dotenv from 'dotenv';
dotenv.config();

import { Annotation, StateGraph } from '@langchain/langgraph';
import { ChatOpenAI } from '@langchain/openai';
import { HumanMessage } from '@langchain/core/messages';

const model = new ChatOpenAI({ model: 'gpt-4o-mini' });

// 子图自己的 State(与父图独立)
const SearchSubgraphState = Annotation.Root({
  query: Annotation<string>(),
  searchResults: Annotation<string[]>({
    reducer: (c, u) => [...c, ...u],
    default: () => [],
  }),
  summary: Annotation<string>(),
});

子图有自己独立的 State 定义,这个State只在子图内部使用,与父图无关。

步骤2:定义子图节点
typescript 复制代码
// 子图内部节点
async function searchNode(state: typeof SearchSubgraphState.State) {
  console.log(`🔍 搜索: ${state.query}`);
  // 模拟搜索结果
  return { searchResults: [`关于"${state.query}"的搜索结果1`, `结果2`] };
}

async function summarizeNode(state: typeof SearchSubgraphState.State) {
  const response = await model.invoke([
    new HumanMessage(`请总结以下搜索结果:\n${state.searchResults.join('\n')}`),
  ]);
  return { summary: response.content as string };
}

searchNode负责执行搜索,返回结果数组, summarizeNode负责使用LLM总结搜索结果。这些节点只在子图内部可见,子图外部无法直接访问这些节点。

步骤3:编译子图
typescript 复制代码
// 构建搜索子图并编译
const searchSubgraph = new StateGraph(SearchSubgraphState)
  .addNode('search', searchNode)
  .addNode('summarize', summarizeNode)
  .addEdge('__start__', 'search')
  .addEdge('search', 'summarize')
  .addEdge('summarize', '__end__')
  .compile(); // 子图也需要 compile

可见子图本身就是一个完整的 StateGraph,独立编译。编译后的子图可以直接被当作父图的节点来使用

,即将compile()后得到Runnable对象,作为节点传入父图。注意,未compile的子图会导致运行时错误。

2、方式一:子图状态与父图字段重叠

当子图和父图有相同字段时,LangGraph 自动处理状态传递:

定义父图State
typescript 复制代码
// 父图的 State
const ParentState = Annotation.Root({
  topic: Annotation<string>(),
  query: Annotation<string>(),   // ← 与子图的 query 字段同名
  summary: Annotation<string>(), // ← 与子图的 summary 字段同名
  finalReport: Annotation<string>(),
});
组装父图
typescript 复制代码
const parentGraph = new StateGraph(ParentState)
  .addNode('planQuery', async (state) => {
    return { query: `深入研究:${state.topic}` };
  })
  // 直接将编译后的子图作为节点传入
  .addNode('search', searchSubgraph)
  .addNode('writeReport', async (state) => {
    const response = await model.invoke([
      new HumanMessage(`基于以下摘要写报告:${state.summary}`),
    ]);
    return { finalReport: response.content as string };
  })
  .addEdge('__start__', 'planQuery')
  .addEdge('planQuery', 'search')
  .addEdge('search', 'writeReport')
  .addEdge('writeReport', '__end__')
  .compile();

addNode('search', searchSubgraph)直接把编译后的子图当节点, LangGraph 自动处理同名字段的传入/传出。

工作流程:

  1. planQuery节点设置query字段
  2. search子图接收query,执行搜索,输出summary
  3. writeReport节点读取summary,生成报告

字段自动映射示意图:
query(同名)
summary(同名)
父图 State

topic, query, summary, finalReport
子图 State

query, searchResults, summary
searchResults

子图私有

不传递给父图
topic, finalReport

父图私有

子图不可见

3、方式二:通过状态转换函数显式映射

当父子图字段名不同时,使用状态转换函数:

定义不同的字段名
typescript 复制代码
// 父图字段名不同
const AnotherParentState = Annotation.Root({
  userQuestion: Annotation<string>(),  // 父图叫 userQuestion
  researchSummary: Annotation<string>(), // 父图叫 researchSummary
});
定义转换函数
typescript 复制代码
// 输入转换:父图 State → 子图输入
function inputTransform(parentState: typeof AnotherParentState.State) {
  return { query: parentState.userQuestion }; // 映射字段名
}

// 输出转换:子图 State → 父图更新
function outputTransform(subState: typeof SearchSubgraphState.State) {
  return { researchSummary: subState.summary }; // 映射字段名
}

在调用子图前,使用input 函数将父图状态转换为子图期望的输入格式;子图执行完后,使用output 函数将子图状态映射到父图的更新。这使得子图完全解耦,不依赖父图的字段命名。

优势:

  • 子图可以复用,不受父图字段名限制
  • 可以做数据转换和清洗
  • 可以过滤不需要的字段
使用转换函数

addNode的配置项传入转换函数即可:

typescript 复制代码
const graphWithTransform = new StateGraph(AnotherParentState)
  .addNode('research', searchSubgraph, {
    input: inputTransform,
    output: outputTransform,
  })
  .addEdge('__start__', 'research')
  .addEdge('research', '__end__')
  .compile();

执行流程:

  1. 父图调用子图前,执行inputTransform
  2. 子图执行
  3. 子图返回后,执行outputTransform
  4. 更新父图State

两种对比:

特性 同名字段自动映射 状态转换函数
实现难度 ⭐ 简单 ⭐⭐ 中等
灵活性 ⭐⭐ 低 ⭐⭐⭐⭐⭐ 高
适用场景 字段名一致 字段名不一致或需转换
可维护性 ⭐⭐⭐ 中 ⭐⭐⭐⭐ 好

三、子图的高级特性

1、子图的独立检查点

子图也可以有自己的 checkpointer(独立于父图)

typescript 复制代码
import { MemorySaver } from '@langchain/langgraph';

const subgraphWithCheckpoint = new StateGraph(SearchSubgraphState)
  .addNode('search', searchNode)
  .addNode('summarize', summarizeNode)
  .addEdge('__start__', 'search')
  .addEdge('search', 'summarize')
  .addEdge('summarize', '__end__')
  .compile({ checkpointer: new MemorySaver() }); // 子图独立持久化

子图独立配置 checkpointer,父图调用子图时,子图会生成子命名空间的检查点,这使得子图的执行可以独立恢复,适合耗时的子流程。

应用场景:

  • 长时间运行的子任务
  • 需要中断恢复的子流程
  • 子图级别的调试和时间旅行

子图和父图的checkpointer可以不同,例如:父图用PostgreSQL,子图用MemorySaver。

2、多层嵌套子图

子图是可以嵌套任意层数的, 每一层都是独立编译的StateGraph

typescript 复制代码
// Level 2:最底层子图(关键词提取)
const keywordSubgraph = new StateGraph(Annotation.Root({
  text: Annotation<string>(),
  keywords: Annotation<string[]>({ default: () => [] }),
}))
  .addNode('extract', async (state) => {
    const resp = await model.invoke([new HumanMessage(`提取关键词:${state.text}`)]);
    return { keywords: (resp.content as string).split(',').map(k => k.trim()) };
  })
  .addEdge('__start__', 'extract')
  .addEdge('extract', '__end__')
  .compile();

// Level 1:中层子图(使用关键词搜索)
const enhancedSearchSubgraph = new StateGraph(SearchSubgraphState)
  .addNode('extractKeywords', keywordSubgraph) // 嵌套 Level 2
  .addNode('search', searchNode)
  .addNode('summarize', summarizeNode)
  .addEdge('__start__', 'extractKeywords')
  .addEdge('extractKeywords', 'search')
  .addEdge('search', 'summarize')
  .addEdge('summarize', '__end__')
  .compile();

多层嵌套示意图:
ENHANCED
KEYWORD
提取关键词
搜索节点
总结节点
主图 Level 0

优势:

  1. 高度模块化
  2. 每层可独立测试
  3. 便于团队协作

四、子图的调试与测试

1、独立测试子图

子图可以像普通图一样独立运行和测试:

typescript 复制代码
// 单独测试搜索子图,无需父图参与
async function testSearchSubgraph() {
  const result = await searchSubgraph.invoke({
    query: 'LangGraph 最新特性',
    searchResults: [],
    summary: '',
  });
  
  console.log('搜索摘要:', result.summary);
  console.log('原始结果:', result.searchResults);
}

await testSearchSubgraph();
// 输出:
// 🔍 搜索: LangGraph 最新特性
// 搜索摘要: LangGraph是一个用于构建Agent的框架...
// 原始结果: ['关于"LangGraph 最新特性"的搜索结果1', '结果2']

测试策略:

  1. 单元测试:每个子图独立测试
  2. 集成测试:父图调用子图的整体测试
  3. Mock测试:Mock子图,测试父图逻辑

2、查看子图执行轨迹

typescript 复制代码
// 使用streamEvents查看子图内部执行
for await (const event of searchSubgraph.streamEvents(
  { query: 'test', searchResults: [], summary: '' },
  { version: 'v2' }
)) {
  if (event.event === 'on_chain_start') {
    console.log('开始节点:', event.name);
  } else if (event.event === 'on_chain_end') {
    console.log('结束节点:', event.name);
  }
}

五、最佳实践和踩坑指南

💡 实践 1:子图的边界设计原则

typescript 复制代码
// ✅ 好的子图:单一职责,输入输出清晰
const goodSearchSubgraph = new StateGraph(Annotation.Root({
  query: Annotation<string>(),    // 输入:要搜索的查询
  results: Annotation<string[]>({ default: () => [] }), // 输出:搜索结果
}));

// ❌ 不好的子图:包含太多不相关功能
const badSubgraph = new StateGraph(Annotation.Root({
  query: Annotation<string>(),
  userProfile: Annotation<object>(),  // 不应该在搜索子图里
  emailToSend: Annotation<string>(),  // 与搜索无关
}));

原因:子图应该遵循单一职责原则,只做一件事并做好。

💡 实践 2:子图状态与父图状态隔离

核心规则:子图不应该直接修改父图的内部状态,只通过明确定义的输入/输出接口通信。

typescript 复制代码
// ✅ 正确:通过output函数明确返回
function outputTransform(subState: typeof SearchSubgraphState.State) {
  return { researchSummary: subState.summary };
}

// ❌ 错误:尝试直接访问父图State(不可能做到)
// 子图内部无法访问父图的其他字段

💡 实践 3:子图命名规范

typescript 复制代码
// ✅ 推荐:清晰的命名
const searchSubgraph = ...;
const writingSubgraph = ...;
const reviewSubgraph = ...;

// 子图内部节点加前缀
.addNode('search_fetch', fetchNode)
.addNode('search_rank', rankNode)

原因:避免节点名冲突,提高可读性。

⚠️ 常见问题

问题 现象 解决方案
子图未 compile 直接作为节点 运行时类型错误 子图必须先 .compile() 再作为节点传入
父子图同名字段类型不一致 状态合并时数据丢失或报错 确保同名字段的 TypeScript 类型完全一致
子图内部节点名与父图冲突 调试时难以区分 子图内部节点建议加前缀,如 search_fetch
子图状态过大 每次父图调用子图都有完整状态拷贝 子图只放必要字段,大数据通过引用(ID)传递
过度嵌套 调试困难,性能下降 嵌套不超过3层,过深考虑重构
忘记状态转换 字段映射错误 仔细检查input/output函数

📝 本章小结

核心知识点回顾

知识点 关键要点 应用场景
子图定义 独立的 StateGraph,单独 compile 可复用的功能模块
同名字段传递 自动映射同名字段 简单的父子图集成
状态转换函数 input/output 显式映射 字段名不同时的集成
独立测试 子图可独立 invoke 模块化测试
多层嵌套 子图可以嵌套子图 复杂系统分层
独立检查点 子图可有独立checkpointer 长时间运行的子任务

🎯 动手练习

练习 1:提取可复用搜索子图

  • 目标:将第6章的 ReAct Agent 的搜索部分抽取为独立子图
  • 要求 :
    1. 子图独立可测试
    2. 父图通过状态转换函数调用
    3. 支持多种搜索源(web/database/API)
  • 验收标准 :
    • 子图单独测试通过
    • 集成到父图后行为不变
    • 可以轻松替换搜索实现

练习 2:三层嵌套工作流

  • 目标:构建"规划→[搜索→[关键词提取]]→写作"的三层嵌套图
  • 要求 :
    1. 每一层都是独立的子图
    2. 每层可独立测试
    3. 状态传递清晰
  • 验收标准 :
    • 每层可独立测试
    • 嵌套后整体运行正常
    • 调试时能看清每层执行轨迹

练习 3:子图复用

  • 目标:同一个审核子图被两个不同的父图复用
  • 要求 :
    1. 父图A是写作工作流
    2. 父图B是代码生成工作流
    3. 共用同一个质量审核子图
  • 验收标准 :
    • 修改审核子图逻辑,两个父图都能受益
    • 审核子图不需要知道父图的上下文
    • 通过状态转换适配不同父图

练习 4:子图版本管理

  • 目标:实现子图的版本控制和灰度发布
  • 要求 :
    1. 同一子图有多个版本(v1/v2)
    2. 父图可以指定使用哪个版本
    3. 支持A/B测试
  • 验收标准 :
    • 可以同时运行不同版本的子图
    • 可以动态切换版本
    • 版本切换不影响其他子图

📚 延伸阅读


下一章:第12章 ------ 多 Agent 系统架构

相关推荐
njsgcs1 小时前
我有待做任务清单和不良操作图片集,如何设计ai agent协助我完成工作
大数据·人工智能
AI科技星1 小时前
《全域数学》第三卷:代数原本 · 全书详述【乖乖数学】
开发语言·人工智能·机器学习·数学建模
AI科技星1 小时前
《全域数学》第一部 数术本源 第三卷 代数原本第14篇 附录二 猜想证明【乖乖数学】
人工智能·算法·数学建模·数据挖掘·量子计算
XD7429716361 小时前
科技早报|2026年5月2日:AI 编程工具开始按用量收费
人工智能·科技·ai编程·github copilot·科技早报
liangdabiao1 小时前
乐高摩托车深度报告-致敬张雪夺冠 -基于llm-wiki技术自动化写文章的效果
运维·人工智能·自动化
KC2701 小时前
Prompt 注入攻击的 5 种姿势和防御指南
人工智能
不懒不懒2 小时前
【从零入门本地大模型:Ollama 安装部署 + Qwen2.5 实现零样本情感分类】
人工智能·分类·数据挖掘·大模型·ollama
徐健峰2 小时前
GPT-image-2 热门玩法实战(二):AI 面相分析 & 个人色彩诊断 — 上传自拍秒出专业报告
人工智能·gpt
冰西瓜6002 小时前
深度学习的数学原理(三十二)—— Transformer全场景掩码机制详解
人工智能·深度学习·transformer