LSP 集成:Language Server Protocol 如何增强 AI 编码

深入 Claude Code 的 LSP 集成——为 AI 提供类型信息和诊断,而非为人类提供 IDE 功能

问题引入

Language Server Protocol (LSP) 是现代 IDE 的基石——它为编辑器提供了代码补全、跳转到定义、查找引用、悬停信息等能力。但 Claude Code 不是 IDE,它是一个 AI 编码助手。那么,为什么它需要 LSP?

考虑这个场景:AI 修改了一个 TypeScript 文件,将一个函数的参数从 string 改为 number。但它没有检查所有调用这个函数的地方——一些调用者仍然传入 string。如果没有 LSP,AI 不知道自己引入了类型错误,直到用户运行 tsc 编译器或在 IDE 中看到红色波浪线。

Claude Code 的 LSP 集成不是为了让终端变成 IDE。它的核心目的是让 AI 在编辑代码后立即获得语义反馈——类型错误、未使用的变量、找不到的引用——这些信息帮助 AI 在同一个交互循环中修复自己引入的问题。


LSP 在 Claude Code 中的角色

传统 IDE 中的 LSP
VS Code / JetBrains
Language Server
开发者
总耗时 ≈ 3 步(并行 = 更快)
Claude Code 中的 LSP
Claude Code
Language Server
DiagnosticRegistry
AI 上下文
Claude AI
总耗时 ≈ 5 步(并行 = 更快)

两个关键区别:

  1. 消费者不同 — IDE 中 LSP 的输出给人看;Claude Code 中 LSP 的输出给 AI 看
  2. 触发方式不同 — IDE 在用户交互时主动请求 LSP;Claude Code 在文件编辑后被动接收诊断,在 AI 主动调用 LSPTool 时才发起请求

LSPServerManager:多语言服务器管理

架构概览

src/services/lsp/LSPServerManager.ts:16-43
TypeScript
16export type LSPServerManager = {
17 initialize(): Promise<void>
18 shutdown(): Promise<void>
19 getServerForFile(filePath: string): LSPServerInstance | undefined
20 ensureServerStarted(filePath: string): Promise<LSPServerInstance | undefined>
21 sendRequest<T>(filePath: string, method: string, params: unknown): Promise<T | undefined>
22 getAllServers(): Map<string, LSPServerInstance>
23 openFile(filePath: string, content: string): Promise<void>
24 changeFile(filePath: string, content: string): Promise<void>
25 saveFile(filePath: string): Promise<void>
26 closeFile(filePath: string): Promise<void>
27 isFileOpen(filePath: string): boolean
28}

LSPServerManager 管理多个语言服务器实例,根据文件扩展名将请求路由到正确的服务器。它使用工厂函数模式(而非类),通过闭包封装内部状态:

src/services/lsp/LSPServerManager.ts:59-65
TypeScript
59export function createLSPServerManager(): LSPServerManager {
60 const servers: Map<string, LSPServerInstance> = new Map()
61 const extensionMap: Map<string, string[]> = new Map()
62 const openedFiles: Map<string, string> = new Map()
63 // ... 闭包内的私有状态
64}

扩展名到服务器的映射

src/services/lsp/LSPServerManager.ts:88-104
TypeScript
88 for (const [serverName, config] of Object.entries(serverConfigs)) {
89 if (!config.command) {
90 throw new Error(`Server ${serverName} missing required 'command' field`)
91 }
92 if (!config.extensionToLanguage ||
93 Object.keys(config.extensionToLanguage).length === 0) {
94 throw new Error(`Server ${serverName} missing required 'extensionToLanguage'`)
95 }
96
97 const fileExtensions = Object.keys(config.extensionToLanguage)
98 for (const ext of fileExtensions) {
99 const normalized = ext.toLowerCase()
100 if (!extensionMap.has(normalized)) {
101 extensionMap.set(normalized, [])
102 }
103 extensionMap.get(normalized)!.push(serverName)
104 }
105
106 const instance = createLSPServerInstance(serverName, config)
107 servers.set(serverName, instance)
108 }

每个语言服务器配置声明了它支持的文件扩展名和对应的语言标识符。一个扩展名可以映射到多个服务器(虽然不常见)。服务器在首次使用时才启动(懒加载)。

workspace/configuration 处理

src/services/lsp/LSPServerManager.ts:124-135
TypeScript
124 instance.onRequest(
125 'workspace/configuration',
126 (params: { items: Array<{ section?: string }> }) => {
127 logForDebugging(
128 `LSP: Received workspace/configuration request from ${serverName}`,
129 )
130 return params.items.map(() => null)
131 },
132 )

