小白 / Xiaobai
开发者 · 产品构建者
持续构建 AI 工程系统、开发者工具与长期数字资产。
关于作者与 XBSTACK →
LangGraph Conditional Router 异常后 Resume 为什么跳过下游节点?1.2.11 复现与临时方案
LangGraph conditional router 抛异常后,invoke(None, config) 为什么看似恢复成功却不再执行 router 和下游节点?本文在 langgraph 1.2.11 上用 InMemorySaver 与 SqliteSaver 独立复现,并验证把可失败路由逻辑移入普通 node 的临时方案。
LangGraph Conditional Router 异常后 Resume 为什么跳过下游节点?1.2.11 复现与临时方案
如果你的 LangGraph 工作流在 conditional router 中抛出异常,之后再用同一个 thread_id 执行 invoke(None, config) 恢复,需要特别注意一个很容易误判的情况:resume 可能正常返回,但 router 没有重新执行,下游节点也没有执行,整个 graph 看起来却像已经结束。
我在 langgraph==1.2.11 上独立复现了这个行为,而且 InMemorySaver 与 SqliteSaver 结果一致。最快判断方法不是只看 resume 有没有抛异常,而是同时检查 router 调用次数、下游节点调用次数和 get_state(config).next。如果异常发生在 conditional-edge router 内,本文复现中 resume 后得到的仍然是节点已经写入的 {"value": 1},但 sink=0,并且没有 pending task。
目前更稳妥的临时方案是:不要在 conditional router 函数里执行可能失败的网络请求、数据库查询、外部规则调用或其他有副作用的工作。把这部分逻辑移动到普通 LangGraph node 中,把路由结果写入 state,再让 conditional edge 只读取 state 并返回分支。 我已经对这个方案做了修复前后实验,两种 checkpointer 都可以在失败后正确恢复。
这不是 LangGraph 官方修复。截至 2026 年 9 月 11 日,上游 Issue #8834 仍为 Open,我没有验证到针对该问题已经发布的正式修复版本,因此生产环境仍应把它视为应用层 workaround,而不是 upstream 已解决。
结论先说:不要用“resume 没报错”判断恢复成功
这次测试得到的关键差异非常明确。
普通 node 首次失败时:
第一次执行:
node -> exception
resume:
node -> router -> sink
calls:
node=2
route=1
sink=1
result:
{"value": 2}
conditional router 首次失败时:
第一次执行:
node -> router -> exception
resume:
直接返回
calls:
node=1
route=1
sink=0
result:
{"value": 1}
pending_after=[]
也就是说,router 抛出的异常第一次确实能被调用方看到,但恢复以后,失败的 router 不再重新运行,下游 sink 也不会执行。
这比单纯“resume 再次报错”更危险。你的恢复代码可能看到:
result = graph.invoke(None, config)
成功返回,然后把这个 run 当成已经恢复完成。实际上它可能只是返回了 router 出错之前,前一个 node 已经提交的 state。
受影响范围:目前只确认我们实际测试过的边界
本次 XBSTACK 实测环境:
| 项目 | 实测环境 |
|---|---|
| 测试日期 | 2026-09-11 |
| LangGraph | 1.2.11 |
| langgraph-checkpoint-sqlite | 3.1.1 |
| Python | 3.10.2 |
| Checkpointer | InMemorySaver、SqliteSaver |
| 执行方式 | 同步 StateGraph.invoke() |
| LLM | 不使用 |
| 网络 | 不使用 |
| 外部数据库/API | 不使用 |
这个结论不应该自动外推到所有历史 LangGraph 版本、未来版本、async graph、Postgres/Redis saver、subgraph 或 LangGraph Platform 的所有运行方式。环境不同,应该重新执行最小复现,而不是直接假定行为完全相同。
最小复现:异常发生在 router 时会发生什么
最小 graph 只有两个业务节点:
START
|
node
|
conditional router
|
sink
|
END
状态只有一个字段:
class State(TypedDict):
value: int
普通节点只写入一个值:
def node(state):
calls["node"] += 1
return {"value": 1}
路由函数第一次调用时模拟临时失败:
def route(state):
calls["route"] += 1
if calls["route"] == 1:
raise ValueError("temporary route failure")
return "sink"
下游节点:
def sink(state):
calls["sink"] += 1
return {"value": state["value"] + 1}
第一次执行:
graph.invoke({"value": 0}, config)
按预期抛出:
temporary route failure
关键在第二次:
result = graph.invoke(None, config)
直觉上我们会预期:
resume
→ retry route
→ route returns "sink"
→ execute sink
→ value = 2
但实际得到的是:
node=1
route=1
sink=0
result={"value": 1}
pending_after=[]
router 没有第二次调用。

