RecursionError: maximum recursion depth exceeded —— 你的函数调用链,踩穿了 CPython 的安全气囊

报错原文

复制代码
class MyNode(BaseModel):
    parent: Self | None
    child: Self | None

await client.aio.models.generate_content(
    model="gemini-2.0-flash",
    contents="...",
    config={"response_mime_type": "application/json", "response_schema": MyNode},
)

# RecursionError: maximum recursion depth exceeded

GitHub 真实案例

googleapis/python-genai#460 ------ Google 官方 GenAI Python SDK(Google 旗下的 Gemini API 客户端库),73 个 👍 和 15 条评论。用户用一个自引用的 Pydantic 模型Self 类型引用自身)调用 Gemini 的结构化输出 API,SDK 在将模型转为 JSON Schema 的过程中掉进了无限递归。

具体链路:

  1. 用户定义了一个带 Self 类型的 Pydantic 模型 MyNodeparent: Self | None; child: Self | None
  2. MyNode 作为 response_schema 传给 generate_content()
  3. SDK 内部调用 Pydantic 的 model_json_schema() 生成 JSON Schema
  4. 因为 MyNode 引用了自身,model_json_schema() 递归地处理类型的 $defs------每次递归都产生一个新的 self-reference,永远不会到达叶子节点
  5. Python 调用栈在默认 1000 层的递归限制处触发了 RecursionError

最讽刺的是:SDK 代码里没有任何显式的循环引用检查 ------它假设传入的 Pydantic 模型一定是 DAG(有向无环图),但 Self 类型天然就是不满足这个假设的。这不是用户的代码写错了,而是 SDK 的 schema 生成器在面对自引用类型时没有一个「终止条件」。


根因:CPython 的递归限制------不只是个数字

sys.setrecursionlimit(1000) 是什么意思?

很多人以为这只是一个「计数器限制」。不对------它背后有三层保护机制,全部写在 CPython 的 C 代码里。

第一层:Python 调用深度计数器(ceval.c

每次 Python 函数调用,CPython 的解释器主循环 _PyEval_EvalFrameDefault 在执行 CALL 字节码之前,都会调用 _Py_EnterRecursiveCall("")。这个函数做的事非常简单:

复制代码
// Python/ceval.c ------ CPython 源码,简化表示
int _Py_EnterRecursiveCall(const char *where) {
    PyThreadState *tstate = _PyThreadState_GET();
    if (tstate->py_recursion_remaining <= 0) {
        _PyErr_Format(tstate, PyExc_RecursionError,
            "maximum recursion depth exceeded %s", where);
        return -1;  // ← 返回错误码,解释器在 eval frame 里检查到 -1 后抛异常
    }
    tstate->py_recursion_remaining--;  // ← 每层调用减去 1
    return 0;
}

关键事实: - py_recursion_remaining 是一个线程局部变量PyThreadState 的字段),初始值 = recursion_limit - 每次 Python 函数调用减 1,函数返回(RETURN_VALUE 字节码)时调用 Py_LeaveRecursiveCall() 加回来 - 当计数器降到 0 时,不是「触发一个异常」,而是在 C 层面 检查返回值并拒绝执行新的 CALL

第二层:「最后机会」保护(limit + 50 → Fatal Error)

CPython 在触发 RecursionError 后,会设置 tstate->overflowed 标志。此时正常的递归限制被临时关闭 ------这是为了让你的 except RecursionError: 中的清理代码能正常运行(清理代码本身也可能有递归调用)。

但如果清理代码本身也进入了无限递归,递归深度超过 limit + 50 时,CPython 会直接 Py_FatalError 终止进程。这不是 Python 异常,而是 C 层面的 abort

复制代码
// ceval.h 注释原文:
// * "last chance" anti-recursion protection is triggered when the recursion
//   level exceeds "current recursion limit + 50". By construction, this
//   protection can only be triggered when the "overflowed" flag is set. It
//   means the cleanup code has itself gone into an infinite loop, or the
//   RecursionError has been mistakenly ignored. When this protection is
//   triggered, the interpreter aborts with a Fatal Error.
第三层:C 栈溢出保护(Python 3.12+)

这是最容易被人忽略的一层。CPython 的 Python 调用栈和 C 调用栈在实现上是耦合 的------每个 Python 函数调用都会在 C 层面新增一个 _PyEval_EvalFrameDefault 的递归调用。所以把 recursion_limit 设成 100000 并不能给你 100000 层的 Python 递归------你的 C 栈先爆了。