某些语言服务器(如 TypeScript)即使在客户端声明不支持 workspace/configuration 时也会发送请求。Claude Code 对每个请求项返回 null,满足协议要求而不提供实际配置。


全局单例与生命周期

src/services/lsp/manager.ts:14-25
TypeScript
14type InitializationState = 'not-started' | 'pending' | 'success' | 'failed'
15
16let lspManagerInstance: LSPServerManager | undefined
17let initializationState: InitializationState = 'not-started'
18let initializationError: Error | undefined
19let initializationGeneration = 0
20let initializationPromise: Promise<void> | undefined

LSP 管理器是一个全局单例,有四种状态:

...

世代计数器

src/services/lsp/manager.ts:145-207
TypeScript
145export function initializeLspServerManager(): void {
146 if (isBareMode()) return
147
148 if (lspManagerInstance !== undefined && initializationState !== 'failed') return
149
150 lspManagerInstance = createLSPServerManager()
151 initializationState = 'pending'
152
153 const currentGeneration = ++initializationGeneration
154
155 initializationPromise = lspManagerInstance
156 .initialize()
157 .then(() => {
158 if (currentGeneration === initializationGeneration) {
159 initializationState = 'success'
160 if (lspManagerInstance) {
161 registerLSPNotificationHandlers(lspManagerInstance)
162 }
163 }
164 })
165 .catch((error: unknown) => {
166 if (currentGeneration === initializationGeneration) {
167 initializationState = 'failed'
168 lspManagerInstance = undefined
169 }
170 })
171}

initializationGeneration 是一个世代计数器,防止过期的初始化 Promise 更新状态。当 reinitializeLspServerManager() 被调用时,世代递增,旧的初始化即使后续完成也不会影响新的状态。

