LangChain LangGraph
Module 1 Module 2 Module 3 Module 4 Module 5 Module 6
Module 4 · Parallelization
1 4-1 Parallelization
2 4-2 Sub Graph
3 4-3 Map Reduce
4 4-4 Research Asst
HomeLangGraphModule 4 · Parallelization4-2 Sub Graph
📓 Notebook 📖 Explained
LANGGRAPH MODULE 4 · LESSON 2

Sub-graphs 子图
模块化与状态边界的艺术

把一个编译好的图作为节点嵌入父图——理解子图如何通信、如何封装复杂逻辑,
构建真正可扩展的多 Agent 系统。

1 什么是子图(Sub-graph)?

子图(Sub-graph)是 LangGraph 中一个核心的高级特性:把一个已经编译好的 StateGraph 作为节点(Node)直接插入另一个父图中。从父图的角度来看,这个子图和普通节点没有任何区别——它依然接收 State、执行逻辑、返回更新;但它内部其实是一张完整的独立图,有自己的状态、节点和边。

可以把子图理解为"图中图"或者"函数中有完整工作流的超级节点"。它让你在 LangGraph 的世界里实现真正意义上的嵌套架构(Nested Architecture)。

子图的直观结构

START
clean_logs
父图节点:清洗数据
子图 A
get_failures
generate_summary
failure_analysis
子图 B
generate_summary
send_to_slack
question_summarization
END
一句话定义

子图 = 一个调用 compile() 后的 StateGraph 实例,它可以被作为节点直接传入父图的 add_node(),从而成为父图执行流程中的一个步骤。

具体添加方式就是一行代码:

# 把编译好的子图作为节点加入父图
entry_builder.add_node("failure_analysis", fa_builder.compile())
entry_builder.add_node("question_summarization", qs_builder.compile())

这里 fa_builder.compile() 返回的是一个 CompiledStateGraph 对象,它和普通函数一样可以被传入 add_node,因为 LangGraph 的编译图实现了 Runnable 接口,与普通 Python 函数具有相同的调用约定。

2 为什么需要子图:模块化与封装

当你构建复杂的 AI 系统时,直接把所有节点堆在一张大图里会带来一系列问题:逻辑混乱、状态污染、难以测试、无法复用。子图正是为了解决这些问题而生。

子图带来的三大核心价值

模块化(Modularity)
  • 每个子图是独立的功能单元
  • 可以单独开发、测试、调试
  • 父图只关心"结果",不关心"过程"
复用性(Reusability)
  • 同一个子图可以插入多个父图
  • 避免重复编写相同逻辑
  • 更新子图逻辑,所有使用处自动生效
状态封装(Encapsulation)
  • 子图拥有自己的私有状态
  • 中间计算结果不污染父图
  • 通过重叠键精确控制通信边界
团队协作
  • 不同团队各自开发不同子图
  • 像微服务一样拼装 Agent 系统
  • 适合大规模多 Agent 架构
多 Agent 系统的最佳实践

在真实的生产系统中,一个"研究助手"可能由多个专门的 Agent 子图构成:搜索子图、总结子图、事实核查子图、格式化子图……每个子图专注一件事,通过父图协调它们的执行顺序和数据流转。子图是构建可维护多 Agent 系统的关键抽象。

维度 不用子图(所有节点在一张图) 使用子图
状态设计 所有中间变量都暴露在父图状态中 子图的私有状态完全隔离
可测试性 必须运行整个大图才能测试某段逻辑 子图可以单独 invoke() 测试
代码组织 几十个节点混在一起,难以阅读 每个子图是独立文件/函数,结构清晰
并行执行 手动协调,容易出错 多个子图节点天然支持并行

3 本课示例场景:日志分析系统

为了演示子图,notebook 构建了一个日志分析系统,这个系统做三件事:

首先,notebook 定义了日志的数据结构:

from operator import add
from typing_extensions import TypedDict
from typing import List, Optional, Annotated

# 每一条日志的数据结构
class Log(TypedDict):
    id: str               # 日志 ID
    question: str         # 用户提问
    docs: Optional[List]  # 检索到的文档(可选)
    answer: str           # LLM 的回答
    grade: Optional[int]  # 评分,0 表示失败(可选)
    grader: Optional[str] # 评分者
    feedback: Optional[str] # 反馈信息

