编程 Pyrefly 深度实战:Meta 用 Rust 重写 Python 类型检查器,从急切推断到每秒 180 万行的架构拆解

2026-07-27 04:44:43 +0800 CST views 7

为什么这次轮到 Python 类型检查器被 Rust 重写?

2026 年 7 月,Meta 的 Pyrefly 又一次爬上了 GitHub Trending。这个项目的一句话简介很低调——"A fast type checker and language server for Python"——但它背后的故事一点都不低调:Meta 把自家用了七年的 OCaml 类型检查器 Pyre 整个扔掉,用 Rust 从零重写了一个,目标是给 Instagram 数千万行的 Python 代码做全量类型检查,并且要快到"每敲一个键都能重新检查一遍"。

如果你关注工具链圈子,会发现一个明显的模式:TypeScript 编译器用 Go 重写拿到 10 倍提速,Vite 的打包内核换成了 Rust 的 Rolldown,Python 的 linter 被 Ruff 一统江湖。现在,轮到 Python 类型检查器了。而且这次是"双龙会"——Meta 的 Pyrefly 和 Astral(Ruff/uv 的作者)的 ty 几乎同期开源,两个 Rust 写的类型检查器正面对决。

这篇文章我会从一个一线程序员的视角,把 Pyrefly 拆开讲清楚:

  • 为什么 mypy 和 Pyright 会遇到性能天花板
  • Pyrefly 的三步架构:导出求解 → 绑定生成 → 约束求解
  • 它和 Astral ty 在设计哲学上的根本分歧(急切推断 vs 渐进保证)
  • 手把手实战:从安装、配置到 CI 集成、从 mypy/Pyright 迁移
  • 大型代码库上的性能调优和踩坑清单

全文比较长,建议先收藏。代码示例都可以直接跑。


一、背景:Python 类型检查的"史前史"与性能困局

1.1 四代类型检查器

Python 类型检查工具的演化,基本可以按"实现语言"划成三代半:

工具出品方实现语言首发年份现状
mypy社区/DropboxPython (mypyc 编译)2012事实标准,速度慢
pytypeGooglePython20152025 年宣布停止维护
PyreMetaOCaml2018被 Pyrefly 取代
PyrightMicrosoftTypeScript2019Pylance 的内核,IDE 王者
PyreflyMetaRust2025 开源本文主角
tyAstralRust2025 开源主要竞品

注意两个关键节点:Google 的 pytype 已经宣布退役,团队直接建议用户迁移到新一代工具;Meta 的 Pyre 也停止了功能开发。老一代检查器的退场不是因为功能不行,而是因为架构撑不住现代 Python 代码库的规模了。

1.2 mypy 慢在哪?不只是"Python 写的"

很多人对 mypy 慢的理解停留在"它是 Python 写的所以慢"。这只对了一半。mypy 其实用 mypyc 把自己编译成了 C 扩展,纯解释执行的开销已经砍掉不少。真正的瓶颈在架构:

第一,全局状态与单线程模型。 mypy 的核心分析流程围绕一个全局的 BuildManager 展开,模块间类型信息高度耦合,导致并行化改造极其困难。十几年的存量代码让"把检查任务拆到多核"变成了不可能完成的重构。在一台 16 核的开发机上,mypy 只能吃满一个核,剩下 15 个核围观。

第二,增量缓存的粒度太粗。 mypy 的增量模式以模块为单位缓存 .mypy_cache,一旦你改了一个被大量下游依赖的基础模块(比如公司内部的 common/types.py),整条依赖链全部失效,冷检查动辄几分钟。

第三,daemon 模式治标不治本。 dmypy 通过常驻进程避免重复启动开销,但它对内存的占用随代码库规模线性增长,而且遇到某些代码模式会静默地给出与非 daemon 模式不一致的结果——这是很多团队在 CI 里不敢用 dmypy 的原因。

Pyright 的情况好一些,TypeScript + Node.js 的组合比 Python 快了一个量级,加上微软持续的性能优化,它在 IDE 场景里体验一直不错。但 Node.js 单线程的老问题同样存在,超大代码库上 Pylance 的内存占用和索引时间依然是 VS Code 用户的经典抱怨。

1.3 Meta 的特殊需求:Instagram 量级的代码库

