Day 17 / 共 60 天 · 阶段 3 控制流

循环与递归上限:怎么防止图"转不停"

D06 那张对话图有个环(tools→model),万一模型一直嚷着调工具、永远不给最终答案,图会不会死循环?答案是不会——LangGraph 用一个超步计数器兜底:每转一个超步 step 加一,超过 stop 就停下抛 GraphRecursionError。今天把这套"步数账"算清楚:recursion_limit 到底限的是什么、账在哪几行记、超限异常长啥样,以及配套的 RemainingSteps 托管值怎么让节点"知道自己快没步数了"。

📍 阶段 3「控制流」(D13-18) · 你在第 17 天
D13 条件边 D14 Branch 路由 D15 Command D16 Send 扇出 D17 递归上限 D18 START/END
💡 一句话锚点 LangGraph 没有"递归调用栈"意义上的递归——这里的"recursion"其实是超步(superstep)次数。引擎维护两个数:step(已经跑到第几超步)和 stop(允许跑到第几步,= 起始 step + recursion_limit + 1)。每个超步开头检查"step > stop 了吗",是就停机、抛 GraphRecursionErrorrecursion_limit 就是"最多允许多少个超步"的保险丝。
L01

痛点:带环的图,凭什么保证会停?

🤔 痛点普通函数递归太深会撞 Python 的 RecursionError(调用栈爆)。但 LangGraph 的循环不是函数递归——它是"model 写通道→触发 tools→写通道→触发 model→……",是数据在通道间来回流,调用栈不会累积。那如果路由逻辑有 bug、条件边永远不返回 END,这个循环靠什么停?总不能让它跑到天荒地老、把 API 额度烧光。
💡 本质:用"超步次数"当保险丝,而不是靠调用栈既然循环体现为"一轮又一轮超步",那限制"最多跑多少轮"就能兜底。引擎在每个超步的最开头做一次 step > stop 检查——像给一台可能停不下来的机器装了个"最多转 N 圈就强制断电"的计数器。这个 N 就是 recursion_limit。它不区分是正常循环还是 bug 死循环,纯粹是次数兜底。
大白话不是"函数套函数套太深"报错,而是"这台机器转的圈数超过你允许的上限了"报错。名字叫 recursion,其实是"超步计数超限"。
L02

step 与 stop:这本账怎么记

循环主体在 PregelLoopstep 初始为 0,每个超步结束 +1:

libs/langgraph/langgraph/pregel/_loop.py:301(初始)· :1217-1219(自增)
        self.step = 0                    # 循环起点
        ...
        if not exiting:
            # increment step
            self.step += 1               # 每个超步收尾时 +1

stop(上限)在循环启动时按 recursion_limit 算出:

libs/langgraph/langgraph/pregel/_loop.py:1700-1701
        self.step = self.checkpoint_metadata["step"] + 1          # 从存档点续跑时的起始步
        self.stop = self.step + self.config["recursion_limit"] + 1  # ★上限 = 起始 + 限额 + 1
step = 0 起步全新运行从 0 开始。若是从 checkpoint 恢复(阶段 6),起始 step 是"存档时的 step + 1"——所以 recursion_limit 是"本次运行的额度",不是全生命周期累计
stop = step + limit + 1★核心公式。举例:全新运行 step=1、limit=25,则 stop=27。允许 step 从 1 走到 27,恰好约 25 个有效超步。+1 是为边界留的余量。
self.step += 1每个超步顺利收尾就把账 +1。跑得越久,step 越逼近 stop。
从 checkpoint 续跑恢复时 step 接着存档点算,stop 重新按当次 config 的 limit 算。这样断点续跑不会"白白继承"上次已耗的步数额度。
控制流:每个超步开头查账,够本就停 step=1 2 step=stop step>stop 正常跑 最后一步 💥停机 tick() 每次开头:if step > stop → status="out_of_steps" → 停 stop = 起始step + recursion_limit + 1
图注:只要图正常在某步走到了 END,就在撞线前主动停;撞线才是异常出口。
L03

超限判定:tick 开头的一句检查

每个超步的入口 tick() 第一件事就是查账:

libs/langgraph/langgraph/pregel/_loop.py:606-609
        # check if iteration limit is reached
        if self.step > self.stop:
            self.status = "out_of_steps"      # ★不直接抛异常,先打个"没步数了"的标记
            return False                       # 返回 False:告诉主循环"别再 tick 了"