从 Python 3.12 开始,CPython 增加了 _Py_ReachedRecursionLimitWithMargin 检查:在每次函数调用前,比较当前 C 栈指针和 c_stack_soft_limit。如果 C 栈快到上限(操作系统分配的栈空间),直接抛 RecursionError------即使你的 Python 递归计数器还有余额。

复制代码
你的 setrecursionlimit(100000)
         ↓
CPython 说:C 栈还剩 2KB,我已经不能再给你一层调用了
         ↓
RecursionError(和你设多少没关系)

关键结论

RecursionError 不是「你递归太深了」,而是 CPython 在执行 CALL 字节码之前,发现三个条件之一不满足: 1. Python 调用深度计数器 ≤ 0 2. 已经触发过 RecursionError 且清理代码仍然在递归(+50) 3. C 栈接近物理上限

回到 python-genai#460:SDK 的 process_schema() 里没有对 Self 类型的终止检查,导致每处理一次 Self 就递归调用一次 process_schema(),直到遍历了 1000 层引用链。这不是算法错误------是 schema 生成器缺少「已访问类型集合」的跟踪。任何一个递归图遍历问题都需要一个 visited set,SDK 没提供。


五种生产级触发场景

场景 1:Schema 生成器对自引用类型无限递归(本次案例的完整模式)

这是最高频的生产 RecursionError 来源------不是你的业务代码,而是 你用的库在处理你的数据模型时触发了递归

复制代码
# 你的业务代码看起来完全无辜
from pydantic import BaseModel

class Category(BaseModel):
    name: str
    parent: "Category | None" = None    # 自引用类型

class Product(BaseModel):
    categories: list[Category]

# 一切正常------直到某个库尝试生成 JSON Schema
# ✨ 第三方库内部:
# def generate_schema(model):
#     for field_name, field_info in model.model_fields.items():
#         field_type = field_info.annotation
#         if is_model(field_type):
#             generate_schema(field_type)  # ← 对 Category,field_type 还是 Category
#                                           # ← 无限递归!没有 visited set

这种场景的特点 :你的代码本身没有 def f(): return f() 这样的显式递归,错误发生在你无法控制的库代码内部。orm_modejson_encodersschema()、FastAPI 的 response_model------所有这些基于类型反射的机制都是潜在的触发点。

修复方向

复制代码
# 正确做法:库应该维护一个已处理类型的集合
def generate_schema(model, _visited=None):
    if _visited is None:
        _visited = set()
    if id(model) in _visited:      # ← 关键:终止条件
        return {"$ref": f"#/$defs/{model.__name__}"}
    _visited.add(id(model))
    # ... 正常处理字段 ...

场景 2:__repr__ / __str__ 的循环引用死锁

这是最隐蔽的递归场景,因为没有显式的递归调用

复制代码
class Node:
    def __init__(self, parent=None):
        self.parent = parent

    def __repr__(self):
        return f"Node(parent={self.parent})"  # ← 隐式递归!

root = Node()
child = Node(parent=root)

print(child)       # __repr__ → str(root) → root.__repr__()
# RecursionError: maximum recursion depth exceeded while getting the repr of an object

CPython 的角度print(child) 触发了 child.__repr__(),而 __repr__ 里的 f"Node(parent={self.parent})" 会让 Python 调用 repr(self.parent)root.__repr__() → 又去 repr(root.parent)(也是 child)→ 无限循环。每次 repr() 调用在 CPython 层面都是一个 CALL 字节码,递归计数器被消耗。

典型触发 : - Django 的 __str__ 方法打印关联对象 - SQLAlchemy 的 __repr__ 打印 backref - dataclass 的自动 __repr__ 遇到循环引用

修复------处理循环引用

复制代码
def __repr__(self):
    return f"Node(parent={'<self>' if self.parent is self else self.parent})"

# 或者更通用的方案:
def safe_repr(obj, visited=None):
    if visited is None:
        visited = set()
    obj_id = id(obj)
    if obj_id in visited:
        return "<circular>"
    visited.add(obj_id)
    # ...

场景 3:sys.setrecursionlimit 调高到 50000 → 生产环境 segmentation fault

复制代码
import sys
sys.setrecursionlimit(50000)

def deep(n):
    if n == 0:
        return 0
    return 1 + deep(n - 1)

