用 AI 智能体将 CPython 形式化为 Lean 模型,发现 418 个 Python bug
Formalizing CPython
研究团队用 Opus 5.5 智能体把 CPython 3.15.2 的函数逐行改写为 Lean,经测试、证明和独立审查后,已完成 4,071 个函数,产出 613,318 行 Lean 代码、18,898 条定理与引理。
AI 智能体编写了 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
简而言之
- 我们将 Opus 5.5 指向 CPython——运行几乎所有 Python 代码的程序——让它用 Lean 重写其函数,Lean 是一种用于编写机器可检查证明的语言。
- 每次重写都逐行复制原始代码,并在任何人尝试对其证明任何东西之前,用数万个输入与真实 Python 进行测试。
- 只有当另一个未参与编写的智能体检查工作并认可其成立时,一个函数才算数。到目前为止,已有 4,071 个函数通过了该检查。
- 结果是 1,539 个文件中共 613,318 行 Lean 代码,包含 18,898 个定理和引理,全部在每次构建时由 Lean 的证明检查器检查。
- 在此过程中,这些证明产生了 418 个缺陷报告。大多数很小,但约有 55 个可以从普通 Python 代码导致解释器崩溃或内存损坏。
已检查并接受已编写,尚未接受未开始
- 整个 CPython16%
- 解释器核心10%
- 内置类型35%
- C 模块19%
- 解析器31%
- 语法规则100%
- 标准库9%
为什么要费这个劲
Python 没有精确的书面定义。有一份参考手册,但实际上“Python”意味着 CPython 所做的任何事情,而 CPython 是数十万行 C 代码加上一个大型标准库。手册涵盖了正常使用情况。它对奇怪的情况着墨甚少,而缺陷恰恰藏在那里:一个对象在字典搜索它时改变了该字典,更新进行到一半时抛出的错误,比 C 代码编写时针对的最大值还大一的数字。
为一个函数编写证明迫使你准确说明它在所有这些情况下的行为。你不能留下任何模糊之处,因为证明检查器不会接受。这使得证明成为发现代码未按所有人假设行事之处的好方法。
我们想看看在像 CPython 这样庞大而古老的代码库上这能走多远,以及当大部分工作由 AI 完成时结果是否仍然可信。
工作原理
每个函数都经过相同的四个步骤。
- 复制用 Lean 重写该函数,逐行遵循原始代码,包括其错误消息和其奇怪角落。
- 测试在至少 50,000 个棘手输入上并排运行副本和真实 Python。任何不一致都意味着副本有误。
- 证明陈述应该始终为真的事情,并让计算机检查证明。如果它实际上是假的,就证明相反的情况。
- 检查另一个未参与工作的智能体对照原始代码审查它并试图打破它。只有幸存下来的才算数。
本页顶部的代码是 CPython 一小部分的第一步:这个 C 函数负责算出一个 range 里有多少个数字,旁边是它的 Lean 副本。该副本保留了 C 代码的精确算术,包括数字过大时如何回绕。关于它的证明表明,这个函数、CPython 用于超大数字的较慢版本,以及二者之间的选择代码,始终给出相同且正确的计数。
什么也没证明的证明
最难的部分不是让 AI 智能体写证明,而是让证明真正有意义。
我们第一次尝试要求智能体追求广度:复制每个函数,并至少为每个函数证明一件事。它们做到了。随后一次独立审查发现,它检查的函数中不到一半站得住脚,有一个文件里只有 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 轮审查审查轮次 →
当正确的证明仍然是错的
经过检查的证明保证某个陈述能从副本推出,但不保证副本与真实 Python 一致,而测试只覆盖你想到的输入。因此,每个已接受的陈述还会对真实 Python 运行。这抓住了几个有效但仍然错误的证明。
16 TB 的字符串
Python 快速 JSON 编码器的副本假设内存请求总是成功。于是它“证明”了编码器可以为任意嵌套深度构建缩进字符串,包括需要 16 TB 内存的深度。真实 Python 会内存耗尽。修复后的副本现在把每个内存请求都视为可能失败。
九个额外步骤,仅此一次
一份证明称,Python 的 pickle 模块的嵌套调用深度不会超过某个特定数值。这一说法基本属实,但有一个例外:在一个全新的 Python 进程中。在那里,它第一次编码字符串时,会悄悄加载一个文本编解码器,而这次加载又使用了九个额外的嵌套调用。我们的测试从未发现这一点,因为测试框架已经加载了该编解码器。
错误类型有家族关系
在 Python 中,捕获 TypeError 也会捕获任何基于它构建的更具体的错误。有三个独立的副本改为检查错误的确切名称。这三个副本都有证明表明,自定义错误类型可以破坏它们。
内存会回来
Python 的 set 的副本假设,一旦内存被释放,同一地址就永远不会再回来。真实的内存分配器一直在复用地址。CPython 使用“地址是否改变?”的检查来发现用户代码何时替换了集合的内部结构。有了地址复用,这个检查就可能被欺骗。修复该副本后,一个原本被接受的证明变成了一个真实的内存错误,现在已成为我们的一个错误报告。
我们发现了什么
当证明失败是因为 Python 本身有错,而不是副本有错时,我们会将其写成错误报告。每份报告都包含一个简短的脚本,用于在 Python 3.15.2 上展示该错误(大多数也适用于开发版本),还会搜索已报告的重复问题,并诚实评估其重要性。
- C 与 Python 版本不一致129
- 其他122
- 结果错误56
- 崩溃28
- 内存损坏27
- 与文档不符26
- 错误或消息有误18
- 缺少输入检查12
大多数问题很小:错误消息有误、某个边缘情况的行为与文档不同、函数的两个版本不一致。大约 55 个问题更严重,仅使用普通 Python 代码就能使 Python 崩溃或损坏其内存,通常是通过让用户代码在 C 代码正在使用容器时更改该容器。Python 不把运行恶意 Python 代码视为安全边界,因此这些是普通错误。尚未出现在 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 就出在这里。
- 先测试,再证明。对错误的副本做证明是浪费时间。
- 当某个命题为假时,保留反例。不要把它弱化到可证明为止。
- 用真实实现检验已证明的命题。关于错误副本的有效证明是你能得到的最具误导性的结果。
- 只统计独立审查者接受的内容,并让该审查者追问:一个有问题的版本能否通过。
我们的智能体所遵循的指令都在仓库里,且意在复用:给执行工作的智能体用的 AGENTS.md,给审查者用的 REVIEW_PROMPT.md,以及带状态的完整函数清单在 coverage/methods。
相关工作
最接近的项目是 julia-in-lean,它为 Julia 语言做了这件事,并直接启发了本项目1。它在 Lean 中为 Julia 的标准库建模,用 Julia 测试这些模型,记录了约 400 个 bug 并提交了 75 个。它发现 130 个快速证明中有 128 个悄悄把困难情形假设掉了,这一发现塑造了我们的审查规则。最近的一篇论文在 Lean 中为 NVIDIA 的 PTX GPU 指令集建模,以证明 GPU 内核正确2,其基础是早先关于 PTX 内存模型的形式化工作3。
具体到 Python,λπ4 和一个 Python 3.3 的 K framework 语义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 操作,然后发送拉取请求。对现有工作的独立审查与新模型同样重要。参见 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}}
}参考文献
- M. Cranmer. Formalizing Julia in Lean (and finding 400+ bugs in Base). Julia Discourse, 2026. 代码:MilesCranmer/julia-in-lean。
- L. Koska, T. Koska, B. Koska. Formalizing PTX in Lean to Prove GPU Kernel Correctness. arXiv:2611.04447, 2026。
- D. Lustig, S. Sahasrabuddhe, O. Giroux. A Formal Analysis of the NVIDIA PTX Memory Consistency Model. ASPLOS 2019。
- J. G. Politz, A. Martinez, M. Milano, S. Warren, D. Patterson, J. Li, A. Chitipothu, S. Krishnamurthi. Python: The Full Monty. A Tested Semantics for the Python Programming Language. OOPSLA 2013。
- D. Guth. A Formal Semantics of Python 3.3. 硕士论文,伊利诺伊大学厄巴纳-香槟分校,2013。代码:runtimeverification/python-semantics。
- M. Bodin, A. Charguéraud, D. Filaretti, P. Gardner, S. Maffeis, D. Naudziuniene, A. Schmitt, G. Smith. A Trusted Mechanised JavaScript Specification. POPL 2014。
- D. Park, A. Ştefănescu, G. Roşu. KJS: A Complete Formal Semantics of JavaScript. PLDI 2015。
- C. Watt. Mechanising and Verifying the WebAssembly Specification. CPP 2018。
- X. Leroy. Formal Verification of a Realistic Compiler. Communications of the ACM 52(7), 2009。
- G. Klein, K. Elphinstone, G. Heiser, J. Andronick, et al. seL4: Formal Verification of an OS Kernel. SOSP 2009。
- W. M. McKeeman. Differential Testing for Software. Digital Technical Journal 10(1):100–107, 1998。
- X. Yang, Y. Chen, E. Eide, J. Regehr. Finding and Understanding Bugs in C Compilers. PLDI 2011。
来源:Hacker News · broyojo.com