测试数据包含两条日志:一条正常日志(无评分),一条失败日志(评分为 0,表示文档检索质量差):

# 正常日志:只有问答,无评分
question_answer = Log(
    id="1",
    question="How can I import ChatOllama?",
    answer="To import ChatOllama, use: 'from langchain_community.chat_models import ChatOllama.'",
)

# 失败日志:评分为 0,表示检索到的文档与问题不相关
question_answer_feedback = Log(
    id="2",
    question="How can I use Chroma vector store?",
    answer="To use Chroma, define: rag_chain = create_retrieval_chain(retriever, question_answer_chain).",
    grade=0,
    grader="Document Relevance Recall",
    feedback="The retrieved documents discuss vector stores in general, but not Chroma specifically",
)

raw_logs = [question_answer, question_answer_feedback]
注意:这是玩具示例

notebook 中的 generate_summary 等函数直接返回硬编码字符串(如 "Poor quality retrieval of Chroma documentation."),是为了专注于演示子图架构,省去了真实 LLM 调用。在生产中,这些节点里会调用真实的 AI 模型来生成总结。

4 定义并编译子图:FailureAnalysisState 与 QuestionSummarizationState

每个子图都需要:① 定义自己的状态类型;② 编写节点函数;③ 用 StateGraph 组装;④ 调用 compile()。子图定义方式和普通图完全相同,只是最终它会被"挂载"到父图上。

失败分析子图(Failure Analysis Sub-graph)

from langgraph.graph import StateGraph, START, END

# ① 子图的完整状态(包含私有字段 failures)
class FailureAnalysisState(TypedDict):
    cleaned_logs: List[Log]   # 与父图共享的输入键
    failures: List[Log]       # 子图私有:过滤出的失败日志
    fa_summary: str           # 与父图共享的输出键
    processed_logs: List[str]  # 与父图共享的输出键

# ② 输出状态(仅包含需要"发布"给父图的键)
class FailureAnalysisOutputState(TypedDict):
    fa_summary: str
    processed_logs: List[str]

# ③ 节点:从 cleaned_logs 中筛选出有 grade 字段(即失败)的日志
def get_failures(state):
    cleaned_logs = state["cleaned_logs"]
    failures = [log for log in cleaned_logs if "grade" in log]
    return {"failures": failures}

# ④ 节点:根据失败日志生成摘要(生产中替换为 LLM 调用)
def generate_summary(state):
    failures = state["failures"]
    fa_summary = "Poor quality retrieval of Chroma documentation."
    return {
        "fa_summary": fa_summary,
        "processed_logs": [f"failure-analysis-on-log-{failure['id']}" for failure in failures]
    }

# ⑤ 构建并编译子图,指定 output_schema 限制输出
fa_builder = StateGraph(
    state_schema=FailureAnalysisState,
    output_schema=FailureAnalysisOutputState   # 关键:限制输出键
)
fa_builder.add_node("get_failures", get_failures)
fa_builder.add_node("generate_summary", generate_summary)
fa_builder.add_edge(START, "get_failures")
fa_builder.add_edge("get_failures", "generate_summary")
fa_builder.add_edge("generate_summary", END)

问题总结子图(Question Summarization Sub-graph)

# ① 子图状态(qs_summary 是私有中间字段,不会发布到父图)
class QuestionSummarizationState(TypedDict):
    cleaned_logs: List[Log]   # 与父图共享的输入键
    qs_summary: str           # 子图私有:中间摘要
    report: str               # 与父图共享的输出键
    processed_logs: List[str]  # 与父图共享的输出键

# ② 输出状态:只发布 report 和 processed_logs 给父图
class QuestionSummarizationOutputState(TypedDict):
    report: str
    processed_logs: List[str]

# ③ 节点:生成问题摘要
def generate_summary(state):
    cleaned_logs = state["cleaned_logs"]
    summary = "Questions focused on usage of ChatOllama and Chroma vector store."
    return {
        "qs_summary": summary,
        "processed_logs": [f"summary-on-log-{log['id']}" for log in cleaned_logs]
    }

# ④ 节点:把摘要发送到 Slack(此处模拟)
def send_to_slack(state):
    qs_summary = state["qs_summary"]
    report = "foo bar baz"   # 生产中:调用 Slack API
    return {"report": report}