deep(45000)  # 在 Python 3.12+ → RecursionError(C栈检查)
             # 在 Python 3.11  → Segmentation fault(C栈炸了,OS kill 进程)

很多人不知道 setrecursionlimit 不能无脑调大。原因已经在根因分析里解释过了------Python 调用栈和 C 调用栈是耦合的。每层 Python 函数调用在 C 层面大约消耗 1-2KB 的栈空间(取决于局部变量和编译器优化)。Linux 默认线程栈大小是 8MB:

复制代码
8MB / 2KB per frame ≈ 4000 层(实际的物理上限)

你把 recursion_limit 设成 50000,CPython 的计数器让你跑,但 C 栈在第 ~4000 层就撞到了 OS 的保护页→ SEGFAULT。

正确做法永远不要在 Python 里靠 setrecursionlimit 来绕过递归限制。 如果算法需要深层递归,用迭代改写或用显式栈模拟:

复制代码
# 不用递归,用显式栈
def deep_iterative(n):
    stack = [n]
    count = 0
    while stack:
        if stack.pop() == 0:
            count += 1
        else:
            stack.append(stack[-1] - 1 if stack else 0)
    return count

# 或用尾递归 + trampoline 模式
def trampoline(fn, *args):
    result = fn(*args)
    while callable(result):
        result = result()
    return result

场景 4:asyncio 协程的递归调用------比同步递归更难发现

复制代码
import asyncio

async def process(item):
    if item.next:
        await process(item.next)  # ← 协程递归
    await item.handle()

# 在 asyncio 里,每个 await = 一层调用栈
# 1000 层的链表 = RecursionError

CPython 的角度await 在底层仍然是一个 Python 函数调用------事件循环调用了你的协程的 send() 方法,协程内部的 await 又调用下一个协程的 send()。CPython 看到的仍然是 1000 层嵌套的 CALL 字节码。

更隐蔽的情况

复制代码
async def a():
    return await b()

async def b():
    return await a()   # ← 不会报 recursion error,因为是 tail-call-like
                        #    但 await c() 后又 await d() 再 await e()...
                        #    1000 层后照样炸

修复------用循环替代递归

复制代码
async def process(item):
    current = item
    while current is not None:
        await current.handle()
        current = current.next

场景 5:__getattr__ / __setattr__ 的无限递归链

复制代码
class Broken:
    def __init__(self):
        self._data = {}

    def __getattr__(self, name):
        return self._data[name]  # ← _data 不存在时,触发 __getattr__ 找 _data
                                  #    __getattr__ 再 try self._data
                                  #    又不存在 → __getattr__ → ... 无限循环

obj = Broken()
print(obj.foo)
# RecursionError: maximum recursion depth exceeded

这是 CPython 的属性查找机制(object.__getattribute__type.__getattr__ 的 fallback 链)导致的。当 self._data 这个属性本身不存在时,Python 会 try __getattr__,而 __getattr__ 又引用 self._data......在 C 代码层面:

复制代码
PyObject_GenericGetAttr(obj, "_data")
  → obj 的 __dict__ 里没有 "_data"
  → 检查 type(obj).__getattr__
  → 调用我们的 __getattr__("_data")
  → __getattr__ 里 self._data → 又调 PyObject_GenericGetAttr
  → 无限循环

修复 :在 __getattr__ 里永远通过 super().__getattribute__object.__getattribute__ 访问实例属性:

复制代码
class Fixed:
    def __init__(self):
        object.__setattr__(self, '_data', {})  # 绕过 __setattr__

    def __getattr__(self, name):
        _data = object.__getattribute__(self, '_data')  # 绕过 __getattr__
        try:
            return _data[name]
        except KeyError:
            raise AttributeError(f"no attribute {name}")

排障流程

当你看到 RecursionError: maximum recursion depth exceeded,按以下顺序排查:

第一步:确认是「显式递归」还是「隐式递归」

复制代码
# 在报错之前插入
import traceback
traceback.print_stack(limit=10)  # 看最近 10 层调用,找到重复出现的函数

如果 traceback 里同一个函数反复出现 → 显式递归,找到终止条件。

如果 traceback 里不同函数交替出现 → 隐式递归(__repr____getattr__、schema 生成器等)。

第二步:确认递归深度

复制代码
import traceback
tb = traceback.extract_tb(sys.last_traceback)
print(f"递归深度: {len(tb)} 层")  # 如果是 1000(默认值),说明没有正确的终止条件

