TL;DR: 子图(subgraph)有独立的 checkpoint namespace,父图默认看不到它的内部状态,这不是 bug 是设计。真正会出事的是三处透传:同名 key 的写入要走 reducer 合并、人审恢复值要能穿过父子两层、streaming token 要显式开 subgraphs=True。三个都有对应开关,关键是知道隔离默认存在。
先立现象:日志里有,状态里没有
典型报障长这样:子图节点日志显示检索正常完成,中间结果打印得清清楚楚,父图后续节点拿到的检索结果却是空。第一反应通常是「数据在哪丢了」,于是去查检索服务、查网络、查序列化,查一圈什么问题都没有。正确的排查方向是先确认一件事:父图 schema 里有没有同名 key。
这个方向差背后是一个心智模型的错误:把子图当成了函数调用。函数调用是「传进去、返回来」,参数和返回值由调用点决定;子图不是,它是一个有自己状态空间的图实例,有独立的 checkpoint namespace(形如 node:uuid),它的内部状态存在自己名下。父图和子图之间没有「参数」和「返回值」,只有一条交换通道:两边 schema 里的同名 key。
这类工单的排查成本格外高,因为每一层单独看都是对的:子图日志显示检索完成,子图 namespace 里确实有结果,父图代码也确实在读 docs。三层都对,拼起来是错的,因为没有任何一层负责「把结果递出去」这件事本身。把心智模型从函数调用切到状态交换,问题的形状立刻从「诡异 bug」变成「接口没接」。
假设父图 state 有 docs 这个 key,子图 state 也有 docs,子图执行完,它写入的 docs 经过 reducer 合并后出现在父图的 state 里,这是唯一的正门。如果父图的 schema 里没定义 docs,子图算出来的检索结果就永远留在子图自己的 namespace 里,父图看不见,日志里的一切都正常,数据就是「丢」了。它没丢,它只是被隔离在了你不知道的一层里。
排查这类问题有一套固定顺序,照着走基本不迷路:第一步,打开父图的 state 定义,确认你想要的 key 在不在接口上;第二步,用 get_state(config, subgraphs=True) 把各层 namespace 的状态一起取出来,对着看那个 key 到底在哪一层、值是什么;第三步,检查两边同名 key 的 reducer 是否一致;第四步,确认这段代码用的是三种接法里的哪一种,因为透传责任在谁那里,取决于接法。四步走完,九成「丢状态」问题都能定位到具体一层。
隔离是设计,不是缺陷
理解了现象,先回答「为什么要这样设计」,不然记住的只是规矩,不是原理。
子图存在的意义是封装:一段流程有自己的输入输出、自己的中间状态,不该让外层全部看见。如果父图默认能看到子图的所有内部状态,封装就成了摆设,两个图的所有 key 共处一个命名空间,谁改个名字都可能踩到别人。namespace 隔离让子图可以放心地有自己的临时变量、自己的中间产物,父图只关心谈好的接口 key。
这个设计和持久化是连着的。checkpoint 按 namespace 分层存储,一个 thread 里父图有父图的快照,子图有子图的快照,各层独立恢复。代价就是开篇那个现象:隔离是默认的,透传是显式的。三条透传路径,一条一条看。
namespace 的具体形态值得看一眼:形如 node:uuid,node 是父图里挂子图的那个节点名,后面接的是这一次运行的 checkpoint 标识。一个 thread 里可以嵌任意多层,父层、子层、孙层,每层各自有完整的快照链。这意味着「恢复到哪一层」是可以分开操作的:子图重跑不碰父图其他节点的快照,父图回滚也不抹掉子图 namespace 里已经存在的历史。把这个分层模型建立起来,本篇后面人审、回放、streaming 的所有规则都能从它推导出来。
还要分清两个坐标系:thread_id 是用户的(一次会话、一次任务),namespace 是图的(一层结构)。同一个 thread_id 下,父子各层的快照都挂在这一个 thread 上,所以「这一次运行的完整现场」等于各层快照的集合;换个 thread_id,子图内部状态互不相通,不存在「子图的全局变量」这种东西。这个保证在并发下尤其值钱:同一时刻有一百个用户的会话在跑同一个子图,每个 thread 各有一套 namespace,彼此的中间状态互相不可见也不可污染,子图代码不需要为并发做任何事。
数据怎么跨边界:同名 key 与 reducer
第一条透传路径是数据。同名 key 的交接不是复制粘贴,是合并:子图写入的值作为增量,交给父图该 key 的 reducer 处理,合并结果才出现在父图里。第 02 篇讲过的合并规则在这里全部适用,而且因为是跨图边界,出错时的现象更有迷惑性。
from typing import Annotated, TypedDict
import operator
class ParentState(TypedDict):
query: str
findings: Annotated[list[str], operator.add] # 父图这一侧也要声明同样的合并规则
class ResearchState(TypedDict):
query: str
findings: Annotated[list[str], operator.add] # 子图写入的增量在这里累积
三个典型的翻车点。其一,父图没定义这个 key,数据被隔离(开篇的现象)。其二,两边 key 同名但 reducer 不一致,比如子图声明了 operator.add,父图用了默认覆盖,合并时按父图的规则走,子图多次写入只留下最后一次,表现是「结果少了」。其三,key 同名但语义不同,父图的 findings 是「最终结论列表」,子图的 findings 是「原始检索片段」,合并出来的东西两个语义各占一半,这种污染最难查,因为它不报错、不少数据,只是内容不对。
再看交接的时机,两个方向规则对称。输入方向:父图把同名 key 的当前值作为子图的初始 state 传进去,子图从这份值开始跑。输出方向:子图整个执行完,同名 key 的最终值作为一个增量,在进入父图下一个节点之前交给父图的 reducer 合并。注意输出交接的单位是「子图跑完的最终值」,不是子图执行过程中每一步的中间值,中间值留在子图 namespace 的快照里,只有最后这一下过边界。想在父图里看到子过程的中间轨迹,走的不是数据透传,是第 10 篇讲的流式通道。
评审嵌套图的设计时,把两边的 state 定义放在一起逐 key 对一遍:同名 key 有几个、reducer 是否一致、语义是否相同。这三个问题五分钟能问完,漏掉一个就是一个线上问题。
顺带说一个这里最常见的「偷懒解」:有人被隔离坑过之后,给两边都定义一个大而全的 data: dict,所有东西往里塞,从此再也不丢数据。数据确实不丢了,schema 的意义也没了:类型信息消失、reducer 没法声明(dict 整体覆盖)、任何一层写脏一个字段全图可见。隔离逼你把接口说清楚,万能 key 是把「说不清楚」制度化,出问题的形态从「数据丢」变成「数据脏」,更难查。接口该几个 key 就几个 key。
三种接法,适用场景不同
子图接进父图有三种接法,差别不在语法而在「谁负责透传」:接法一靠 schema 同名 key 自动透传,接法二靠函数体手工翻译,接法三干脆传控制权。三种接法的断点粒度、解耦程度、维护成本完全不同,选错接法是后面一切别扭的源头。
接法一:编译后的子图直接当节点。
parent = StateGraph(ParentState)
parent.add_node("research", research_subgraph) # research_subgraph 是 compile() 的产物
同名 key 自动透传,最常用的接法。适合「子流程有自己的内部结构,但对父图只暴露输入输出」的场景。它的前提是上一节那三问都有答案:同名 key 是有意设计的接口,不是碰巧撞名。checkpointer 不传给子图时,1.x 会把父图的 checkpointer 自动带到子图,各层 namespace 各存各的快照。invoke 时的 config 也是整条链共用的:thread_id、超时、递归预算这些都顺着调用传下去,你只对顶层发起调用,两层跟着同一个 thread 走。嵌套越深,步数预算消耗越快,这个账在第 08 篇的 recursion_limit 里展开。
接法一有一个常被低估的性质:子图内部同样按 superstep 存快照,存在子 namespace 里。所以接法一的子图内部可以断点、可以 interrupt、可以回放,恢复粒度跟顶层图一样细。这是它和接法二最本质的差别,接法二里子图对外只是一个函数调用,断点只到函数边界。选接法之前先回答一个问题:子流程跑一半挂了,你要的是「从子流程内部那一步继续」,还是「整个子流程重跑一遍也无所谓」。答案是要,就接法一;答案是无所谓,接法二的解耦收益才真正归你。
接法二:用函数包一层,显式做字段映射。
def call_research(state: ParentState) -> dict:
result = research_subgraph.invoke({"query": state["query"]})
return {"findings": result["summary"]}
子图 schema 和父图完全解耦,靠这层函数翻译。字段名不一致、要做裁剪清洗、子图是别人的库不方便改 schema 时用这种。代价要说清楚:函数体对 LangGraph 来说就是一个普通节点,子图的中间过程对父图完全不可见,checkpointer 的断点恢复只覆盖到函数边界,子图内部跑到一半挂了,恢复时整个函数重跑,子图内部没有更细粒度的断点。如果子流程长、贵、需要中途暂停,这个代价不可接受,回到接法一。
解耦换来的东西也讲清楚。一是演进自由:函数体里的子图想怎么改内部 state 都行,父图看见的只有函数签名,接口稳定,内部随便动,这是团队边界场景最想要的性质。二是可测试性:这个函数是一个纯调用,单测时直接构造输入、断言输出,不需要起整个父图。三是映射集中:字段怎么翻译、裁掉什么、清洗什么,全部写在函数体里一处可见,code review 盯住一个函数就够。三个收益都不是虚的,问题是它们和「断点粒度」不在一个方向上,选哪种接法取决于你的子流程更需要哪一头。
接法三:子图节点直接指挥父图跳转。 节点返回 Command 时可以指定作用域是父图:
from langgraph.types import Command
def handoff(state) -> Command:
return Command(goto="reviewer", graph=Command.PARENT,
update={"current": "review"})
这是三条路径里最特殊的一条:它传的不是数据,是控制权。swarm 式移交靠它实现,子 agent 决定「这事归谁」,直接让父图路由,不用先退回父节点再判断一遍。两个工程细节要注意:update 里的 key 写的是父图 schema 的 key,合并走父图那一侧的 reducer,别按子图的规则去预测结果;goto 的目标是父图的节点名,这个字符串没有编译期检查,写错了运行时跑到才炸,值得为所有 goto 目标建一张常量表。能力越大越要克制:散落在子图各处的 PARENT 级 goto 会让父图的流程控制变得难以追踪,评审时值得要求所有跨图跳转集中在一处定义,别让「下一个去哪」的决策散在五个文件里。第 09 篇讲 swarm 拓扑时会回到这个机制。
三种接法放到一张表里对比:
| 接法 | 透传方式 | 子图内部断点 | 适合 |
|---|---|---|---|
| 编译图直接当节点 | 同名 key 走两边 reducer | 有,粒度同顶层 | 长流程、要人审、要细恢复 |
| 函数包装显式映射 | 函数体手工翻译 | 无,只到函数边界 | schema 解耦、接口稳定、可单测 |
| Command 指挥父图 | 控制权 + update 合并 | 同所在接法 | swarm 移交、动态路由 |
人审与回放:隔离的两个深水区
人审要穿两层。 子图里的 interrupt() 触发后,父图也停在对应节点,整个 thread 处于等待。恢复时,resume 值要能从父图传进子图 interrupt 所在的那一层,调用方式不变:还是对顶层图 invoke Command(resume=...),运行时沿着 namespace 层级把恢复值送到子图的 interrupt 处,父图此时扮演的是通道,不需要你分别对两层各 resume 一次。这里有一个容易漏的配置:父图和子图都要有 checkpointer。用接法一且不显式传参时父图的 checkpointer 会自动带下去,但用接法二函数包装时,子图是你在函数体内手动 invoke 的,它的 checkpointer 挂没挂、挂的是哪个,全看你自己的代码。漏了的症状很刁钻:暂停看起来发生了,恢复行为却不可预期,因为子图的暂停状态无处安放。排查子图人审问题,第一步永远是确认两层 checkpointer 都在。
回放有过历史 bug。 旧版本在「interrupt 加 subgraph」组合下做时间旅行回放时,可能复用残留的 RESUME 值,导致恢复值莫名其妙地出现在不该出现的地方,这个问题在 1.1 版本修复。现在还停在旧版本的团队,调试人审回放遇到「恢复值来路不明」,先升版本再查自己的代码,别急着怀疑自己的逻辑。
子图人审的其他纪律和第 06 篇完全一致:payload 带 id、resume 值校验、前置副作用幂等,一层都不因为嵌套而豁免,反而要多答一问:这个 interrupt 的 id 在整个 thread 范围内唯一吗。两个不同的子图实例若用同样的业务 id(比如都叫 approval),映射恢复时照样撞车,id 的构成里带上子图业务域的前缀是便宜的保险。
调试嵌套状态时有个好用的工具:get_state 支持 subgraphs=True,能把各层 namespace 的状态一起拿出来。开篇那个「日志里有、状态里没有」的问题,用它一查便知:子图 namespace 里有没有那个 key,父图 state 里有没有,一眼定位是谁的 schema 漏了定义。
还有一层认知要摆正:父图「默认看不到」子图状态,不等于「拿不到」。快照都在同一个 thread 下,调试、审计、追责的时候,各层 namespace 的完整状态随时可取。隔离约束的是图的执行时的数据流(不声明的 key 不过边界),不是运维时的可观测性。执行时严格、排查时透明,这才是这个设计的完整面貌。
streaming 的透传开关
前端联调时最常见的现象:stream_mode="messages" 下,顶层图里 LLM 的 token 能一路推到前端,子图里 LLM 的 token 却凭空消失,用户看到的是「回答前半段有打字机效果,后半段突然一次性蹦出来」。机制很简单:流式输出默认只在顶层流动,嵌套一层就断了,子图的流没有通道冒泡上来。
修复是一个参数:
for chunk in app.stream(inputs, config, subgraphs=True, stream_mode="messages"):
...
subgraphs=True 让内部子图的流也冒泡上来。代价是事件量变大,而且现在事件里混着两层甚至多层的输出,前端要按事件 metadata 里的来源信息过滤出自己要渲染的那一层,不然同一段回答可能被重复渲染。
这条开关在 map-reduce 场景还有放大效应:如果并行分支里各跑着一个子图实例,每个实例的流都往上冒,事件量按并行任务数翻倍。第 08 篇的 Send 场景叠加子图时,前端过滤逻辑要按「namespace 加任务标识」两级来做,只按层级过滤会把五十个并行任务的 token 混在一段里。构建这类前端之前,先拿一个两并行、两层的最小图把事件形状看清楚,再写渲染逻辑。
这类问题的定位思路值得记一下:前端报「有些回答没有流式效果」,先问那段内容是哪个节点产出的。顶层 LLM 节点的输出有流、子图 LLM 节点的输出没流,基本就是这一条;整段都没流,那是另一个问题(比如走了非 LLM 的普通节点,本来就没有 token 级的流)。工单来了先分层,再查参数。另外 stream_mode 可以传列表同时要几种事件,subgraphs=True 对列表里的每种模式都生效,子图里的事件会按同样的模式集合冒上来,组合使用时前端的事件分发逻辑要按「模式加来源」二维来写。
子图的测试:先独立,再接口
嵌套图测试分两层做,别一上来就测整条链。第一层,子图独立测:把子图单独 compile、单独 invoke,用最小输入断言它的输出 key 和值形状,这一层测试不依赖父图,改动内部逻辑时跑得最快。第二层,接口契约测:构造父图,用一个桩子图(直接返回固定值的假 compile 图)替换真子图,断言同名 key 的合并结果符合预期。第二层专门抓「接口对不上」这类问题:父图改了 reducer、改了 key 名,契约测试第一时间报错,不用等线上数据丢失。
两层都过了再考虑少量端到端用例穿全链。这个顺序的价值在于定位速度:端到端测试红了,你不知道是子图逻辑变了还是接口变了;分层测试红了,红在哪层,问题就在哪层。第 15 篇讲评测回归体系时,子图的接口契约测试应该作为独立的一类用例登记,它的失败含义和功能失败不同,处理路径也不同。涉及人审的子图还要留一条带暂停恢复的用例:invoke 到停,Command(resume=...) 续上,断言最终 state,确认恢复值真的穿过了两层(第 06 篇的三步法直接适用)。
接口 key 是契约,按契约的纪律维护
子图跑起来之后,同名 key 就是两个图之间的公开契约,值得按对外接口的纪律来维护。契约要写下来:哪些 key 是接口、类型是什么、reducer 是什么、语义是什么,一页纸写清楚,放在两个 state 定义都能引用的地方。契约变更要走流程:加 key 影响小,改 reducer 影响合并结果,删 key 或改语义等于破坏性变更,要确认所有消费方都迁移完。改名迁移期可以双写:新旧两个 key 同时输出,消费方切完再删旧的。
这套纪律听起来重,但对比一下没有它的事故成本:某个子图把 summary 从字符串改成了列表,父图的 prompt 模板还是按字符串拼的,跑起来不报错,只是模型收到一串 Python repr 当上下文,回答质量悄悄劣化,两周后才有人发现。状态接口的破坏性变更不会有编译器拦着,契约纪律就是那道拦截。
三个高频反模式
透传的坑讲完了,再补三个真实代码里反复出现的反模式,都是「当时图省事、后来还债」的形态。
反模式一:万能 key。 上一节刚提过,这里补它的变体:不是 data: dict,而是把枚举型的分类字段合并成一个 meta,两层各自往里加自己的键。三个月后没人说得清 meta 里有哪些约定,任何重构都变成考古。
反模式二:函数包装里手写 checkpointer。 接法二里有人图方便,在函数体内给子图 compile(checkpointer=InMemorySaver())。测试环境看着一切正常,生产上每次调用都新建一个内存存档器,子图的断点和暂停状态随函数返回蒸发,而且因为是内存对象,不报错、不告警,只有「恢复行为偶尔不对」这种模糊现象。规矩是:包装函数里的 checkpointer 必须从外部传入,生产传真的,测试传内存的,选择权交给调用方。
反模式三:三层起步。 子图里套子图再套子图,三层以上之后,namespace 路径变长、streaming 事件多层叠加、人审恢复跨多层、调试要同时看三份快照,复杂度不是线性涨是相乘。深的嵌套几乎总是可以压扁:中间那层如果没有独立的权限边界或团队边界,把它并成函数节点,层数降下来,透传点少一半。嵌套深度本身值得设一条团队红线,比如默认不超过两层,超过要说明理由。三个反模式的共同点是当时都更省事,判断要不要还债的办法也一致:数一数这个结构省下的代码行数,和它带来的透传点数量,哪个数在涨,答案就在哪边。
什么时候不拆子图
拆子图的收益是状态隔离和团队边界,成本全部在透传上:数据要过 reducer 合并,人审要穿两层,流式要开开关,调试要多查一层 namespace。收益成立时这些成本值得付,不成立时每一条都是纯负担。
几个具体的判断标准。如果拆完之后两个图的同名 key 超过三分之一,说明它们本来就是同一个状态域,拆开只会让每个读写都过两层合并,这种「拆」要合回去。如果拆的动机只是「让这个文件短一点」,用普通函数节点就够了,函数节点没有 namespace、没有恢复语义的复杂性,封装性对代码组织来说完全够用。真正值得拆的信号是硬需求:不同部分需要不同的工具集或权限边界(这一条和第 09 篇判断多 agent 的第一信号是同一条),或者不同团队各自维护各自的流程,需要明确的接口契约。
团队边界这条值得展开一句。比如平台组维护检索子图,业务组维护编排父图,各自发版节奏不同:这时子图的价值不在运行时,在协作上,接口 key 就是两个组的接口文档,函数包装(接法二)反而是更合适的接法,因为它把接口收敛到一个函数签名。反过来,一个人维护的小项目里,子图的协作价值为零,只剩透传成本,判断标准要按前两条执行。同一个结构问题,答案随组织形状变。
还有一个务实的迁移路径:拿不准时先写成函数节点,跑清楚业务之后,哪一段真的长出了「需要独立断点、独立人审、独立权限」的需求,再把那一段升级成子图。升级有固定的四步:把函数体里的流程挪出来定义成独立的图;选定接口 key,父子两边 schema 各自声明并对齐 reducer;确认 checkpointer 的挂法,接法一靠自动下传,接法二手动挂;最后把两层测试补上。函数节点改成子图是局部改动,反过来把错拆的子图合并回去,要动的是所有透传点的状态,所以「先函数后子图」的方向几乎总是更便宜。
最后把嵌套图的评审检查单收敛成五条,评审嵌套方案时过一遍:
| 检查项 | 抓的问题 |
|---|---|
| 两边 schema 的同名 key:数量、reducer、语义三对齐 | 数据丢失与语义污染 |
| 接法选择:子流程要不要内部断点 | 恢复粒度与预期不符 |
| 父子两层 checkpointer 是否都在 | 人审恢复不可预期 |
| streaming 是否需要 subgraphs=True | 前端缺流式效果 |
| PARENT 级 goto 是否集中定义 | 控制流散落难追踪 |
子图不是什么危险特性,它的问题全部出在「以为没有隔离」。把隔离当成默认、透传当成显式动作,剩下的就是按检查单逐条确认的体力活。在系列里的位置也顺带说一句:第 06 篇的 interrupt 在单图里立起来,本篇把它带进嵌套结构,第 08 篇再把执行单元从一个变成几十个。控制部分三篇是同一个主题的三级台阶:先管住一次暂停,再管住一层结构,最后管住一片并行。
延伸阅读
- Use Subgraphs 指南:三种接法与 namespace 说明
- LangGraph 仓库:issue 区可按 subgraph 关键字搜到真实案例
- Streaming 文档:subgraphs=True 与流式语义