对照实验:把同一个临时异常放进普通 node
为了排除“LangGraph resume 本来就不能恢复异常”这种解释,我又做了一个控制组:把第一次异常从 route() 移到 node()。
def node(state):
calls["node"] += 1
if calls["node"] == 1:
raise ValueError("temporary node failure")
return {"value": 1}
router 保持纯函数:
def route(state):
calls["route"] += 1
return "sink"
恢复以后:
node=2
route=1
sink=1
result={"value": 2}
MemorySaver 和 SqliteSaver 都得到相同结果。
这说明问题并不是简单的“checkpoint 无法恢复异常”,而是失败发生在普通 node task 与 conditional routing 阶段时,恢复行为不同。
为什么会出现“恢复成功,但下游什么都没执行”
这里需要区分两层证据。
第一层是 XBSTACK 自己测到的外部行为:
router 第一次抛异常
→ node 的 state 已经存在
→ resume 不再调用 router
→ downstream 不执行
→ 没有 pending task
→ invoke 正常返回
这是我们已经重复跑通的事实。
第二层来自上游 Issue #8834 对 LangGraph source 的追踪。该 Issue 描述的执行路径中,普通 state write 与 branch/router 的执行存在先后关系;router 出错时前面的普通 writes 已经存在,而错误不会在 resume 时表现成一个正常待执行的 router task。恢复阶段重新应用普通 writes 后,这个步骤可能被判断为已经完成。
这与本地观测到的最终状态一致:
node result: persisted
router failure: happened
pending task: none
downstream: never executed
但本文没有提交 LangGraph runtime source patch,因此这里不把某一行内部实现宣称为最终官方 root cause。更准确的说法是:
在 LangGraph 1.2.11 的已测执行路径中,conditional router failure 没有像普通 node failure 一样留下可以通过
invoke(None, config)重新执行的 pending task。

为什么生产环境里更危险
如果 router 只是下面这种纯判断:
def route(state):
return "approve" if state["score"] > 0.8 else "review"
风险相对低,因为函数只读取已经存在的 state。
真正危险的是 router 逐渐变成了一个业务步骤:
def route(state):
policy = load_policy_from_database()
quota = billing_service.check_quota(state["user_id"])
feature = feature_flag_service.get("new_flow")
if not quota:
return "quota_exceeded"
if policy.requires_review:
return "human_review"
return "execute"
或者直接调用 LLM:
def route(state):
decision = llm.invoke(...)
return decision.route
这时 router 已经不是简单的“分支选择器”,而是一个真正可能失败的业务步骤。网络超时、API 429、数据库临时故障、Provider 错误、权限服务不可用,都可能让异常发生在 conditional edge 阶段。
如果恢复监控只判断:
resume request returned 200
或者:
graph.invoke() did not raise
就可能把没有真正继续执行的工作流标记成成功。
已验证的临时方案:把可失败路由逻辑变成普通 node
我最终采用的 workaround 不是 catch 掉所有异常,也不是手工修改 checkpoint,而是调整 graph topology。
原来:
node
|
v
conditional router
|
+----> sink
改成:
node
|
v
router_node
|
v
pure selector
|
+----> sink
router_node 负责真正可能失败的工作:
def router_node(state):
decision = risky_routing_logic()
return {
"route_decision": decision
}
conditional edge 只做纯选择:
def selector(state):
return state["route_decision"]
Graph wiring:
builder.add_edge("node", "router_node")
builder.add_conditional_edges(
"router_node",
selector,
{
"sink": "sink",
},
)
为什么这个方式能够恢复
现在真正可能抛异常的是 router_node,而不是 conditional-edge 函数。
当 router_node 第一次失败以后,本次实测的 checkpoint 状态是:
pending_before=["router_node"]
再执行:
graph.invoke(None, config)
LangGraph 会重新执行这个 node。最终:
node=1
router_node=2
selector=1
sink=1
result.value=2
pending_after=[]
MemorySaver 和 SqliteSaver 两组全部通过。

修复前后结果
| 场景 | node | 可失败 routing logic | selector | sink | Resume 结果 |
|---|---|---|---|---|---|
| 普通 node failure | 2 | — | 1 | 1 | 正常恢复 |
| conditional router failure | 1 | 1 | — | 0 | 下游被跳过 |
| routing logic 移入普通 node | 1 | 2 | 1 | 1 | 正常恢复 |
workaround 组还多了一个关键差异:
异常后:
pending_before=["router_node"]
而原始 router failure 是:
pending_before=[]
这正是生产恢复系统应该重点关注的状态差异。