qs_builder = StateGraph(QuestionSummarizationState, output_schema=QuestionSummarizationOutputState)
qs_builder.add_node("generate_summary", generate_summary)
qs_builder.add_node("send_to_slack", send_to_slack)
qs_builder.add_edge(START, "generate_summary")
qs_builder.add_edge("generate_summary", "send_to_slack")
qs_builder.add_edge("send_to_slack", END)
两个子图的对比

两个子图都拥有 cleaned_logs(输入)和 processed_logs(输出)这两个与父图共享的键。但它们各自的私有中间状态(failuresqs_summary)互不干扰,也不会出现在父图的状态中。

5 状态共享机制:重叠键(Overlapping Keys)是通信的桥梁

子图和父图之间的通信是 Sub-graph 最需要深入理解的部分。核心规则只有一条:通信通过重叠的键名(Key Name)来实现。

进入子图时(父图 → 子图)

当父图执行到子图节点时,LangGraph 会自动把父图当前状态中与子图状态定义中同名的键值对提取出来,作为子图的输入状态传入。

raw_logs: [...]
cleaned_logs: [log1, log2]
fa_summary: ""
report: ""
processed_logs: []
父图状态(进入子图前)
cleaned_logs: [log1, log2]
failures: []
fa_summary: ""
processed_logs: []
子图初始状态(只有重叠键被传入)

cleaned_logs 是父图和子图共同定义的键,因此它的值从父图自动流入子图。而父图的 raw_logs 等键,子图不认识(不在子图的 State 定义中),所以不会传入。

退出子图时(子图 → 父图)

子图执行完成后,LangGraph 会把子图输出状态中与父图状态同名的键值对写回父图状态。子图私有的键(如 failuresqs_summary)不会出现在父图中。

fa_summary: "Poor quality..."
processed_logs: ["failure-analysis-on-log-2"]
(子图内部的 failures 字段不输出)
子图输出(经过 output_schema 过滤)
raw_logs: [...]
cleaned_logs: [...]
fa_summary: "Poor quality..."
report: ""
processed_logs: ["failure-analysis-on-log-2"]
父图状态(子图完成后更新)
并行子图与 Reducer 的关系

两个子图并行运行时,都会输出 processed_logs 键。如果父图状态中该键没有 Reducer,两个并行子图的输出会产生冲突(谁的覆盖谁?)。解决方案有两种:

方案 A:给父图的 processed_logsAnnotated[List, add] Reducer,把两个子图的结果列表合并。

方案 B(notebook 采用):为每个子图设置 output_schema,让不同子图输出不同的键(如 fa_summary vs report),避免冲突。但 processed_logs 两个子图都输出,所以父图仍然需要 Reducer。

状态键的完整映射关系

键名 父图状态 失败分析子图 问题总结子图 说明
raw_logs 父图私有,子图不可见
cleaned_logs 有(输入) 有(输入) 重叠键:父图 → 两个子图
failures 有(私有) 子图 A 私有中间状态
qs_summary 有(私有) 子图 B 私有中间状态
fa_summary 有(输出) 重叠键:子图 A → 父图
report 有(输出) 重叠键:子图 B → 父图
processed_logs 有(带 Reducer) 有(输出) 有(输出) 两个子图都输出,父图用 add 合并

6 将子图添加到父图:add_node 与并行执行

理解了状态共享机制之后,把子图加入父图就非常直观了。关键步骤是:定义父图状态、编写父图节点、用 add_node 把编译好的子图挂载上去。

父图状态定义

from operator import add
from typing import Annotated

# 父图的完整状态
class EntryGraphState(TypedDict):
    raw_logs: List[Log]      # 输入:原始日志
    cleaned_logs: List[Log]  # 清洗后的日志(传入两个子图)
    fa_summary: str          # 来自失败分析子图
    report: str              # 来自问题总结子图
    # 两个子图都会输出 processed_logs,用 add Reducer 合并列表
    processed_logs: Annotated[List[str], add]
为什么 cleaned_logs 不需要 Reducer?

因为两个子图只是读取 cleaned_logs,并不修改或输出它(output_schema 里没有这个键)。只有当多个并行节点都往同一个键写入时,才需要 Reducer。

父图的清洗节点

