LSP for LLM:用标准协议将大模型接入IDE的工程实践 大模型编程助手大家已经用得很多了可一旦你尝试把某个模型接入团队自己的 IDE 工作流很快会遇到一个尴尬问题模型本身不难接难的是为每个编辑器各写一套协议。VSCode 一套插件、JetBrains 一套插件、网页编辑器再一套插件每套都维护一遍很多团队就被耗死在这个环节。如果有一个统一的标准协议能把“AI 补全”“AI 诊断”“AI 解释代码”这些能力包装成标准服务任何编辑器只要实现一次客户端就能接入模型侧也不需要关心用户到底在用哪个编辑器——这就省下了大量重复工作。这个思路并不是新造的轮子而是把编辑器生态里已经非常成熟的 LSPLanguage Server Protocol用在大模型场景上。可以说LSPs for LLMs 指的不是某一个具体开源项目而是正在形成的一类工程模式用 LSP 把大模型能力标准化地接到 IDE 中。这篇文章会讲清楚三件事LSP 协议里哪些机制最关键LSP 与大模型结合有哪几种典型架构形态以及如何用一个最小 Python 示例把 LLM 包装成一个真正的 Language Server并接入 VSCode。读完你可以照着跑通一条完整链路也会知道为什么大家在谈论 Agent 工具时常常把它和 MCP 弄混——这两个协议解决的问题其实完全不同。1. 这篇文章真正要解决的问题先回到一个更普遍的开发场景。假设你所在的小组自研了一个基于大模型的代码补全服务内部评测效果不错模型延迟也控制在了可接受范围。接下来要落地到每天使用的编辑器里大家分工时才发现VSCode 需要写 TypeScript 插件通过某种私有协议把光标位置、当前文件内容发给后端JetBrains 系插件又要用 Kotlin 或 Java 重新实现一遍同样的逻辑如果还有内部 Web IDE又是一套新协议。这里的工程成本已经和模型能力无关了它纯粹是“连接”的成本。过去几年编辑器生态对这类问题给出的答案是 LSP把语言分析能力统一封装为 Language Server客户端只要实现一套协议就能获得补全、跳转、诊断、重命名等能力。如今把这个协议复用在大模型场景下好处非常直接。一是标准统一。编辑器侧不用为每家模型厂商开发专属适配模型侧也不用维护多套插件只要实现 LSP 服务端就能同时服务 VSCode、Neovim、Eclipse 等客户端。二是上下文结构化。LSP 消息里天然包含文件 URI、行号、列号、文档版本号模型服务拿到的是清晰的代码位置和内容而不是让用户把代码复制粘贴到聊天框。三是有生态工具可用。官方 SDK、调试工具、各种语言的 LSP 库都很成熟不需要从零造 JSON-RPC 通信层。从这些问题出发LSPs for LLMs 实际要回答的是当大模型要成为编程基础设施的一部分时我们用什么标准接口去对接它。本文不是只讲协议理论而是会给出一个可运行的最小实现并讨论上下文管理、延迟、Agent 接入、协议边界等工程问题。2. LSP 协议的关键机制一包一 JSON一插即用LSP 的全称是 Language Server Protocol最初由微软设计用于解决“每种语言都要为每个编辑器各写一套插件”的问题。它把语言能力拆成两个角色Language Server独立进程负责分析代码提供补全、诊断、跳转等能力。Language Client编辑器进程把用户操作转换成 LSP 请求发给 Server再把 Server 返回的结果渲染到界面上。Server 和 Client 之间通过 JSON-RPC 通信。通信方式默认走标准输入输出stdio也支持 TCP 或命名管道。所以一个 Server 说起来就是一个命令行程序从 stdin 读请求把响应写到 stdout。真正让 LSP 能正确解析消息的是消息头它规定了消息采用类似 HTTP 的格式Content-Length: 123\r\n \r\n { jsonrpc: 2.0, id: 1, method: initialize, params: { } }每个 LSP 消息都分成头和信息体两部分。头必须包含Content-Length表示后面 JSON 内容的字节长度用空行分隔消息体是一段 JSON-RPC 2.0 报文。这里的字节数不是字符数中文等 UTF-8 多字节字符会占多个字节所以实现时必须按字节计算不能直接用字符串长度。LSP 消息的三种类型需要分清。第一类是请求Request它带有id字段Server 收到后必须回一个带有相同id的响应。典型的请求包括initialize、textDocument/completion、textDocument/hover等。第二类是响应Response它对应某个请求包含result或error。第三类是通知Notification它没有id不需要回应典型的有textDocument/didOpen、textDocument/didChange、initialized、shutdown。很多人第一次手写 LSP 时会踩同一个坑只处理了initialize和completion却忽略了didOpen和didChange。结果编辑器里永远拿不到当前文档内容因为 Server 根本不知道用户打开了什么文件。LSP 的语义是文件内容不是从补全请求里重新传一份而是通过didOpen、didChange同步到 ServerServer 负责维护“当前文档状态”。这个设计避免了大文件反复传输但也要求 Server 必须实现文档状态的缓存。对于 LLM 场景格外重要的还有shutdown和exit。编辑器退出时会先发shutdown请求再发exit通知。如果 Server 没有妥善处理退出逻辑会导致编辑器卡住或僵尸进程残留。初学者实现 LSP Server 时建议先把这几个基础方法跑通再考虑加入模型调用。3. LSP 与 LLM 结合的四种架构形态LSPs for LLMs 不是什么官方标准名称它是我对当前工程实践的一个概括。梳理下来绝大多数项目都逃不开四种形态。第一种LLM 直接作为 LSP Server。这种模式把补全、诊断、代码解释全部封装成一个标准语言服务器模型通过 HTTP 或其他内部接口在服务端被调用。编辑器只负责打开文件、收集光标位置、发送请求完全不需要知道模型是什么、部署在哪里。本文的示例就属于这一类。它的优点是复用标准协议缺点是模型延迟若过高会影响编辑体验通常需要配合异步请求和结果缓存。第二种LSP 作为 Agent 的语义工具。Agent 不再通过“正则匹配代码文件”来理解工程而是把 LSP Server 当成一个可以对话的代码语义服务。Agent 可以去调 LSP 的文本文档同步、查找定义、查找引用、获取诊断信息等方法拿到的都是结构化结果。相比自己解析 AST这种方式更接近“IDE 视角下的代码理解”。第三种统一 LSP 网关背后接多个模型。同一套 LSP 接口暴露给 IDE网关层根据请求类型做路由生成补全用小模型回答复杂问题用大模型本地优先场景甚至可以回退到关键词补全。IDE 侧代码不需要变动。这种架构很适合企业内多个模型并存的情况但网关会成为一个有状态服务需要特别关注连接管理、超时、容错和请求量控制。第四种编辑器插件仍然私有协议但内部转发给 LSP。不少成熟的 AI 编程插件对外仍然用自己的扩展点但内部已经把补全、诊断转发给了一个标准 LSP Server。这种形态看起来像“换汤不换药”实际上对团队很有价值即使插件层暂时不能标准化底层能力已经可以被其他客户端复用了。四种形态没有绝对好坏。如果目标是快速给团队编辑器接入一个 AI 补全服务第一种最直接如果目标是做一个能理解整个仓库的编程 Agent第二种更合适如果后端模型不止一个第三种是必要的中间层。4. 环境准备本地模型服务与 Python 依赖在动手写代码之前先明确运行环境。本文示例使用 Python 3.8 以上版本只需要两个依赖标准库json、sys以及用于发起 HTTP 请求的第三方库requests。如果不想安装requests也可以用标准库urllib.request替换但多写几行代码。python3 --version pip install requests示例中LLM 服务以一个兼容 OpenAI Chat Completions 接口的本地服务为例。常见做法是启动一个本地推理引擎使用对应的兼容端点地址形如http://127.0.0.1:11434。模型名称以你本地实际可用模型为准本文代码里只是一个占位符。如果你目前没有本地模型服务建议先用一个“模拟返回固定字符串”的函数验证 LSP 协议链路再换成真实模型调用这样排查问题会容易很多。另外需要准备一个调试工具一个能手动发送 LSP 消息的客户端脚本。很多人一开始不知道如何验证 Server 是否正常工作其实可以用一个简单的 Python 脚本读入多行 JSON自动计算Content-Length并把 LSP 消息写到标准输出。后面章节会给出完整代码。安装完成后先做一次连通性检查确认本地模型服务已启动并能返回补全结果。curl http://127.0.0.1:11434/v1/models如果这个命令能返回模型列表说明模型服务在线可以继续。如果没有模型服务也没关系稍后示例中会提供 mock 分支。5. 最小可运行示例把 LLM 包装成一个 LSP Server这部分是全文核心。我会用 Python 写一个极简 LSP Server它支持initialize、textDocument/didOpen、textDocument/didChange、textDocument/completion和shutdown方法。补全请求会把光标前面的代码文本发送给本地模型服务模型返回的文本作为补全候选返回给编辑器。5.1 服务端核心代码保存以下代码为lsp_llm_server.py。#!/usr/bin/env python3 # lsp_llm_server.py import os import sys import json import requests # 本地模型服务地址请改成你实际使用的服务地址 LLM_ENDPOINT http://127.0.0.1:11434/v1/chat/completions # 模型名称以你本地实际模型名为准 LLM_MODEL your-local-model-name # 缓存当前打开的文档内容key 是文件 URI documents {} def read_message(): 从标准输入读取一个完整的 LSP 消息返回字典EOF 时返回 None。 headers {} line b while True: byte os.read(sys.stdin.fileno(), 1) if not byte: return None if byte b\n: if line b: break key, _, value line.decode(utf-8).partition(:) headers[key.strip().lower()] value.strip() line b else: line byte content_length int(headers.get(content-length, 0)) if content_length 0: return None body b while len(body) content_length: chunk os.read(sys.stdin.fileno(), content_length - len(body)) if not chunk: return None body chunk return json.loads(body.decode(utf-8)) def send_message(message): 把一个字典序列化为 LSP 消息写入标准输出。 data json.dumps(message, ensure_asciiFalse).encode(utf-8) header fContent-Length: {len(data)}\r\n\r\n.encode(utf-8) sys.stdout.buffer.write(header data) sys.stdout.buffer.flush() def send_response(request_id, result): send_message({jsonrpc: 2.0, id: request_id, result: result}) def line_text(document_text, line): lines document_text.splitlines() return lines[line] if line len(lines) else def get_context(document_text, position): 取光标前的代码片段作为补全上下文。 line position.get(line, 0) character position.get(character, 0) lines document_text.splitlines() if line 0: # 单行场景直接取当前行光标前文本 return lines[0][:character] if lines else # 多行场景返回当前行之前的所有内容 当前行光标前内容 prefix_lines \n.join(lines[:line]) current_line lines[line][:character] if line len(lines) else return prefix_lines \n current_line def call_llm(prompt_text): 调用大模型接口返回补全文本列表。没有模型服务时返回 mock 结果。 prompt_text prompt_text or try: resp requests.post( LLM_ENDPOINT, json{ model: LLM_MODEL, messages: [ {role: system, content: 你是一个代码补全引擎只输出补全结果不要解释。}, {role: user, content: f请补全以下代码片段\n{prompt_text}} ], max_tokens: 64, temperature: 0.2, stream: False }, timeout5 ) resp.raise_for_status() content resp.json()[choices][0][message][content].strip() return [content] if content else [# TODO] except Exception as exc: # 模型服务不可用时返回固定字符便于验证协议是否通 return [f# LLM 不可用: {type(exc).__name__}] def handle_initialize(message): result { capabilities: { textDocumentSync: 1, completionProvider: { triggerCharacters: [.] } }, serverInfo: { name: lsp-llm-demo, version: 0.1.0 } } send_response(message.get(id), result) def handle_did_open(message): text_document message[params][textDocument] documents[text_document[uri]] text_document[text] def handle_did_change(message): params message[params] uri params[textDocument][uri] # 极简实现直接用最新全文覆盖不处理增量变化 documents[uri] params[contentChanges][-1][text] def handle_completion(message): params message[params] uri params[textDocument][uri] position params[position] document_text documents.get(uri, ) context get_context(document_text, position) suggestions call_llm(context) # 注意LSP 的 character 按 UTF-16 code unit 计算 # 含中文等字符时要转换为字符偏移再做切片 items [] for suggestion in suggestions: items.append({ label: suggestion if len(suggestion) 60 else suggestion[:60] ..., insertText: suggestion }) send_response(message.get(id), {isIncomplete: False, items: items}) def main(): while True: message read_message() if message is None: break method message.get(method) if method initialize: handle_initialize(message) elif method initialized: # 编辑器通知 Server 初始化完成无需响应 pass elif method textDocument/didOpen: handle_did_open(message) elif method textDocument/didChange: handle_did_change(message) elif method textDocument/completion: handle_completion(message) elif method shutdown: send_response(message.get(id), None) elif method exit: break else: # 未实现的方法返回 MethodNotFound 错误 if id in message: send_message({ jsonrpc: 2.0, id: message[id], error: {code: -32601, message: Method not found} }) if __name__ __main__: main()代码里有几个关键点需要解释。read_message是协议的“地基”。它先逐字节读取消息头直到遇到空行再根据Content-Length读取完整 JSON。这里没有使用input()因为input()默认按文本行读取无法正确处理消息体内部可能出现的换行、以及二进制的 UTF-8 内容。send_message每次写消息时都重新计算Content-Length并写入\r\n\r\n作为头和体的分隔符。这里尤其要注意json.dumps(..., ensure_asciiFalse)会把中文输出为 UTF-8 中文字符因此字节数必须是用 UTF-8 编码后的字节长度而不是字符个数。handle_completion是业务核心。它从didOpen缓存的文档内容中取出光标前的代码片段作为 LLM 的上下文再把模型返回的文本转换成 LSP 补全项。insertText表示最终插入文档的内容label只是展示文本二者可以不同。如果你的模型返回结果比较长建议只把第一行作为 label完整内容作为 insertText。5.2 调试客户端与协议验证服务器写完之后不能直接双击运行它需要等待客户端发消息。为了快速验证我写了一个send_lsp.py脚本。它可以读入多行 JSON自动计算每条消息的Content-Length并发送给标准输出这样就能通过管道直接把消息喂给服务器。#!/usr/bin/env python3 # send_lsp.py import sys import json def send(message): data json.dumps(message, ensure_asciiFalse).encode(utf-8) sys.stdout.buffer.write(fContent-Length: {len(data)}\r\n\r\n.encode(utf-8)) sys.stdout.buffer.write(data) sys.stdout.buffer.flush() if __name__ __main__: for line in sys.stdin: line line.strip() if line: send(json.loads(line))在终端里先启动 LSP Server再用管道把调试消息发过去。下面的命令会依次发送initialize、didOpen和completion三个消息。printf %s\n \ {jsonrpc:2.0,id:1,method:initialize,params:{processId:null,rootUri:null,capabilities:{}}} \ {jsonrpc:2.0,method:textDocument/didOpen,params:{textDocument:{uri:file:///demo.py,languageId:python,version:1,text:import }}} \ {jsonrpc:2.0,id:2,method:textDocument/completion,params:{textDocument:{uri:file:///demo.py},position:{line:0,character:7}}} \ | python3 send_lsp.py | python3 lsp_llm_server.py运行后你会看到服务器返回两个 JSON 响应分别对应id为 1 和 2 的请求。initialize的响应里包含 capabilitiescompletion的响应里包含items数组。如果没有模型服务items里会出现一条“LLM 不可用”的提示文本这正好说明协议链路已经通了问题在模型服务端。这个调试方法非常重要。以后接入真实 IDE 时如果发现补全不生效你先用这个最小链路确认 Server 本身没问题再去看 IDE 插件配置。5.3 接入 VSCode通过管道验证通过后接下来把它接进 VSCode。你需要创建一个最小扩展工程。先创建以下两个文件。{ name: lsp-llm-demo, displayName: LSP LLM Demo, description: A minimal LSP client connecting to LLM-powered language server, version: 0.0.1, publisher: demo, engines: { vscode: ^1.85.0 }, categories: [ Other ], activationEvents: [ onLanguage:python ], main: ./extension.js, contributes: { commands: [] }, dependencies: { vscode-languageclient: ^9.0.1 } }// extension.js const vscode require(vscode); const { LanguageClient } require(vscode-languageclient); let client; function activate(context) { const serverOptions { command: python3, args: [/absolute/path/to/lsp_llm_server.py] }; const clientOptions { documentSelector: [{ scheme: file, language: python }] }; client new LanguageClient( lsp-llm-demo, LSP LLM Demo, serverOptions, clientOptions ); context.subscriptions.push(client.start()); } function deactivate() { if (!client) { return undefined; } return client.stop(); } module.exports { activate, deactivate };注意args里的路径要改成你自己机器上lsp_llm_server.py的绝对路径。然后把整个目录放进 VSCode 的扩展目录或者在开发模式下按 F5 启动 Extension Development Host接着新建一个 Python 文件输入import并触发补全就能看到来自 LSP Server 的补全项。如果你之前完全没有写过 VSCode 扩展第一反应可能会觉得工程复杂。实际上这个示例已经是最小结构了一个package.json描述扩展入口一个extension.js创建 LanguageClient它负责启动 Python 子进程、把编辑器的文本同步通知发过去、并把补全结果渲染回来。核心的补全逻辑仍然在 Python 侧。6. LSP 与 MCP 的边界两条容易混淆的协议线当前 Agent 生态里还有一个协议概念特别热就是 MCPModel Context Protocol。很多人会问既然有了 MCP为什么还需要 LSP它们到底有什么区别这里需要把两者边界讲清楚。MCP 解决的是“LLM 与应用/数据源/工具之间的连接”。你可以把它理解成模型侧的 USB 接口模型通过 MCP 去访问文件系统、数据库、网页、内部 API 等外部工具。它关心的是模型如何调用工具、如何获取上下文所以更靠近 Agent 的“行动层”。LSP 解决的是“编辑器与语言能力服务之间的连接”。它关心的是代码补全、跳转、诊断、重命名这些 IDE 操作如何标准化所以更靠近编辑器的“显示与编辑层”。两者不是替代关系而是分工关系。一个典型的 AI 编程助手内部可能同时用到两条协议线一条是 MCP 线Agent 通过它去读取仓库、搜索代码、操作 Git另一条是 LSP 线Agent 通过它把诊断结果推送到编辑器面板或者让编辑器触发一次补全。就连“打开文件缓存”这类工作也是 LSP 的领域。如果把 LSP 和 MCP 混在一层去实现很容易出现“服务职责模糊”的问题。比如把 MCP 工具实现了半天结果发现编辑器根本不认识 MCP 的补全请求反过来把 LSP Server 当成 Agent 工具调用入口也会因为缺少工具描述、参数校验而变得难以维护。判断标准很简单消息的消费方是谁。如果消费方是 IDE 的文本编辑界面走 LSP如果消费方是大模型推理进程走 MCP 或内部工具协议。7. 编码 Agent 与 LSP 的工程化实践把 LSP 放到更大的编码 Agent 工程里价值会更明显。先说最常见的场景Agent 需要理解用户当前打开的代码。传统的做法是直接读文件、用正则提取代码块但这种方式很脆弱因为文件可能有语法错误、有多个语法版本的混用、有预处理器宏。LSP 提供的是经过解析的语义信息比如定义位置、引用列表、诊断结果。Agent 只需要维护一个 LSP 客户端就能获得“IDE 眼中的代码状态”。再说 LSP 在 Agent 输出侧的价值。Agent 生成代码后如果直接写入文件用户很难感知改动范围。但若通过 LSP 的补全、代码操作CodeAction或诊断推送能力编辑器会以原生 UI 展示改动建议用户可以逐一接受或拒绝这就天然形成了一道人工审批关卡。对于生产环境的代码改动这个机制胜过“Agent 直接改文件后你去翻 git diff”。另外一个工程取舍是延迟预算。编辑器里的补全通常要求毫秒级体验Agent 式对话可以容忍秒级响应。如果同一个 LSP Server 同时承担补全和复杂问答必须做好分级补全请求走小模型、用缓存诊断和解释类请求走大模型、允许更长超时。服务端如果只有一路线程处理所有请求一个慢的 LLM 调用会把后续所有补全都阻塞掉所以 LSP Server 内部一定要把模型调用放到线程池或异步任务中。业界比较前沿的方向是让 LSP Server 直接暴露“语义工具”给 Agent例如textDocument/definition、textDocument/references、textDocument/diagnostic。Agent 把这些 LSP 方法当作工具调用就能在行动前先做“建图式”的代码探索。这个模式下LSP Server 的职责就不仅是补全而是一个代码理解底座。8. 常见问题与排查思路问题现象可能原因排查方式解决方案启动后编辑器提示无法启动语言服务器python3 不在 PATH或脚本路径错误在终端手动执行python3 /path/to/lsp_llm_server.py看是否有报错修改serverOptions中的 command/args确保路径为绝对路径initialize 请求发出后没有响应读取消息时 Content-Length 计算错误用 5.2 节的管道调试脚本快速复现检查消息解析逻辑务必用 UTF-8 字节长度补全始终返回空列表未注册 completionProvider或 didOpen 没有同步文档查看 initialize 响应里的 capabilities确认文本同步和补全声明在 capabilities 中补上completionProvider并确认已处理didOpenLLM 调用超时或报错本地模型服务未启动、上下文过大、模型名不对先用 curl 测试模型服务接口是否可用缩短上下文、增大超时时间、检查模型名编辑器中输入中文时补全位置错乱LSP 的 character 按 UTF-16 code unit 计算直接用 Python 字符偏移会错打印光标前文本检查中文前后位置在服务端按 UTF-16 code unit 换算后再切片退出编辑器后 Python 进程还残留没有处理 shutdown 和 exit 的退出逻辑查看是否有僵尸 python 进程在exit通知里 break 主循环必要时显式退出如果问题出在“编辑器能启动 Server但没有任何输出”优先看 VSCode 的“输出”面板切换到对应语言服务器名称那里能看到 Server 的 stdout/stderr。这一步定位问题的效率最高。9. 最佳实践与工程建议协议层已经跑通后真正的工程挑战在“如何稳定运行”。下面这组实践是我认为 LSP 与 LLM 结合时最容易踩的坑提前规避会省很多事。上下文瘦身是第一优先级。不要把整个文件全文都塞给模型更不要把整个仓库都发过去。补全场景下取光标前若干行就够诊断场景可以取当前函数或当前类跨文件理解才考虑引入仓库检索。上下文过大不仅增加 token 成本还会显著提高延迟直接影响编辑体验。建议在服务端做一个“上下文预算”配置按场景分别控制。模型调用必须异步化。前面提过LSP 的请求/响应模型在单个线程里是阻塞的。如果补全请求内部同步等待 LLM 返回编辑器会感觉“卡死”后续所有请求也会排队。更稳妥的方法是收到补全请求后先返回一个空结果等模型结果回来后再用workspace/applyEdit或推送通知更新编辑器。如果产品要求实时补全至少要把模型调用放到独立线程池并设置合理的超时时间。流式输出要谨慎落地。对话类 Agent 可以流式输出但补全场景的流式体验很难做好。如果模型生成一半用户就停止了操作后半截内容要不要插入插入后会不会破坏语法这是一个产品决策不是技术决策。初期更推荐非流式、短 token 的补全先把稳定性做起来再慢慢优化体验。控制触发频率。不一定每个字符都要触发补全。配置triggerCharacters只在.、(等符号后触发而非每次击键都发请求。同时做结果缓存同一文件同一位置的补全结果在文件保存或光标显著移动前可以直接复用。这个优化能把模型服务端压力降低一半以上。对模型输出要有安全护栏。模型给出的补全内容可能包含危险代码、删库命令、越权操作等。对自动插入的代码至少要做一次危险模式扫描对涉及文件写入、执行命令的 Agent 行为要有人工确认机制。模型输出不属于可信代码这一点在团队协作中尤其要讲清楚。做好灰度与回滚。语言服务器是一个独立进程可以在编辑器侧方便地切换版本。团队接入时不要直接让所有人强制升级而是先让部分用户使用新 Server观察补全接受率、请求错误率、平均延迟再逐步放量。后端模型变更同样要支持按用户灰度避免模型升级导致体验明显回退。日志和指标要前置设计。至少要记录每个请求的耗时、Token 数、错误类型、LLM 返回是否为空。没有这些数据你很难回答“为什么最近补全变慢了”“为什么这个用户补全率很低”。LSP 是一个很适合埋点的位置协议消息已经天然带了方法和文件 URI只需要在 Server 侧加一行统计。比起追求“开箱即用的 AI 补全体验”我更建议读者先把协议链路理解透彻。当你开始写自己的第一个 LSP 转发服务时不要一上来就接模型先用 mock 结果跑通协议再逐步加上模型调用、缓存、异步、指标这条路径是最稳的。把标准协议吃透后你会发现不同模型、不同编辑器、不同 Agent 框架之间的迁移成本都会大幅下降。