Meta 内部的 Python 代码库(以 Instagram 为代表)是千万行级别的。他们对类型检查器的需求非常极端:

  1. 全量检查必须在 CI 可接受的时间内完成——不能是十分钟,最好是十秒。
  2. IDE 里必须做到按键级响应——你敲下一个字符,类型错误的红线要立刻出现或消失。
  3. 必须能对没有标注的代码做推断——存量代码不可能全部补上类型标注,检查器得自己推。

Pyre 用 OCaml 已经比 mypy 快很多,但 OCaml 的并行支持(在 Multicore OCaml 落地之前)一直是痛点,而且 OCaml 的人才池太小,招人困难是真实的工程约束。于是 2023 年前后,Meta 内部启动了 Rust 重写计划,2025 年 5 月在 PyCon 上正式开源,命名 Pyrefly——取"萤火虫"(firefly)之意,官方吉祥物就是一只萤火虫。

按官方公布的数据,Pyrefly 的检查吞吐量达到了 每秒 180 万行代码 的量级。作为对比,mypy 在同类硬件上大约是每秒几万行。这不是 10% 的优化,是两个数量级的差距。


二、核心架构:Pyrefly 是怎么做到快的

Pyrefly 的速度不是单纯靠"Rust 快"堆出来的,架构设计上有几个关键决策值得每个做工具链的程序员学习。

2.1 复用 Ruff 的解析器:不重复造轮子

Pyrefly 没有自己写 Python 解析器,而是直接使用了 Ruff 项目的 ruff_python_ast / ruff_python_parser crate。这是个很有意思的信号:Rust Python 工具链生态已经开始出现"公共地基"。Ruff 的解析器经过了海量真实代码的锤炼,错误恢复能力强(对 IDE 场景至关重要——用户敲一半的代码是语法不完整的),性能也被打磨到了极致。

Meta 和 Astral 是竞争对手(Pyrefly vs ty),但 Pyrefly 依然用 Ruff 的解析器。这种"竞品之间共享底层组件"的格局,在 JS 工具链圈(大家都用自己的 parser)反而少见。

2.2 三步流水线:导出求解 → 绑定生成 → 约束求解

Pyrefly 的类型检查流程可以拆成三个阶段,理解这个流水线是理解它一切设计的钥匙:

第一步:模块导出分析(Exports)。

对每个模块,先算出"它对外导出了什么"。这一步要处理 Python 最讨厌的特性之一——from module import * 的传递性。import * 的语义依赖于目标模块的 __all__ 或全部公开符号,而目标模块可能又 import * 了别的模块,形成传递链。Pyrefly 把这一步单独提出来做,好处是:一个模块的导出集合只依赖源码本身和它 import 的模块的导出集合,不依赖任何类型信息。这一步很轻,可以大规模并行。

第二步:绑定生成(Bindings)。

把每个模块的语句转换成"绑定"(binding)。你可以把绑定理解为"某个名字在某个位置的定义及其类型的计算规则"。比如这段代码:

x = 42          # 绑定1: x -> Literal[42]
y = x + 1       # 绑定2: y -> 依赖绑定1的求解结果
def f() -> int:
    return y    # 绑定3: f 的返回 -> 依赖绑定2

每条语句、每个作用域都会产生绑定。控制流(if/while/try)会产生"分支合并"型的绑定,比如:

if cond:
    a = "hello"
else:
    a = 42
reveal_type(a)  # str | int —— 由 phi 型绑定合并两个分支

熟悉编译器的同学会立刻反应过来:这就是 SSA(静态单赋值)里的 phi 节点思想。Pyrefly 把 Python 的流敏感类型分析(flow-sensitive analysis)建模成了绑定图上的数据流问题。

第三步:绑定求解(Solving)。

对绑定图做求解,算出每个绑定的具体类型。遇到循环依赖(Python 里递归函数、相互递归的类定义非常常见)怎么办?Pyrefly 的答案是引入 Type::Var 占位符:先插一个类型变量进去让求解继续推进,等信息足够了再回填。这是 Hindley-Milner 类型推断体系里的经典技术,但用在 Python 这种有子类型、有渐进类型的语言上需要大量工程妥协。

