架构模式

子图 (Subgraphs)

使用子图实现逻辑封装和状态隔离,构建可复用的复杂系统

📚 学习目标

学完这篇文章后,你将能够:

  • 理解子图在系统解耦中的作用
  • 学会创建并嵌套 StateGraph
  • 掌握父图与子图之间的状态传递机制
  • 解决多代理系统中的状态污染问题

前置知识

在开始学习之前,建议先阅读:

你需要了解:

  • 软件工程中的模块化/封装概念

1 什么是子图?

子图 本质上就是一个普通的 Graph,但它被当作另一个 Graph 的节点来使用。

为什么要用子图?

  1. 封装性:父图不需要知道子图的内部细节,只关心输入输出。
  2. 状态隔离:子图可以有自己的 Schema,避免与父图或其他子图的状态冲突。
  3. 复用性:定义一次子图,可以在多个地方引用。

子图的典型使用场景

  • 检索子流程:把“检索 + 过滤 + 重排序”封装成子图
  • 评估子流程:把“多维评分 + 结论输出”封装成子图
  • 多代理隔离:每个 agent 维护自己的消息历史

💡 直觉类比

如果父图像“页面路由”,子图就像“页面组件”。父图负责调度,子图负责细节。

子图可以嵌套多层

当业务更复杂时,子图还可以嵌套子图:

⚠️ 注意

多层嵌套虽然强大,但要控制子图之间的输入/输出契约,否则维护成本会迅速上升。


2 创建与使用子图

步骤 1:定义子图

这就和定义普通 Graph 一模一样。

import { Annotation, StateGraph, START, END } from '@langchain/langgraph';

const childState = Annotation.Root({
  input: Annotation<string>(),
  localResult: Annotation<string>({
    default: () => '',
  }),
});

const childProcess = async (state: typeof childState.State) => {
  return { localResult: `child processed: ${state.input}` };
};

// 子图编译后就是一个 runnable
const childGraph = new StateGraph(childState)
  .addNode('child_process', childProcess)
  .addEdge(START, 'child_process')
  .addEdge('child_process', END)
  .compile();

步骤 2:在父图中使用

将编译好的子图直接作为 addNode 的第二个参数。

const parentState = Annotation.Root({
  globalInput: Annotation<string>(),
  result: Annotation<string>({
    default: () => '',
  }),
});

// 关键点:如果父 state 与子 state 字段不匹配,直接挂 subgraph 往往不够
// 这里我们用一个 bridge node 做转换(更明确、更可控)
const bridgeNode = async (state: typeof parentState.State) => {
  const childOut = await childGraph.invoke({ input: state.globalInput });
  return { result: childOut.localResult };
};

export const parentGraph = new StateGraph(parentState)
  .addNode('run_child', bridgeNode)
  .addEdge(START, 'run_child')
  .addEdge('run_child', END)
  .compile();

💡 设计建议

如果父子状态差异较大,优先使用 bridge 节点,而不是直接把子图挂上去。

代码解析

  1. 子图编译后是 runnable,父图里可以用 invoke() 调用。
  2. 用 bridge node 的最大好处:你可以显式定义“父输入 -> 子输入”和“子输出 -> 父更新”。

3 状态传递与转换

父图和子图的状态 Schema 通常不同,LangGraph 如何处理数据流动?

情况 A:Schema 包含关系

如果子图需要的字段,父图都有,LangGraph 会自动透传。

情况 B:显式转换 (Wrapper Node)

如果 Schema 完全不同,建议包裹一层函数节点来做转换。

const bridgeNode = async (state) => {
  // 1. 转换父状态 -> 子输入
  const childInput = { localResult: state.globalInput };
  
  // 2. 调用子图
  const childOutput = await childGraph.invoke(childInput);
  
  // 3. 转换子输出 -> 父状态更新
  return { globalInput: childOutput.localResult + " processed" };
};

// 父图添加的是 bridgeNode,而不是直接添加 childGraph
parentGraph.addNode("bridge", bridgeNode);

3.1 字段映射表(让契约更清晰)

当父子状态差异较大时,建议先画一张“字段映射表”:

父图字段子图字段说明
globalInputinput父图输入转子图输入
resultlocalResult子图输出回写父图

💡 设计建议

在 bridge 节点里显式做映射,避免“隐式透传”带来的耦合。

3.2 子图复用(一次定义,多处调用)

同一个子图可以被多个父图复用:

const childGraph = new StateGraph(childState)
  .addNode('child_process', childProcess)
  .addEdge(START, 'child_process')
  .addEdge('child_process', END)
  .compile();

