LangGraph Subgraph 实战:子图、Worker State 与多 Agent 局部状态怎么设计?
这篇文章记录了我在贵阳实验室的实战过程。我坚信,在技术下行的时代,程序员唯一的护城河就是通过 AI 建立属于自己的数字资产。
本文解决的问题
- LangGraph Subgraph 适合什么场景?
- 子图和普通 node 有什么区别?
- 父图 State 和 Worker State 应该怎么拆?
- 多 Agent 系统里哪些 Worker 适合独立成 Subgraph?
- 子图执行结果应该如何回写父图?
- 如何避免子图状态污染全局状态?
- Subgraph 和 Checkpointer、Human-in-the-loop、失败恢复有什么关系?
适合谁读
- 已经写过 LangGraph Supervisor / Worker 系统的开发者。
- 发现单个 StateGraph 越写越臃肿的人。
- 正在设计多 Agent 子任务、局部状态和工具隔离的人。
- 准备把 LangGraph Demo 改造成生产级 Agent 系统的全栈工程师。
一、小白的硬核实战观察
昨天下午,在给我们的微信表情包投稿实验室部署新的多 Agent 审核和优化流程时,我差点把键盘给砸了。
起初,我图省事,把所有的逻辑:提取用户表情包描述、调用 Dall-E 3 生成微调 Prompt、跑 OpenCV 提取图像通道做透明度检测、再把数据格式化发给微信审核接口,全部写在了同一个主 StateGraph 里。整个图里有 18 个 Node,全都共用一个全局 State 字典。
在单用户低并发的调试阶段,这套系统看起来完美无缺。可是当晚上我把它推到飞牛 NAS 的 Docker 容器里进行压力测试时,各种让人崩溃的“灵异现象”立刻接踵而至。由于所有的 Worker 都在读写全局的 messages 列表,图像压缩节点会意外读取到前面格式化节点生成的 JSON 字符串,直接导致类型转换崩掉;更致命的是,只要有一个 Worker 的工具调用超时触发重试,由于没有局部状态的隔离,整个主图的状态全被回滚到了最初的输入节点。
那一刻我生理上感到了剧烈的恶心,揉着酸痛的太阳穴坐在电脑前反思。我再次确认了那个早已被无数全栈工程师踩出来的真理:不要试图把所有 Agent 都塞进一个大图里。大图不是能力强,而是状态边界不清,这在生产环境里就是一颗定时炸弹。
这也是为什么在经历了几次深夜排障后,我下定决心把原先的臃肿架构彻底打碎,全面重构成父子图(Subgraph)拓扑结构。今天我们就来聊透 LangGraph 的 Subgraph 到底该怎么设计,以及怎么通过局部 Worker State 实现真正的生产级状态隔离。
二、Subgraph 和普通 node 有什么区别?
普通 node 是一个执行点,Subgraph 是一组有内部流程的执行单元。
在 LangGraph 的概念体系里,普通的 Node 本质上只是一个纯 Python 函数或可调用对象(Runnable)。它接收当前的 State,完成特定的计算或 I/O,然后返回一个更新后的字典交给 Edge 进行路由判定。
而 Subgraph(子图)则是一个被编译过的独立 StateGraph 实例。它拥有自己独立的节点、边、控制流逻辑以及最重要的——独立的 State 状态字典。在父图中,你只需把子图的编译产物作为一个普通的 Node 添加进去,LangGraph 的 runtime 就会在执行到该节点时,自动接管并启动子图的局部生命周期。
Subgraph vs 普通 Node
为了在进行架构设计时能够做出科学选型,我们可以在下表中对比它们的物理差异:
| 维度 | 普通 Node | Subgraph |
|---|---|---|
| 状态空间 | 共享父图的全局 State,没有局部隔离 | 拥有完全独立的子图 State,与父图物理隔离 |
| 控制流复杂度 | 只能通过 return 将结果传给父图边进行单次分支判断 | 内部可以有极其复杂的循环、条件 Edge 以及中断挂起 |
| 持久化与快照 | 随父图 Checkpointer 记录,无法对内部子节点执行细粒度回档 | 支持独立配置 Checkpointer,可在子图内部任意节点触发中断和状态恢复 |
| 异常边界 | 任何报错都会直接向上传播到父图,导致父图物理崩溃 | 可在子图内捕获异常,并输出格式化 error_payload,实现局部熔断与重试 |
| 适合场景 | 简单的字段格式化、单次 LLM 判定、轻量工具调用 | 独立的 Worker 团队、复杂的文献搜索过滤流、带有内部审批的敏感任务 |
正如 LangGraph 官方说明指出的,当一个 Worker 节点内部需要调用多个不同的 Tool,且其任务无法在单次调用中完成,而是需要经历“输入解析 -> 循环调用 -> 结果校验 -> 最终汇总”这一完整流程时,强行把它写成普通 node 会导致主图节点的疯狂膨胀,此时就必须将其物理剥离为 Subgraph。
三、父图应该负责什么?
父图只负责全局调度,不应该管理每个 Worker 的所有中间状态。
在多 Agent 系统中,父图应当被视为“项目经理”或“总指挥官”。它站在全局的视角上,去倾听用户的初始需求,并根据目标的复杂度,选择不同的“子团队”(Worker Subgraph)去分头攻坚。
1. 父图 State 字段规划
为了保持主图的清爽,父图的 State 字典应该仅保留全局级别的控制变量,千万不要把每个 Worker 的临时变量或冗余的消息往里塞。在生产实践中,我通常会将父图 State 的字段控制在以下范围:
- user_id: 系统用户身份唯一标识,用于鉴权与路由隔离。
- session_id: 客户端会话标识,用于多轮对话日志归档。
- thread_id: Checkpointer 持久化快照定位的主键。
- task_goal: 用户输入的最终意图描述。
- current_worker: 当前被激活的子图或节点名称。
- final_summary: 最终生成并经过审核的可交付产物。
- risk_level: 全局安全风控评估评级。
- is_approved: 针对高风险操作的全局人工审批状态。
- global_error: 系统级异常或业务流程熔断的错误详情。
2. 父图的核心职责边界
在代码实现上,父图的节点应当聚焦于以下逻辑:
- 意图解析(Intent Parser):读取用户 prompt,结合历史记忆,确定核心的任务目标。
- 任务指派(Dispatcher):将 task_goal 转化为子图所需的 input 结构体,唤醒对应的 Subgraph。
- 流程控制与中断(Orchestrator):在需要人工介入的节点配置 interrupt_before,控制状态机的流转。
- 结果拼装(Result Aggregator):读取子图返回的结构化输出,执行跨 Worker 数据的二次校对与融合。
- 兜底异常处理(Global Fallback):在子图彻底失效时,提供系统层面的优雅降级。
四、Worker Subgraph 应该负责什么?
Worker Subgraph 应该像一个小团队,内部完成任务,外部只交付结构化结果。
与父图的高屋建瓴不同,Worker Subgraph 承载的是具体的业务攻坚。每一个 Subgraph 都应当是高内聚、低耦合的,它在内部维护一套只和当前业务相关的工具链和控制链。
以一个多 Agent 的研发系统为例,我们可以将复杂的任务拆解为四个独立的 Worker Subgraph:
- research_subgraph: 专门负责根据关键词爬取 GitHub 代码库,解析源码结构,提取依赖关系。
- coding_subgraph: 专门负责读取待修改的文件内容,接收重构指令,自动定位代码段并调用补丁工具生成修改后的源码。
- test_subgraph: 运行测试脚本,捕获报错日志,并尝试将测试失败的堆栈信息循环喂给 Coding 节点执行自动修复。
- review_subgraph: 扮演质检员角色,用静态扫描工具检测代码的规范度,检查是否有越权漏洞。
Worker Subgraph 内部结构
在一个典型的 Subgraph 内部,通常会定义如下的微型流程图:
子图输入 (从父图映射而来)
↓
Local Input Parser (提取子任务参数)
↓
Tool Selection (通过 LLM 判断该使用哪个工具)
↓
Tool Execution (运行具体工具并捕获异常)
↓
Quality Gate (校验工具返回的数据是否合格)
├─ 不合格 -> 环路返回重试
└─ 合格 -> Summary Node (提取核心结论)
↓
子图输出 (映射并写入父图)
子图执行完毕后,它的职责也就宣告结束。它会把内部庞大而混乱的中间数据(比如上百条搜索网页正文、临时 debug 日志等)物理留在自己的 State 中,只把一个极其干净的结构化结果返回给父图。例如:
{
"status": "success",
"result_summary": "已定位内存泄露代码段,自动注入 WeakRef 机制修补,压测通过",
"confidence_score": 0.95,
"artifacts": {
"patch_path": "/deploy/patches/fix_mem_leak.patch",
"changed_files": ["src/utils/session.py"]
}
}
五、Worker State 怎么设计?
Worker State 只保存这个 Worker完成任务所需的局部上下文。
在定义 Subgraph时,首要任务就是为它设计一套独立的 State。在 Python 中,我们通常使用 TypedDict 或者 Pydantic 来定义状态的 Schema。
为了防止局部状态向外溢出导致状态污染,Worker State 的设计应当严密遵循最小必要原则:
from typing import TypedDict, List, Dict, Any, Optional
class ResearchWorkerState(TypedDict):
# 局部控制属性
worker_task: str
local_messages: List[Dict[str, Any]]
# 工具中间结果
web_search_queries: List[str]
raw_document_payloads: List[str]
extracted_findings: List[str]
# 局部错误处理与重试
tool_error_count: int
last_known_exception: Optional[str]
# 子图最终输出载荷
structured_output: Dict[str, Any]
在这个 ResearchWorkerState 中,raw_document_payloads 可能会占用大量的内存和 Token 额度。如果这些原始的文档片段随全局 State 一起在主图里四处流转,不仅会让调试日志变得冗长难读,还会导致后续调用 Coding Worker 时把这些无关上下文全部带入,造成大模型注意力涣散,甚至触发 Token 超限报错。
通过将其锁死在子图 State 中,在子图生命周期结束时,这些数据会随之被物理归档隔离,父图只能拿到最后的 structured_output。
六、父图和子图之间怎么传递状态?
父图传任务,子图还结果,不要双向裸传完整 State。
在 LangGraph 中,父子图之间的状态交接物理路径清晰,通常是通过输入与输出映射(Input/Output Mapping)实现的。
当子图作为节点加入父图时,有两种主要的交接设计模式:
模式 1:基于 key 的直接包含与命名空间隔离(V0.2 标准模式)
如果子图的 State 中包含了与父图 State 同名的 Key,LangGraph 会在调用子图时,自动把父图中同名 Key 的数据复制到子图的初始化 State 中。子图执行结束时,会再次把同名 Key 的新数据覆盖写回父图。
但如果你不希望它们同名发生潜在覆盖,我们必须通过显式的 node 函数包装,或者定义专用的 handoff 处理器。
状态映射物理代码示例
下面我写一段可直接运行的 Python 伪代码,展示如何在父图中安全编译并挂载具有状态映射的子图:
from langgraph.graph import StateGraph, START, END
# 1. 定义父图 State
class ParentState(TypedDict):
global_task: str
global_context: str
global_messages: List[Dict[str, Any]]
research_summary: str
error_log: str
# 2. 定义子图 State (字段与父图有所区别)
class SubgraphState(TypedDict):
local_goal: str
search_context: str
local_history: List[Dict[str, Any]]
local_output: str
# 3. 构造子图逻辑
sub_builder = StateGraph(SubgraphState)
def sub_init_node(state: SubgraphState):
# 子图内部的初始化动作
return {"local_history": state.get("local_history", []) + [{"role": "system", "content": f"子图任务启动: {state['local_goal']}"}]}
def sub_action_node(state: SubgraphState):
# 模拟子图工具调用
findings = "在代码库中发现 3 处内存泄露风险"
return {"local_output": findings}
sub_builder.add_node("sub_init", sub_init_node)
sub_builder.add_node("sub_action", sub_action_node)
sub_builder.add_edge(START, "sub_init")
sub_builder.add_edge("sub_init", "sub_action")
sub_builder.add_edge("sub_action", END)
# 编译子图
sub_graph = sub_builder.compile()
# 4. 在父图中挂载子图 (关键:编写输入与输出桥接函数)
parent_builder = StateGraph(ParentState)
# 桥接:将父图的 State 转换为子图所需的输入格式
def call_research_subgraph(state: ParentState):
# 物理隔离,只把必要数据包装给子图
sub_inputs = {
"local_goal": state["global_task"],
"search_context": state["global_context"],
"local_history": []
}
# 唤醒子图运行
sub_result = sub_graph.invoke(sub_inputs)
# 映射:将子图计算完成的 output 写回到父图的指定字段中
return {
"research_summary": sub_result["local_output"],
"global_messages": state["global_messages"] + [{"role": "assistant", "content": "研究子图已完成分析"}]
}
# 将桥接函数挂载为父图节点
parent_builder.add_node("research_worker", call_research_subgraph)
parent_builder.add_edge(START, "research_worker")
parent_builder.add_edge("research_worker", END)
parent_graph = parent_builder.compile()
这种通过桥接函数(Adaptor Pattern)进行状态交接的做法,是防范状态串线和控制流污染最稳妥的工业级设计。它确保了父图和子图的代码可以被独立的开发人员维护,并可以在不需要知道对方具体 State 字段的前提下进行单体测试。
七、Subgraph 如何避免状态污染?
状态污染不是代码风格问题,而是生产事故源头。
在分布式或高并发环境中,当多个 Worker 节点无序地对全局状态进行读写时,会产生很多毁灭性的后果:
- 消息回溯污染:Reviewer 发现 Coding Worker 的 patch 有 Bug,下发指令重构。如果 global messages 里充满了上一次 Coding Worker 调用工具产生的堆栈噪音,LLM 会误以为那些错误是新的输入,导致输出南辕北辙。
- 数据越权泄露:如果多个租户的任务共享了底层主图,且中间某个子图的局部临时原文直接写到了全局 messages 里,若网关层拦截失效,用户 A 可能会直接在对话框中读取到用户 B 的敏感文档提取摘要。
防御状态污染的 3 大物理铁律
为了在生产环境中彻底杜绝污染,我们必须在代码层面落实以下规约:
- 输入值只读隔离(Deepcopy Input):在桥接函数将数据丢给子图前,使用
copy.deepcopy()执行深拷贝。防止子图内部对 dict 或 list 字段执行物理原地修改(In-place Modification),从而反向污染父图状态。 - 单向结果归纳(Structured Writeback):禁止子图直接在父图的 messages 中做 append。子图执行结果必须包装为强类型的 Pydantic 结构,只改写父图中特设的 summary 字段。
- 熔断退出异常机制(Isolated Error Payload):子图抛出异常时,内部的全局 try-except 应该在子图 State 中写入包含错误类型、是否可重试和诊断日志的结构化 error_payload,而不是直接抛出未经处理的底层 Runtime Error 导致父图崩盘。
八、Subgraph 和 Checkpointer 怎么配合?
父图要能恢复主流程,子图要能恢复局部任务。
当我们的 Agent 编排系统包含子图时,状态的持久化和断点恢复就会进入多层维度。
根据 LangGraph 官方底层设计,Checkpointer 在处理子图时表现为两种形态:
- 隐式传播(Implicit Propagation):如果你在编译父图时传递了一个 Checkpointer 实例(例如 SqliteSaver),LangGraph 会在编译时,自动将这个持久化机制向下传播到所有的 Subgraph 中。
- 独立持久化(Isolated Checkpointer):在一些高度复杂的业务中,你可能希望子图拥有自己独立的事务历史记录、独立的数据落盘策略。此时你可以在编译子图时,给子图显式配置另外一个专属的 Checkpointer,使其状态存储到不同的物理表甚至不同的 Redis 实例中。
当父图和子图共享 Checkpointer 时,底层数据库在 checkpoints 表中写入的 Key 会包含路径路径栈(Path Stack)。
例如,在子图内的节点 sub_action 写入快照时,存盘的 thread_id 可能会被自动解析并展开为类似于 parent_thread_abc:research_subgraph 的复合索引。这使得系统在遭遇断电、服务崩溃等物理灾难后,不仅能拉起主图的执行线,还能无缝恢复到子图崩溃前执行到的那个具体子节点上。
九、Subgraph 和 Human-in-the-loop 怎么配合?
审批可以发生在父图,也可以发生在子图,但审批结果必须回到正确的状态边界。
在带有审核(Human-in-the-loop)的 Agent 系统中,我们经常需要在子图内部触发挂起等待。比如,财务子图在调用微信退款 API 前,必须等待财务主管的批准。
子图内部触发 Interrupt 的生命周期
当我们在子图的特定节点配置了 interrupt_before 时,状态机会在子图执行到该节点前强行挂起:
父图启动
↓
执行到子图节点
↓
子图初始化
↓
子图执行到 [财务退款 Node] -> 触发 interrupt_before 挂起
↓
Checkpointer 保存子图状态快照,系统返回前端,连接断开
↓
财务主管在后台点击「确认退款」 -> 发送批准 Payload 激活 API
↓
API 接收请求,提取 thread_id,调用父图 resume
↓
LangGraph 通过路径栈,精准定位到子图的 [财务退款 Node] 快照
↓
注入批准 Payload,子图继续执行完毕
↓
子图返回结果给父图,父图流程继续
需要注意的是,如果在子图挂起期间,由于前端部署代码变更或服务更新,父图的 State 定义发生了不兼容的修改,我们在恢复子图状态时,极易触发反序列化错误。因此在进行 Human-in-the-loop 设计时,子图的输入与输出契约必须保持绝对的物理稳定,不能随意增删或更改字段的类型。
十、什么时候应该拆成 Subgraph?
为了防止过度工程,我们在对项目进行重构时,需要建立起一套严格的决策漏斗。
拆分评估漏斗图
我们可以对照下面的条件判断是否需要为某个 Worker 独立建图:
是否满足以下条件之一?
- 节点数超过 3 个
- 包含循环、重试或回滚逻辑
- 拥有局部的、不希望污染全局的 State 字段
- 拥有专属于该 Worker 的多轮工具调用链
- 包含需要独立进行 Human-in-the-loop 审批的节点
├─ 是 -> 拆分为 Subgraph (推荐)
└─ 否 -> 保留为父图中的普通 Node (保持设计极简)
例如,如果你的 Coding Worker 仅仅是接收一个 prompt,调一次 GPT-4o 生成一段 Python 代码,那把它写成一个普通的 node 绝对是最清爽的选择。只有当它不仅要生成代码,还要把代码写进临时文件,跑 pytest 测试,测试不通过还要循环重试,这时候才应该果断将其重构成独立的子图。
十一、常见坑与常见报错 (Error Logs)
在实战嵌套子图的过程中,有三个非常经典而且隐蔽的报错,我在这里把它们的报错现场和物理根因整理出来,方便大家快速排查。
1. AttributeError: Subgraph state mapping failure
- 现象:在父图调用子图节点时,整个程序突然中断,抛出属性映射异常。
- 报错文本:
AttributeError: 'ParentState' object has no attribute 'local_goal'. Error occurred during mapping input keys in node 'research_worker'. - 原因:在定义父图到子图的映射时,没有显式提供桥接函数,而是直接将子图挂载为节点。Astro 或 Python 运行时在尝试从父图 State 中读取与子图 State 同名的 Key 时,发现父图里根本没有
local_goal字段。 - 对策:在父图挂载子图前,务必编写如我上面所示的
call_research_subgraph适配器函数,完成字段到字段的手动映射,或者在父图中也声明同名的初始化 Key。
2. GraphRecursionError: Recursion limit reached in nested graph
- 现象:在子图循环测试代码时,程序在运行了十多秒后崩溃。
- 报错文本:
langgraph.errors.GraphRecursionError: Recursion limit of 25 reached in subgraph 'coding_subgraph'. - 原因:父图编译时默认的
recursion_limit通常是 25。当子图内部发生高频重试(例如 Coding 节点与 Test 节点循环跳转)时,其递归计数值不仅在子图内累加,还会向上传播触发父图的上限。 - 对策:在 invoke 父图时,配置更高的
recursion_limit参数,或者在子图内部的 Condition Edge 中,设计一个基于计数器的硬上限熔断逻辑,超过 5 次重试后主动抛出格式化 error_payload 退出。
3. TypeError: Object of type ‘UUID’ is not JSON serializable
- 现象:当配置了 Sqlite 或 Postgres 持久化存储后,Graph 在子图执行结束准备写盘时崩溃。
- 报错文本:
TypeError: Object of type 'UUID' is not JSON serializable. Failed to serialize state checkpoint for thread 'usr_102:task_001'. - 原因:在子图的某个工具节点中,把原始的 UUID 对象或者外部 client 连接实例直接写进了
local_history或 State 字段。当 Checkpointer 尝试对子图状态执行 JSON 或 Msgpack 序列化时触发物理崩溃。 - 对策:在 State 定义中,确保所有的状态值都只包含原生的 Python 基本类型(如
str,int,float,dict,list),写入前务必调用str(uuid_obj)转换。
十二、上线检查清单
在将父子图系统打包部署到生产环境前,建议对照以下十点进行终极审计:
- 每一个 Subgraph 的 State 是否都进行了严格的最小必要字段控制?
- 父图与子图的桥接函数是否配置了深拷贝,以防局部修改反向污染主图?
- 子图内部的报错是否都被 try-except 捕获,并转化为了结构化的
error_payload? - 父图是否能够通过读取子图返回的
status字段,执行正确的 retry / fallback 路由? - 所有的
thread_id在网关层是否都绑定了属主校验,杜绝越权访问? - 子图的
recursion_limit是否针对长循环测试场景进行了专项调优? - 在需要人工审批的子图节点前,是否配置了正确的
interrupt_before参数? - 是否对 Checkpointer 数据库在多层子图 Path Stack 下的写入和读取性能进行了并发压测?
- 分布式日志追踪中,每次子图内工具的执行是否都带上了父图下发了
request_id? - 子图的输入输出数据格式是否编写了 Pydantic 校验 Schema,以保证版本向前兼容?
FAQ
LangGraph Subgraph 和普通节点有什么区别?
普通节点是一个简单的 Python 函数,执行一次即退出;而 Subgraph 是一个编译后的独立有向图,内部有自己独立的状态空间、多节点控制流、重试和挂起逻辑。
什么时候需要把 Worker 拆成 Subgraph?
当一个 Worker 节点内部的计算变得复杂,需要多个工具协同、发生多轮循环重试、拥有局部中间变量、或者需要独立的局部人工审批时,就应当拆为 Subgraph。
父图和子图应该共享同一个 State 吗?
不建议完全共享。完全共享会导致状态边界模糊、上下文 Token 泄露以及并发覆盖冲突。推荐采用父图保存全局控制字段、子图保存局部任务字段的物理隔离设计。
子图内部失败后应该怎么办?
子图应当在内部捕获异常,并在其 State 中写入格式化的错误诊断载荷,以 status="failed" 的形式正常退出,交由父图进行高维度的流程重试、人工接入或流程降级。
Checkpointer 是如何记录子图状态的?
Checkpointer 通过在持久化主键中注入路径路径栈来区分父子图状态。在恢复状态时,它会读取该栈结构,将计算引擎无缝引导恢复到子图内部被挂起或崩溃前的具体节点上。
系列导航
LangGraph 生产级 Agent 编排实战系列:
- 第 1 篇:Supervisor / Worker
- 第 2 篇:状态隔离
- 第 3 篇:Human-in-the-loop
- 第 4 篇:失败恢复
- 第 5 篇:Observability
- 第 6 篇:Checkpointer
- 第 7 篇:Subgraph
继续阅读
- LangGraph Checkpointer 实战:MemorySaver、SQLite、Redis 怎么选?
- LangGraph Observability 实战:如何追踪每个 Agent 的决策路径?
- LangGraph 多智能体失败恢复:Tool Error、Timeout 与重试策略
- LangGraph Human-in-the-loop 实战:多智能体审批流怎么做?
- LangGraph 状态隔离实战:thread_id、session_id、user_id 怎么设计?
- LangGraph 多智能体协作实战:Supervisor、Worker 与状态交接怎么设计?
- LangGraph Memory and Checkpointing
- LangGraph 实战指南
- AI 实战指南