2.3 模块为中心,而非符号为中心

这是 Pyrefly 和 ty 最大的架构分歧点,值得展开说。

Pyrefly 的求解单位是模块:检查模块 A 时,把 A 的所有绑定一次性求解完,结果整体缓存。如果模块 B 依赖 A,B 直接拿 A 的求解结果。这个设计的优点是:

  • 架构简单:不需要复杂的依赖追踪框架,模块级 DAG 调度就够了
  • 并行天然友好:不同模块的求解任务几乎无共享状态,扔进线程池就能跑满所有核
  • 吞吐极高:批量处理对缓存局部性友好,这是"每秒 180 万行"的底气

代价是增量粒度粗:你改了模块 A 里的一个函数体,A 整个模块要重算(下游模块如果 A 的导出接口没变则可以不动)。Pyrefly 赌的是:单模块重算足够快(毫秒级),粗粒度增量在体感上和细粒度没区别。

而 Astral 的 ty 走的是另一条路:基于 Salsa 框架(rust-analyzer 同款)做细粒度增量计算,依赖追踪精确到查询级别,改一行代码只重算真正受影响的查询。理论上更优雅,代价是框架复杂度高、单次全量检查的吞吐略低。

这是典型的"暴力美学 vs 精密仪器"之争。我个人的判断:在 Rust 的性能基数上,Pyrefly 的粗粒度方案在 95% 的场景下体感不会输,因为当单模块检查只要几毫秒时,用户根本感知不到你是重算了整个模块还是只重算了一个函数。工程上简单的方案往往活得更久。

2.4 急切推断 vs 渐进保证:两种哲学

第二个关键分歧是对未标注代码的态度。

Pyrefly 选择急切推断(eager inference):即使函数没写返回值标注,也主动推断出具体类型:

def make_config():          # 没有返回标注
    return {"debug": True, "retries": 3}

cfg = make_config()
reveal_type(cfg)            # Pyrefly: dict[str, bool | int]
cfg.uppercase()             # Pyrefly 直接报错:dict 没有 uppercase 方法

而 ty 提出了"渐进保证"(gradual guarantee)原则:给未标注的代码更多宽容,倾向于推断为更宽松的类型,避免在存量无标注代码库上炸出海量误报。同样的代码,ty 早期版本会更保守地对待。

两种哲学各有道理:

  • 急切推断抓 bug 能力强,但在老代码库上初次接入时报错数量可能吓人
  • 渐进保证接入友好,但可能漏掉一些真实 bug

Meta 的选择反映了它的场景:内部代码库有强制的类型标注文化,急切推断的误报可控,换来的是更强的检出率。如果你的项目是十年老库、类型标注覆盖率不到 20%,接入时要有心理准备(后文有渐进接入方案)。

2.5 为什么 Rust,而不是继续 OCaml 或者换 Go?

TypeScript 编译器重写选了 Go,Pyrefly 选了 Rust,这两个选择经常被拿来对比。我的理解:

  • TS 编译器重写(tsgo)的首要目标是"移植"——保持行为 100% 兼容,Go 的 GC 和结构体模型与原 TS 代码的写法最接近,移植成本最低。
  • Pyrefly 是"重新设计"——不背历史包袱,追求极限性能。类型检查器的核心数据结构(类型的 interning、绑定图、arena 分配)在无 GC 的 Rust 里可以做得非常紧凑。类型求解是典型的"海量小对象 + 复杂生命周期"场景,GC 语言的停顿和内存膨胀在千万行代码库上会被放大。
  • 另外一个现实因素:Rust 的前端工具链生态已经成型。Ruff 的 parser 直接拿来用,这在 Go 生态里没有对等物。

三、上手实战:从安装到 CI

讲完原理,来点实际的。以下所有命令在 macOS / Linux 上验证思路一致。

3.1 安装与第一次检查

# pip 安装(推荐用 uv,快)
uv pip install pyrefly
# 或者
pip install pyrefly

# 检查整个项目
pyrefly check

# 检查单个文件
pyrefly check src/main.py

来一个能体现推断能力的例子:

# demo.py
from dataclasses import dataclass

@dataclass
class User:
    name: str
    age: int

