大模型编程助手大家已经用得很多了,可一旦你尝试把某个模型接入团队自己的 IDE 工作流,很快会遇到一个尴尬问题:模型本身不难接,难的是为每个编辑器各写一套协议。VSCode 一套插件、JetBrains 一套插件、网页编辑器再一套插件,每套都维护一遍,很多团队就被耗死在这个环节。
如果有一个统一的标准协议,能把“AI 补全”“AI 诊断”“AI 解释代码”这些能力包装成标准服务,任何编辑器只要实现一次客户端就能接入,模型侧也不需要关心用户到底在用哪个编辑器——这就省下了大量重复工作。这个思路并不是新造的轮子,而是把编辑器生态里已经非常成熟的 LSP(Language 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同步到 Server,Server 负责维护“当前文档状态”。这个设计避免了大文件反复传输,但也要求 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_ascii=False).encode("utf-8") header = f"Content-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 }, timeout=5 ) 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_ascii=False)会把中文输出为 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_ascii=False).encode("utf-8") sys.stdout.buffer.write(f"Content-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的响应里包含 capabilities,completion的响应里包含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 生态里还有一个协议概念特别热,就是 MCP(Model 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,并确认已处理didOpen |
| LLM 调用超时或报错 | 本地模型服务未启动、上下文过大、模型名不对 | 先用 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 框架之间的迁移成本都会大幅下降。