尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

让大模型看懂代码库:LSP与LLM结合的完整实战指南

让大模型看懂代码库:LSP与LLM结合的完整实战指南 平时写代码的时候大家可能都有过这种体验让大模型帮你生成一段调用代码它给出的方法名看起来头头是道一查根本不存在让它补全某个模块里的函数它完全不知道你当前项目里有哪些符号让它重构代码它甚至会把已经过期的 API 继续用下去。这不是大模型不够聪明而是它缺少一种“当前项目语义信息”的获取通道。模型只知道通用语法不熟悉你本地工程里真实存在的类、函数、变量和类型关系。于是这两年出现了一个很有意思的方向——把经典的 LSPLanguage Server Protocol语言服务器协议接入 LLM让语言服务器充当大模型的眼睛帮它看清楚代码库的真实结构。这篇文章会从 LSP 的原理讲起说明为什么 LSP 对大模型应用如此重要然后给出一个可直接运行的“LLM LSP”最小实战案例最后总结接入过程中的高频问题和工程建议。无论你是做大模型应用开发、AI 编程助手还是想给团队内部的代码智能体补充语义能力这篇文章都能提供一条可以落地的思路。1. 背景与核心概念1.1 LSP 到底是什么LSP 的全称是 Language Server Protocol也就是语言服务器协议。它最初由微软提出目的是解决一个非常头疼的问题以前每种语言都要为每个编辑器单独开发一套插件VSCode 一套、Vim 一套、Emacs 一套、Sublime 一套重复造轮子。协议出现之后语言的语法分析、补全、跳转、诊断这些重量级逻辑被独立成“语言服务器”编辑器只需要通过统一的 JSON-RPC 消息和语言服务器通信。协议标准化了编辑器只需要实现一次客户端就能对接所有语言服务器。VSCode 能补全 Python靠的是 Pyright能补全 Go靠的是 gopls能补全 C/C靠的是 clangd。这些语言服务器都是 LSP 的具体实现。我们平时在 IDE 里每天都会用到 LSP只是多数情况下没有感知。每当你输入代码编辑器把textDocument/didChange通知给语言服务器服务器分析完后返回诊断信息每当你输入一个点编辑器把textDocument/completion请求发过去语言服务器就返回当前上下文里可用的成员列表。这些交互都是通过 JSON-RPC 完成的。1.2 LLM 为什么需要 LSP大语言模型本质上是一个“文本预测器”它靠海量训练数据学会了代码的统计规律但它没有实时看代码库的能力。当模型被问到“当前仓库里UserService有哪些方法”时它的唯一信息来源是训练数据里的记忆而不是你项目里真实的代码。这就造成了两个典型问题幻觉 API模型返回了看起来合理但实际不存在的函数名。上下文缺失模型不知道当前模块的导入路径、类型别名、重载函数。LSP 恰好能弥补这一点。语言服务器由本地编译器或静态分析器驱动它读取真实代码构建符号表能准确回答“某个文件里有哪些类”“某个类有哪些方法”“某个位置是什么类型”“某个变量在哪里定义”。如果把这些信息作为工具返回给 LLM模型就相当于拿到了真实项目的地图回答准确率会明显提升。1.3 LSPs for LLMs 的常见形态现在“LSPs for LLMs”并没有一个严格统一的定义更多是一种架构思想。常见的落地形态有三种第一种是AI 编程助手。IDE 插件在收到用户自然语言指令后先通过 LSP 拿到光标位置的符号、类型和代码范围再把这些结构化信息拼进 Prompt最后让 LLM 生成补全或修改建议例如 GitHub Copilot Chat 在 VSCode 里做代码解释、修复、重命名时背后就有 LSP 提供代码上下文。第二种是Code Agent / 自动化任务。智能体收到一个任务比如“修复所有文件里的某个 Bug”Agent 需要定位文件、分析符号引用、修改代码、检查诊断。这些步骤如果全部靠 LLM 猜测会非常不可靠最稳妥的做法是调 LSP 的textDocument/definition、textDocument/references、textDocument/documentSymbol、textDocument/diagnostic来获得准确定位。第三种是对话式代码问答。用户问“这个handle函数为什么报错”系统先调用 LSP 的 hover 和诊断接口拿到错误信息再交给 LLM 解释根因、给修复建议。这个模式实现起来最快也是很多企业知识库问答和代码审计系统采用的路线。在这三种形态里LSP 扮演的角色都不是“替 LLM 思考”而是“替 LLM 感知代码库”。这是两者最核心的分工关系。2. 环境准备与版本说明为了让你能够完整跑通后面的实战示例这里先明确环境需求。本文的代码示例是基于 Python 写的理论上支持 Windows、macOS、Linux 三个平台但更推荐在 macOS 或 Linux 上运行因为子进程管理、stdio 管道处理在这些系统上更顺畅。运行环境建议如下Python 3.9 及以上版本。需要安装pygls库用于实现一个极简语言服务器。pygls 是 Python 生态中最流行的 LSP 框架之一具体版本请以你安装时 pypi 上的最新稳定版为准。本文的 API 用法适配 pygls 较新的主流版本。需要安装requests库用于编写客户端向语言服务器发送 JSON-RPC 消息以及向本地 LLM 服务发送 HTTP 请求。如果演示 LLM 调用部分建议本地准备一个 OpenAI 兼容的推理服务例如 Ollama、vLLM、llama.cpp server 等。你也可以直接改用 OpenAI API代码里只需要修改base_url和api_key。需要说明的是LSP 协议本身的版本目前主流是 3.17这也是各大语言服务器普遍支持的版本。本文示例只用到其中几个稳定方法不依赖任何新版本特性所以即使你用的语言服务器版本较旧也能正常对齐。项目结构上演示时会包含三个文件llm-lsp-demo/ ├── demo_lsp_server.py # 基于 pygls 的极简语言服务器 ├── lsp_client.py # 封装 JSON-RPC 客户端 └── llm_agent.py # 将 LSP 结果交给 LLM 的演示入口如果版本在后续迭代中变化请优先参考官方文档和项目内 README本文重点演示的是链路和思路不是某一版 API 的精确复刻。3. LSP 协议工作原理解读3.1 客户端-服务器模型LSP 的通信模型很简单只有两个角色客户端通常是编辑器或 Agent和服务器语言服务器。客户端负责收集用户的编辑行为例如打开文件、修改内容、移动光标然后把这些事件转成协议消息发给服务器。服务器负责分析和响应比如返回补全列表、跳转位置、诊断结果然后通过 JSON-RPC 消息回给客户端。在传输层上LSP 最常见的方式是stdio也就是客户端启动一个语言服务器子进程然后往子进程的 stdin 写请求从 stdout 读响应。这种方式最简单也是 VSCode 默认采用的通信方式。除了 stdioLSP 也支持 TCP 或命名管道但核心交互模型完全一致。3.2 JSON-RPC 消息格式LSP 的消息格式基于 JSON-RPC 2.0。每个消息是 JSON 对象并且传输时外面包了一层Content-Length头用于告诉接收方这次消息体有多长。一个典型的消息长这样Content-Length: 139\r\n \r\n {jsonrpc:2.0,id:1,method:initialize,params:{processId:1234,rootUri:file:///path/to/project,capabilities:{}}}在写客户端时要特别注意这个头部字节数必须准确否则服务器会解析失败。实战里最常见的坑就是这个长度算错导致消息错位。3.3 常用方法说明LSP 涉及的方法很多但面向 LLM 集成的场景只需要重点关注下面几个方法名用途对 LLM 的价值initialize建立会话确定能力启动一切的前提textDocument/hover获取鼠标悬停位置的类型、文档注释解释变量/函数是什么textDocument/completion获取当前上下文补全项让 LLM 知道你正在编辑的位置能填什么textDocument/definition找到符号定义位置让 LLM 定位实现textDocument/references找到所有引用位置分析影响面、批量修改textDocument/documentSymbol获取文件内符号树让 LLM 快速理解文件结构textDocument/diagnostic获取诊断、报错信息修复 Bug 时获取真实错误原因3.4 关键点LSP 解决的核心问题LSP 返回的不是自然语言而是结构化数据。比如completion返回的是一个CompletionItem列表每一项包含label、kind、detail、documentation等字段definition返回的是Location包含文件和行号。这意味着我们在接 LLM 的时候不能直接把原始 JSON 一股脑塞进 Prompt。更好的做法是把结构化结果转换成一段自然语言摘要比如当前文件 src/service/user_service.py 第 42 行可用补全项 - create_user(params: CreateUserRequest) - User - delete_user(user_id: int) - bool - find_user_by_email(email: str) - User | None这样 LLM 得到的是一段语义清晰、token 友好的上下文而不是它不擅长解析的嵌套 JSON。4. 完整实战案例用 LSP 给 LLM 提供代码上下文现在进入核心环节。我们来实现一个最小闭环启动一个极简语言服务器客户端发送补全和悬停请求拿到结果后交给 LLM让 LLM 基于真实代码符号回答用户问题。这个过程虽然短但已经跑通了“LLM 通过 LSP 感知代码库”的完整链路。4.1 创建项目结构先在任意目录下创建项目文件夹mkdir llm-lsp-demo cd llm-lsp-demo安装依赖pip install pygls requests4.2 编写极简语言服务器我们用 pygls 写一个非常小巧的语言服务器。它不需要真正解析代码而是模拟一个“用户注册相关符号”的代码库返回一些示例补全项和悬停信息。这样能让你快速理解 LSP 交互流程而不用陷入编译器的复杂逻辑。文件路径demo_lsp_server.pyimport logging from pygls.lsp import LanguageServer from pygls.lsp.methods import COMPLETION, HOVER, TEXT_DOCUMENT_DID_OPEN from pygls.lsp.types import ( CompletionItem, CompletionItemKind, CompletionList, CompletionParams, Hover, HoverParams, MarkupContent, MarkupKind, Position, Range, TextDocumentItem, DidOpenTextDocumentParams ) logging.basicConfig(levellogging.INFO, format%(levelname)s - %(message)s) server LanguageServer(demo-lsp-server, v0.1) # 模拟符号表 SYMBOLS { create_user: { label: create_user(user: UserCreateRequest) - User, detail: 创建一个新用户并写入数据库, kind: CompletionItemKind.Function, }, delete_user: { label: delete_user(user_id: int) - bool, detail: 根据用户 ID 删除用户, kind: CompletionItemKind.Function, }, get_user_by_email: { label: get_user_by_email(email: str) - User | None, detail: 通过邮箱查询用户不存在时返回 None, kind: CompletionItemKind.Function, }, } server.feature(COMPLETION) def complete(ls: LanguageServer, params: CompletionParams): items [] prefix # 简单演示从当前行文本中提取光标前最近的一个标识符 line params.context.trigger_kind if params.context else None for name, info in SYMBOLS.items(): items.append( CompletionItem( labelinfo[label], kindinfo[kind], detailinfo[detail], insert_textname, ) ) return CompletionList(is_incompleteFalse, itemsitems) server.feature(HOVER) def hover(ls: LanguageServer, params: HoverParams): doc ls.workspace.get_document(params.text_document.uri) line doc.lines[params.position.line] if params.position.line len(doc.lines) else for name, info in SYMBOLS.items(): if name in line: content f**{name}** \n{info[detail]} return Hover( contentsMarkupContent(kindMarkupKind.Markdown, valuecontent), rangeRange( startPosition(lineparams.position.line, character0), endPosition(lineparams.position.line, characterlen(name)), ), ) return Hover( contentsMarkupContent(kindMarkupKind.Markdown, value未识别符号), ) if __name__ __main__: server.start_io()这段代码做了三件事定义了一个SYMBOLS字典模拟项目里真实的函数签名。在收到completion请求时返回所有模拟函数名和说明。在收到hover请求时检查当前行是否包含模拟符号并返回说明文档。实际的 pygls API 可能因版本不同略有变化但整体思路是一致的。你完全可以用 gopls、clangd、Pyright 等成熟服务器替换这个示例服务器客户端代码不需要大改。4.3 编写 JSON-RPC 客户端接下来写客户端。客户端的职责是启动语言服务器子进程按 LSP 的消息格式发请求、读响应并封装成好用的接口。文件路径lsp_client.pyimport json import struct import subprocess from typing import Any, Dict, Optional class LSPClient: def __init__(self, server_command: list, root_uri: str): self.server_command server_command self.root_uri root_uri self.process: Optional[subprocess.Popen] None self.request_id 0 def start(self): self.process subprocess.Popen( self.server_command, stdinsubprocess.PIPE, stdoutsubprocess.PIPE, stderrsubprocess.PIPE, ) init_params { processId: None, rootUri: self.root_uri, capabilities: {}, } response self.send_request(initialize, init_params) self.send_notification(initialized, {}) return response def _send_data(self, data: bytes): if not self.process or not self.process.stdin: raise RuntimeError(server not started) header fContent-Length: {len(data)}\r\n\r\n.encode(utf-8) self.process.stdin.write(header data) self.process.stdin.flush() def _read_message(self) - Optional[Dict[str, Any]]: if not self.process or not self.process.stdout: raise RuntimeError(server not started) content_length None while True: line self.process.stdout.readline() if not line: return None if line in (b\r\n, b\n, b): break if line.lower().startswith(bcontent-length:): content_length int(line.split(b:)[1].strip()) if content_length is None: return None payload self.process.stdout.read(content_length) return json.loads(payload.decode(utf-8)) def send_request(self, method: str, params: Dict[str, Any]) - Dict[str, Any]: self.request_id 1 message { jsonrpc: 2.0, id: self.request_id, method: method, params: params, } data json.dumps(message, ensure_asciiFalse).encode(utf-8) self._send_data(data) while True: response self._read_message() if response is None: raise RuntimeError(connection closed early) if response.get(id) self.request_id: return response def send_notification(self, method: str, params: Dict[str, Any]): message {jsonrpc: 2.0, method: method, params: params} data json.dumps(message, ensure_asciiFalse).encode(utf-8) self._send_data(data) def request_completion(self, uri: str, line: int, character: int) - Dict[str, Any]: params { textDocument: {uri: uri}, position: {line: line, character: character}, context: {triggerKind: 1}, } return self.send_request(textDocument/completion, params) def request_hover(self, uri: str, line: int, character: int) - Dict[str, Any]: params { textDocument: {uri: uri}, position: {line: line, character: character}, } return self.send_request(textDocument/hover, params) def stop(self): if self.process: self.process.terminate() self.process.wait()这个客户端实现了 LSP 通信的完整基础流程启动进程、握手、发送请求、读取响应。关键点在于_read_message方法它必须严格按Content-Length头去读取消息体。很多初学 LSP 的人会在这里踩坑因为如果直接按行读取 JSON遇到多行 JSON 就会解析失败。4.4 编写 LLM Agent 入口现在写主程序。它启动语言服务器请求补全和悬停信息然后组织成 Prompt 发给本地 LLM。这里使用 OpenAI 兼容接口作为示例。文件路径llm_agent.pyimport json import requests from lsp_client import LSPClient # 配置 LLM 服务这里以本地 Ollama 为例 LLM_BASE_URL http://localhost:11434/v1 LLM_MODEL qwen2.5-coder:7b API_KEY ollama # Ollama 本地服务通常不校验 key FILE_URI file:///tmp/user_service.py def call_llm(system_prompt: str, user_prompt: str) - str: response requests.post( f{LLM_BASE_URL}/chat/completions, headers{Authorization: fBearer {API_KEY}}, json{ model: LLM_MODEL, messages: [ {role: system, content: system_prompt}, {role: user, content: user_prompt}, ], temperature: 0.2, }, timeout30, ) data response.json() return data[choices][0][message][content] def format_lsp_result(completion_result: dict, hover_result: dict) - str: lines [] # 处理补全结果 items [] if result in completion_result and completion_result[result]: raw completion_result[result] if isinstance(raw, dict): items raw.get(items, []) elif isinstance(raw, list): items raw if items: lines.append(当前上下文中可用的函数/补全项) for item in items: lines.append(f- {item.get(label, )}: {item.get(detail, )}) else: lines.append(未获取到补全项。) hover_content if result in hover_result and hover_result[result]: raw hover_result[result] if isinstance(raw, dict): contents raw.get(contents, ) if isinstance(contents, dict): hover_content contents.get(value, ) else: hover_content str(contents) lines.append(光标位置悬停信息) lines.append(hover_content) return \n.join(lines) def main(): client LSPClient( server_command[python, demo_lsp_server.py], root_urifile:///tmp, ) try: client.start() print(LSP 初始化完成) completion_result client.request_completion(FILE_URI, line3, character10) hover_result client.request_hover(FILE_URI, line5, character5) context_text format_lsp_result(completion_result, hover_result) print(----- LSP 返回的上下文 -----) print(context_text) system_prompt ( 你是一个代码助手。下面给出了从语言服务器获取的真实代码上下文。 请基于这些信息回答用户问题不要编造当前代码库中不存在的函数。 ) user_question 当前代码库里有哪些可用函数帮我生成一段调用 create_user 的示例。 answer call_llm(system_prompt, f代码上下文\n{context_text}\n\n用户问题{user_question}) print(----- LLM 回答 -----) print(answer) finally: client.stop() if __name__ __main__: main()这里的call_llm函数使用 OpenAI 兼容接口方便接入各种本地推理服务。如果你使用的是其他厂商的 API只需要替换base_url、api_key和model参数即可。4.5 运行与验证首先在终端启动本地大模型服务这里以 Ollama 为例ollama serve ollama pull qwen2.5-coder:7b然后运行主程序python llm_agent.py预期会看到类似这样的输出LSP 初始化完成 ----- LSP 返回的上下文 ----- 当前上下文中可用的函数/补全项 - create_user(user: UserCreateRequest) - User: 创建一个新用户并写入数据库 - delete_user(user_id: int) - bool: 根据用户 ID 删除用户 - get_user_by_email(email: str) - User | None: 通过邮箱查询用户不存在时返回 None 光标位置悬停信息 未识别符号 ----- LLM 回答 ----- 当前代码库里可用的函数有 create_user、delete_user、get_user_by_email。 下面是调用 create_user 的示例 user create_user(UserCreateRequest(name张三, emailzhangsanexample.com))请注意这里的关键不在于补全列表本身而在于LLM 没有根据自己记忆中的 API 凭空回答而是严格使用了语言服务器提供的符号。这就从根源上避免了幻觉 API 的问题。4.6 结果说明这个 Demo 最核心的链路就是LLM Agent 收到用户问题。Agent 先向语言服务器发起 LSP 请求。语言服务器基于真实代码库返回结构化符号信息。Agent 将符号信息转成自然语言摘要。LLM 基于摘要生成回答或补全建议。如果你的语言服务器替换成 Pyright 或 gopls那么返回的就是你项目里真实符号整个过程就具备生产落地价值了。5. 常见问题与排查思路下面把实践中最常遇到的问题整理成表每个问题都给出现象、原因和解决思路。问题现象常见原因解决思路语言服务器启动后客户端一直收不到响应子进程启动失败或 JSON-RPC 的 Content-Length 头解析错误先单独运行python demo_lsp_server.py检查是否有报错检查客户端_read_message的读取逻辑确认长度读取准确init 请求返回 error code -32600initialize 参数格式不完整缺少capabilities检查 JSON-RPC 2.0 格式确认jsonrpc、id、method、params字段齐全补全结果为空列表语言服务器没有加载对应文件或未声明补全能力先通过textDocument/didOpen打开文件确认服务器 capabilities 里声明了completionProviderLLM 调用超时本地模型推理慢或网络连接不通为请求设置超时时间例如 30 秒改用更小的模型确认 LLM 服务地址可访问LSP 返回数据太大Prompt 超长较大项目里 references 或 documentSymbol 返回大量节点在转发给 LLM 前做过滤和摘要只保留符号名、文件路径、行号等核心字段LLM 仍然编造 API提示词没有强调“只能使用给定符号”在 system prompt 中增加约束例如“如果上下文中没有该函数直接说明不存在不要猜测”频繁启动语言服务器导致性能差每个请求都新起一个子进程对语言服务器做常驻管理做成单例服务只建立一次会话后续复用连接排查时建议按这个顺序先确认服务器能独立启动再确认 JSON-RPC 消息发送正确再确认响应能读回最后才是调 LLM 的逻辑。大多数问题都出在前两步而不是 LLM 本身。6. 最佳实践与工程建议6.1 优先使用成熟语言服务器不要从零实现一个语言服务器。pyright、clangd、gopls、rust-analyzer、typescript-language-server这些项目经过了大量实际项目验证解析准确率高性能也更好。你的核心工作量应该放在“如何把 LSP 返回的结果组织给 LLM”而不是重复造语言服务器。6.2 对 LSP 结果做摘要和缓存LSP 返回的原始数据往往有很多字段直接全量塞进 Prompt 既浪费 token 又降低响应质量。建议做两层处理摘要层从 CompletionItem、SymbolInformation 等结构中只提取 label、kind、detail、location去掉无用元数据。缓存层同一个文件、同一个位置、同一段代码内容下的 LSP 响应可以缓存设置 TTL比如 30 秒。代码内容没有变化时不必重复请求。6.3 给 LLM 明确指令约束很多“模型乱编”的问题不是模型能力不行而是提示词没有设置边界。在系统提示词里要有类似这样的话当前项目符号仅以上下文提供的为准。如果待调用的函数不在列表中请明确回复“当前上下文中没有该函数”不要猜测或补全可能不存在的 API。这条约束能显著减少幻觉。6.4 注意异步与超时LSP 是请求-响应模型LLM 请求也可能很慢。在工程落地时不要让 Agent 主流程同步等待所有请求完成。更合理的做法是批量并行请求多个 LSP 方法统一等待结果后合并上下文。同时给所有网络请求设置超时避免一个慢服务拖垮整个 Agent。6.5 权限与安全边界语言服务器本质上是一个可以执行代码、读写文件的高权限进程。如果只在本地 IDE 场景使用问题不大但如果要远程部署成 Agent 服务就必须注意不要随便启动不可信的语言服务器。远程场景建议通过 SSH 隧道或带鉴权的 TCP 端口访问不要裸暴露 0.0.0.0。Agent 的代码修改动作应该限定在指定工作目录避免越权写入。对 LLM 生成的代码建议继续做静态检查确认没有明显安全风险后再落地。6.6 引入诊断信息提升修复能力如果做的是 Bug 修复类 Agent建议把textDocument/diagnostic也纳入上下文。修复前先拿到真实报错修复后再请求一次诊断能自动验证是否修复成功。这个循环比单纯让 LLM 阅读代码更可靠。7. 总结与下一步学习方向这篇文章从 LSP 的诞生讲起分析了 LLM 代码应用为什么需要 LSP并通过一个可运行的最小 Demo 展示了完整的“LLM LSP”链路。核心收获有四点LSP 是 LLM 理解真实代码库的可靠通道能有效减少 API 幻觉。LLM 负责语义理解和生成LSP 负责符号定位和结构分析二者的分工要清晰。接入 LSP 的关键不是实现协议而是把结构化结果转换成对 LLM 友好的上下文。生产落地时要特别关注摘要、缓存、超时、权限这些工程细节。接下来如果想继续深入建议按这个顺序学习读 LSP 官方规范重点看textDocument/*系列方法请求和响应结构。尝试把 Demo 里的服务器替换成 Pyright 或 gopls用真实项目跑一遍。研究 pygls 的高级用法理解workspace、diagnostics、settings的机制。在开源 Code Agent 框架里接入 LSP例如给 Agent 增加一个get_symbol_context工具函数。如果对性能有追求可以研究 LSP 的增量同步、多根工作区、语义令牌等进阶能力。大模型代码应用正在从“背题模式”走向“查阅模式”LSP 在其中扮演的角色会越来越重要。如果你正准备给自己的 AI 编程工具补充真实代码上下文能力不妨从今天这个最小闭环开始改造。你自己动手跑通一次会比看十篇文章更有效果。
返回列表