step > stop超限判定就这一行。注意是严格大于——等于 stop 时还允许跑最后一步。
status = "out_of_steps"★关键设计:这里不抛异常,只把循环状态标成 "out_of_steps"。为什么?因为抛异常的地方要拿到完整 config 拼一条友好的错误信息(L04),而 tick 里信息不全。这是"标记状态、上层决定如何报错"的解耦。
return False主循环看到 tick 返回 False 就退出"继续转"的 while,进入收尾/报错流程。
💡 为什么分两步(先标记、后抛)?tick 是热路径、被反复调用,让它保持简单(只判断+置状态);把"组织错误信息 + 抛异常"这种一次性、需要上下文的活,留给外层的 invoke/stream 收尾处理。职责分离让核心循环更干净。
L04

GraphRecursionError:友好报错在外层抛

循环退出后,外层检查状态,若是 "out_of_steps" 就拼错误信息、抛异常:

libs/langgraph/langgraph/pregel/main.py:3002-3011
            if loop.status == "out_of_steps":
                msg = create_error_message(
                    message=(
                        f"Recursion limit of {config['recursion_limit']} reached "
                        "without hitting a stop condition. You can increase the "
                        "limit by setting the `recursion_limit` config key."
                    ),
                    error_code=ErrorCode.GRAPH_RECURSION_LIMIT,
                )
                raise GraphRecursionError(msg)

异常类本身很简单,但 docstring 直接教你怎么解决:

libs/langgraph/langgraph/errors.py:67-87
class GraphRecursionError(RecursionError):
    """Raised when the graph has exhausted the maximum number of steps.
    This prevents infinite loops. To increase the maximum number of steps,
    run your graph with a config specifying a higher `recursion_limit`.
    Examples:
        graph.invoke(
            {"messages": [("user", "Hello, world!")]},
            {"recursion_limit": 1000},          # ← 第二个位置参数就是 config
        )
    """
继承 RecursionError它是 Python 内置 RecursionError 的子类——语义上"到达递归/迭代极限",但触发机制是超步计数不是调用栈。
create_error_message + ErrorCode错误信息带一个错误码 GRAPH_RECURSION_LIMITerrors.py:35)和排障文档链接。LangGraph 所有大类错误都走这套带码+带链接的格式。
提示"提高 recursion_limit"报错文案直接告诉你解法:在 config 里调大 recursion_limit。但要先想清楚——是真需要更多步,还是循环根本停不下来(bug)
limit 必须 ≥ 1另有校验:recursion_limit < 1 直接 ValueError(main.py:2563-2564)。0 步的图没有意义。
📝 怎么调 app.invoke(输入, {"recursion_limit": 100})——config 作为 invoke 的第二个参数传入。默认值由 DEFAULT_RECURSION_LIMIT 决定(_internal/_config.py:32,本版本取环境变量、缺省 10007;历史版本常见默认是 25)。生产里建议显式设一个符合你业务的小值,别依赖默认——默认太大反而让死循环烧很久才报错。
L05

RemainingSteps:让节点"知道自己还剩几步"

有时你想让节点主动在快没步数时收尾(比如"再没步数就直接给个兜底答案")。LangGraph 提供两个托管值(还记得 D05 说的 managed 字段吗)让节点读到步数信息:

libs/langgraph/langgraph/managed/is_last_step.py(全文)
class IsLastStepManager(ManagedValue[bool]):
    @staticmethod
    def get(scratchpad):
        return scratchpad.step == scratchpad.stop - 1     # 是不是最后一步

IsLastStep = Annotated[bool, IsLastStepManager]

class RemainingStepsManager(ManagedValue[int]):
    @staticmethod
    def get(scratchpad):
        return scratchpad.stop - scratchpad.step          # 还剩几步

RemainingSteps = Annotated[int, RemainingStepsManager]
ManagedValue托管值 = 引擎运行时算出来、注入状态的字段,不由你写(D05 讲 _get_channel 时它走的是"托管"分支,不进 self.channels)。这也是 D11 说的"input/output 不许有托管字段"的那类东西。
RemainingSteps = stop - step直接用今天的两个账相减,得到"还能跑几个超步"。在状态里声明 remaining: RemainingSteps,节点里读 state["remaining"] 就知道余量。
IsLastStep更省事的布尔版:"是不是就剩最后一步了"。create_react_agent(阶段 9)内部就用它——快到上限时不再调工具,直接让模型收尾,避免撞 GraphRecursionError。
scratchpadstep/stop 从一个叫 scratchpad(草稿本)的运行时对象读,它随每个任务传递(D42 会再见到)。
数据结构:三个数派生出全部"步数信号" step已跑到第几超步 stopstep起始+limit+1 step>stop → 停硬保险丝 stop-stepRemainingSteps step==stop-1IsLastStep 仅靠 step 与 stop 相减/比较
图注:GraphRecursionError(保险丝)和 RemainingSteps/IsLastStep(仪表盘)全都从 step、stop 两个数派生。
🍼 一句话recursion_limit 是"硬保险丝"(撞了就炸),RemainingSteps/IsLastStep 是"仪表盘"(让节点看着余量主动收手,优雅退出)。好的 Agent 两者都用:仪表盘正常收尾,保险丝兜底防失控。
L06

