1. 先说人话:Lean是给数学命题写“编译期检查”的那门语言
第一次听说“用编译器验证数学证明”,我脑子里冒出来的是那句经典质疑:“编译器不就是一个会查类型的工具吗,它怎么知道我证明得对不对?”后来真正上手Lean语言,写了几十个命题、跑通了不少证明之后,我才意识到:这个印象虽然不够精确,但方向完全没错——Lean确实把“证明”变成了一个可以被编译器逐条检查的“类型正确的构造”。你可以把它想象成一位极其严格、不会通融的审稿人,你交上去的每一步推理,它都要当面验算一遍,验不过就拒绝接收。
这几年Lean热度持续走高,其实是两条线在同时推进。一条来自数学社区:大量经典定理被搬进mathlib这个形式化数学库里,逐条编译验证;另一条来自AI社区:大模型生成的数学推理经常“看着合理、实际翻车”,大家急需一个硬性的裁判,Lean恰好就是这个裁判。所以现在只要聊数学定理证明,几乎绕不开Lean和AI的搭配。
这篇教程打算从最基础的地方讲起:编译器凭什么敢验证数学证明,它的内核机制是什么,然后带你从装环境开始,亲手写完几个能通过检查的证明,最后聊聊AI在Lean工作流里到底能帮上多少忙。适合有编程基础、但第一次接触形式化验证的读者。不需要你懂很深的形式逻辑,跟着敲完例子,你就能理解整套机制是怎么转起来的。
1.1 它和普通编程语言到底差在哪里
如果你写过Java、C++或者Python,脑海里“编译器”这个词通常意味着:语法检查、类型检查、生成可执行文件。Lean表面上也做这些事,但它处理的对象不一样。在普通语言里,你写一个函数,声明参数类型和返回类型,编译器检查函数体是否满足这个签名;在Lean里,你写的“类型”本身就是一条数学命题,比如∀ n : Nat, ∃ m : Nat, m > n,而“函数体”则是这条命题的证明构造。
这个区别非常关键。普通程序的目标是“让机器完成计算”,编译检查是为了防止你写出类型错乱、运行时爆炸的代码。Lean的目标是“让机器验证你的推理成立”,编译检查实际上就是在做数学审稿。你在Lean里写的theorem,本质上是在声明一个类型,而by ...后面那一堆策略命令,是在指挥编译器帮你构造出一个符合这个类型的对象。
用程序员熟悉的类比来说:一个函数声明了“我要返回一个整数”,编译器就得确认你return的位置确实是整数;在Lean里,命题1 + 1 = 2也是一个类型,编译器得确认你后面给出的证明项属于这个类型。这就是为什么大家叫它“数学证明的编译器”,因为它真的在编译期就把整条推理链路检查完了。
1.2 为什么现在聊Lean总要带上AI
最近几个月的热搜词里,“Lean语言”和“AI”几乎捆在一起出现,不是没有原因的。大模型在数学推理上有个很尴尬的现状:它能用自然语言写出一段非常像样的“证明过程”,但中间的某一步可能悄悄把一个符号放错了位置,甚至使用了根本不成立的变换。人类读起来容易忽略这种细节,因为人的阅读会自动补全逻辑漏洞,而数学对漏洞零容忍。
这时候Lean的价值就出来了。不管AI生成的证明片段看起来多么顺滑,最终都必须喂给Lean编译器做检查。检查不过,就是不过,没有任何商量余地。于是AI和Lean的组合就变成了一套极其干净的“生成-检查”闭环:AI负责提出候选方案,Lean负责做真伪判定。你在论文里、博客里看到“先用AI生成证明,再用Lean验证”,说的就是这个流程。
我自己的感受是,目前AI写Lean还远远谈不上“全自动证明”,但作为辅助工具已经很好用了。它帮你补tactic、帮你翻译命题语句、帮你从错误信息里找线索,这些都很靠谱。真正决定证明能否通过的,永远是Lean内核那套严格得近乎固执的检查规则。接下来我们就拆开看看这套规则到底是怎么运作的。
2. 编译器凭什么敢“验证”数学证明——Lean的内核机制
2.1 把命题写进类型系统
要理解Lean,得先接受一个看起来有点绕的设定:数学命题是一种类型,数学证明是这个类型的实例。这不是比喻,是Lean底层逻辑的直接体现。在Lean里,你可以直接检查一些表达式的类型,比如在VS Code里输入:
#check 42 #check Nat.gcd你会看到输出分别是42 : Nat和Nat.gcd : Nat → Nat → Nat。这个好理解,每个表达式都有类型。但你再试试:
#check (1 + 1 = 2)输出会是1 + 1 = 2 : Prop。也就是说,等式1 + 1 = 2本身是一个类型,这个类型属于Prop这个大类型。所谓证明1 + 1 = 2,就是构造一个类型为1 + 1 = 2的项。最简单的构造方式是rfl,它表示“左右两边按定义就是相等的”:
example : 1 + 1 = 2 := by rfl这条代码的意思就是:我要构造一个类型为1 + 1 = 2的项,我用rfl来充当这个项。编译器检查rfl的类型,确认它确实指向1 + 1 = 2,于是证明通过。
这听起来可能有些抽象,但程序员朋友应该能get到:这不就是把“函数返回类型”的检查逻辑扩展到了整个数学命题上吗?普通编译器检查一个int类型的返回值,Lean检查的是一个Prop类型的证明项。底层机制惊人地一致,这正是“命题即类型”这句话的真实含义。
2.2 极小化可信计算基:内核就是那个固执的检查员
如果把Lean比作一家出版社,那“内核”就是那位只认死理、绝不放水的终审编辑。整个Lean系统其实分两层:外层是一大堆方便人类使用的工具,比如各种策略、宏、自动化搜索工具,它们负责把人类写的“半成品证明”翻译成机器能懂的原始证明项;内层是一个小得多的检查器,叫做kernel,它只负责一件事:判断一个证明项是否符合声明的类型。
这个设计有一个专有名词叫“极小化可信计算基”(TCB)。意思是,整个验证系统里,我们最终必须信任的那部分代码越少越好。外层工具可以写得非常灵活、非常复杂,甚至偶尔有bug都行,因为再花哨的工具生成的结果最后都要交到内核手里重新检查一遍。内核的规则极少:怎样判断两个类型定义相等、怎样展开定义、怎样做约归。这些规则简单到可以用少量代码实现,也因此更容易被严格审查。
为什么这件事对“验证数学证明”如此重要?因为如果你要验证一个数学定理,绝对不能依赖一个几百MB、行为复杂的编译器——那样的话,证明正确与否就依赖于这个巨无霸编译器有没有bug了。Lean把最终裁判压缩成一个小小的内核,所有高级工具都只是“生成候选证明”的助手。这个架构思想,说得直白一点,就是“外界的工具再花哨,末了也得过内核这一关”。这也是Lean能用来形式化经典数学定理的重要底气。
2.3 从策略到证明项:人和编译器之间怎么对话
新手刚接触Lean时,最常见的困惑是:我在by后面写的是啥?为什么我写了simp、omega这种看起来像命令的词,编译器就知道我在证明?这就要搞清楚策略(tactic)的作用了。
by后面的内容是策略模式下的一连串指令。你可以把它们理解为“给编译器下达的构造指令”:把目标化简、把某个假设应用到目标上、做归纳假设、用线性算术搜索器穷举等等。每执行一条策略,Lean都会更新当前状态——要么把目标改写得更简单,要么拆出子目标,要么直接构造出证明项的一部分。当所有目标都被清零,证明项也就完整了。
举例来说,你用omega证明一个自然数不等式,底层的策略执行器可能会尝试大量算术变换,最终拼出一个证明项。这个证明项可能非常冗长,完全不适合人阅读,但没关系,只要内核检查它符合命题类型就行。你可以随时用#print查看证明项的底层结构,但我个人平时很少这么做,因为策略生成出来的玩意儿根本不是给人读的。
这个设计有一个实际好处:你写证明时可以不用关心底层证明项长什么样,只需要关注当前目标、当前上下文,和下一步该用什么策略。VS Code里Lean插件会实时在左侧显示当前目标,相当于给你开了一个“证明调试器”,写证明的体验非常接近写代码时看变量值变化。
3. 新手快速上手:从装环境到写出第一个被编译器接受的证明
3.1 安装Lean 4和VS Code,5分钟把环境跑起来
如果你之前被C++编译器安装折腾得够呛,那Lean这套反而简单。官方推荐的路径是用一个叫elan的工具来管理Lean版本,它的用法和rustup几乎一样。打开终端,执行:
curl -sSL https://get.lean-lang.org/elan-init.sh | sh脚本跑完后,elan会安装到本地。接着安装Lean 4稳定版工具链:
elan toolchain install leanprover/lean4:stable这一步会把Lean编译器、标准库和配套工具都装好。然后是创建工程。Lean的自带构建工具叫lake,类似Cargo或npm。新建一个项目:
lake new lean_demo cd lean_demolake new会生成一个目录,里面有Lean文件夹和一个lakefile.lean。默认工程模板里有一个Main.lean文件,你可以直接在里面对应的地方写证明代码,也可以新建一个.lean文件。接着用VS Code打开这个目录,在扩展市场装一个叫“Lean4”的官方插件,装好后打开任意.lean文件,插件会自动加载Lean环境。
注意:如果你之前刚折腾完VSCode的C++编译器,可能会习惯性地到处找“编译器路径”之类的配置。Lean这边不用。插件是通过elan自动找到Lean可执行文件的,你只需要保证项目根目录有lakefile即可。最后跑一下工程:
lake build如果一切正常,说明环境没问题。第一次进入工程时,如果你的项目依赖了mathlib这个大数学库,构建过程会比较漫长,可能要等一段时间下载和编译。我建议新手先不加mathlib依赖,后面需要某些数学定理时再加上,否则首秀会被编译时间劝退。
3.2 常用声明和第一份“编译通过的证明”
先把最常用的几个命令搞清楚,它们是新手“喂给编译器”的日常工具:
| 命令 | 作用 | 示例 |
|---|---|---|
#check | 查看表达式的类型或定理的完整类型签名 | #check Nat.add_assoc |
#eval | 执行一个可计算表达式,输出结果 | #eval 1 + 1 |
example | 声明一个待证明的命题,只做临时验证 | example : 1 + 1 = 2 := by rfl |
theorem | 声明一个正式的定理,后续可以复用 | theorem my_add_assoc : ... |
lemma | 和theorem类似,通常用于辅助引理 | lemma add_zero' : ... |
写进一个.lean文件里试试:
import Mathlib -- 如果你用了标准模板,通常会带 #check Nat.add_assoc #eval 1 + 1 example : 1 + 1 = 2 := by rfl左边VS Code的Lean面板会展示#check的结果:Nat.add_assoc : ∀ (a b c : Nat), a + b + c = a + (b + c)。这个类型签名本身就是一个命题,也就是说Nat.add_assoc这个定理已经是“一个类型为加法结合律命题的项”,我们后续可以把它当作证明构件拿来用。这就像你在普通语言里调用一个函数:它已经存在,你只需要告知编译器“我想用它”。
刚上手时,我的建议是先养成“先声明、后证明”的习惯。先用example或theorem把命题写清楚,再在by后面逐步用策略完成任务。看到左边目标窗口变成“No goals”,编译器才会给好脸色。
3.3 手动完成一个带量的数学证明
光是一个1 + 1 = 2不过瘾,我们来点更带感的。先试一个最常见的自然数命题:对任意自然数n,都有n + 0 = n。注意这个命题在自然数定义里并不完全平凡,因为加法的定义是递归地展开第一个参数。在Lean里可以这样证明:
lemma add_zero_right (n : Nat) : n + 0 = n := by induction n with | zero => simp | succ n ih => simp [ih]我来逐行解释编译器在这个过程中到底检查了什么。第一行induction n with把证明变成了两个子目标:基例0 + 0 = 0和归纳步Nat.succ n + 0 = Nat.succ n。基例里simp可以根据自然数加法的定义直接把左边化简成0,于是目标变成0 = 0,编译器接受。归纳步里,ih就是我们归纳假设n + 0 = n,而目标左边Nat.succ n + 0会在加法定义下展开成Nat.succ (n + 0),simp [ih]用归纳假设替换掉n + 0,得到Nat.succ n = Nat.succ n,完成。
这个例子不大但在入门教程里很经典,因为它第一次让你“看见”编译器在检查归纳证明里的每一步:归纳假设被明确声明出来,替换时编译器要核对类型完全一致。你写错任何一个地方,左侧目标窗口就会立刻变红,告诉你哪一步没对上。
example (a b c : Nat) : (a + b) + c = a + (b + c) := by exact Nat.add_assoc a b c更复杂的命题,比如加法结合律,直接用标准库里现成的定理Nat.add_assoc就能一行解决。exact的作用是“我要把一个已知的项作为完整证明递给编译器”,编译器检查它的类型和当前目标是否完全一致。如果类型不一致,它会直接报type mismatch。这三个小例子跑通了,你就完成了从“写代码”到“构造证明项”的初次体验。
4. AI在Lean工作流里到底能干多少活
4.1 AI补全tactic:Copilot这类工具的真实体验
把AI拉进Lean工作流,最朴素也最实用的场景就是让AI补全by后面的策略。我在VS Code里用GitHub Copilot写Lean代码时,它经常能猜到我想用的定理名。比如我写了example (a b c : Nat) : a + (b + c) = (a + b) + c := by,停在换行处,Copilot会建议exact Nat.add_assoc a b c。这个建议恰好能用,因为加法结合律定理的方向、变量顺序,它都对了。
但AI的建议不能盲信。有一次我想证明一个关于乘法的简单命题,它给我建议了rw [Nat.mul_comm],我一看就知道这是把交换律当成化简规则在用,确实没问题;可另一次它给我推荐了一个从没见过的定理名,我猜大概是它“捏造”出来的,跑一下#check,编译器立刻报unknown identifier。这让我彻底明白:AI生成的文本只是候选方案,MVP(最小可行证明)不是它说了算,是编译器说了算。
值得提一下的是,Lean插件本身也内置了一些基于搜索的策略建议,比如当你把光标停在某个目标上时,插件会提示有哪些常量和simp定理可以用于当前目标。这类建议比大模型凭空生成的更可靠,因为它的候选集来自已经导入的库。我的习惯是:先用插件自带建议探路,再用AI补全作为加速工具,最后永远以编译器的检查结果为准。
4.2 让AI帮我把自然语言命题翻译成Lean声明
“把自然语言数学命题翻译成Lean代码”是我觉得AI目前最有价值的用法。数学证明的难点常常不在纯推理,而在于你要先写对命题的正式表述。比如你有一个自然语言命题,“如果两个自然数相等,那么它们的平方也相等”,让AI翻译成Lean代码,它会给你:
theorem sq_eq_of_eq {a b : Nat} (h : a = b) : a ^ 2 = b ^ 2 := by rw [h]这个声明其实已经猜到了关键:h : a = b是前提假设,rw [h]在目标里把b替换成a,目标变成a ^ 2 = a ^ 2,于是证明完成。这个例子说明AI对Lean语法和常用策略已经有不错的“语感”。
但坑也很多。AI有时会把命题里的省略信息脑补错,比如把“存在一个大于所有自然数的自然数”翻译成看似合理但是假命题的表达式,更常见的是漏掉量化符的括号范围。我的应对办法是三步走:第一步让AI把命题翻译成theorem声明,第二步立刻用#check检查这个声明的类型是否合理,第三步如果后面要手写证明,再回到VS Code里对着目标逐步推进。不管AI翻译得多么像样,只要#check报错或者编译失败,就得回头改,这一点没有任何回旋余地。
4.3 为什么AI写出来的证明必须过Lean这一关
AI写数学证明本身就有点“黑盒表演”的味道。大模型没有内置逻辑真值的概念,它只是根据上下文生成一串看起来合理的符号。在数学这种一步错、步步错的场景里,这种“看起来合理”是最危险的。Lean给这个危险世界装上了一道硬性闸门:内核检查不过,证明就不成立。这个机制的价值,怎么强调都不过分。
也正是因为这一点,现在很多数学和AI交叉的研究会把Lean当作“测试场”:让模型在Lean环境里做证明,每生成一步,编译器就实时反馈对不对。模型通过试错、编译错误信息、目标状态来调整策略,最终完成证明。这个模式下的“证明成功”是真实可信的,因为它过了内核检查,而不只是模型自己觉得“应该对了”。
当然也别神化这个流程。Lean验证的是“在给定公理和定义下命题成立”,至于这些公理是否刻画了现实世界,那是另一个问题。但至少在我们关心的数学推理内部,AI加Lean的组合能真正把“生成”和“验证”拆开,生成可以用模糊的启发式,验证必须用严格的形式化。我个人非常看好这个方向,因为它终于让AI在数学领域学棋手和棋谱的关系:模型负责出招,验证器负责结论。
5. 常见报错和排坑实录
5.1 高频报错:unknown identifier、type mismatch
新手在Lean里遇到最多的三种错误,我把它们整理成了一张速查表,按实际踩坑频率从高到低排列:
| 报错信息 | 大概率原因 | 解决办法 |
|---|---|---|
unknown identifier 'xxx' | 定理名打错了,或者没有import对应的库 | 用#check确认名称,确认import Mathlib |
type mismatch | 你提供的证明项类型和目标不一致 | 用#check查看目标类型,检查参数顺序和变量绑定关系 |
unsolved goals | 策略执行完,还有子目标没被处理 | 查看左侧目标窗口,继续用策略解决剩余目标 |
举一个真实的例子,假设我想证明加法交换律的一个特例,却在策略里写:
example (a b : Nat) : a + b = b + a := by exact Nat.add_assoc a b a这行代码会直接报type mismatch,因为Nat.add_assoc给出的类型是a + b + c = a + (b + c),和当前目标a + b = b + a的左右两侧方向、结构完全不同。编译器不会因为我们看着“这俩都有加法”就通融。遇到这种报错,我的排查顺序是:先看目标窗口里当前目标长什么样,再看我提供的项类型是什么,最后用change或者更合适的策略(比如omega、rw [Nat.add_comm])修正方向。
5.2 内存不足、heartbeat超时这类工程问题
如果你的证明比较复杂,或者策略跑得太深,Lean会报类似maxHeartbeats exceeded的错误。所谓heartbeat是Lean给策略执行设定的计算预算,防止一个自动策略无限搜索下去。遇到这种情况,可以在证明前面加一句:
set_option maxHeartbeats 4000000把预算调大,让自动化策略有更多时间搜索。如果预算调到极大还是跑不完,那大概率不是预算问题,而是策略本身不合适,需要换一条更聪明的路径。
另一个常见问题是编译大型库时内存吃紧,也就是大家常说的“编译器堆空间不足”。我第一次编译mathlib的时候,内存小跑一会儿就爆了,后来发现是可以控制的。用lake build -j1限制并行度,让编译任务一个一个来,能显著降低峰值内存。如果电脑内存实在不够,建议先在较小的库上练习,不必一上来就编译完整版mathlib,省心很多。
5.3 从C++/Java带过来的“main类型”困惑
在热搜词里有一句“编译器未包含main类型”,这个报错常见于C++或Java新人配置编译环境时。很多第一次接触Lean的人,顺手把这个困惑也带进来了:我是不是必须在工程里写一个main函数,编译器才肯干活?答案要分场景。
用lake new lean_demo创建的默认工程通常包含Main.lean,里面会有def main : IO Unit := ...这样一个入口,如果你把它改成纯数学证明工程,那就不需要main了。事实上,你在一个.lean文件里写的example、theorem、lemma,本身就会在编译时被检查,不需要任何程序入口。main只和“可执行程序”有关,和“数学证明库”无关。把这两件事分开,就能避免把其他主流语言的习惯误带到Lean里。
我的经验是:写数学证明时,几乎可以不去碰main。你只需要把注意力放在theorem声明和左侧的目标窗口上,编译器会像对待一个库工程一样,逐个检查你的证明是否成立,检查完了就通过,不要求你提供一个可运行的程序的入口。
6. 一点个人体会
写这篇教程的过程其实也是我重新反思“证明”的过程。以前我看到一段数学证明,脑海里会自动省略一些“显然”的步骤,而Lean最不买账的就是“显然”。它逼着我把每一步推理都变成编译器能查的对象,这个体验一开始有些难受,用久之后反而上瘾。
最后分享几个我一直在用的土办法。第一,遇到一个目标,先用simp和omega探路,能自动解就自动解,解不了再手写归纳;第二,写证明之前,永远先确认theorem声明本身没有歧义,声明错了后面全是白干;第三,AI生成代码尽量当草稿用,让它负责提速,让Lean负责把关,两者各司其职是最舒服的配合方式。希望这篇教程能让你少走一些弯路,早日享受“编译器给你过证明”的踏实感。