# 父图中的普通节点:对原始日志做清洗
def clean_logs(state):
    raw_logs = state["raw_logs"]
    cleaned_logs = raw_logs   # 生产中:做实际清洗处理
    return {"cleaned_logs": cleaned_logs}

组装父图:把子图作为节点挂载

entry_builder = StateGraph(EntryGraphState)

# 挂载普通节点
entry_builder.add_node("clean_logs", clean_logs)

# 挂载子图节点:compile() 返回的 CompiledGraph 直接传入
entry_builder.add_node("question_summarization", qs_builder.compile())
entry_builder.add_node("failure_analysis", fa_builder.compile())

# 定义执行顺序:先清洗,然后两个子图并行执行
entry_builder.add_edge(START, "clean_logs")
entry_builder.add_edge("clean_logs", "failure_analysis")       # 并行分支 1
entry_builder.add_edge("clean_logs", "question_summarization")   # 并行分支 2
entry_builder.add_edge("failure_analysis", END)
entry_builder.add_edge("question_summarization", END)

graph = entry_builder.compile()

一个节点有两条出边指向不同的节点,LangGraph 会自动并行执行这两个节点。这里 clean_logs 完成后,failure_analysisquestion_summarization 会同时运行,最后都到达 END 时再汇总。

父图执行流程(含并行子图)

START
clean_logs
父图普通节点
子图 A: failure_analysis
get_failures → generate_summary
输出: fa_summary, processed_logs
子图 B: question_summarization
generate_summary → send_to_slack
输出: report, processed_logs
两个子图并行执行,LangGraph 自动等待两者都完成
END

可视化嵌套结构:xray=1

LangGraph 提供了一个特别有用的调试参数:在调用 get_graph() 时传入 xray=1,可以"透视"子图内部结构,把整个嵌套图完整地画出来。

# xray=1 表示"透视":显示子图的内部节点,而不只是显示一个黑盒
display(Image(graph.get_graph(xray=1).draw_mermaid_png()))

不加 xray 时,子图只显示为一个矩形节点;加了 xray=1,可以看到子图内部所有节点和边的详细连接关系,非常适合调试多层嵌套的复杂图。

7 输出状态模式(Output Schema):精确控制子图的返回

子图中的 output_schema 参数是一个非常重要的设计决策,它决定了子图向父图"发布"哪些键,有效地实现了子图的状态封装。

不使用 output_schema 时的问题

如果不指定 output_schema,子图会把它的完整状态作为输出——包括所有私有中间字段。

问题在于:当两个子图并行运行时,它们都有 cleaned_logs 字段,且值相同(都从父图读取进来)。两个子图都把 cleaned_logs 写回父图,就会产生冲突,需要给父图的 cleaned_logs 加一个 Reducer(如 operator.add)来合并——但这会把同样的日志列表加两遍!

# 不用 output_schema 时,父图需要给 cleaned_logs 加 Reducer
class EntryGraphState(TypedDict):
    raw_logs: List[Log]
    # 因为两个子图都会"回写" cleaned_logs,需要 Reducer
    cleaned_logs: Annotated[List[Log], add]  # 会产生重复数据!
    fa_summary: str
    report: str
    processed_logs: Annotated[List[str], add]

使用 output_schema 的优雅解决方案

通过为每个子图定义 output_schema,精确控制它向父图发布哪些键,彻底避免 cleaned_logs 的冲突问题:

# 失败分析子图只发布这两个键
class FailureAnalysisOutputState(TypedDict):
    fa_summary: str            # 父图有,回写到父图
    processed_logs: List[str]  # 父图有,回写到父图
    # cleaned_logs 和 failures 不在这里 → 不会回写父图

# 问题总结子图只发布这两个键
class QuestionSummarizationOutputState(TypedDict):
    report: str               # 父图有,回写到父图
    processed_logs: List[str]  # 父图有,回写到父图
    # cleaned_logs 和 qs_summary 不在这里

# 于是父图的 cleaned_logs 不需要 Reducer 了!
class EntryGraphState(TypedDict):
    raw_logs: List[Log]
    cleaned_logs: List[Log]    # 普通字段即可,无需 Reducer
    fa_summary: str
    report: str
    processed_logs: Annotated[List[str], add]  # 仍然需要,因为两个子图都输出它
output_schema 的本质

output_schema 相当于子图的"对外接口"或"公开 API"。子图内部可以有任意多的私有中间状态,但只有 output_schema 中列出的键才会"穿越边界"传回父图。这是实现真正状态封装的关键机制。