const parentA = new StateGraph(parentState)
  .addNode('run_child', async (state) => ({
    result: (await childGraph.invoke({ input: state.globalInput })).localResult,
  }))
  .addEdge(START, 'run_child')
  .addEdge('run_child', END)
  .compile();

const parentB = new StateGraph(parentState)
  .addNode('run_child', async (state) => ({
    result: (await childGraph.invoke({ input: state.globalInput })).localResult,
  }))
  .addEdge(START, 'run_child')
  .addEdge('run_child', END)
  .compile();

ℹ️ 说明

复用时保持子图输入/输出稳定,可以降低后期维护成本。


4 什么时候用子图?一个判断清单

推荐用子图

  • 你想把一段复杂流程封装成“黑盒模块”(例如:检索子流程、评估子流程)
  • 你需要隔离状态(尤其是 messages)
  • 你想复用同一套流程(多处调用)

不一定要用子图

  • 只是 2-3 个节点的直线流程
  • 你没有复用需求,且 state 非常简单

5 多代理状态隔离示例

在多代理系统中,每个代理都有自己的 messages 历史。如果公用一个状态,Agent A 的思考过程会污染 Agent B 的上下文。

解决方案: 将每个 Agent 封装为子图,它们各自维护 messages,只向父图返回最终结论。


6 异常隔离与降级

子图失败时,父图可以选择“中止”或“降级”。下面是一个简化的异常隔离示例:

const safeBridgeNode = async (state: typeof parentState.State) => {
  try {
    const childOut = await childGraph.invoke({ input: state.globalInput });
    return { result: childOut.localResult, status: 'ok' };
  } catch (error) {
    return { result: '子图失败,已降级', status: 'degraded' };
  }
};

ℹ️ 说明

对于“可容错”子流程(如推荐/排序),降级比直接失败更适合生产系统。


7 子图调试与观测

子图也是图,所以可以像普通图一样可视化:

const mermaid = childGraph.getGraph().drawMermaid();
console.log(mermaid);

💡 调试建议

  • 先单独运行子图,确认输入输出契约正确。
  • 再把子图接入父图,观察父子状态映射是否一致。

8 子图作为节点 vs 手动调用

两种方式差异如下:

方式优点适用场景
直接添加子图代码简洁状态字段一致,结构简单
bridge 调用子图控制力强状态字段不同、需要转换

ℹ️ 说明

当你需要“日志、校验、降级”等能力时,bridge 更灵活。


9 子图在多代理中的复用

一个常见做法是把“检索/分析/评估”做成子图,让不同代理共享:

const retrievalSubgraph = new StateGraph(SharedState)
  .addNode('retrieve', retrieveNode)
  .addEdge(START, 'retrieve')
  .addEdge('retrieve', END)
  .compile();

const analystNode = async (state: typeof ParentStateAnnotation.State) => {
  const { docs } = await retrievalSubgraph.invoke({ query: state.userInput });
  return { processedData: docs.join('\n') };
};

💡 思路

让“通用流程”变成子图,多个代理直接复用,能显著减少重复代码。


💡 练习题

  1. 代码题:创建一个“研究员子图”(负责生成长文)和一个“编辑子图”(负责润色),然后在一个“主编父图”中串联它们。尝试直接添加子图和通过 bridge node 添加两种方式。

    点击查看答案

    直接添加适用于状态字段兼容;如果字段不同,需要 bridge 节点显式转换输入/输出。

  2. 思考题:如果子图抛出异常,父图会发生什么?如何隔离故障?

    点击查看答案

    未捕获异常会导致父图执行失败。可在 bridge 节点捕获异常并降级返回。

  3. 操作题:让父图同时调用两个子图,并合并结果输出。

    点击查看答案

    可以在父图中增加两个 bridge 节点分别调用子图,再用一个 merge 节点整合结果。

  4. 思考题:什么时候不适合使用子图?

    点击查看答案

    当流程很短、没有复用需求或状态极其简单时,子图会增加不必要的复杂度。

  5. 操作题:设计一个“检索子图”,返回结构化输出并被父图消费。

    点击查看答案

    在子图中输出 { docs, score },父图在 bridge 节点里将其映射到自身状态字段。


✅ 总结

本章要点

  • 子图是 LangGraph 实现模块化的核心机制。
  • 编译后的 Graph 就是一个 Runnable,可以像普通函数一样被调用。
  • 通过子图可以有效解决 Context 污染问题。
  • bridge 节点能让父子状态映射更清晰、更可控。

下一步:如何让多个节点或子图同时跑起来?学习并行处理

登录以继续阅读

解锁完整文档、代码示例及更多高级功能。

立即登录

On this page