程云来 / 杭州
Back to Blog·LangGraph
·About 8 min

LangGraph(三):重试不等于可靠,让失败停在边界内

把网络失败、模型判断和外部副作用分开,沿着恢复路径设计重试、预算与幂等,而不是给整条图套一个重试装饰器。


先把“失败”拆成三种现场

在 LangGraph1 中,同一个 Exception 可能对应完全不同的处理方式。设想三次都返回 HTTP 504:第一次发生在搜索请求,第二次发生在模型调用,第三次发生在发布接口。表面都是“超时”,但资料调研流程中至少有三类失败:

现场更可能的原因合理的默认动作
搜索请求超时网络抖动、上游限流有限次退避重试,保留已完成来源
模型返回无法解析的结构输出不符合 schema、提示词或模型变化记录原始响应摘要,进入修复 / 人工分支
发布接口收到两次请求客户端重试、恢复重跑、网关超时依据幂等键返回同一结果,禁止盲目创建

把三类错误交给同一个“自动重试”装饰器,会把临时网络问题和重复写入混在一起。比如搜索超时重试一次通常只是多等几秒;发布接口超时却可能出现“服务端已经发布,客户端没收到响应”,再次创建文章就会产生重复内容。可靠性从划分失败边界开始:每个节点要说明失败证据在哪里、重试是否安全、超过预算后交给谁。

模型可以判断,代码必须掌握控制流

模型适合判断“资料是否足够”或“这段草稿是否需要补充”,但不应该直接返回任意函数名。例如让模型返回 "next": "publish_article",就等于把发布权限交给一段可能不稳定的文本。一个可审计的结构是:模型只输出受约束的判断字段,普通代码校验后路由:

text
模型节点:{ "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 个问题,而不是再次调用模型拆题。节点边界越清楚,重试范围越小;节点边界越模糊,失败时越容易重复昂贵或有副作用的工作。

失败记录至少应带上这些信息:

text
thread_id、run_id、节点名、尝试次数、错误分类、发生时间、外部请求 ID

一个简单的分类函数可以先把策略写成代码,而不是散落在异常处理里:

python
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_idrevision 是业务状态中的字段,publish_api 代表支持幂等键的发布接口:

python
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_idrevision 应来自业务数据库或内容版本系统,而不是从正文截取。外部 API 收到相同幂等键时要返回已存在的结果;如果供应商没有幂等能力,就先在自己的业务数据库建立唯一约束和状态机。

更安全的控制流是:

正在绘制图表…
查看 Mermaid 源码
flowchart LR
    A[检索资料] --> B[生成草稿]
    B --> C{人工审核}
    C -->|退回| B
    C -->|通过| D[检查发布记录]
    D -->|未发布| E[带幂等键发布]
    D -->|已发布| F[返回原结果]

Mermaid 大图

可滚动查看图表,点击缩放比例可恢复 100%。按 Esc 关闭。

查询结果是恢复安全性的事实来源,不能只依赖内存标志位。

循环必须有预算和出口

“资料不够就继续检索”是合理的业务策略,也是一个容易失控的循环。它可能不断增加来源、token、延迟和成本,却没有让证据质量变好。

至少把一项预算写进 State 或路由条件:

  • 最大检索次数和来源数量;
  • 单次运行最长时间;
  • 模型 token 和费用上限;
  • 草稿最大长度;
  • 超限后的人工接管或失败状态。

预算不是运营文档里的提醒,而是可测试的行为。例如 attempts >= 3 时停止检索、elapsed_ms >= 30_000 时进入人工处理,token_count >= 20_000 时拒绝继续生成。达到上限后应产生 budget_exceeded 这样的稳定错误类型,前端和监控才能知道它是“主动停止”,不是“服务无响应”。

两条完整的排查路径

搜索超时后从入口重跑

症状是用户看到问题被重新拆分,之前找到的来源也消失。先看最后一个成功检查点和 collect_sources 的外部请求 ID;如果检查点记录了问题拆分,但重试从入口开始,说明节点边界或恢复配置没有生效。处理方式是给检索节点设置有限退避和独立重试,验证时让上游第一次超时、第二次成功,确认问题拆分只执行一次。

审核通过后出现两篇文章

症状是两个发布记录对应同一个草稿版本。先按 thread_idrevision 和外部请求 ID 对齐 Trace 与业务数据库;如果两次请求没有共享幂等键,说明恢复和写入之间缺少业务约束。处理方式是给发布接口增加唯一幂等键,并在重复请求时返回第一次结果;验证时重复调用 resume 和发布请求,数据库只能出现一条成功记录。

这两条路径体现了同一个原则:先找到最后一个可验证事实,再决定重试范围。不要先把整条图重新运行一遍,再从最终错误猜原因。把“最后一个成功检查点”打印出来,往往比把完整异常堆栈再加长一页更有用。

失败边界还需要业务判断

幂等键只能保护已经定义好的业务写入,不能让任意副作用自动安全;错误分类也不能替代正确的业务语义。有些流程即使技术上可以自动重试,也应该因为成本、风险或合规要求转人工。

参考资料

Footnotes

  1. LangGraph 官方文档:提供带状态、节点和恢复边界的图执行运行时。