两处设计取舍 + 一处边界

🎨 设计取舍①:为什么用"超步计数"而不是 Python 的递归深度限制? LangGraph 的循环是数据在通道间流动(BSP 超步模型),不累积调用栈,所以 Python 的 sys.setrecursionlimit 根本管不到它。源码选择自己维护 step/stop 计数好处:上限的含义清晰("最多几个超步")、可按次运行灵活配置(config 传 recursion_limit)、且对"从 checkpoint 续跑"能正确重新计额度。代价:名字叫 "recursion" 容易让人误以为是栈递归,产生"我的图没递归啊怎么报 recursion error"的困惑——这其实是"超步数超限",本教程特意点破这层。
🎨 设计取舍②:超限为什么"先标记状态、后由外层抛异常"? tick 里只写 status="out_of_steps"; return False,真正 raise GraphRecursionErrormain.py 外层。好处:热路径 tick 保持极简且无副作用(不在深处抛异常打断栈);错误信息的组织(读 config 里的 limit、拼文案、加错误码和文档链接)集中在一处,一致且好维护。代价:读源码时"判定超限"和"抛异常"分散在两个文件,得顺着 status 这个信号跨文件追——理解了 status 机制就不难。
⚠️ 边界:调大 recursion_limit 常常是"治标",先查是不是真死循环GraphRecursionError 时,报错文案会建议你"提高 recursion_limit"——但这往往是陷阱。真正该先问:① 条件边有没有一条会被满足的通往 END 的出路?(D06 的坑)② 是不是某状态字段该累加却在覆盖,导致"永远达不到终止条件"?③ 并行扇出(Send,D16)是不是量爆了?只有确认是"业务确实需要很多超步"(如深度多轮工具调用),才该调大 limit。盲目调大只会让死循环烧更久、更贵才报错。生产环境反而建议设一个合理的上限当护栏。
L07

今日小结 + 动手 + 明日预告

🧠 今天你应该能回答

  • LangGraph 的 "recursion" 到底指什么?(超步次数,不是函数调用栈递归)
  • step 和 stop 分别是什么?(已跑超步数 / 允许的上限)
  • stop 怎么算?(起始 step + recursion_limit + 1)
  • 超限判定在哪、怎么判?(_loop.py tick 开头 if step > stop
  • 为什么 tick 里不直接抛异常?(先标记 status=out_of_steps,外层 main.py 才组织信息抛 GraphRecursionError)
  • 怎么调上限?(invoke 第二参数 config 里传 recursion_limit
  • RemainingSteps / IsLastStep 是什么?(托管值,让节点读到剩余步数以便主动优雅收尾)

✋ 10 分钟动手

# 1. 看 step/stop 记账
sed -n '606,609p' libs/langgraph/langgraph/pregel/_loop.py
sed -n '1700,1701p' libs/langgraph/langgraph/pregel/_loop.py

# 2. 看超限报错组织
sed -n '3002,3011p' libs/langgraph/langgraph/pregel/main.py

# 3. 看异常类与托管值
sed -n '67,87p' libs/langgraph/langgraph/errors.py
cat libs/langgraph/langgraph/managed/is_last_step.py

# 4. 亲手制造一个死循环,观察 GraphRecursionError
python3 -c "
from langgraph.graph import StateGraph, START, END
from typing_extensions import TypedDict
class S(TypedDict): n:int
def loop(s): return {'n': s['n']+1}
g=StateGraph(S); g.add_node('a', loop)
g.add_edge(START,'a'); g.add_edge('a','a')   # a→a 永远循环,没有 END 出口
try:
    g.compile().invoke({'n':0}, {'recursion_limit': 5})
except Exception as e:
    print(type(e).__name__)   # GraphRecursionError
"
💡 明日预告 · Day 18今天反复提到 START / END。明天 D18 就专门拆这两个"虚拟节点":constants.pySTART/END 为什么用 sys.intern 定义、set_entry_point 其实只是 add_edge(START, key) 的语法糖、以及 validate 如何靠"START 必须是某条边的起点"来判断图有没有入口——阶段 3 控制流收官。
← Day 16 · Send 动态扇出 Day 18 · START/END 与入口 →