第三步:找到「谁在消耗递归栈」

复制代码
python -c "
import sys, traceback
sys.settrace(lambda frame, event, arg: 
    print(f'{event:6} | {frame.f_code.co_name:30} | {frame.f_code.co_filename}:{frame.f_lineno}')
)
# 然后运行你的代码
"

输出会显示每一次函数调用事件。找到「A 调用 B → B 调用 A → A 调用 B」的模式。

第四步:验证是不是第三方库的问题

复制代码
# 如果 traceback 最后几层全是 pydantic/pydantic_core/fastapi 的代码
# → 不是你的代码在递归,是库在处理你的数据时触发了
# → 检查你是否传入了自引用类型 / 循环引用的数据

第五步:不要无脑 setrecursionlimit

复制代码
import sys
sys.setrecursionlimit(5000)  # ← 你只是推迟了爆炸,而且可能把 RecursionError 变成 segfault

只在以下情况可以调整 : - 你需要处理一个已知深度的数据(如 DFS 遍历深度为 3000 的树),且你计算过 C 栈安全边界 - 处理完立刻调回默认值


总结

层级 理解
初级 「递归函数要有终止条件,或者用 sys.setrecursionlimit(50000) 调大限制」
中级 RecursionError 不是 Python 抛的异常,是 CPython 在 ceval.c_Py_EnterRecursiveCall 里检查 py_recursion_remaining 计数器后拒绝执行下一个 CALL 字节码。默认 1000 层的限制有三层含义:① Python 调用深度计数器;② 「最后机会」+50 层的 Fatal Error 保护(防止 except 块发生二次递归炸进程);③ Python 3.12+ 的 C 栈溢出保护(_Py_ReachedRecursionLimitWithMargin),让你即使调大 limit 也不会 segmentation fault。生产环境最常见的 RecursionError 根源不是你的代码写了 def f(): return f(),而是你传给第三方库的数据结构(自引用的 Pydantic 模型、循环引用的 ORM 对象、带 __repr__ 的双向关联)触发了库内部的无限递归。修复的正确姿势是给递归算法加 visited set用迭代改写,而不是调大限制。
记忆锚点 RecursionError = CPython 在执行 CALL 字节码前,发现 Python 调用栈(计数器 or C 栈物理空间)已经不能安全支持下一层调用了。 不是「你的递归写错了」,是「某段代码对某个数据结构的遍历没有终止条件」。往回追一层,看是谁在遍历你的自引用数据。

同类家族

  • RecursionError: maximum recursion depth exceeded while calling a Python object → Python 函数递归超限
  • RecursionError: maximum recursion depth exceeded in comparison → 对象比较(__eq__ / __lt__)导致无限递归
  • RecursionError: maximum recursion depth exceeded while getting the repr of an object__repr__ 循环引用
  • Fatal Python error: Cannot recover from stack overflow. → 第二层保护触发(limit + 50 层的最后防线)
  • Segmentation fault (core dumped) → 你不是在 Python 3.12+,且把 recursionlimit 调到超过 C 栈物理上限了
相关推荐
cui_ruicheng1 小时前
Python从入门到实战(三):流程控制与循环语句
开发语言·python
Rauser Mack2 小时前
Vibe coding游戏实战:零代码编程五子棋小游戏
人工智能·python·游戏·html·prompt
FriendshipT2 小时前
Ultralytics:解读PSABlock模块
人工智能·pytorch·python·深度学习·目标检测
hhzz2 小时前
全局实例跟踪(GIT):像人类一样定位目标——VideoCube基准与SiamFC实战全解析
大数据·python·计算机视觉·目标跟踪·数据分析
hhzz11 小时前
基于监控视频的水位尺自动识别技术方案与实现
python·opencv·yolo·图像识别·cv
yongche_shi11 小时前
ragas官方文档中文版(五十)
开发语言·python·ai·ragas·如何评估和改进 rag 应用
weixin_4080996712 小时前
OCR批量识别图片方案:从手动处理到自动化API系统(Python/Java/PHP实战)
图像处理·python·ocr·文字识别·api调用·批量识别·石榴智能
AI行业学习12 小时前
Notepad++ 官方下载 + 完整安装 + 全套优化配置(2026最新)
开发语言·人工智能·python·前端框架·html·notepad++
大圣编程13 小时前
Python中continue语句的用法是什么?
开发语言·前端·python