LSPs for LLMs 这个题目,最近在 AI 辅助编程和 LLM Agent 方向讨论得越来越多。LSP 本意是 Language Server Protocol,也就是语言服务器协议,它负责让编辑器拿到类型、补全、诊断、跳转定义这些 IDE 级能力。LLM 则是大语言模型。把两组词放在一起,指的不是协议本身变成了大模型,而是一条实用工程路径:当 LLM 需要理解或修改代码时,不要只把文件文本和选中片段塞进 Prompt,而是先通过 LSP 从语言服务器取回结构化的语义信息,再把这些信息作为上下文交给模型。
很多编程助手给人的第一印象是“不够聪明”,其实不一定是模型能力不足,而是上下文太薄。模型拿到的内容往往只是当前文件里的一段代码,看不到函数定义的类型、调用点、诊断信息、引用关系。它只能靠自己训练时积累的先验知识去猜。而 LSP 背后是一套已经成熟的编译器级代码分析能力,恰好能补上这块空白。
这篇文章会用一个本地可跑的最小 Demo 演示整条链路:安装 pyright 语言服务器,用 Python 写一个极简 LSP 客户端,从当前文件里取出诊断和符号信息,组装成 Prompt,再调用一个 OpenAI 兼容接口拿到修复建议。读者对象是正在开发编程助手、IDE 插件、LLM Agent 工具链的开发者。
1. LSP 与 LLM 的组合到底解决什么问题
1.1 LSP 先解决了编辑器与语言服务器之间的通信问题
LSP 最早要解决的是编辑器生态分裂的问题。在 LSP 出现之前,一种语言要在一个编辑器里获得代码补全、跳转、诊断能力,通常需要为该编辑器单独写插件。编辑器数量一多,维护成本会迅速膨胀。
LSP 的思路是把“语言智能”从编辑器里抽出来,放进一个独立进程,称为语言服务器。编辑器是客户端,语言服务器是服务端,双方通过标准协议通信。协议底层的传输格式是 JSON-RPC 2.0,所有请求、通知、响应都是结构化 JSON。
一份最典型的 LSP 交互流程大致是这样的:
客户端 -> 服务器:initialize 请求 服务器 -> 客户端:initialize 响应,告知服务器支持哪些能力 客户端 -> 服务器:initialized 通知 客户端 -> 服务器:textDocument/didOpen 通知,告诉服务器文件内容 客户端 -> 服务器:textDocument/hover 或 textDocument/definition 等请求 客户端 -> 服务器:shutdown 请求 客户端 -> 服务器:exit 通知对 LLM 应用来说,LSP 最大价值在于:你不需要自己去解析 AST、做类型推导、维护跨文件索引。语言服务器已经把这件事做了很多年,并且做得比普通脚本更可靠。你只需要把它的结果读出来,组织成 Prompt 或工具调用结果即可。
1.2 纯文本上下文会让 LLM 丢失太多语义
现在很多 AI 编程功能的实现方式是:用户在编辑器里选中一段代码,插件把文本发给模型,问“这段代码有没有问题”或“帮我改一下”。这种方式在简单场景下有效,但只要代码涉及类型、跨文件调用、编译错误,纯文本就明显不够。
举个例子,给你看两行代码:
def add(a, b): return a + b result = add(1, "2")模型大概能猜出这里有问题,但它不知道类型检查器具体报了什么,不知道add是在哪个类里定义的,不知道这个函数在同一个项目里被其他模块以什么类型调用。如果 prompt 里只给这段文本,模型给出的建议很容易变得空泛,比如“请确保传入相同类型”之类。
换成 LSP 增强后的上下文,你可以在 Prompt 中直接给出这类信息:
pyright 诊断: 第 5 行,第 10 列: Argument of type "str" cannot be assigned to parameter "b" of type "int" in function "add"模型看到的是确定的问题描述,而不是靠猜。它可以直接从“类型不匹配”这个结论出发,给出把"2"改为2或对b做类型转换的方案。这就是 LSP 对 LLM 的最直接价值:把“模型需要推断的信息”变成“语言服务器已经计算好的信息”。
1.3 LSP 能为 LLM 提供的信息类型
LSP 并不是只提供诊断。一个完整的语言服务器可以暴露很多能力,通常通过初始化时返回的capabilities字段声明。
下面这张表整理了与 LLM 上下文构建关系最密切的几类 LSP 能力:
| LSP 方法 | 返回的信息 | 对 LLM 的价值 | 典型使用场景 |
|---|---|---|---|
textDocument/diagnostic或textDocument/publishDiagnostics | 错误、警告、提示信息 | 让模型知道代码当前有哪些确定问题 | 代码评审、自动修复 |
textDocument/documentSymbol | 文件中的类、函数、变量符号树 | 让模型快速了解文件结构和职责 | 代码概览、重构、补全 |
textDocument/hover | 符号的类型签名和文档注释 | 让模型获得准确类型,减少猜测 | 分析函数行为、生成调用代码 |
textDocument/definition | 符号定义的位置 | 让模型跳转到真正的实现 | 跨文件分析、代码修复定位 |
textDocument/references | 符号被引用的位置列表 | 让模型知道改动影响范围 | 重构、影响面分析 |
textDocument/completion | 当前位置可用的补全候选 | 结合模型生成更符合上下文的代码 | 代码生成、自动补全 |
这里要注意的是,LSP 返回的是结构化 JSON,这对 LLM 场景很友好。你可以直接把它转成 JSON 片段塞进 Prompt,也可以先做过滤,只保留诊断和符号。相比直接塞整段源文件,结构化信息在 Token 利用率和准确率上都有优势。
2. 准备工作区:依赖、测试项目和 LSP 通信基础
2.1 为什么选择 pyright 作为第一个接入的语言服务器
学习 LSP 有一个心理门槛:不想从零开始写语言服务器,又怕协议太复杂。这里建议先把现成语言服务器作为“黑盒”接入,等跑通了再深入协议内部。
选择 pyright 有几个原因。第一,它的类型检查能力很强,产生的诊断信息非常清晰。第二,Python 测试项目足够简单,前后代码都不需要额外编译。第三,pyright 的包发布在 PyPI 上,用pip安装即可,不需要在一开始接触 Node.js 工具链。
同一个思路也适用于其他语言,比如 C/C++ 用 clangd,Go 用 gopls,Java 用 jdtls。它们都遵循同一套 LSP 协议,换语言只是换启动命令和文件 URI,核心链路不变。
2.2 安装依赖并验证
先准备一个干净的工作目录,然后安装 pyright。
python -m pip install pyright安装完成后做两步验证:
pyright --version which pyright-langserver如果pyright --version能正常输出版本号,说明主命令可用。which pyright-langserver用于确认语言服务器二进制也在 PATH 中,后面 Python 客户端需要通过它启动子进程。
环境要求可以参考这张表:
| 项目 | 最低要求 | 说明 |
|---|---|---|
| 操作系统 | Linux、macOS 或 Windows | 不同平台主要影响 file URI 写法 |
| Python | 3.10 或更高 | 文章示例使用现代类型和语法 |
| pyright | 最新稳定版 | 通过pip install pyright安装 |
| 网络 | 可访问 package index | 安装 Python 依赖时需要 |
2.3 创建一个带语义信息的测试项目
在同一个目录下创建sample_project,里面放两个文件。
sample_project/ calculator.py main.pycalculator.py定义了一个简单的计算器类:
class Calculator: def add(self, a: int, b: int) -> int: return a + b def multiply(self, a: int, b: int) -> int: return a * bmain.py故意写了一个类型错误,用来验证 LSP 诊断能否被读到:
from calculator import Calculator def main() -> None: calc = Calculator() result = calc.add(1, "2") print(result) if __name__ == "__main__": main()先不用 LSP,直接在命令行运行静态检查,确认 pyright 能看到这个类型错误:
pyright sample_project正常情况下会看到类似这样的输出:
sample_project/main.py:6:19 - error: Argument of type "str" cannot be assigned to parameter "b" of type "int" in function "add"这一步很关键。如果命令行静态检查已经看不到错误,后面 LSP 客户端拿不到诊断,问题出在文件本身或 pyright 配置,而不是协议交互。
2.4 JSON-RPC 消息格式和 Content-Length 帧
LSP 客户端和语言服务器之间通过标准输入和标准输出通信,但并不是一行一个 JSON。每个消息都有固定的帧格式:header 加 body。
header 里最重要的字段是Content-Length,表示 body 的字节数。body 是 JSON 文本。一个完整的消息长这样:
Content-Length: 152\r\n \r\n {"jsonrpc":"2.0","id":1,"method":"initialize","params":{"processId":null,"rootUri":"file:///path/to/sample_project