先把“失败”拆成三种现场
在 LangGraph1 中,同一个 Exception 可能对应完全不同的处理方式。设想三次都返回 HTTP 504:第一次发生在搜索请求,第二次发生在模型调用,第三次发生在发布接口。表面都是“超时”,但资料调研流程中至少有三类失败:
| 现场 | 更可能的原因 | 合理的默认动作 |
|---|---|---|
| 搜索请求超时 | 网络抖动、上游限流 | 有限次退避重试,保留已完成来源 |
| 模型返回无法解析的结构 | 输出不符合 schema、提示词或模型变化 | 记录原始响应摘要,进入修复 / 人工分支 |
| 发布接口收到两次请求 | 客户端重试、恢复重跑、网关超时 | 依据幂等键返回同一结果,禁止盲目创建 |
把三类错误交给同一个“自动重试”装饰器,会把临时网络问题和重复写入混在一起。比如搜索超时重试一次通常只是多等几秒;发布接口超时却可能出现“服务端已经发布,客户端没收到响应”,再次创建文章就会产生重复内容。可靠性从划分失败边界开始:每个节点要说明失败证据在哪里、重试是否安全、超过预算后交给谁。
模型可以判断,代码必须掌握控制流
模型适合判断“资料是否足够”或“这段草稿是否需要补充”,但不应该直接返回任意函数名。例如让模型返回 "next": "publish_article",就等于把发布权限交给一段可能不稳定的文本。一个可审计的结构是:模型只输出受约束的判断字段,普通代码校验后路由:
模型节点:{ "needs_more_sources": true }
↓ schema 校验、权限检查、预算检查
路由函数:true → collect_sources
false → write_draft如果输出不符合 schema,进入明确的 invalid_model_output 分支;不要把错误文本再次交给模型,让它无限猜测下一个节点。路由函数只读取通过校验的字段,这样同一个 State 重放时,控制流仍然可解释。读者可以用一个返回未知字段的 fake model 验证:图应该进入失败分支,而不是执行 publish_article。
重试要停在节点边界
搜索节点失败时,已经完成的问题拆分和旧来源不需要全部重算。假设 split_questions 已经把一个主题拆成 4 个问题,collect_sources 在第 3 个问题超时;合理的恢复是保留前两个问题的结果,只重试第 3 个问题,而不是再次调用模型拆题。节点边界越清楚,重试范围越小;节点边界越模糊,失败时越容易重复昂贵或有副作用的工作。
失败记录至少应带上这些信息:
thread_id、run_id、节点名、尝试次数、错误分类、发生时间、外部请求 ID一个简单的分类函数可以先把策略写成代码,而不是散落在异常处理里:
from typing import Literal
RetryClass = Literal["retryable", "repairable", "terminal"]
def classify_error(error: Exception) -> RetryClass:
if isinstance(error, TimeoutError):
return "retryable"
if isinstance(error, ValueError):
return "repairable"
return "terminal"这个示例不是完整的供应商错误表,重点是让“可重试 / 可修复 / 终止”成为可测试的分支。超过次数上限后应进入死信、人工接管或明确失败状态,而不是继续让模型决定要不要再试。
恢复为什么会重复执行
Checkpoint 恢复的是图的运行位置,不是“之前的副作用已经被外部系统理解”。服务请求超时后,客户端可能再次提交;带 interrupt 的节点恢复时,暂停点之前的代码也可能重新执行。因此,读取和计算通常可以重做:重复查询仍是查询;发布、发邮件、扣款和创建订单不能默认安全重做:每执行一次都可能改变外部世界。
把副作用放在人工决定之后,并给外部调用一个由业务主键和版本组成的幂等键。下面的 article_id、revision 是业务状态中的字段,publish_api 代表支持幂等键的发布接口:
from typing_extensions import TypedDict
class PublishState(TypedDict):
article_id: str
revision: str
draft: str
def publish_article(state: PublishState) -> dict[str, str]:
idempotency_key = f"{state['article_id']}:{state['revision']}"
result = publish_api.create(
markdown=state["draft"],
idempotency_key=idempotency_key,
)
return {"article": result.url}这里的 article_id 和 revision 应来自业务数据库或内容版本系统,而不是从正文截取。外部 API 收到相同幂等键时要返回已存在的结果;如果供应商没有幂等能力,就先在自己的业务数据库建立唯一约束和状态机。
更安全的控制流是:
查看 Mermaid 源码
flowchart LR
A[检索资料] --> B[生成草稿]
B --> C{人工审核}
C -->|退回| B
C -->|通过| D[检查发布记录]
D -->|未发布| E[带幂等键发布]
D -->|已发布| F[返回原结果]查询结果是恢复安全性的事实来源,不能只依赖内存标志位。
循环必须有预算和出口
“资料不够就继续检索”是合理的业务策略,也是一个容易失控的循环。它可能不断增加来源、token、延迟和成本,却没有让证据质量变好。
至少把一项预算写进 State 或路由条件:
- 最大检索次数和来源数量;
- 单次运行最长时间;
- 模型 token 和费用上限;
- 草稿最大长度;
- 超限后的人工接管或失败状态。
预算不是运营文档里的提醒,而是可测试的行为。例如 attempts >= 3 时停止检索、elapsed_ms >= 30_000 时进入人工处理,token_count >= 20_000 时拒绝继续生成。达到上限后应产生 budget_exceeded 这样的稳定错误类型,前端和监控才能知道它是“主动停止”,不是“服务无响应”。
两条完整的排查路径
搜索超时后从入口重跑
症状是用户看到问题被重新拆分,之前找到的来源也消失。先看最后一个成功检查点和 collect_sources 的外部请求 ID;如果检查点记录了问题拆分,但重试从入口开始,说明节点边界或恢复配置没有生效。处理方式是给检索节点设置有限退避和独立重试,验证时让上游第一次超时、第二次成功,确认问题拆分只执行一次。
审核通过后出现两篇文章
症状是两个发布记录对应同一个草稿版本。先按 thread_id、revision 和外部请求 ID 对齐 Trace 与业务数据库;如果两次请求没有共享幂等键,说明恢复和写入之间缺少业务约束。处理方式是给发布接口增加唯一幂等键,并在重复请求时返回第一次结果;验证时重复调用 resume 和发布请求,数据库只能出现一条成功记录。
这两条路径体现了同一个原则:先找到最后一个可验证事实,再决定重试范围。不要先把整条图重新运行一遍,再从最终错误猜原因。把“最后一个成功检查点”打印出来,往往比把完整异常堆栈再加长一页更有用。
失败边界还需要业务判断
幂等键只能保护已经定义好的业务写入,不能让任意副作用自动安全;错误分类也不能替代正确的业务语义。有些流程即使技术上可以自动重试,也应该因为成本、风险或合规要求转人工。
参考资料
- LangGraph Persistence(官方文档,访问日期:2026-08-24)
- LangGraph Graph API(官方文档,访问日期:2026-08-24)
- LangGraph Interrupts(官方文档,访问日期:2026-08-24)
Footnotes
-
LangGraph 官方文档:提供带状态、节点和恢复边界的图执行运行时。 ↩