不建议用 try/except 把 router 错误全部吞掉
你也可以写成:
def route(state):
try:
return remote_decision()
except Exception:
return "fallback"
但这解决的是另一个问题:它相当于告诉业务系统“任何路由错误都可以安全走 fallback”。
对于真正允许 degraded mode 的业务,这可能合理。但如果异常意味着权限系统不可用、付款状态未知、审批规则未加载、风控检查失败,直接 fallback 可能引入更严重的业务错误。
所以更通用的原则是:
需要重试的工作放进 node;只负责读取已经确定 state 的选择逻辑留给 conditional edge。
这样失败语义、checkpoint 和恢复边界会更清楚。
哪些逻辑应该从 router 移出去
建议重点检查 conditional router 里有没有:
- HTTP / RPC 请求;
- LLM 调用;
- 数据库查询;
- Redis / cache 访问;
- 文件读取;
- 外部策略服务;
- Feature Flag 服务;
- 权限判断中的远程调用;
- Billing / quota 查询;
- 需要 retry 的业务规则;
- 有 side effect 的任何操作。
conditional router 更适合保留:
def route(state):
if state["approved"]:
return "execute"
if state["needs_review"]:
return "review"
return "reject"
也就是:根据 state 做选择,而不是在选择阶段再生产新的不稳定信息。
怎么判断自己的工作流是否可能踩到这个问题
第一,搜索代码中的:
add_conditional_edges(
第二,检查对应 router 是否访问外部依赖。
第三,故意让 router 第一次调用抛异常。
第四,用同一个 thread_id:
graph.invoke(None, config)
然后不要只看返回值,要同时检查:
state = graph.get_state(config)
print(state.next)
print(router_calls)
print(downstream_calls)
如果出现:
resume success
state.next == []
downstream_calls == 0
就不能把这个 run 当成成功恢复。
正式修复现在是什么状态
截至 2026 年 9 月 11 日,LangGraph Issue #8834 仍处于 Open 状态,因此现在不能写“升级到某某版本即可解决”,也不应该把本文 workaround 说成 LangGraph 官方推荐方案。
真正的 upstream 修复至少需要明确一个语义:当 node state write succeeded 但 conditional router failed 以后,resume 应该重新执行失败的 routing step 并继续 downstream,或者保留一个明确 unresolved failed task,让 graph 不能被误判成正常结束。
最危险的是本文复现到的第三种状态:
resume 正常返回
pending=[]
downstream 未执行
因为它最容易让上层系统误判。
生产环境检查清单
如果你的 LangGraph workflow 依赖 checkpoint/resume,建议加入以下 failure injection:router timeout、router dependency error、router invalid response,以及 router 第一次失败、第二次成功。
恢复测试不要只断言:
assert no_exception
应该检查:
assert downstream_executed
assert final_state_is_complete
同时检查 pending task,但也不能只依赖 next == [],因为本文原始复现恰好就是 next=[],却没有执行 downstream。
最后,把 routing logic 移进普通 node 后,它可能在恢复时重新执行。如果节点会付款、发送消息、创建订单或写第三方系统,仍然必须设计 idempotency key 或业务去重。本文 workaround 解决的是“失败步骤能被恢复”,不是自动解决 side effect 重放。
最终结论
LangGraph 1.2.11 下,如果一个普通 node 已经写入 state,而它后面的 conditional router 抛出异常,invoke(None, same_config) 可能直接返回已经写入的状态,不重新执行 router,也不执行 downstream,并且没有 pending task。
XBSTACK 在 InMemorySaver 和 SqliteSaver 上都复现了这个结果。
当前验证有效的应用层规避方式是:
可失败 routing logic
→ 普通 node
conditional edge
→ 只读取 state 做纯分支选择
修改以后,同样的第一次失败场景中:
pending_before=["router_node"]
resume
router_node retry
selector
sink
result.value=2
两种 saver 都恢复正常。
如果你的 router 里正在调用数据库、API、LLM 或远程策略服务,建议在继续依赖 checkpoint resume 之前先做一次 failure injection。
不要用“resume 没抛异常”来判断 LangGraph 工作流已经恢复完成。
相关内容
- LangGraph Checkpointer:Memory、SQLite、Redis 怎么选
- LangGraph Agent 错误恢复、Retry 与 Timeout
- LangGraph 取消执行后 Checkpoint 为什么可能丢状态
- LangGraph Thread / Session 状态隔离
- LangGraph 专题
一手证据
- LangGraph upstream Issue #8834:Resume after a conditional-router exception skips routing and returns normally
- XBSTACK 独立最小复现与已验证 workaround
- 本地实验源目录:
experiments/langgraph-conditional-router-resume-repro/
继续按生产级 LangGraph 路线读,不再重复看泛入门
这一类文章统一沉淀到 LangGraph 专题页,按状态隔离、Checkpointer、HITL、失败恢复、Observability、Supervisor/Worker、Subgraph 和 Memory 顺序阅读。
继续阅读
返回专题 →AI 工程周报
只发真正改变工程判断的变化、故障、实验和新资产。
参与讨论
问题、验证与勘误
登录后可发表评论。所有新评论先进入审核;审核期间仅评论者本人和管理员可见,通过后才公开。