这解决了一个真实的 bug(issue #15521):loadAllPlugins() 被 memoize 且在启动早期调用(通过 getCommands 预取),此时 marketplace 还未协调,导致插件列表为空。LSP 用空列表初始化后就再也没有重新初始化。修复方案是在插件刷新时调用 reinitializeLspServerManager()

健康检查

src/services/lsp/manager.ts:100-110
TypeScript
100export function isLspConnected(): boolean {
101 if (initializationState === 'failed') return false
102 const manager = getLspServerManager()
103 if (!manager) return false
104 const servers = manager.getAllServers()
105 if (servers.size === 0) return false
106 for (const server of servers.values()) {
107 if (server.state !== 'error') return true
108 }
109 return false
110}

isLspConnected() 检查是否至少有一个非错误状态的服务器。这个函数支撑了 LSPTool.isEnabled()——只有当 LSP 可用时,LSPTool 才会出现在工具列表中。


LSP Diagnostic Registry:被动诊断注入

LSP 集成最重要的功能不是 LSPTool(那是 AI 主动使用的),而是被动诊断注入——语言服务器在后台自动发送诊断,系统将其注入到 AI 的上下文中。

通知处理流程

src/services/lsp/passiveFeedback.ts:125-279
TypeScript
125export function registerLSPNotificationHandlers(
126 manager: LSPServerManager,
127): HandlerRegistrationResult {
128 const servers = manager.getAllServers()
129
130 for (const [serverName, serverInstance] of servers.entries()) {
131 serverInstance.onNotification(
132 'textDocument/publishDiagnostics',
133 (params: unknown) => {
134 // Validate params structure
135 if (!params || typeof params !== 'object' ||
136 !('uri' in params) || !('diagnostics' in params)) {
137 return
138 }
139
140 const diagnosticParams = params as PublishDiagnosticsParams
141
142 // Convert LSP diagnostics to Claude format
143 const diagnosticFiles = formatDiagnosticsForAttachment(diagnosticParams)
144
145 // Register for async delivery
146 registerPendingLSPDiagnostic({
147 serverName,
148 files: diagnosticFiles,
149 })
150 },
151 )
152 }
153}

每个语言服务器都注册了 textDocument/publishDiagnostics 通知处理器。当文件被编辑后,语言服务器重新分析并推送新的诊断信息。

严重度映射

src/services/lsp/passiveFeedback.ts:18-35
TypeScript
18function mapLSPSeverity(
19 lspSeverity: number | undefined,
20): 'Error' | 'Warning' | 'Info' | 'Hint' {
21 switch (lspSeverity) {
22 case 1: return 'Error'
23 case 2: return 'Warning'
24 case 3: return 'Info'
25 case 4: return 'Hint'
26 default: return 'Error'
27 }
28}

LSP 协议使用数字表示严重度,Claude Code 将其转换为字符串标签。默认值是 Error——当严重度未知时,宁可高估危险性。

DiagnosticRegistry:去重与限流

src/services/lsp/LSPDiagnosticRegistry.ts:41-47
TypeScript
41const MAX_DIAGNOSTICS_PER_FILE = 10
42const MAX_TOTAL_DIAGNOSTICS = 30
43const MAX_DELIVERED_FILES = 500
44
45const pendingDiagnostics = new Map<string, PendingLSPDiagnostic>()
46const deliveredDiagnostics = new LRUCache<string, Set<string>>({
47 max: MAX_DELIVERED_FILES,
48})

三重限制防止诊断信息淹没上下文:

  1. 每文件最多 10 条 — 排序后优先保留高严重度(Error > Warning > Info > Hint)
  2. 总共最多 30 条 — 全局限制
  3. 交叉轮次去重 — 已经发送过的诊断不再重复发送(基于 LRU 缓存,最多跟踪 500 个文件)

去重的键由消息、严重度、范围、来源和代码组成:

src/services/lsp/LSPDiagnosticRegistry.ts:110-124
TypeScript
110function createDiagnosticKey(diag: {
111 message: string
112 severity?: string
113 range?: unknown
114 source?: string
115 code?: unknown
116}): string {
117 return jsonStringify({
118 message: diag.message,
119 severity: diag.severity,
120 range: diag.range,
121 source: diag.source || null,
122 code: diag.code || null,
123 })
124}

文件编辑时重置

src/services/lsp/LSPDiagnosticRegistry.ts:372-379
TypeScript
372export function clearDeliveredDiagnosticsForFile(fileUri: string): void {
373 if (deliveredDiagnostics.has(fileUri)) {
374 logForDebugging(
375 `LSP Diagnostics: Clearing delivered diagnostics for ${fileUri}`,
376 )
377 deliveredDiagnostics.delete(fileUri)
378 }
379}

当文件被编辑时(FileWriteTool 或 FileEditTool 触发),该文件的已交付诊断被清除。这确保了新的诊断即使内容相同也会被重新发送——因为它们现在对应的是修改后的代码。


LSPTool:AI 主动查询

LSPTool 让 AI 可以主动请求 LSP 功能,而不仅仅被动接收诊断。

支持的操作

src/tools/LSPTool/prompt.ts:3-21
TypeScript
3export const DESCRIPTION = `Interact with Language Server Protocol (LSP) servers...
4
5Supported operations:
6- goToDefinition: Find where a symbol is defined
7- findReferences: Find all references to a symbol
8- hover: Get hover information (documentation, type info)
9- documentSymbol: Get all symbols in a document
10- workspaceSymbol: Search for symbols across the workspace
11- goToImplementation: Find implementations of an interface
12- prepareCallHierarchy: Get call hierarchy item at a position
13- incomingCalls: Find all callers of a function
14- outgoingCalls: Find all callees of a function`

九种操作覆盖了代码导航的核心需求。注意 incomingCallsoutgoingCalls 需要两步协议——先 prepareCallHierarchy 获取 CallHierarchyItem,再用它请求实际的调用关系。

坐标转换

src/tools/LSPTool/LSPTool.ts:427-513
TypeScript
427function getMethodAndParams(input: Input, absolutePath: string) {
428 const uri = pathToFileURL(absolutePath).href
429 // Convert from 1-based (user-friendly) to 0-based (LSP protocol)
430 const position = {
431 line: input.line - 1,
432 character: input.character - 1,
433 }
434 // ...
435}

LSP 协议使用 0-based 坐标,但编辑器和 FileReadTool 使用 1-based 坐标。LSPTool 在边界处进行转换,让 AI 可以直接使用从 Read 工具输出中看到的行号。

Gitignore 过滤

src/tools/LSPTool/LSPTool.ts:556-611
TypeScript
556async function filterGitIgnoredLocations<T extends Location>(
557 locations: T[],
558 cwd: string,
559): Promise<T[]> {
560 const uniquePaths = uniq(uriToPath.values())
561 const BATCH_SIZE = 50
562 for (let i = 0; i < uniquePaths.length; i += BATCH_SIZE) {
563 const batch = uniquePaths.slice(i, i + BATCH_SIZE)
564 const result = await execFileNoThrowWithCwd(
565 'git', ['check-ignore', ...batch],
566 { cwd, timeout: 5_000 }
567 )
568 // ... parse ignored paths
569 }
570 return locations.filter(loc => !ignoredPaths.has(filePath))
571}

LSP 服务器可能返回 node_modules 或其他 gitignored 目录中的结果。Claude Code 使用 git check-ignore 批量过滤这些结果(每批 50 个路径),避免 AI 被不相关的引用分散注意力。

文件大小限制

src/tools/LSPTool/LSPTool.ts:53
TypeScript
53const MAX_LSP_FILE_SIZE_BYTES = 10_000_000

超过 10MB 的文件被拒绝进行 LSP 分析。大文件通常是生成的代码或数据文件,LSP 分析它们既慢又没有价值。

延迟工具设置

src/tools/LSPTool/LSPTool.ts:137-139
TypeScript
137 shouldDefer: true,
138 isEnabled() {
139 return isLspConnected()
140 },

LSPTool 被标记为 shouldDefer: true——它不会出现在初始 prompt 中,AI 需要通过 ToolSearchTool 加载。isEnabled() 检查确保只有当至少一个语言服务器连接成功时,工具才可用。


Bridge LSP 共享

在 Bridge 模式下(Claude Code 作为 VS Code 扩展的后端运行),LSP 的角色发生了变化。VS Code 已经有自己的语言服务器,所以 Claude Code 不需要再启动一套。

src/services/lsp/manager.ts:145-150
TypeScript
145export function initializeLspServerManager(): void {
146 // --bare / SIMPLE: no LSP
147 if (isBareMode()) {
148 return
149 }
150 // ...
151}

在 bare 模式(脚本化 -p 调用)下,LSP 被完全禁用——没有用户交互,不需要诊断反馈。

在 Bridge 模式下,诊断信息可能直接从 VS Code 的 LSP 客户端推送过来(通过 MCP SDK),而不是由 Claude Code 自己管理语言服务器。这避免了两个 LSP 客户端竞争同一个语言服务器的问题。


与文件编辑工具的集成

LSP 集成最有价值的地方在于它与文件编辑工具的自动配合:

sequenceDiagram
    participant AI as Claude
    participant Edit as FileEditTool
    participant LSP as LSPServerManager
    participant Reg as DiagnosticRegistry
    participant Attach as 附件系统

    AI->>Edit: 编辑 file.ts
    Edit->>Edit: 写入文件

    Edit->>LSP: clearDeliveredDiagnostics(file.ts)
    Edit->>LSP: changeFile(file.ts, newContent)
    Edit->>LSP: saveFile(file.ts)

    Note over LSP: TypeScript 服务器重新分析

    LSP-->>Reg: publishDiagnostics<br>2 errors, 1 warning

    Note over AI: AI 继续执行下一步操作

    Reg-->>Attach: 待发送: 3 个诊断
    Attach-->>AI: [附件] TypeScript 错误:<br>1. Type 'string' not assignable...<br>2. Property 'foo' does not exist...

    Note over AI: AI 看到错误后自动修复

这个流程完全自动化——AI 不需要主动调用任何东西就能获得诊断反馈。FileEditTool 在写入文件后通知 LSP 服务器,服务器分析后推送诊断,诊断通过附件系统注入到 AI 的下一个查询中。


设计启示

Claude Code 的 LSP 集成体现了几个核心设计原则:

  1. 为 AI 设计,而非为人类设计 — LSP 的输出不是用来在 UI 中画红色波浪线的。它被转换为结构化文本,作为附件注入 AI 的上下文

  2. 被动优先,主动辅助 — 诊断注入是自动的(被动),LSPTool 是按需的(主动)。大多数时候 AI 不需要主动调用 LSP——错误信息会自动送上门

  3. 体积控制 — 每文件 10 条、总共 30 条、LRU 去重——这些限制确保 LSP 信息不会挤占其他有价值的上下文空间

  4. 懒启动 — 语言服务器按需启动,LSPTool 延迟加载。在不需要 LSP 的场景中(纯文本编辑、bash 操作),系统不付出 LSP 的初始化成本

  5. 防御性错误处理 — 初始化失败返回 undefined 而非抛出异常;世代计数器防止过期回调;每个服务器的通知处理互相隔离——LSP 的任何问题都不会影响 Claude Code 的核心功能