def load_users(raw: list[dict]):
    # 注意:没有写返回类型标注
    return [User(name=d["name"], age=d["age"]) for d in raw]

users = load_users([{"name": "alice", "age": 30}])

# Pyrefly 推断出 users: list[User],下面这行会被抓出来
print(users[0].email)   # error: Object of class `User` has no attribute `email`

运行 pyrefly check demo.py,你会得到清晰的错误定位。注意 load_users 完全没有返回标注,Pyrefly 依然推断出了 list[User]——这就是急切推断的价值。

3.2 配置:pyproject.toml 一等公民

Pyrefly 的配置直接写在 pyproject.toml 里(也支持独立的 pyrefly.toml):

[tool.pyrefly]
# 要检查的代码
project-includes = ["src/**/*.py", "tools/**/*.py"]
project-excludes = ["**/migrations/**", "**/*_pb2.py"]

# 指定 Python 版本语义(影响 sys.version_info 分支裁剪)
python-version = "3.12"

# 第三方包的搜索路径,通常自动从当前环境探测
# site-package-path = [".venv/lib/python3.12/site-packages"]

[tool.pyrefly.errors]
# 按错误类别开关
import-error = false        # 老项目先关掉 import 解析错误,逐步治理
bad-assignment = true
missing-attribute = true

几个实用细节:

  • python-version 会影响 if sys.version_info >= (3, 12): 这类代码的分支裁剪,跨版本库一定要设对。
  • import-error 单独开关的设计非常贴心:接入初期最大的噪音源就是各种解析不到的第三方包,先关掉它可以让你专注于真实的类型错误。
  • 行内抑制用 # pyrefly: ignore,等价于 mypy 的 # type: ignore 但更精确:
result = legacy_api()  # pyrefly: ignore  # 老接口返回 Any,暂时豁免

3.3 IDE 集成:真正的杀手锏

Pyrefly 不只是 CLI 检查器,它同时是一个完整的 LSP 语言服务器——这是它和 mypy 的本质区别(mypy 从来不是为 IDE 设计的)。

VS Code 里直接装官方扩展 pyrefly,装完建议在设置里让它接管类型检查(和 Pylance 二选一,同时开会有重复报错):

// .vscode/settings.json
{
  "python.languageServer": "None",   // 关掉 Pylance 的类型分析
  "pyrefly.displayTypeErrors": true
}

Neovim 用户用 nvim-lspconfig:

require('lspconfig').pyrefly.setup{
  cmd = { "pyrefly", "lsp" },
}

体验上的差别在大型项目里最明显:Pylance 在 5000+ 文件的 monorepo 里冷启动索引常常要一两分钟,Pyrefly 的目标是秒级就绪、按键级反馈。悬停显示推断类型、跳转定义、自动补全这些都已支持。

3.4 CI 集成

GitHub Actions 示例:

name: typecheck
on: [push, pull_request]

jobs:
  pyrefly:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: astral-sh/setup-uv@v4
      - run: uv pip install --system pyrefly
      - run: pyrefly check --output-format=github  # 错误直接标注到 PR diff 上

由于 Pyrefly 全量检查足够快,你可以直接放弃增量缓存的心智负担,每次 CI 全量跑——这本身就是新一代工具带来的工作流简化:当全量足够快,增量就是不必要的复杂度。

3.5 从 mypy / Pyright 迁移

Pyrefly 内置了迁移工具,能自动转换配置:

# 自动读取 mypy.ini / pyproject.toml [tool.mypy] 并生成 pyrefly 配置
pyrefly init

# 存量错误太多?一键打上抑制注释,先让 CI 变绿,再逐步清理
pyrefly check --suppress-errors

--suppress-errors 是我最欣赏的工程化设计:它会给所有现存错误自动插入 # pyrefly: ignore 注释。这套"先冻结存量、再阻止增量"的打法,是大型老项目接入静态检查的唯一现实路径——和 Ruff 的 --add-noqa 一脉相承。清理时反向操作:

# 移除已经不再需要的 ignore 注释
pyrefly check --remove-unused-ignores

