📖 本章学习目标
- ✅ 理解子图的概念与在大型系统中的价值
- ✅ 掌握将子图作为节点嵌入父图的两种方式
- ✅ 学会设计父子图之间的状态转换(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 自动处理同名字段的传入/传出。
工作流程:
- planQuery节点设置
query字段 - search子图接收
query,执行搜索,输出summary - 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();
执行流程:
- 父图调用子图前,执行inputTransform
- 子图执行
- 子图返回后,执行outputTransform
- 更新父图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、独立测试子图
子图可以像普通图一样独立运行和测试:
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']
测试策略:
- 单元测试:每个子图独立测试
- 集成测试:父图调用子图的整体测试
- 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 的搜索部分抽取为独立子图
- 要求 :
- 子图独立可测试
- 父图通过状态转换函数调用
- 支持多种搜索源(web/database/API)
- 验收标准 :
- 子图单独测试通过
- 集成到父图后行为不变
- 可以轻松替换搜索实现
练习 2:三层嵌套工作流
- 目标:构建"规划→[搜索→[关键词提取]]→写作"的三层嵌套图
- 要求 :
- 每一层都是独立的子图
- 每层可独立测试
- 状态传递清晰
- 验收标准 :
- 每层可独立测试
- 嵌套后整体运行正常
- 调试时能看清每层执行轨迹
练习 3:子图复用
- 目标:同一个审核子图被两个不同的父图复用
- 要求 :
- 父图A是写作工作流
- 父图B是代码生成工作流
- 共用同一个质量审核子图
- 验收标准 :
- 修改审核子图逻辑,两个父图都能受益
- 审核子图不需要知道父图的上下文
- 通过状态转换适配不同父图
练习 4:子图版本管理
- 目标:实现子图的版本控制和灰度发布
- 要求 :
- 同一子图有多个版本(v1/v2)
- 父图可以指定使用哪个版本
- 支持A/B测试
- 验收标准 :
- 可以同时运行不同版本的子图
- 可以动态切换版本
- 版本切换不影响其他子图
📚 延伸阅读
下一章:第12章 ------ 多 Agent 系统架构