AI 智能体用 Lean 形式化 CPython 并发现 418 个缺陷
研究团队用 Opus 5.5 智能体将 CPython 3.15.2 的内部实现逐行翻译为 61.3 万行 Lean 代码,并对照真实 Python 逐项测试、由机器校验全部证明,过程中向 CPython 提交了 418 条缺陷报告。该工作首次给出可机器验证的 CPython 形式化模型,为解释器语义正确性提供了新的工程范式。
报道正文
本文目录20 个章节
AI agent 编写了 613,318 行 Lean 代码,逐行复刻 Python 的内部实现,针对真实 Python 逐一测试了每个部分,让计算机检查了每一个证明,并在此过程中发现了 418 个 Python 缺陷报告。
CPython · Objects/rangeobject.c
static unsigned longget_len_of_range(long lo, long hi, long step){ assert(step != 0); if (step > 0 && lo < hi) return 1UL + (hi - 1UL - lo) / step; else if (step < 0 && lo > hi) return 1UL + (lo - 1UL - hi) / (0UL - step); else return 0UL;}Lean 复刻版 · RangeSlice/Model.lean
def getLenOfRange (lo hi step : Int) : Int := if step > 0 ∧ lo < hi then uadd 1 (usub (usub (toU hi) 1) (toU lo) / toU step) else if step < 0 ∧ lo > hi then uadd 1 (usub (usub (toU lo) 1) (toU hi) / usub 0 (toU step)) else 0 -- step ≠ 0 is assumed by every proof-- uadd, usub: C's unsigned arithmetic,-- which wraps around past 2^64左侧是 CPython C 源码中的一个函数,右侧是 agent 为其编写的 Lean 复刻版。将鼠标悬停在某一行上即可查看其对应行。
摘要
- 我们让 Opus 5.5 面向 CPython——运行几乎所有 Python 代码的程序——用 Lean 重写其函数,Lean 是一种用于编写机器可检验证明的语言。
- 每个重写版本都逐行复刻原始实现,并在任何人尝试对其证明任何性质之前,先针对真实 Python 在数万个输入上进行测试。
- 一个函数只有在第二个未参与编写的 agent 检查工作并认可其成立后,才算通过。目前已有 4,071 个函数通过了该检查。
- 最终成果是横跨 1,539 个文件的 613,318 行 Lean 代码,包含 18,898 个定理和引理,全部在每次构建时由 Lean 的证明检查器检查。
- 在此过程中,这些证明发现了 418 个缺陷报告。大多数问题较小,但约有 55 个可以从普通 Python 代码导致解释器崩溃或内存损坏。
已检查并接受已编写,尚未接受未开始
- 整个 CPython16%
- 解释器核心10%
- 内置类型35%
- C 模块19%
- 解析器31%
- 语法规则100%
- 标准库9%
CPython 3.15.2 各部分中已被复刻、测试、证明并通过审查接受的比例。没有可检查内容的代码(例如内存记账)被排除在外。
为什么要费这个劲
Python 并没有精确的书面定义。有一份参考手册,但在实践中,“Python”指的是 CPython 实际所做的一切,而 CPython 是数十万行 C 代码加上一个庞大的标准库。手册涵盖的是正常用法,对边缘情况几乎只字不提,而 bug 恰恰藏在那里:一个对象在字典查找它的过程中修改了该字典,一次更新进行到一半时抛出的错误,一个比 C 代码当初设计所能处理的最大值还大 1 的数字。
为一个函数编写证明,会迫使你精确说明它在所有这些情况下的行为。你不能留下任何含糊之处,因为证明检查器不会接受。这使得证明成为发现代码行为与所有人假设不符之处的好方法。
我们想看看,在一个像 CPython 这样庞大而古老的代码库上,这件事能做到什么程度,以及当大部分工作由 AI 完成时,结果是否仍然可信。
工作原理
每个函数都要经过同样的四个步骤。
- 复制 用 Lean 重写该函数,逐行遵循原始代码,包括其错误消息和古怪的边角情况。
- 测试 在至少 50,000 个刁钻输入上并排运行副本和真正的 Python。任何不一致都意味着副本有误。
- 证明 陈述某个应当始终成立的性质,让计算机检查证明。如果它实际上是假的,那就改为证明这一点。
- 核查 由一个没有参与工作的第二个 agent 对照原始代码进行审查,并尝试攻破它。只有经受住考验的才算数。
本页顶部的代码是 CPython 一小部分的第 1 步:那个计算 range 中有多少个数字的 C 函数,以及它的 Lean 副本。副本保留了 C 代码的精确算术,包括其数字过大时如何回绕。关于它的证明表明,这个函数、CPython 用于超大数字的较慢版本,以及二者之间做选择的代码,始终给出相同且正确的计数。
什么也证明不了的证明
最难的部分不是让 AI agent 写出证明,而是得到真正有意义的证明。
我们最初的尝试是要求智能体追求广度:复制每一个函数,并至少证明每个函数的一件事。它们做到了。随后一项单独的审查发现,它检查的函数中只有不到一半经得起推敲,而在一个文件里这一比例仅为 21%。这些证明全都正确,只是没有说明任何有用的东西。例如,“如果这个函数返回一个数字,那么这个数字至少为零”是正确的,但一个总是崩溃的函数同样满足这一点。
因此,审查者现在会对每一个证明提出一个问题:一个损坏版本的函数是否也能通过它?
| 函数版本 | “如果它返回,答案就 ≥ 0” | “它总是返回正确的计数” |
|---|---|---|
| 真实版本 | 通过 | 通过 |
| 总是崩溃 | 通过 | 被捕获 |
| 对非常大的数字返回 0 | 通过 | 被捕获 |
| 倒计数时差一 | 通过 | 被捕获 |
关于同一个函数的两个真实陈述。第一个让所有有问题的版本都通过了,因此一个函数即使只有看起来不成立的证明,也不算数。
修复一个弱证明通常意味着回头把副本做得更贴近现实,例如让用户的代码在函数执行中途抛出错误。下面的图表展示了十个文件的修复进展。每个点代表一轮审查。折线有时会下降:当审查者发现某个已接受的证明与真实 Python 不符时,所有依赖该证明的内容都会被撤回。
已接受的函数Objects/typeobject.c142 个中 135 个已接受 · 5 轮审查Objects/genobject.c56 个中 56 个已接受 · 5 轮审查Objects/setobject.c46 个中 46 个已接受 · 7 轮审查Modules/_json.c48 个中 45 个已接受 · 5 轮审查Lib/enum.py39 个中 37 个已接受 · 9 轮审查Lib/random.py38 个中 37 个已接受 · 3 轮审查Objects/abstract.c43 个中 41 个已接受 · 2 轮审查Objects/rangeobject.c33 个中 33 个已接受 · 2 轮审查Lib/pickletools.py33 个中 31 个已接受 · 2 轮审查Modules/binascii.c40 个中 35 个已接受 · 2 轮审查审查轮次 → 每轮审查后各文件已审查函数中被接受的比例,取自项目历史。每个点代表一次审查;所有图表的刻度相同,从 0% 到该文件中所有已审查的函数。已接受的函数包括审查者确认无需检查的函数,例如内存记账。
当正确的证明仍然出错时
经过检查的证明保证某个陈述能从副本中推出。它并不保证副本与真实 Python 一致,而测试只覆盖你想到要尝试的输入。因此,每个已接受的陈述还会针对真实 Python 运行。这抓出了若干有效但仍然错误的证明。
16 TB 的字符串
Python 快速 JSON 编码器的副本假设内存请求总是成功。因此它“证明”了编码器可以为任意嵌套深度构建缩进字符串,包括需要 16 TB 内存的深度。真实 Python 会耗尽内存。修复后的副本现在将每个内存请求视为可能失败。
多出九步,仅此一次
一项证明曾指出,Python 的 pickle 模块的嵌套调用深度不会超过某个特定数值。事实证明这基本正确,但有一个例外:在一个全新的 Python 进程中。在那里,它第一次编码字符串时,会悄悄加载一个文本编解码器,而这次加载又额外使用了九层嵌套调用。我们的测试从未发现这一点,因为测试框架早已加载了该编解码器。
错误类型有家族关系
在 Python 中,捕获 TypeError 也会一并捕获任何基于它构建的更具体的错误。有三份独立的副本改为检查错误的确切名称。这三份副本都曾有证明表明,自定义错误类型可以使其失效。
会回来的内存
Python set 的那份副本假设:一旦内存被释放,同一地址就永远不会再回来。而真实的内存分配器一直在复用地址。CPython 使用“地址是否改变?”的检查来发现用户代码何时替换了 set 的内部结构。在地址复用的情况下,这个检查可能被欺骗。修复这份副本后,一个原本被接受的证明变成了一个真实的内存 bug,如今已成为我们的 bug 报告之一。
我们的发现
当一项证明失败的原因在于 Python 本身有错、而非副本有错时,我们会将其写成 bug 报告。每份报告都包含一个简短的脚本,用于在 Python 3.15.2 上展示该 bug(大多数也能在开发版上复现),还会检索是否已有重复报告,并如实评估其影响程度。
- C 与 Python 版本不一致129
- 其他122
- 结果错误56
- 崩溃28
- 内存损坏27
- 与文档不符26
- 错误或消息有误18
- 缺少输入检查12
按类型划分的 418 份 bug 报告。“C 与 Python 版本不一致”是指某个模块有两套实现,一套是 C 编写的快速版本,另一套是 Python 编写的备用版本,而它们对同一调用给出不同结果。
大多数问题都很小:错误消息有误、某个边缘情况的行为与文档不同、同一函数的两个版本结果不一致。约有 55 个问题更为严重,仅用普通的 Python 代码就能使 Python 崩溃或损坏其内存,通常是通过在 C 代码正在使用某个容器时让用户代码修改该容器来实现的。Python 并不把运行恶意的 Python 代码视为安全边界,因此这些都属于普通 bug。尚未出现在 CPython 跟踪器上的问题正在被报告给该项目,从本文开始。以下是六个不同类型的小例子:
崩溃
一个导致 Python 崩溃的字典调用
对对象的属性字典使用 StrEnum 键调用 setdefault 时,如果同一类的另一个对象拥有该属性而当前对象没有,解释器就会崩溃。
class Key(StrEnum):
NAME = "name"
class User: pass
a = User(); a.name = "ada"
b = User(); b.age = 36
vars(b).setdefault(Key.NAME, "anon")
# Segmentation fault内存损坏
一次破坏内存的 XML 编辑
对 ElementTree 元素的反向切片赋值本应像列表一样执行插入操作。但它却复制了一个子元素却没有增加引用,同时丢失了另一个子元素。
e = ET.Element("root")
e[:] = [ET.Element(f"a{i}") for i in range(7)]
e[5:2] = [ET.Element("X")]
[c.tag for c in e]
# 'a2' appears twice, 'a4' is gone错误报错
一个破坏解析器的邮件头
方括号内包含空格的 Message-ID 会让 Python 的邮件解析器因内部错误而失败。该邮件头来自发送邮件的人。
msg = email.message_from_string(
"Message-ID: <a@[ x]>\n\nhi\n",
policy=policy.default)
msg["Message-ID"]
# UnboundLocalError静默的错误结果
取决于位置的 Base32 填充
Python 3.15 新增的 base32 解码器可以忽略指定的字符。如果 = 是其中之一,它出现的位置仍然会改变结果。
a2b_base32(b"AAAAAAA", ignorechars=b"=")
# Error: Incorrect padding
a2b_base32(b"AAAA=AAA", ignorechars=b"=")
# b'\x00\x00\x00\x00'缺失的错误
一个消失的枚举错误
将枚举值赋给第二个成员本应引发错误。如果该类的 __repr__ 有问题,错误会被吞掉,调用会静默地什么都不做。
class W(Enum):
A = 1; B = 2
def __repr__(self): return 5
W.B._add_value_alias_(3)
W.A._add_value_alias_(3) # returns NoneC 与 Python 不一致
两个 pickler,两个限制
Python 有一个 C 实现的 pickler 和一个纯 Python 实现的 pickler。它们在不同的嵌套深度处放弃。这一差异最早于 2008 年被报告,至今仍未解决。
v = []
for _ in range(400): v = [v]
pickle.dumps(v, 5) # works
pickle._dumps(v, 5) # RecursionError每份报告及其脚本都在仓库中。
在你自己的项目中这样做
这些内容并非 Python 专属。只要你能读到源代码、并且能运行一个可用的版本,这套方法就适用:另一种语言、一个库、一种文件格式、一个编译器。对我们来说,真正起作用的是:
- 选定一个确切的版本,并坚持使用它。每一份副本都指向该版本中的具体行。
- 先列出全部内容。 以完整清单为基准统计进度,这样就不会有东西被悄悄跳过。
- 照抄代码,而不是照抄文档。 依据文档写出的副本会与文档一致,而 bug 恰恰出现在代码与文档不一致的地方。
- 让用户代码“乱来”。 让模型回调抛出错误,或在操作进行到一半时改动状态。最严重的 bug 就出在这里。
- 先测试,再证明。 对一份错误的副本做证明只是浪费时间。
- 当某个命题为假时,保留反例。 不要把命题不断弱化,直到它变得可证明。
- 用真实实现核对已证明的命题。 对一份错误副本做出的有效证明,是你可能得到的最具误导性的结果。
- 只统计独立评审者认可的内容, 并让该评审者追问:一个有缺陷的版本是否也能通过?
我们的 agent 所遵循的指令都在仓库中,且意在可复用:AGENTS.md 供执行工作的 agent 使用,REVIEW_PROMPT.md 供评审者使用,而包含各函数及其状态的完整清单在 coverage/methods。
相关工作
最接近的项目是 julia-in-lean,它为 Julia 语言做了同样的事,并直接启发了本项目1。它在 Lean 中为 Julia 的标准库建模,用 Julia 测试这些模型,记录了约 400 个 bug 并提交了 75 个。它发现 130 个快速证明中有 128 个悄悄把困难情形假设掉了,这一发现塑造了我们的评审规则。最近的一篇论文在 Lean 中为 NVIDIA 的 PTX GPU 指令集建模,以证明 GPU kernel 的正确性2,其基础是早先关于 PTX 内存模型的形式化工作3。
具体到 Python,λπ4 以及 Python 3.3 的 K 框架语义5 从数学上描述了这门语言;而本项目则直接复制 CPython 的实际代码。JavaScript6,7 和 WebAssembly8 也有类似的工作,而 CompCert9 和 seL410 仍然是完全经过验证的软件的高标准。在同一输入上比较两个实现,即本项目的测试部分,可以追溯到几十年前11,12。
当前进展
项目始于 2026 年 10 月 29 日。在 26,004 个值得检查的函数中,已有 4,071 个(15.7%)完成了复制、测试、证明并通过了审查。Lean 代码共 613,318 行,分布在 1,539 个文件中,包含 18,898 个定理和引理;其中 3,834 个顶层性质已被证明,588 个被证明为假并给出了反例。Python 的大部分内容仍在前方,尤其是用 Python 本身编写的那一大部分标准库。多线程目前不在范围内。在这 57 个文件中,迄今为止复制的每个函数都通过了审查。已接受函数最多的 30 个文件如下:
Python/bytecodes.c310Grammar/python.gram278Lib/collections/__init__.py184Lib/_pydecimal.py160Lib/_pydatetime.py150Modules/_struct.c122Objects/longobject.c119Objects/listobject.c114Objects/memoryobject.c107Objects/dictobject.c104Objects/bytesobject.c100Objects/exceptions.c97Objects/unicodeobject.c94Objects/bytearrayobject.c93Modules/arraymodule.c84Lib/_collections_abc.py81Modules/_operator.c73Modules/itertoolsmodule.c72Modules/_collectionsmodule.c64Python/codecs.c61Lib/operator.py59Lib/_pyio.py58Objects/typeobject.c57Lib/weakref.py54Objects/genobject.c46Lib/urllib/parse.py46Parser/action_helpers.c43Modules/_zoneinfo.c43Objects/floatobject.c38Objects/tupleobject.c37
本项目开放协作,欢迎参与贡献。CPython 的大部分内容尚未建模:请挑选一个其函数在 coverage/methods 中仍标记为 todo 的文件,遵循 AGENTS.md,并提交 pull request。对现有工作的独立审查与新模型同等重要。参见 CONTRIBUTING.md。
引用
@misc{andrews2026formalizing,
author = {Andrews, David and Zhang, Chao},
title = {Formalizing {CPython}: A Lean Model of {CPython} 3.15.2},
year = {2026},
howpublished = {\url{https://broyojo.com/python-formalization/}},
note = {Code: \url{https://github.com/Broyojo/python-formalization}}
}