迁移时的语义差异要心里有数:

  1. Pyrefly 推断更激进,mypy 下沉默的代码可能会冒出新错误——多数是真问题,别急着抑制,先看一眼。
  2. # type: ignore 注释 Pyrefly 也认,但建议逐步换成 # pyrefly: ignore,后者支持指定错误码,粒度更细。
  3. mypy 插件生态(如 pydantic.mypy 插件)不兼容。好消息是 Pyrefly 对 dataclass、Pydantic v2、attrs 这些主流库的支持是内建的,多数场景不再需要插件。

四、深入类型系统:几个值得玩味的推断案例

4.1 流敏感收窄(Narrowing)

def process(value: str | int | None):
    if value is None:
        return "empty"
    # 此处 value: str | int
    if isinstance(value, str):
        return value.upper()      # value: str
    return value + 1              # value: int,收窄完成

这是各家检查器的基本功,但 Pyrefly 在跨函数收窄上做了不少工作,支持 TypeIs / TypeGuard 自定义收窄函数:

from typing import TypeIs

def is_str_list(v: list[object]) -> TypeIs[list[str]]:
    return all(isinstance(x, str) for x in v)

def handle(items: list[object]):
    if is_str_list(items):
        # items 在这里被收窄为 list[str]
        print(", ".join(items))   # OK,join 接受 Iterable[str]

4.2 泛型与新语法(PEP 695)

Python 3.12 的新泛型语法 Pyrefly 支持得很完整:

class Stack[T]:
    def __init__(self) -> None:
        self._items: list[T] = []

    def push(self, item: T) -> None:
        self._items.append(item)

    def pop(self) -> T:
        return self._items.pop()

s = Stack[int]()
s.push(42)
s.push("oops")   # error: Argument `str` is not assignable to parameter of type `int`

更狠的是无标注场景下的泛型实例化推断:

s2 = Stack()      # 此刻 T 未定
s2.push(1)
s2.push(2.5)
reveal_type(s2)   # Stack[int | float] —— 从 push 调用反向推断出 T

这种"从使用处反推类型参数"的能力来自求解器的 Type::Var 机制——Stack() 构造时 T 是个类型变量占位,后续的 push 调用不断给它添加约束,最终合并求解。mypy 在这种场景下通常直接放弃,给你一个 Stack[Any]Any 是类型系统的黑洞:一旦出现,它污染所有下游表达式。急切推断堵住的正是这些 Any 泄漏点。

4.3 重载与字面量类型

from typing import overload, Literal

@overload
def fetch(fmt: Literal["json"]) -> dict: ...
@overload
def fetch(fmt: Literal["text"]) -> str: ...

def fetch(fmt: str) -> dict | str:
    ...

x = fetch("json")
reveal_type(x)    # dict —— 通过字面量参数精确选择重载

配合流敏感分析,即使 fmt 是变量,只要控制流上能确定其字面量值,重载选择依然精确。这在写 SDK/客户端库时极其有用。


五、性能实测思路与调优清单

5.1 怎么公平地做基准测试

给你的项目做检查器选型时,建议这样测:

# 1. 冷检查(清缓存后全量)
time pyrefly check
time mypy . --no-incremental

# 2. 热检查(改一个叶子文件后)
touch src/utils/helpers.py && time pyrefly check
touch src/utils/helpers.py && time mypy .

# 3. 改基础模块(依赖链雪崩场景)
touch src/common/types.py && time pyrefly check
touch src/common/types.py && time mypy .

社区多个中型项目(10 万~50 万行)的公开测试大致呈现这个量级关系:mypy 冷检查分钟级,Pyright 十秒级,Pyrefly / ty 秒级甚至亚秒级。场景 3(基础模块改动)最能拉开差距——mypy 的缓存雪崩在这里暴露无遗,而 Pyrefly 靠"全量本来就快"直接抹平了这个问题。

5.2 调优清单

实际接入后如果觉得不够快,按这个顺序排查:

  1. 排除生成代码。protobuf 生成的 *_pb2.py、ORM migration、vendor 目录是常见的检查时间黑洞,用 project-excludes 剔掉。
  2. 检查 site-packages 探测。如果 Pyrefly 把整个 conda 环境的几千个包都扫了,首次启动会明显变慢。确保用项目级 venv,或显式配置 site-package-path
  3. 控制 stub 质量。第三方包缺 stub 时会回退到源码分析,个别"动态魔法"重的库(比如老版本 SQLAlchemy)会拖慢求解。装 types-* stub 包或写最小 stub。
  4. CI 上别开 debug 日志--verbose 的 I/O 开销在大项目上可观。
  5. 内存不是问题就别管。Pyrefly 用内存换速度(类型 interning 表、模块缓存全在内存),千万行级代码库大约吃几个 GB,CI runner 给够内存即可。