两种方法的对比

方面 不使用 output_schema 使用 output_schema
输出内容 子图的完整状态(所有键) 仅 output_schema 中指定的键
并行冲突处理 父图中共享键都需要 Reducer 只有真正被多个子图输出的键需要 Reducer
封装程度 低,内部细节暴露 高,私有字段完全隔离
父图状态复杂度 高,需要包含所有子图的中间字段 低,父图只关心最终结果

8 完整运行演示与结果解读

将所有代码组合在一起运行,观察最终的输出结果:

# 准备测试数据
question_answer = Log(
    id="1",
    question="How can I import ChatOllama?",
    answer="To import ChatOllama, use: 'from langchain_community.chat_models import ChatOllama.'",
)
question_answer_feedback = Log(
    id="2",
    question="How can I use Chroma vector store?",
    answer="To use Chroma, define: rag_chain = create_retrieval_chain(...).",
    grade=0,
    grader="Document Relevance Recall",
    feedback="The retrieved documents discuss vector stores in general, but not Chroma specifically",
)
raw_logs = [question_answer, question_answer_feedback]

# 运行图,传入原始日志
result = graph.invoke({"raw_logs": raw_logs})

实际输出结果

{
  'raw_logs': [
    {'id': '1', 'question': 'How can I import ChatOllama?', ...},
    {'id': '2', 'question': 'How can I use Chroma vector store?', ..., 'grade': 0, ...}
  ],
  'cleaned_logs': [
    {'id': '1', ...},  # 清洗节点处理后(本例等于原始数据)
    {'id': '2', ..., 'grade': 0}
  ],
  'fa_summary': 'Poor quality retrieval of Chroma documentation.',
                # ↑ 来自失败分析子图
  'report': 'foo bar baz',
            # ↑ 来自问题总结子图
  'processed_logs': [
    'failure-analysis-on-log-2',  # 来自子图 A(只处理了有 grade 的日志)
    'summary-on-log-1',           # 来自子图 B(处理了所有日志)
    'summary-on-log-2'
  ]
}

逐字段解读

输出字段 来源 说明
raw_logs invoke 传入 原始输入,全程保留在父图状态中
cleaned_logs clean_logs 节点 清洗后的日志,被两个子图读取;本例与 raw_logs 相同
fa_summary 失败分析子图 只分析了 id=2 的失败日志(grade=0),生成摘要
report 问题总结子图 send_to_slack 节点生成并"发送"报告
processed_logs 两个子图 (add 合并) 子图 A 产生 1 条 + 子图 B 产生 2 条,Reducer 合并为 3 条
processed_logs 的合并顺序

输出中 failure-analysis-on-log-2 出现在前两个 summary-on-log-* 之前,这反映了两个子图完成的顺序。由于是并行执行,具体顺序可能因运行环境而异,但 Reducer(operator.add)会把所有结果都收集进来。

总结:构建子图系统的完整步骤

子图 vs 普通函数节点:什么时候用子图?

如果某段逻辑内部有多个步骤、需要自己的中间状态、或者可能需要独立复用,用子图。如果只是简单的数据转换或单次调用,用普通函数节点即可。另外,也可以把子图封装为一个函数节点(在函数内部调用 subgraph.invoke(state)),这种方式更灵活,可以在传入之前手动映射状态键,但失去了 LangGraph 对嵌套图的原生追踪与可视化支持。

延伸:函数节点方式调用子图

# 另一种方式:把子图包在函数里,手动做状态映射
compiled_fa = fa_builder.compile()

def run_failure_analysis(state):
    # 手动提取需要传给子图的字段
    sub_input = {"cleaned_logs": state["cleaned_logs"]}
    # 调用子图
    sub_output = compiled_fa.invoke(sub_input)
    # 手动把子图输出映射回父图状态
    return {
        "fa_summary": sub_output["fa_summary"],
        "processed_logs": sub_output["processed_logs"]
    }

# 使用方式与普通节点完全一样
entry_builder.add_node("failure_analysis", run_failure_analysis)

这种方式适用于父图和子图没有重叠键的情况,或者需要在调用子图前后做额外处理的场景。缺点是 LangGraph Studio 等工具无法直接可视化内部的子图结构。