Pyrefly 深度解剖:Meta 用 Rust 重写的 Python 类型检查器——从 OCaml 时代的 Pyre 到 1.8 万行/秒的模块中心求解架构
一、背景:Python 类型检查为什么又"内卷"了
2026 年的 Python 类型检查赛道,已经卷成了红海。mypy 是老牌标杆,pyright 靠 VS Code 的 Pylance 占据 IDE 心智,Astral(ruff 和 uv 的作者)在做 ty,而 Meta 则掏出了 Pyrefly——一个用 Rust 从零重写的类型检查器 + Language Server,前身是 Meta 内部服役多年、用 OCaml 编写的 Pyre。
就在几天前,Pyrefly v1.1 正式发布。这个节点值得停下来认真拆一拆这个项目:它不是又一个"Rust 重写宣言",而是 Meta 把 Instagram 数千万行 Python 代码上跑出来的类型检查经验,用一套全新的架构重新表达了一遍。
先抛一个直觉性的问题:mypy 已经存在十几年了,为什么大厂还要前赴后继地重写类型检查器?
答案藏在三个痛点里:
痛点一:速度。 mypy 是 Python 写的(虽然可以用 mypyc 编译),在几十万行的 monorepo 上全量检查一次动辄几分钟。类型检查一旦慢到需要"等待",开发者就会把它从内环开发流中移除,只留在 CI 里——而 CI 阶段才发现类型错误,修复成本已经翻了几倍。
痛点二:IDE 一致性。 很多团队的现状是:CI 里跑 mypy,IDE 里用 pyright(Pylance),两者的类型推断规则并不完全一致。于是出现了经典的扯皮场景——"我本地 IDE 没报错啊,怎么 CI 挂了?"两套类型系统实现,意味着两套心智模型。
痛点三:增量与并行。 传统检查器的增量模式大多是"文件级缓存",改一个被大量依赖的基础模块,下游全部重算。在超大代码库上,这种增量几乎等于全量。
Pyrefly 的设计目标就是同时干掉这三个痛点:一个二进制,同时是命令行检查器和 LSP 服务器(保证 CI 与 IDE 行为一致);Rust 实现 + 无锁并行(Meta 官方给出的数字是每秒 180 万行代码的检查吞吐);模块级并行 + 细粒度增量。
二、从 Pyre 到 Pyrefly:一次架构层面的"推翻重来"
2.1 OCaml 时代的遗产
Pyre 是 Meta 在 2018 年开源的类型检查器,用 OCaml 编写。OCaml 在写编译器/类型系统这件事上是老牌选手(Rust 编译器的第一版就是 OCaml 写的),模式匹配和代数数据类型让类型规则的表达非常优雅。
但 Pyre 在工程上遇到了几堵墙:
- 生态墙:OCaml 的并行故事一直很挣扎(OCaml 5 之前没有真正的多核并行 GC),Pyre 靠多进程 + 共享内存 hack 出并行,复杂且脆弱。
- 人才墙:会 OCaml 的工程师是稀缺资源,社区贡献门槛极高。
- 架构墙:Pyre 的增量系统是围绕"函数级依赖追踪"设计的,精细但状态管理极其复杂,bug 难以排查。
Pyrefly 选择 Rust,除了性能,更重要的是拿到了 rayon(数据并行)、дашmap 之类的并发基础设施,以及一个活跃到几乎所有新编译器项目都在用 Rust 的社区(ruff、uv、Biome、Turbopack、oxc……)。
2.2 核心决策:模块中心(Module-Centric),而非符号中心
这是 Pyrefly 架构里最值得展开的一点,也是它和 pyright/mypy 最大的分野。
传统类型检查器的思路是符号中心:要知道 foo.bar 的类型,就去解析 foo,找到 bar 的定义,递归求解它依赖的符号……这是一种"按需拉取"(lazy pull)模型,好处是只算需要的部分,坏处是调用图深、递归爆栈风险、缓存粒度难以控制,并且很难并行——因为你不知道接下来要算哪个符号。
Pyrefly 反其道而行之,采用模块中心的三阶段流水线:
阶段1:Exports 求解
解析每个模块的导出表,处理 import * 的传递闭包
(这是模块间唯一的"全局"依赖)
阶段2:Bindings 生成
将模块内每个语句转换为"绑定"(binding)
每个绑定 = 一个名字 + 一个待求解的类型表达式
此阶段模块之间完全独立,可全量并行
阶段3:Solutions 求解
求解所有绑定,遇到跨模块引用时去拿对方模块的 solutions
遇到递归/环时插入 Type::Var 占位符,事后回填
用一个简化的例子说明 bindings 长什么样。源码:
x: int = 42
def f(y: str) -> list[str]:
return [y, str(x)]
Pyrefly 内部会生成类似这样的绑定集合(伪表示):
binding x := check_assign(literal(42), annotation(int))
binding f := function(params=[y: str], ret=list[str], body=...)
binding f.return := check_return(list_expr([y, call(str, [x])]), list[str])
关键洞察在于:绑定是可以拓扑排序并批量求解的。整个模块变成一张绑定依赖图,求解过程就是图上的传播,而不是符号中心模型里那种不可预测的递归下降。这带来三个直接收益:
- 并行天然成立:模块之间只在阶段 3 有数据依赖,且依赖的是不可变的 solutions 表,用 rayon 并行调度即可,无需复杂锁。
- 增量粒度清晰:某模块变更后,重算它的 bindings/solutions;下游模块只有在其 导出接口 变化时才需要重算——如果你只改了函数体没改签名,下游完全不用动。这就是所谓的"指纹式增量"(接口指纹不变则剪枝)。
- 内存换速度是显式的:Pyrefly 官方明确说了设计取舍——"以内存换速度",所有模块的 solutions 常驻内存,换来 IDE 场景毫秒级响应。
2.3 处理递归:Type::Var 占位符机制
Python 代码里循环依赖是家常便饭:模块 A import B,B 又 import A;类方法返回自身类型;递归函数没写返回注解。符号中心模型处理这些要靠"正在计算"标记 + 各种特判。
Pyrefly 的做法更系统化:当求解器遇到尚未解出的依赖时,插入一个 Type::Var(类型变量占位符),继续往下算,等对方解出后再回填并做一致性收敛。这本质上是把 Hindley-Milner 风格的 unification 思想嫁接到了 Python 的渐进类型系统上。
对递归函数的推断是个直观例子:
def fib(n): # 没有任何注解
if n <= 1:
return n
return fib(n - 1) + fib(n - 2)
Pyrefly 会给 fib 的返回类型先插一个 Var,对 return n 推出 int,对递归调用处 fib(...) + fib(...) 用 Var 参与运算约束,最终收敛为 (n: int) -> int(实际推断中 n 未注解会按调用点/默认规则处理,这里取简化情形)。重点是:未注解代码也能得到有意义的推断,这是 Pyrefly 相比 mypy 默认行为激进的地方——它的哲学是"尽力推断所有表达式的类型",而不是"没有注解就放弃"。
三、代码实战:从安装到全家桶
3.1 五分钟上手
pip install pyrefly
# 或者用 uv(更快)
uvx pyrefly init
在项目根目录初始化:
pyrefly init
这条命令会在 pyproject.toml 里写入 [tool.pyrefly] 配置段,并且——这是个很贴心的细节——自动迁移已有的 mypy / pyright 配置。如果你的项目里有 mypy.ini 或者 pyright 的配置段,Pyrefly 会尽量翻译成等价配置,存量项目的切换成本被压到了很低。
跑第一次检查:
pyrefly check --summarize-errors
对存量大项目,直接跑通常会爆出几百上千个错误。Pyrefly 提供了渐进采纳的开关:
# 给所有现存错误自动加忽略注释,先让 CI 绿灯,再逐步还债
pyrefly check --suppress-errors
# 之后修复了一批代码,清理已经不再需要的忽略注释
pyrefly check --remove-unused-ignores
这一对命令组合,本质上就是"类型债务的快照与摊销"工作流,和 ruff 的 --add-noqa 思路一脉相承——先冻结存量,只对增量严格。这是所有在大型遗留代码库上推类型检查的团队最需要的能力。
3.2 配置详解
pyproject.toml 中的典型配置:
[tool.pyrefly]
# 检查范围
project-includes = ["src/**"]
project-excludes = ["**/tests/fixtures/**", "**/*_pb2.py"]
# Python 版本与平台,影响 sys.version_info / sys.platform 分支的窄化
python-version = "3.12"
python-platform = "linux"
# 第三方包的搜索路径(通常自动从环境探测,也可显式指定)
site-package-path = [".venv/lib/python3.12/site-packages"]
# 错误级别定制
[tool.pyrefly.errors]
bad-assignment = "error"
missing-attribute = "error"
import-error = "warn"
几个实用细节:
python-version不只是摆设。Pyrefly 会据此对if sys.version_info >= (3, 12):这类代码块做静态分支消除,检查时只走匹配的分支,避免对不可能执行的代码报错。- 对于没有类型标注的第三方库,Pyrefly 遵循 PEP 561 查找 stub,找不到时按
Any处理,同时可以配置untyped-def-behavior来决定对无注解函数是"推断"还是"视为 Any",前者更严格后者更宽容。
3.3 类型窄化:Pyrefly 展示肌肉的地方
流敏感分析(flow-sensitive narrowing)是衡量类型检查器智商的核心指标。看几个 Pyrefly 处理得很漂亮的例子:
from typing import assert_type
def demo(x: int | str | None):
if x is None:
return
# 此处 x: int | str
if isinstance(x, int):
assert_type(x, int) # ✅
return
assert_type(x, str) # ✅ 剩余分支自动收窄
def walrus(items: list[str | None]):
# 海象运算符 + 列表推导中的窄化
return [y.upper() for item in items if (y := item) is not None]
# y 在推导式体内被窄化为 str,.upper() 不报错
再看对 TypeGuard / TypeIs 的支持:
from typing import TypeIs
def is_str_list(val: list[object]) -> TypeIs[list[str]]:
return all(isinstance(x, str) for x in val)
def process(val: list[object]):
if is_str_list(val):
# val 已窄化为 list[str]
print(", ".join(val)) # ✅ 不需要 cast
以及泛型 + ParamSpec 的装饰器场景——这是 mypy 长期以来的重灾区:
from typing import Callable, ParamSpec, TypeVar
from functools import wraps
import time
P = ParamSpec("P")
R = TypeVar("R")
def timed(fn: Callable[P, R]) -> Callable[P, R]:
@wraps(fn)
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
t0 = time.perf_counter()
try:
return fn(*args, **kwargs)
finally:
print(f"{fn.__name__}: {time.perf_counter() - t0:.3f}s")
return wrapper
@timed
def fetch(url: str, retries: int = 3) -> bytes: ...
fetch("https://example.com", retries=1) # ✅ 签名完整保留
fetch(123) # ❌ Pyrefly 报错:int 不是 str
装饰器不丢签名、报错落在调用点而不是装饰器内部——这些细节决定了类型检查器在真实工程里是"助手"还是"累赘"。
3.4 IDE 集成:一个二进制通吃
Pyrefly 的 LSP 和 CLI 是同一个二进制、同一套求解引擎:
# VS Code:直接装 Pyrefly 扩展(Marketplace / OpenVSX 都有)
# Neovim (nvim-lspconfig)
require("lspconfig").pyrefly.setup({})
# 任何支持 LSP 的编辑器
pyrefly lsp
值得一提的是 PyCharm 2026.1 也已经支持通过 LSP 接入 Pyrefly 作为类型引擎——JetBrains 愿意给第三方类型检查器开口子,侧面说明了这套 LSP 生态的成熟。
IDE 场景里 Pyrefly 的杀手锏是冷启动速度。模块中心架构下,打开项目后各模块并行完成 bindings 阶段,你点开任何一个文件都能立即拿到 hover / 跳转 / 补全,不需要等一个全局的"analyzing..."进度条走完。对于万级文件数的 monorepo,这个体验差距是代际的。
3.5 CI 集成
GitHub Actions 示例:
jobs:
typecheck:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v5
- run: uv pip install --system pyrefly
- run: pyrefly check --output-format=github
--output-format=github 会输出 GitHub Annotations 格式,错误直接标注在 PR 的 diff 行上。由于单次全量检查通常在秒级完成,你甚至不需要为它设计缓存策略——这是"快到不需要增量"的另一种表述。
四、性能分析:为什么它能做到 180 万行/秒
把 Pyrefly 的性能来源拆开,大致是四层叠加:
第一层:Rust 基线优势。 无 GC 停顿、紧凑内存布局、单态化泛型。相对 Python 实现的 mypy 有 10-50 倍的基线差距,相对 TypeScript 写的 pyright 也有数倍优势(Node 的 JIT 很强,但对象头开销和 GC 压力在大堆场景吃亏明显)。
第二层:模块级并行。 bindings 阶段完全并行,solutions 阶段以模块为任务粒度用工作窃取调度。16 核机器上接近线性加速。这是 OCaml 版 Pyre 用多进程共享内存苦苦模拟的东西,在 Rust + rayon 下变成了几行代码。
第三层:数据结构抠细节。 类型对象大量使用 interning(驻留),int、str | None 这类高频类型全局只存一份,比较退化为指针比较;小集合用 SmallVec 避免堆分配;AST 解析用的是 Ruff 家的 parser(Pyrefly 直接复用了 ruff 的 Python 解析器 crate,开源协作的典范)。
第四层:接口指纹剪枝。 前面提过的增量策略——改函数体不改签名,下游零重算。在 IDE 高频编辑场景,绝大多数按键触发的重检查被剪枝到只剩当前模块。
给一个粗略的横向感受(不同项目差异很大,仅供方向参考):在一个 30 万行的中大型代码库上,mypy 冷跑数分钟、热跑数十秒;pyright 冷跑约一分钟;Pyrefly 冷跑通常在个位数秒。当检查快过 git status,它就能进入你的保存钩子、pre-commit、甚至每一次按键。
五、横向对比:Pyrefly vs mypy vs pyright vs ty
| 维度 | Pyrefly | mypy | pyright | ty (Astral) |
|---|---|---|---|---|
| 实现语言 | Rust | Python | TypeScript | Rust |
| 定位 | 检查器+LSP 一体 | 检查器为主 | LSP 为主(Pylance) | 检查器+LSP 一体 |
| 速度 | 极快 | 慢 | 快 | 极快 |
| 推断哲学 | 激进推断无注解代码 | 保守,未注解默认不查 | 较激进 | 渐进保证(gradual guarantee) |
| 生产成熟度 | Meta 内部大规模使用 | 生态标杆、插件多 | VS Code 事实标准 | 仍在快速迭代 |
| 存量迁移 | 自动迁移 mypy/pyright 配置 | — | — | 部分兼容 |
几个选型建议,直接给结论:
- 新项目 + 追求极致开发体验:Pyrefly 或 ty 二选一。Pyrefly 更成熟(Meta 内部背书 + v1.x 版本号),ty 的类型理论设计更学院派(严格遵循 gradual typing 的可靠性原则)。
- 存量 mypy 项目:如果 mypy 速度已经成为瓶颈,Pyrefly 的配置自动迁移 +
--suppress-errors存量冻结是目前迁移成本最低的路径。但注意 mypy 插件(如 SQLAlchemy、Django 插件)没有对应物,重度依赖插件的项目先别动。 - VS Code 重度用户:Pylance 依然是补全体验的天花板(微软私有的模型加持),可以 Pylance 管 IDE、Pyrefly 管 CI——但这又回到了双引擎不一致的老问题,权衡取舍。
- 观望者:类型检查器和格式化工具不同,切换是可逆的、低风险的(它不改你的代码)。在 CI 里并行跑两个检查器观察一个月,用数据做决定。
六、值得借鉴的工程思想
跳出类型检查这个具体领域,Pyrefly 有几个设计决策对所有做开发者工具的人都有参考价值:
1. "以内存换速度"要说出口。 很多工具在内存和速度之间和稀泥,结果两头不讨好。Pyrefly 在架构文档里白纸黑字写明取舍方向,后续所有优化决策都有了准绳。工程上最贵的不是某个具体选择,而是摇摆。
2. 复用生态而不是重造轮子。 Pyrefly 直接用 ruff 的 parser crate。同赛道竞争者(Astral 的 ty 和 ruff 同门)的开源组件照用不误——代码没有立场,质量就是一切。
3. CLI 与 LSP 同源。 "CI 报的错和 IDE 报的错必须一字不差",这个约束看起来朴素,却是开发者信任的基石。任何 lint/检查类工具,双实现就是双倍的信任成本。
4. 为渐进采纳设计逃生舱。 --suppress-errors / --remove-unused-ignores 这对命令,体现的是对存量代码现实的尊重。工具再好,无法在遗留代码库上落地就是零。
七、总结与展望
Pyrefly 是 Meta 十年 Python 类型检查经验(Pyre 时代踩过的所有坑)的一次 Rust 重铸。它最有价值的部分不是"快"这个结果,而是达成快的路径:模块中心的三阶段求解架构、Type::Var 占位符处理递归、接口指纹增量剪枝——这套设计让并行和增量从"艰难的优化"变成了"架构的自然推论"。
往前看,Python 类型检查的战局大概率是 Rust 双雄(Pyrefly vs ty)逐步蚕食 mypy 的 CI 份额、挑战 pyright 的 IDE 份额。而对普通开发者来说,这场竞争的最大红利是:类型检查正在变得快到"无感",快到可以放进每一次保存、每一次按键。当反馈回路缩短到毫秒级,类型注解才真正从"文档"变成了"实时护栏"。
如果你的团队还在忍受分钟级的 mypy,或者忍受 CI 和 IDE 各说各话的类型报错——花一个下午试试 pip install pyrefly && pyrefly init,大概率会回不去。