5.3 已知的坑

老实交代目前(2026 年中)的不足,别只听我吹:

  • 插件机制缺失:mypy 插件重度用户(自定义 ORM 类型魔法)暂时无法平替,只能等内建支持或改代码。
  • 个别高级类型特性的边缘行为与 mypy 不一致:比如复杂的 ParamSpec 转发链、递归 TypedDict,两边都可能有 bug,遇到不一致先查两家的 issue 区。
  • 急切推断的误报:对返回类型高度动态的代码(大量 getattr、运行时构造类),推断结果可能过于具体,需要手动标注 Any 或抑制。
  • 生态位竞争的不确定性:Pyrefly 和 ty 都在快速迭代,语义细节都还在变。锁版本、升级前跑全量对比,是这个阶段用新工具的基本素养。

六、Pyrefly vs ty:怎么选?

把前文的分析收敛成一张决策表:

维度Pyrefly (Meta)ty (Astral)
增量策略模块级重算,靠吞吐取胜Salsa 细粒度增量
未标注代码急切推断,检出率优先渐进保证,兼容性优先
背后驱动Instagram 千万行库的实战需求Ruff/uv 工具链生态的版图
适合谁类型标注覆盖率高、追求最大检出率的团队存量老代码多、想和 Ruff/uv 全家桶统一的团队

我的建议很实用主义:

  1. 新项目:两个都试,跑一遍你的代码库,看误报率和错误信息质量,选顺眼的。反正切换成本极低(都读标准 typing 语义,没有配置绑架)。
  2. mypy 老用户:Pyrefly 的 pyrefly init + --suppress-errors 迁移路径更成熟,先试它。
  3. 已经全家桶 Ruff + uv:等 ty 稳定后统一到 Astral 系,工具链一致性有长期价值。
  4. 无论选谁,都别再新开 mypy 项目了。这话说得重,但 2026 年了,让 CI 里的类型检查从分钟级降到秒级,是你能给团队做的性价比最高的基建升级之一。

七、总结:工具链的"Rust 化"是趋势还是泡沫?

回到开头的问题:为什么 Python 工具链一个接一个被 Rust 重写?

我的答案是:这不是语言崇拜,是"开发内环"(inner loop)性能需求的必然结果。当 AI 辅助编程让代码产量暴涨、monorepo 让代码库规模暴涨之后,"保存后等 30 秒看类型错误"的工作流彻底不可接受了。检查器必须快到融入按键反馈的节奏里,这个性能要求只有系统级语言 + 精心设计的架构能满足。

Pyrefly 给我们展示的不只是一个快的类型检查器,还有几条值得反复咀嚼的工程经验:

  1. 当全量足够快,增量就是多余的复杂度——性能优化的最高境界是让整个缓存层变得不必要。
  2. 复用生态地基(Ruff parser)比什么都自己写更聪明,哪怕地基来自竞争对手。
  3. 迁移工具的完成度决定采用率--suppress-errors 这种"先冻结存量"的设计,比任何性能数字都更能推动老项目迁移。
  4. 架构简单是长期竞争力。模块级并行虽然"粗暴",但它好维护、好扩展、不容易出玄学 bug。

Python 类型检查的战国时代才刚开始。mypy 不会立刻消失(它依然是 typing 规范的参考实现),但增长曲线已经属于 Rust 双雄了。如果你的团队还在忍受分钟级的类型检查,这个周末花两个小时,跑一次 pyrefly check,你大概率回不去了。

有问题欢迎留言讨论,我会挑有代表性的坑写后续实战篇。

推荐文章

PHP 压缩包脚本功能说明
2024-11-19 03:35:29 +0800 CST
Rust 并发执行异步操作
2024-11-19 08:16:42 +0800 CST
Vue 3 中的 Fragments 是什么?
2024-11-17 17:05:46 +0800 CST
程序员茄子在线接单