# Step 53: LSP 集成
一句话导读:接入 LSP(语言服务器协议),让 AI 改完代码后能拿到客观的编译诊断并自我纠错——而它的客户端骨架几乎就是 step47 手写 MCP 的复制品,唯一实质差异只有「消息分帧」这一处。
# 一、这一步做了什么(What)
接入 LSP(Language Server Protocol),把主观的「我觉得这代码对」换成客观的「编译器说它真的过」。三个新文件加一套接线:
services/lsp/client.ts:手写 LSP 客户端——长度前缀分帧 + initialize 握手 +didOpen+ 收诊断 + 按扩展名路由;services/lsp/exampleServer.mjs:自带的玩具 LSP server(跑正则:var→警告、;;→错误、TODO→提示),永远能跑;services/lsp/passiveFeedback.ts:被动反馈闭环——Write 文件后自动诊断,把报错追加进工具结果喂回模型;tools/diagnostics.ts的getDiagnostics工具(对照真实的mcp__ide__getDiagnostics)+/lsp命令。
LSP_SERVERS 里除了玩具 server,还默认启用了真实的 typescript-language-server。启动时工具数 37→38。
# 二、面试官视角:为什么 LSP 排在 MCP 之后,而不是从头写?(Why)
面试题:LSP 和 MCP 是两个完全不同的协议(一个管语言智能、一个管工具调用),为什么你说手写 MCP 客户端的代码「几乎能直接搬过来」写 LSP?它们到底共享了什么?
因为二者是同一套骨架的两个应用。把它们并排看:
| MCP(step47) | LSP(本步) | |
|---|---|---|
| 协议 | JSON-RPC 2.0 | JSON-RPC 2.0 |
| 通信 | spawn 子进程 + stdio | spawn 子进程 + stdio |
| 握手 | initialize → initialized | initialize → initialized |
| 消息分帧 | 一行一个 JSON(\n 分隔) | Content-Length: N\r\n\r\n + JSON(长度前缀) |
唯一实质差异是分帧方式。 请求-响应按 id 配对、通知无 id、握手时序——全都一样。所以把 LSP 排在 MCP 之后是有意的教学顺序:一旦你手写过一个 JSON-RPC-over-stdio 客户端,第二个只需要换掉分帧那一小块。这也揭示一个更深的点:「协议骨架」是可复用的资产——真正让每个协议不同的,往往只是外层的封帧和内层的消息语义,中间的传输机制高度同构。
那为什么 LSP 偏要用长度前缀而不是像 MCP 那样换行分帧?因为 LSP 的 JSON 里可能塞着整段源代码(didOpen 要把文件内容发过去),源代码自带换行,用 \n 分帧会把一条消息从中间切断。长度前缀 Content-Length: N 明确告诉你「接下来 N 个字节是一条完整消息」,内容里有多少换行都不影响。
# 三、原理:它是怎么工作的(How)
# 反直觉点:诊断是「推」来的,不是「问」来的
最容易想错的地方:你不是「问服务器某文件有没有错」,而是把文件内容 didOpen 发过去,服务器分析完主动 publishDiagnostics 推回来。
客户端 ──textDocument/didOpen(把文件内容也发过去)──→ 语言服务器
客户端 ←──textDocument/publishDiagnostics(分析完主动推回)── 语言服务器
2
所以 openAndDiagnose() 的本质是「打开文档 → 等服务器把诊断推回来」,而不是发一个请求同步拿结果。客户端要缓存服务器推来的诊断,并「等」它到达:
this.notifyHandlers.set("textDocument/publishDiagnostics", (p) => {
const key = this.normUri(p.uri);
this.diagnostics.set(key, p.diagnostics ?? []);
this.lastDiagAt.set(key, Date.now());
});
2
3
4
5
# 长度前缀分帧(读端)
题眼代码——从字节流里按 Content-Length 精确切出整条消息(一条可能分几次到达,也可能一次到好几条):
private onData(chunk: Buffer): void {
this.buf = Buffer.concat([this.buf, chunk]);
while (true) {
const sep = this.buf.indexOf("\r\n\r\n"); // 头体分界
if (sep === -1) return; // 头还没收全
const header = this.buf.subarray(0, sep).toString("ascii");
const len = parseInt(/Content-Length:\s*(\d+)/i.exec(header)![1], 10);
const start = sep + 4;
if (this.buf.length < start + len) return; // 体还没收全,等更多数据
const body = this.buf.subarray(start, start + len).toString("utf-8");
this.buf = this.buf.subarray(start + len); // 消费掉这条,留剩余给下一轮
this.dispatch(JSON.parse(body));
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
# 闭环自纠:主动 vs 被动
LSP 对 AI 的价值不是审美,是把「能写代码」变成「能交付能跑的代码」。两种触发方式:
| 主动(getDiagnostics 工具) | 被动(passiveFeedback 钩子) | |
|---|---|---|
| 谁发起 | 模型自己决定调 | 系统在 Write 后自动跑 |
| 机制 | 一个 ToolDef | step36 的 PostToolUse 钩子返回 { result } 追加文本 |
| 效果 | 「让我查一下有没有错」 | 「你刚写的文件有这几个错,去修」直接塞回结果 |
被动反馈复用了扩展名路由 + 严重级过滤(只报错误/警告,跳过 info/hint):
hooks.onPostToolUse("lsp-passive-feedback", async (e) => {
if (e.tool !== "Write" || e.isError) return; // 只在写文件成功后
const languageId = languageIdFor(e.input.file_path);
const targets = getClients().filter((c) => c.handles(languageId)); // 扩展名路由
// ... openAndDiagnose 拿诊断,只留 severity ≤ 2 的
return { result: e.result + `\n\n⚠ 语言诊断...` + problems.join("\n") };
});
2
3
4
5
6
7
实测:写 const port: number = "8080" 会自动把 Type 'string' is not assignable to type 'number'(code 2322)追加进 Write 结果,模型下一轮就看到并去修。
# 四、深入追问(面试常见 follow-up)
Q:诊断是「推」的,你怎么知道「推完了」,什么时候该返回?如果服务器先推一条空的呢?
A:这正是最大的坑。真实服务器在 didOpen 瞬间常先推一条空诊断,语义分析完再推带错误的。如果「收到第一条就返回」就会拿到空的假结论。解法是 waitDiagnostics 用防抖:收到诊断后再等一个 700ms 的「安静期」,期间没有新推送才算稳定,取最后一次;到硬上限(8s)无论如何返回当前所有。
private waitDiagnostics(uri, waitMs, quietMs = 700) {
// 收到诊断后等 quietMs 安静期没新推送 → resolve 最后一次;超 waitMs → 兜底返回
}
2
3
Q:你说连玩具 server 一次就通,连真实 typescript-language-server 却拿到 0 条诊断,为什么?
A:URI 不匹配。真实服务器回推诊断时会把 uri 重规范化(d:→d%3A、中文百分号编码),和我们 pathToFileURL 生成的字符串不逐字相等。我们按原始字符串当 map 的 key 去查,永远查不到 → 0 条。修法是 key 统一 decodeURIComponent 归一(normUri)后再比对。玩具 server 原样返回 uri,所以永远碰不到这个坑——这就是「连真实服务器」的教学价值:协议骨架很快通,真正的坑全在边界细节。
Q:一个 .py 文件,为什么不能发给 TypeScript 语言服务器让它自己判断?
A:会白等——TS 服务器对 python 无能为力,还占着 8s 超时。所以每个 server 声明 languages,用扩展名 → languageId → 匹配 server 做精确路由。handles() 是判据:含 *(玩具 catch-all)来者不拒,否则只处理声明过的语言。.ts 发给 demo() + ts,.py 只发给 demo(),ts 被排除。
Q:主动工具和被动钩子,为什么两个都要?留一个不行吗?
A:覆盖不同场景。主动 getDiagnostics 让模型在想确认时自查(「我改完了,查一下」);被动钩子保证即使模型忘了查,写完文件也会被强制喂回报错。前者靠模型自觉,后者是系统兜底——两者叠加才形成可靠的「写→查→修」闭环。
# 五、踩坑 / 设计权衡
两个「连真实 server 才暴露」的边界 bug(玩具 server 碰不到):
| Bug | 根因 | 修法 |
|---|---|---|
| URI 不匹配 | 服务器回推时重规范化 uri(d:→d%3A),和 pathToFileURL 不逐字相等 | key 统一 decodeURIComponent 归一 |
| 空诊断竞速 | didOpen 瞬间先推空,语义分析完再推带错的 | waitDiagnostics 改防抖,等 700ms 安静期取最后一次 |
教训:玩具 server 一次推完、uri 原样返回,永远碰不到这些。协议骨架(分帧/握手/didOpen)几小时就通,真正吃时间的是 URI 规范化、异步推送时序这类真实世界的边界。
# 六、与真实源码的对照
| 我们的实现 | Claude Code 源码 |
|---|---|
| 手写 Content-Length 分帧 | LSPClient.ts 用 vscode-jsonrpc 库做同样的事 |
| 玩具 server + 真实 typescript-language-server | 连各语言服务器(pyright/gopls/jdtls…) |
按 languages 扩展名路由 | config.ts 里每个 server 声明支持的语言/文件模式 |
| 只做诊断 | 还有跳转/引用/悬停/补全,及崩溃重启 |
LSP_SERVERS 写死配置 | LSP server 只能由插件贡献 + 按 scope 合并 |
# 七、一句话总结
LSP = 复用 MCP 的 JSON-RPC 骨架,只换分帧(长度前缀),做一件事:让编译器主动把诊断推回来喂给 AI;主动工具 + 被动钩子叠成「写→查→修」闭环,而真正的难点全在 URI 归一和防抖这类连玩具 server 碰不到的边界细节。
# 下一节预告
下一步(跳过 step54 OAuth)进入 step55 本地 REPL Bridge——把正在跑的会话「桥」给第二个前端,让另一个终端也能看到实时输出并发指令。