我见过太多把 Neovim 折腾得花里花哨、状态栏文件树配色换了一轮又一轮,但打开 Python 文件却依然只有语法高亮的朋友。问题根本不在插件多少,而在 LSP——语言服务器协议。编辑器本身只负责编辑,代码补全、跳转定义、类型诊断、重命名这些"智能"行为,全部由独立进程里的语言服务器完成。这次我花了一个晚上,把 Neovim 的 Python LSP 配置彻底捋顺,路线同时覆盖 pyright 和 pyrefly 两个服务器。如果你已经在用 Neovim,却总觉得 Python 开发差点意思,或者刚想从 VS Code 迁过来,这篇可以直接当配置手册用。
1. 为什么我最后选了 pyright 和 pyrefly 来救 Python 开发体验
1.1 先弄清楚:Neovim 的"智能"到底是谁提供的
很多初学 Neovim 的人会有一个误解,以为装了 treesitter 就拥有了代码分析能力。实际上 treesitter 做的是语法高亮和结构解析,它让编辑器知道"这个 token 是变量还是函数",但它不会告诉你"这个变量类型不匹配""这个函数有三个调用方分别在哪"。这些能力来自 LSP 协议里的文本同步、诊断推送、跳转请求。
LSP 的全称是 Language Server Protocol,本质是一条 JSON-RPC 通信通道。语言服务器是一个独立进程,编辑器把当前文件内容、光标位置通过协议告诉它,它再把补全列表、诊断、定义位置返回给编辑器。Neovim 从 0.5 开始内置了 LSP 客户端,意味着不需要任何插件就能跑起一套完整的语言服务流程,插件只是帮你省去繁琐的默认参数配置。
1.2 市面上的 Python LSP,各有各的脾气
我简单扫了一圈当前主流的 Python LSP,列个表也许更直观:
| 服务器 | 开发方 | 实现语言 | 特点 | 适合场景 |
|---|---|---|---|---|
| pyright | Microsoft | TypeScript | 生态最成熟,文档全,类型推断强 | 大多数项目的首选,社区教程多 |
| pyrefly | Meta | Rust | 性能激进,并行检查,年轻但发展快 | 大型仓库、对保存后诊断速度敏感的人 |
| basedpyright | 社区维护的 pyright 分支 | TypeScript | 修复了 pyright 部分顽固问题,加了 stricter 选项 | 需要更严厉 lint 的团队 |
| jedi-language-server | 社区(基于 jedi) | Python | 支持 Python 2,轻量 | 老项目兼容场景 |
| python-lsp-server | Palantir 接手维护 | Python | 前身是 pyls,可插拔 | 轻量配置,但补全能力相对弱 |
我挑 pyright 和 pyrefly 来讲,不是别的不好,而是这两个刚好代表了两条最典型的路线:pyright 是"稳",pyrefly 是"新"。而且它们都遵循标准 LSP 协议,在 Neovim 里的接入方式几乎一样,切换成本极低。你完全可以两个都装上,按项目自由切换。
1.3 为什么我会同时配两个而不是只锁死一个
原因很简单:不同项目的痛点不一样。给一个 2000 行的小工具做类型检查,pyright 的体验已经足够顺滑,报错信息也更细致,有的放矢。但到了十万行以上的项目,每次保存后等诊断结果的那几秒就非常煎熬,这时候 pyrefly 基于 Rust 的并行检查优势就很明显。两个服务器同时配置,就像手里同时有电钻和螺丝刀,不同场景换着用,谁也不碍着谁。
提示:Neovim 里的 LSP 配置并不绑定某个服务器。只要服务器实现了 LSP 协议,你只需告诉 Neovim"启动命令是什么、项目根目录怎么判断、哪些文件类型启用",剩下的补全、跳转、诊断都是同一套客户端机制。这就是为什么切换 pyright 和 pyrefly 只是几行配置的差别。
2. Neovim 侧接入 LSP 前,先把三条工作链理顺
2.1 三个组件各司其职
在写配置之前,有必要把 Neovim 侧参与 LSP 的三个部分拆清楚,否则你会遇到"明明配置了却没反应"的情况。
第一是内置客户端,也就是vim.lsp.*这组 API。它负责和语言服务器进程建立连接,管理 buffer 的同步、诊断的回调、补全请求的收发。从 Neovim 0.5 开始就有了,但越新版本越稳定,建议你用 0.10 及以上。
第二是nvim-lspconfig插件。它本身不提供语言能力,只是给每个语言服务器准备了一套默认参数,比如 pyright 的启动命令、文件类型、root_dir 判断方式。你只要写require('lspconfig').pyright.setup({ ... }),它会在你打开.py文件时自动用这些参数拉起服务器。
第三是cmp-nvim-lsp。如果你在用 nvim-cmp 做补全菜单,这个插件能生成一套标准的 capabilities(客户端能力声明),告诉语言服务器"我支持 snippet、我支持补全项的 resolve"。没有这一步,补全列表可能弹不出来,或者弹出的只有裸词,没有函数签名和文档。
2.2 一段最基础的 LSP 启动逻辑
其实不管 lspconfig 包了多少层,底层做的事情就这几行:
vim.lsp.start({ name = 'pyright', cmd = { 'pyright-langserver', '--stdio' }, root_dir = vim.fs.root(0, { 'pyproject.toml', 'setup.py', '.git' }), })vim.lsp.start拿到cmd和root_dir之后,会去启动外部进程,建立 stdio 通信,然后把这个 buffer attach 上去。lspconfig 做的,就是替你把 pyright 的默认启动命令和 root_dir 判定规则填好。所以当你遇到"lspconfig 不认识 pyrefly"这种问题时,完全可以直接vim.lsp.start手动接入,远没有想象中复杂。
2.3 环境准备清单
在动手配置之前,先把环境理顺,避免后面排查时人麻:
- Neovim 版本:
nvim --version,建议 0.10 以上。0.9 也能跑,但部分 API 和 lspconfig 新版本不兼容。 - Node.js / npm:安装 pyright 时需要,建议 Node 18+。
- Python3 与 pip:安装 pyrefly 和 pyright 的 pip 版时需要。
- nvim-lspconfig:务必保持最新版本。pyrefly 是较新的服务器,老版本 lspconfig 很可能还没有内置它的模块。
2.4 键位绑定:让 LSP 能力真正可用
配好服务器不上键位,等于买了车不点火。我强烈建议在on_attach回调里做 buffer 级别的键位映射,这样每个文件只在自己作用域生效:
local on_attach = function(client, bufnr) local bufopts = { noremap = true, silent = true, buffer = bufnr } vim.keymap.set('n', 'gd', vim.lsp.buf.definition, bufopts) vim.keymap.set('n', 'K', vim.lsp.buf.hover, bufopts) vim.keymap.set('n', 'gr', vim.lsp.buf.references, bufopts) vim.keymap.set('n', '<leader>rn', vim.lsp.buf.rename, bufopts) vim.keymap.set('n', '[d', vim.diagnostic.goto_prev, bufopts) vim.keymap.set('n', ']d', vim.diagnostic.goto_next, bufopts) vim.keymap.set('n', '<leader>ca', vim.lsp.buf.code_action, bufopts) end注意buffer = bufnr这个参数,它把映射限制在当前 buffer 内,避免切换文件后键位串掉。如果你不想手动配这么多,也可以装nvim-lspconfig官方推荐的 keymap 片段,但我的经验是手写一份反而更清晰,想改键位时一目了然。
3. pyright 配置落地:安装、参数、验证一条龙
3.1 两种安装方式,任选其一
pyright 的官方发行方式是 npm 包,装完直接有pyright和pyright-langserver两个命令。第二个才是 LSP 启动时要用的。
npm install -g pyright如果不想碰 npm 全局,也可以走 pipx:
pipx install pyright这里有个非常容易踩的坑:npm 全局安装后,命令可能不在当前 shell 的 PATH 里。尤其用 nvm 或 asdf 管理 Node 版本的机器,npm 全局 bin 目录经常和系统 PATH 脱节。装完先执行pyright --version验证一下,如果提示 command not found,查一下npm root -g返回的路径,把它的上一层bin目录手动加进 PATH。
3.2 lspconfig 里的 pyright setup 完整块
我的init.lua里 pyright 这部分配置如下:
local capabilities = require('cmp_nvim_lsp').default_capabilities() require('lspconfig').pyright.setup({ capabilities = capabilities, on_attach = on_attach, settings = { python = { analysis = { typeCheckingMode = 'basic', diagnosticMode = 'workspace', useLibraryCodeForTypes = true, autoSearchPaths = true, indexing = true, inlayHints = { variableTypes = true, functionReturnTypes = true, parameterNames = 'all', }, }, }, }, })capabilities是补全菜单能否显示 snippet、能否增量同步的关键,on_attach用来挂上一节里那组键位。这两项几乎每个 LSP 都要带,属于通用模板,真正体现差异的是settings。
3.3 每个字段背后的逻辑,别照抄不思考
typeCheckingMode是最重要的开关,它有四个等级:off、basic、standard、strict。basic只报明显的类型错误,适合新项目;strict会强制要求所有函数参数和返回值都有类型注解,没有注解的地方一律报 warning 甚至 error。我的建议是:不要一上来就 strict,尤其老项目,你会被几万行报错淹没。从我实际经验看,basic起步,等项目类型注解覆盖率上来之后再升到standard,性价比最高。
diagnosticMode默认是openFilesOnly,也就是只对当前打开的文件做检查。我改成workspace后,整个项目的错误都会在保存时汇总,跳转诊断非常方便。但代价是首次索引和保存后的诊断延迟变大,如果你的项目超过几万行,建议还是回到openFilesOnly。
useLibraryCodeForTypes决定是否扫描三方库源码来推导类型。开true之后,pandas、numpy 这类重库的补全质量和类型提示会明显变好,代价是首次索引慢一点。autoSearchPaths让 pyright 自动去找项目里的.venv或venv目录,不用手动写解释器路径。
indexing控制全量索引,开true后跳转定义在大项目里更丝滑。inlayHints是内联提示,会在行内显示变量类型推断结果和函数参数名提示,看代码时非常直观,我个人觉得比装各种装饰插件都实用。
3.4 验证配置是否真的生效
配置写完,重启 Neovim,随便打开一个.py文件。先用:LspInfo查看当前 buffer attach 了哪个 client,确认列表里出现pyright。
接着做三件事验证功能:把光标放在某个变量上按K,能弹出 hover 说明服务器正常;按gd跳到定义处说明文本同步正常;故意写一行x = "abc" + 1,看看有没有红色诊断波浪线。如果有诊断但没有可见标记,可能是vim.diagnostic.config没开 virtual_text 和 signs,执行下面这段查看或修改:
vim.diagnostic.config({ virtual_text = true, signs = true, underline = true, })4. pyrefly 切换指南:安装、接入与跨工具差异
4.1 pyrefly 是什么,凭什么值得配
pyrefly 是 Meta 开源的类型检查器,Rust 实现,同时提供 LSP 服务器。它最大的卖点是性能:因为底层是 Rust,且检查采用并行策略,在大型代码库上的诊断速度和全量索引速度都比 pyright 有明显优势。像我们平时写的工程里,一个文件动不动几千行,pyrefly 保存后几乎秒出诊断,这就是我切过去的最直接动力。
4.2 安装和启动验证
pyrefly 走 PyPI 分发,装起来非常简单:
pip install pyrefly # 或者用 pipx 隔离环境 pipx install pyrefly装完执行pyrefly language-server --help,如果能出来参数说明,说明命令可用。它的 LSP 启动命令是pyrefly language-server,后面接--stdio时配置略有区别,但在 Neovim 里通常不需要显式传。
4.3 在 Neovim 中接入:先查 lspconfig 是否认它
较新版本的 nvim-lspconfig 已经内置了 pyrefly 的 server_configuration,你可以直接打开这个文件确认:
~/.local/share/nvim/lazy/lspconfig.nvim/lua/lspconfig/server_configurations/pyrefly.lua如果存在,直接 setup 就能用:
require('lspconfig').pyrefly.setup({ capabilities = capabilities, on_attach = on_attach, })如果找不到这个文件,说明你的 lspconfig 版本太老,或者插件管理器还没更新。这种情况下不用慌,手动接入非常简单,用vim.lsp.start就能拉起:
vim.api.nvim_create_autocmd('FileType', { pattern = 'python', callback = function() local root_dir = vim.fs.root(0, { 'pyproject.toml', 'setup.py', '.git' }) if root_dir then vim.lsp.start({ name = 'pyrefly', cmd = { 'pyrefly', 'language-server' }, root_dir = root_dir, capabilities = capabilities, }) end end, })这段代码相当于手工替 lspconfig 填默认值,完全够用。
4.4 pyrefly 和 pyright 配置体系的差异
pyright 的配置主要通过 LSP 的settings字段传递,写在 Neovim 的 setup 里;pyrefly 则更倾向于项目内声明式配置,也就是在pyproject.toml里写[tool.pyrefly]段落。我的项目里一个典型配置长这样:
[tool.pyrefly] strict = true include = ["src", "tests"] exclude = ["build", "dist", "venv"]你需要留意的是:不要假设 pyright 的所有字段在 pyrefly 里同名生效。比如 typeCheckingMode 这种概念,pyrefly 有自己的表达方式,报错的严肃程度也和 pyright 不完全一致。建议以你安装版本的官方文档为准,因为 pyrefly 还在快速迭代,版本之间配置键名有变动的可能。我踩过最狠的一次就是按网上旧教程写配置,升级后一堆键不认,最后还是翻官方文档解决的。
5. 实际踩坑记录:从"进程没起来"到"补全不弹"的排查链路
5.1 坑一:"pyright: command not found"
现象很明显:启动 Neovim 后发现:LspInfo里面没有 pyright 客户端,命令行敲pyright也提示找不到。大概率是 npm 全局 bin 路径不在 PATH 里。排查命令:
which pyright # 看有没有被识别 npm root -g # 看全局 node_modules 路径 echo $PATH # 看 PATH 里有没有对应 bin 目录解决方式两种:一是在 shell 配置里把 bin 目录加进 PATH;二是不改 PATH,直接用npx pyright --version验证后,把 lspconfig 的 cmd 改成npx pyright-langserver --stdio。后者改 lspconfig 默认 cmd 的方式不够优雅,但胜在不影响系统环境。
5.2 坑二:进程起来了,补全菜单就是不弹
这个坑我帮别人排查过好几次。:LspInfo明明显示 pyright 已连接,K能用、gd能用,偏偏补全菜单不出现。根源多半是 capabilities 没设置对。如果你直接用了vim.lsp.protocol.make_client_capabilities()这种裸生成的 capability,语言服务器不会把 snippet 补全等能力返回给你,cmp 菜单就只显示纯文本候选甚至不弹。
最省事的解决方式:
local capabilities = require('cmp_nvim_lsp').default_capabilities()cmp_nvim_lsp会把 nvim-cmp 支持的所有能力细节填进 capabilities,这样服务器返回的补全项才完整。如果你还没装cmp_nvim_lsp,先去装它,别手写。
5.3 坑三:pyrefly 连上了,但啥也不返回
如果 pyrefly 已经被 attach,但 hover、诊断、补全全部没反应,第一件事不是改配置,而是看日志。用:LspLog打开 LSP 日志,或者直接看~/.local/state/nvim/lsp.log。
日志里最常见的错误是"初始化参数解析失败"或"找不到项目配置",这两个都可以归结到 root_dir 上。pyrefly 启动后会以 root_dir 为基准去加载 pyproject.toml,如果你在用vim.lsp.start手动配置时没传 root_dir,或者 root_dir 指向了错误的目录,服务器等于在一个空房子里干活,自然不会有结果。确认方式就是检查:LspInfo里显示的 root directory 是不是你的项目根目录。
另外,如果你同时装了 pyrefly 和 pyright,并且两个都通过 lspconfig 注册过,同一个 Python 文件可能会被两个 server 同时接管,这时候也会出现"补全时好时坏"的诡异情况。我建议默认只注册一个,另一个保留手动启动能力,避免双 client 打架。
5.4 坑四:LSP 看不到虚拟环境
autoSearchPaths开启的情况下,pyright 会自动去找项目里的.venv,但如果你把虚拟环境放在别的位置,或者项目是 monorepo 结构,自动搜索会失灵。手动指定解释器路径是更可靠的方式:
settings = { python = { pythonPath = '/path/to/your/venv/bin/python', }, }这里特别注意:Neovim 里的python3_host_prog和 LSP 用的解释器是两回事。前者是给 Neovim 的远程插件(比如一些依赖 python 的 nvim 插件)用的运行时,后者是语言服务器做类型分析时用的解释器。我见过有人把两个混为一谈,改了g:python3_host_prog以为 LSP 就会换解释器,结果完全不生效。
6. 按项目自动切换 LSP 的最终方案与我的顺手配置
6.1 我的选择策略:pyproject.toml 里有 pyrefly 就用 pyrefly
来回手动切换太麻烦,我最后做了一套自动判断逻辑:打开 Python 文件时,如果项目根目录的pyproject.toml里声明了[tool.pyrefly],就启动 pyrefly;否则启动 pyright。这个方式非常符合我的实际使用习惯,因为新项目我都在 pyproject 里写明了选哪个服务器。
具体代码:
local function project_prefers_pyrefly(root) if not root then return false end local pyproject = root .. '/pyproject.toml' if vim.fn.filereadable(pyproject) ~= 1 then return false end for _, line in ipairs(vim.fn.readfile(pyproject)) do if line:match('^%[tool%.pyrefly%]') then return true end end return false end vim.api.nvim_create_autocmd('FileType', { pattern = 'python', callback = function() local root = vim.fs.root(0, { 'pyproject.toml', 'setup.py', '.git' }) for _, client in ipairs(vim.lsp.get_clients({ bufnr = 0 })) do if client.name == 'pyright' or client.name == 'pyrefly' then vim.lsp.stop_client(client.id) end end if project_prefers_pyrefly(root) then vim.lsp.start({ name = 'pyrefly', cmd = { 'pyrefly', 'language-server' }, root_dir = root, capabilities = capabilities, on_attach = on_attach, }) else require('lspconfig').pyright.setup({ capabilities = capabilities, on_attach = on_attach, settings = { python = { analysis = { typeCheckingMode = 'basic', diagnosticMode = 'workspace', useLibraryCodeForTypes = true, autoSearchPaths = true, indexing = true, }, }, }, }) end end, })这段代码的逻辑是:每次打开 Python 文件,先停掉当前 buffer 上已有的 pyright 或 pyrefly 客户端,再根据项目 pyproject 决定启动哪个。vim.fs.root是 Neovim 0.10 的 API,用它判断项目根目录非常干净。如果你只想用 pyright,把 pyrefly 相关的条件删掉即可。
注意:如果你用的是老版本 lspconfig,同时又在全局
init.lua里提前注册过 pyright,上面的 autocmd 里那句stop_client是必须的,否则会重复启动两个客户端。
6.2 我现在的最终配置一页纸
如果你不想折腾自动切换,只想有一套能直接用的配置,参考这一版(假设已安装 pyright、pyrefly、cmp-nvim-lsp):
local capabilities = require('cmp_nvim_lsp').default_capabilities() local on_attach = function(client, bufnr) local bufopts = { noremap = true, silent = true, buffer = bufnr } vim.keymap.set('n', 'gd', vim.lsp.buf.definition, bufopts) vim.keymap.set('n', 'K', vim.lsp.buf.hover, bufopts) vim.keymap.set('n', 'gr', vim.lsp.buf.references, bufopts) vim.keymap.set('n', '<leader>rn', vim.lsp.buf.rename, bufopts) end require('lspconfig').pyright.setup({ capabilities = capabilities, on_attach = on_attach, settings = { python = { analysis = { typeCheckingMode = 'basic', diagnosticMode = 'workspace', useLibraryCodeForTypes = true, inlayHints = { variableTypes = true, functionReturnTypes = true, }, }, }, }, }) -- pyrefly 单独用 autocmd 手动启动 vim.api.nvim_create_autocmd('FileType', { pattern = 'python', callback = function() local root = vim.fs.root(0, { 'pyproject.toml', 'setup.py', '.git' }) if root and vim.fn.filereadable(root .. '/pyproject.toml') == 1 and vim.fn.readfile(root .. '/pyproject.toml'):match('^%[tool%.pyrefly%]') then vim.lsp.start({ name = 'pyrefly', cmd = { 'pyrefly', 'language-server' }, root_dir = root, capabilities = capabilities, on_attach = on_attach, }) end end, })这个小版本的好处是 windows 上也不好乱,pyright 走 lspconfig 的全套默认,pyrefly 只在特定项目里被手动唤醒。等真需要频繁切换的时候,再升级成 6.1 的自动判断版。
6.3 我实际用下来的感受
我现在的状态是:个人小工具项目默认 pyright,一个两万多行代码的仓库型项目完整转到 pyrefly。保存后的诊断等待时间是变化最明显的,pyrefly 几乎秒出,pyright 在同样项目上会有可见的停顿。但 pyright 的报错信息更细致,某些边界情况的解释也更好懂,平时写小项目我反而更愿意看 pyright 的诊断。
配置这件事没有绝对答案。先把 pyright 配稳,再给 pyrefly 留一条手动启动的退路,是我目前觉得性价比最高的方案。你完全可以按自己的项目规模选边站,LSP 的切换成本本来就不高,今天不满意明天再改也来得及。