news 2026/7/6 2:55:55

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
RecursionError: maximum recursion depth exceeded —— 你的函数调用链,踩穿了 CPython 的安全气囊

报错原文

classMyNode(BaseModel):parent:Self|Nonechild:Self|Noneawaitclient.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(constchar*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--;// ← 每层调用减去 1return0;}

关键事实: -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来源——不是你的业务代码,而是你用的库在处理你的数据模型时触发了递归

# 你的业务代码看起来完全无辜frompydanticimportBaseModelclassCategory(BaseModel):name:strparent:"Category | None"=None# 自引用类型classProduct(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——所有这些基于类型反射的机制都是潜在的触发点。

修复方向

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

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

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

classNode:def__init__(self,parent=None):self.parent=parentdef__repr__(self):returnf"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):returnf"Node(parent={'<self>'ifself.parentisselfelseself.parent})"# 或者更通用的方案:defsafe_repr(obj,visited=None):ifvisitedisNone:visited=set()obj_id=id(obj)ifobj_idinvisited:return"<circular>"visited.add(obj_id)# ...

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

importsyssys.setrecursionlimit(50000)defdeep(n):ifn==0:return0return1+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/2KBperframe4000实际的物理上限

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

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

# 不用递归,用显式栈defdeep_iterative(n):stack=[n]count=0whilestack:ifstack.pop()==0:count+=1else:stack.append(stack[-1]-1ifstackelse0)returncount# 或用尾递归 + trampoline 模式deftrampoline(fn,*args):result=fn(*args)whilecallable(result):result=result()returnresult

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

importasyncioasyncdefprocess(item):ifitem.next:awaitprocess(item.next)# ← 协程递归awaititem.handle()# 在 asyncio 里,每个 await = 一层调用栈# 1000 层的链表 = RecursionError

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

更隐蔽的情况

asyncdefa():returnawaitb()asyncdefb():returnawaita()# ← 不会报 recursion error,因为是 tail-call-like# 但 await c() 后又 await d() 再 await e()...# 1000 层后照样炸

修复——用循环替代递归

asyncdefprocess(item):current=itemwhilecurrentisnotNone:awaitcurrent.handle()current=current.next

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

classBroken:def__init__(self):self._data={}def__getattr__(self,name):returnself._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__访问实例属性:

classFixed:def__init__(self):object.__setattr__(self,'_data',{})# 绕过 __setattr__def__getattr__(self,name):_data=object.__getattribute__(self,'_data')# 绕过 __getattr__try:return_data[name]exceptKeyError:raiseAttributeError(f"no attribute{name}")

排障流程

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

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

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

如果 traceback 里同一个函数反复出现→ 显式递归,找到终止条件。
如果 traceback 里不同函数交替出现→ 隐式递归(__repr____getattr__、schema 生成器等)。

第二步:确认递归深度

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

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

python-c"import sys, tracebacksys.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

importsyssys.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 栈物理上限了
版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/7/6 2:55:35

GraphRAG 实战:工程实践里的常见坑

《GraphRAG 实战&#xff1a;工程实践里的常见坑》看起来是个大话题&#xff0c;但真落到项目里&#xff0c;常常就是几个具体选择。下面我尽量按实际开发时会遇到的问题来讲。摘要这篇面向需要构建企业知识库和复杂问答系统的开发者&#xff0c;但不会把“GraphRAG 实战&#…

作者头像 李华
网站建设 2026/7/6 2:55:09

大部分管理信息系统(MIS)都少不了员工

当为员工创建帐号并分配相应的权限后&#xff0c;该帐号即可登录系统并进行相应的操作。当员工与系统进行交互操作时&#xff0c;系统会把员工Id、操作时间、操作IP、操作内容等信息记录到操作日志中&#xff0c;以便随时审计。   这样&#xff0c;从Domain的角度讲&#xff…

作者头像 李华
网站建设 2026/7/6 2:48:26

企业级Agentic AI实战指南:从概念到落地的智能体架构与应用

&#x1f680; 30款热门AI模型一站整合&#xff0c;DeepSeek/GLM/Qwen 随心用&#xff0c;限时 5 折。 &#x1f449; 点击领海量免费额度 最近和不少企业技术负责人交流&#xff0c;发现一个普遍现象&#xff1a;大家聊起大模型和生成式AI&#xff08;GenAI&#xff09;时头…

作者头像 李华