LangGraph 学习之路:让工作流返工,也让它及时停下

上一篇把不同文件送进了不同的提取节点,路径有了分支,但一直在往前走。今天我想让它循环处理,大概的思路就是:如果一份报告检查没通过,修正以后再检查,直到合格为止。

我给这次尝试加了一个限制:最多修正两次。两次都没改好,就留下当前草稿和未解决的问题。否则一条回边连上去,报告又一直不合格,流程就可能反复走下去,从而死循环了。

首先看它怎样来回走

为了看清循环,我先不接模型,也不重新展开文件解析。输入是一份预先准备的报告草稿,只有一句话:

text 复制代码
出差前需要审批,报销时需要提供发票。

检查规则也很简单:第一行要有 # 出差资料整理,最后一行要有 来源:travel_policy.txt。修正函数一次补一项,所以这份草稿需要来回两次。

从代码仓库根目录运行:

powershell 复制代码
cd chapters/ch04_bounded_loops
uv sync --locked
uv run --locked python -m chapter_demo

程序会输出报告和日志。我先看日志这一段:

text 复制代码
处理日志:
1. 开始:最多修正 2 次
2. 第 1 次检查:缺少标题、缺少来源行
3. 第 1 次修正:补上标题
4. 第 2 次检查:缺少来源行
5. 第 2 次修正:补上来源行
6. 第 3 次检查:通过
7. 结束:报告通过检查

这里修正了两次,却感觉检查了三次。初稿先检查一次,每次改完也要重新检查;第二次修正完成时,还不能直接宣布通过。

最后得到的草稿是:

markdown 复制代码
# 出差资料整理

出差前需要审批,报销时需要提供发票。

来源:travel_policy.txt

包内有这份虚构资料,不过这个只是补上来源标记,没有判断正文是否符合原文。真实报告的质量显然不能靠这两条规则决定,这里先用它们把循环搞清楚。

回到检查节点,用的仍然是普通边

图中的路径如下。菱形代表路由函数中的判断,不是另外注册的节点:

flowchart TD S([START]) --> P[初始化] P --> C[检查报告] C --> R{检查结果与剩余次数} R -->|通过| A[接受报告] R -->|不合格且还有次数| F[修正一项] F --> C R -->|不合格且次数用尽| X[停止修正] A --> E([END]) X --> E

修正之后回到检查节点,就是这一行:

python 复制代码
builder.add_edge("repair_report", "check_report")

它和前面几篇用过的普通边没有区别。图允许再次进入已经执行过的节点,下一次检查读到的是修正后的状态。

条件判断放在检查之后。要保存哪些信息,可以先看状态里的这几个字段:

python 复制代码
class ReportState(TypedDict):
    draft: str
    max_repairs: int
    repair_mode: Literal["fix", "unchanged"]
    repairs: NotRequired[int]
    checks: NotRequired[int]
    issues: NotRequired[list[str]]
    status: NotRequired[Literal["running", "accepted", "exhausted"]]
    logs: Annotated[list[str], add]

这里的类型来自 typing,add 来自 operator。初始化节点把修正次数和检查次数都设为 0。max_repairs 是允许修正几次,repairs 是已经修正几次;issues 留下最近一次检查发现的问题。

这些字段的更新方式也不同。草稿会覆盖成新版本,问题列表也会被本次检查结果替换。日志沿用第二篇的累积方式,每个节点只交回新增的一条,方便顺着历史看它走过的路径。

最后一次修正,也得有机会通过

路由函数这样写:

python 复制代码
def route_after_check(state: ReportState) -> Literal["accepted", "repair", "exhausted"]:
    if not state["issues"]:
        return "accepted"
    if state["repairs"] >= state["max_repairs"]:
        return "exhausted"
    return "repair"

再把三种结果接到节点:

python 复制代码
builder.add_conditional_edges("check_report", route_after_check, {
    "accepted": "accept_report",
    "repair": "repair_report",
    "exhausted": "stop_repairing",
})

我特意把"有没有问题"放在前面。默认样例经过第二次修正后,repairs 已经等于上限 2,但第三次检查没有发现问题,所以仍然进入接受分支。只有还存在问题时,才需要判断能不能继续修。

如果先看到次数用尽就结束,这份已经合格的报告也会被拒绝。我给这个边界单独加了测试,避免之后调整路由时把最后一次机会截掉。

修正次数在 repair_report 节点里加一,检查次数则在 check_report 里加一。计数跟着实际执行的操作走,初稿不算一次修正。

可以换成已经完整的样例试试:

