
上一篇把不同文件送进了不同的提取节点,路径有了分支,但一直在往前走。今天我想让它循环处理,大概的思路就是:如果一份报告检查没通过,修正以后再检查,直到合格为止。
我给这次尝试加了一个限制:最多修正两次。两次都没改好,就留下当前草稿和未解决的问题。否则一条回边连上去,报告又一直不合格,流程就可能反复走下去,从而死循环了。
首先看它怎样来回走
为了看清循环,我先不接模型,也不重新展开文件解析。输入是一份预先准备的报告草稿,只有一句话:
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
包内有这份虚构资料,不过这个只是补上来源标记,没有判断正文是否符合原文。真实报告的质量显然不能靠这两条规则决定,这里先用它们把循环搞清楚。
回到检查节点,用的仍然是普通边
图中的路径如下。菱形代表路由函数中的判断,不是另外注册的节点:
修正之后回到检查节点,就是这一行:
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,无需模型密钥。