把一个编译好的图作为节点嵌入父图——理解子图如何通信、如何封装复杂逻辑,
构建真正可扩展的多 Agent 系统。
子图(Sub-graph)是 LangGraph 中一个核心的高级特性:把一个已经编译好的 StateGraph 作为节点(Node)直接插入另一个父图中。从父图的角度来看,这个子图和普通节点没有任何区别——它依然接收 State、执行逻辑、返回更新;但它内部其实是一张完整的独立图,有自己的状态、节点和边。
可以把子图理解为"图中图"或者"函数中有完整工作流的超级节点"。它让你在 LangGraph 的世界里实现真正意义上的嵌套架构(Nested Architecture)。
子图 = 一个调用 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 函数具有相同的调用约定。
当你构建复杂的 AI 系统时,直接把所有节点堆在一张大图里会带来一系列问题:逻辑混乱、状态污染、难以测试、无法复用。子图正是为了解决这些问题而生。
在真实的生产系统中,一个"研究助手"可能由多个专门的 Agent 子图构成:搜索子图、总结子图、事实核查子图、格式化子图……每个子图专注一件事,通过父图协调它们的执行顺序和数据流转。子图是构建可维护多 Agent 系统的关键抽象。
| 维度 | 不用子图(所有节点在一张图) | 使用子图 |
|---|---|---|
| 状态设计 | 所有中间变量都暴露在父图状态中 | 子图的私有状态完全隔离 |
| 可测试性 | 必须运行整个大图才能测试某段逻辑 | 子图可以单独 invoke() 测试 |
| 代码组织 | 几十个节点混在一起,难以阅读 | 每个子图是独立文件/函数,结构清晰 |
| 并行执行 | 手动协调,容易出错 | 多个子图节点天然支持并行 |
为了演示子图,notebook 构建了一个日志分析系统,这个系统做三件事:
日志包含用户问题、答案、评分、反馈等字段。先经过一个 clean_logs 节点做数据清洗。
子图 A(失败分析):找出评分为 0 的日志,生成失败模式摘要 fa_summary。
子图 B(问题总结):对所有日志的问题生成总结报告 report,并发送到 Slack。
两个子图各自产生的摘要和日志处理记录,通过重叠键自动合并回父图状态。
首先,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 模型来生成总结。
每个子图都需要:① 定义自己的状态类型;② 编写节点函数;③ 用 StateGraph 组装;④ 调用 compile()。子图定义方式和普通图完全相同,只是最终它会被"挂载"到父图上。
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)
# ① 子图状态(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(输出)这两个与父图共享的键。但它们各自的私有中间状态(failures、qs_summary)互不干扰,也不会出现在父图的状态中。
子图和父图之间的通信是 Sub-graph 最需要深入理解的部分。核心规则只有一条:通信通过重叠的键名(Key Name)来实现。
当父图执行到子图节点时,LangGraph 会自动把父图当前状态中与子图状态定义中同名的键值对提取出来,作为子图的输入状态传入。
cleaned_logs 是父图和子图共同定义的键,因此它的值从父图自动流入子图。而父图的 raw_logs 等键,子图不认识(不在子图的 State 定义中),所以不会传入。
子图执行完成后,LangGraph 会把子图输出状态中与父图状态同名的键值对写回父图状态。子图私有的键(如 failures、qs_summary)不会出现在父图中。
两个子图并行运行时,都会输出 processed_logs 键。如果父图状态中该键没有 Reducer,两个并行子图的输出会产生冲突(谁的覆盖谁?)。解决方案有两种:
方案 A:给父图的 processed_logs 加 Annotated[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 合并 |
理解了状态共享机制之后,把子图加入父图就非常直观了。关键步骤是:定义父图状态、编写父图节点、用 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,并不修改或输出它(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_analysis 和 question_summarization 会同时运行,最后都到达 END 时再汇总。
LangGraph 提供了一个特别有用的调试参数:在调用 get_graph() 时传入 xray=1,可以"透视"子图内部结构,把整个嵌套图完整地画出来。
# xray=1 表示"透视":显示子图的内部节点,而不只是显示一个黑盒
display(Image(graph.get_graph(xray=1).draw_mermaid_png()))
不加 xray 时,子图只显示为一个矩形节点;加了 xray=1,可以看到子图内部所有节点和边的详细连接关系,非常适合调试多层嵌套的复杂图。
子图中的 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,精确控制它向父图发布哪些键,彻底避免 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 相当于子图的"对外接口"或"公开 API"。子图内部可以有任意多的私有中间状态,但只有 output_schema 中列出的键才会"穿越边界"传回父图。这是实现真正状态封装的关键机制。
| 方面 | 不使用 output_schema | 使用 output_schema |
|---|---|---|
| 输出内容 | 子图的完整状态(所有键) | 仅 output_schema 中指定的键 |
| 并行冲突处理 | 父图中共享键都需要 Reducer | 只有真正被多个子图输出的键需要 Reducer |
| 封装程度 | 低,内部细节暴露 | 高,私有字段完全隔离 |
| 父图状态复杂度 | 高,需要包含所有子图的中间字段 | 低,父图只关心最终结果 |
将所有代码组合在一起运行,观察最终的输出结果:
# 准备测试数据
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 条 |
输出中 failure-analysis-on-log-2 出现在前两个 summary-on-log-* 之前,这反映了两个子图完成的顺序。由于是并行执行,具体顺序可能因运行环境而异,但 Reducer(operator.add)会把所有结果都收集进来。
明确哪些键是父图与子图共享的(重叠键),哪些是子图私有的中间状态。画出"状态流转图"。
用 TypedDict 定义完整状态,再定义 OutputState 精确控制对外发布的键,避免并行冲突。
用 subgraph.compile().invoke({...}) 单独测试每个子图的逻辑是否正确,再集成到父图。
分析哪些键会被多个并行子图写入,为这些键加上 Annotated[..., add] 等 Reducer。
调用 builder.add_node("name", subgraph.compile()),连接边,编译父图,运行。
如果某段逻辑内部有多个步骤、需要自己的中间状态、或者可能需要独立复用,用子图。如果只是简单的数据转换或单次调用,用普通函数节点即可。另外,也可以把子图封装为一个函数节点(在函数内部调用 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 等工具无法直接可视化内部的子图结构。