powershell 复制代码
uv run --locked python -m chapter_demo --sample complete

它只检查一次,修正次数是 0。允许修正两次,也不意味着一定要把两次用完。

如果一直修不好呢

先把修正次数改成一次:

powershell 复制代码
uv run --locked python -m chapter_demo --max-repairs 1

报告开头的输出会变成:

text 复制代码
报告检查结果
状态:修正次数用尽
修正次数:1 / 1
检查次数:2
剩余问题:缺少来源行

它已经补上标题,但来源行还缺着。流程走到 stop_repairing,保留当前草稿并结束,命令退出码为 1。

我还试了 --max-repairs 0。结果是先检查初稿,发现两项缺失,然后停止;修正节点一次也没有执行。这个结果也符合"零次修正"的含义。

不过,默认修正函数每次都能解决一项问题,还看不出"怎么修都不对"的情况。我又加了一个实验选项,让修正节点保留原文:

powershell 复制代码
uv run --locked python -m chapter_demo --repair-mode unchanged

这次仍然是两次修正、三次检查,但最后的状态是次数用尽,两个问题都还在。日志中的修正会明确写出"保持原文(无效修正实验)"。

这个实验比顺利通过更能看出次数限制的作用。草稿毫无变化,计数仍然前进,到上限就结束。以后换成模型来修正,也不能指望"再试一次总会变好"。

这里也没有自动重试异常。修正节点每次正常返回,只是返回的草稿没改善。节点抛异常后要不要重试,是另一件事。

图的执行上限,管的是另一种计数

LangGraph 还有一个 recursion_limit。我在调用时显式设成 32,与业务上的两次修正分开:

python 复制代码
result = graph.invoke(inputs, config={"recursion_limit": 32})

注意它在配置字典的顶层,不放到 configurable 里面。

这个参数限制的是单次图运行的 super-step 数量。顺序执行的节点处于不同 super-step;并行节点可以处于同一个 super-step。因此不能把它直接当作修正次数、模型调用次数,或者所有图中通用的节点调用次数。这些语义可以对照官方递归上限说明和图执行模型。

本章一直顺序执行,初始化、每次检查、每次修正和最后的结束节点都要占用执行步骤。把业务预算设成 2,再把 recursion_limit 也设成 2,图来不及完成正常路径。

我把图上限故意调小了一次:

powershell 复制代码
uv run --locked python -m chapter_demo --recursion-limit 3

命令输出:

text 复制代码
图执行中断:达到 recursion_limit=3 的执行上限。
这次没有得到业务完成结果;检查循环条件,或为正常路径设置足够的图执行上限。

此时框架抛出 GraphRecursionError,命令行入口捕获后返回退出码 3。它没有走到"修正次数用尽"的业务结束节点,也没有拿到这次调用正常返回的最终状态。官方的错误说明也提醒,执行超限既可能来自意外循环,也可能是正常流程需要更多步骤。

所以遇到这个错误,我会先看日志和图的路径,确认是否缺少结束条件;如果路径本来就有界,再检查上限是否足够。单纯把数字加大,并不能补上遗漏的业务判断。

还可以给这个例子加一条"正文不能为空"的规则。只加检查会发生什么?修正节点得跟着改,两次修正也未必够了。我觉得这个小改动很适合拿来继续试。

下一篇准备让模型参与文档摘要。到那时,检查和修正会有更实际的输入,不过"最多做几次、没做好怎样结束"仍然需要我们自己决定。

配套代码:langgraph-learning-journey

目录为 chapters/ch04_bounded_loops。示例使用 Python 3.12、LangGraph 1.2.16,无需模型密钥。

相关推荐
我的div丢了肿么办1 小时前
go语言抽离路由文件以及对路由进行分组
后端·go
zz在掘金337081 小时前
Django __init__.py 高级应用速记
后端
软件工程师文艺6661 小时前
Teamcenter FMS 文件管理系统深度解析:缓存策略、SSL 部署与性能调优
后端
Gopher_HBo1 小时前
类型系统 —— Type、rtype 与运行时类型元数据
后端
今天早睡1 小时前
并发分支、同步汇总与 State 合并
后端·架构
王川7081 小时前
商协会后台前端的模块聚合实践
前端·后端
旋律4141 小时前
String、StringBuilder、StringBuffer:三个"字符串",到底区别在哪?
java·后端
小卿噢1 小时前
94% 的集群在空等——数据倾斜实测
后端
没事写点啥1 小时前
WeKnora 问答实现
后端·agent·ai编程