ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

vscode插件快餐教程(7) - 从头开始写一个完整的lsp工程:用TaoToken统一Key打通AI补全链路

vscode插件快餐教程(7) - 从头开始写一个完整的lsp工程:用TaoToken统一Key打通AI补全链路 1. 从零搭 LSP 工程时AI 补全请求为什么总卡在鉴权上如果你正在写一个 VS Code 语言服务器插件前面几节应该已经把 client 和 server 的骨架跑通了createConnection建好连接onCompletion返回几个写死的CompletionItem按 F5 能在新窗口里看到补全列表。但接下来想把补全结果换成大模型实时生成的内容时问题就来了——语言服务器进程里怎么安全地拿到模型能力又不让每个插件各自维护一份密钥。这个场景在 vscode 插件开发里非常典型。LSP 的 server 端是一个独立进程它通过connection.onCompletion接收补全请求返回CompletionItem[]。如果想让这个返回值来自大模型server 进程就需要发起一次 HTTP 请求。问题在于请求的 endpoint 写在哪、Key 从哪读、多个插件之间怎么复用同一套配置。我见过不少项目直接把 Key 硬编码在server.ts里或者每个插件各写一份.env结果换一次 Key 要改五六个仓库。TaoToken 在这里扮演的角色就是一个统一的模型接入层。它提供兼容 OpenAI 风格的接口语言服务器只要把baseURL指向https://taotoken.net/api用同一个 Key 就能调用不同模型。这样 LSP 工程里只需要维护一份配置client 端和 server 端都不用关心具体模型厂商的差异。对于正在写 vscode 插件、想让 AI 补全链路走统一 Key 通道的开发者来说这一节要解决的就是「server 进程如何拿到模型能力」这个具体问题。先说清楚整体链路VS Code 编辑器触发补全 → client 通过 IPC 把请求发给 server → server 的onCompletion回调被调用 → 回调里向 TaoToken 发请求 → 拿到模型返回的文本 → 包装成CompletionItem[]返回给编辑器。我们要改的就是中间那一步把原来写死的数组换成真实请求。在动手之前你需要确认三件事Node 环境能跑建议 18 以上、已经有一个能跑通的 LSP 骨架工程、以及一个 TaoToken 的 API Key。Key 的获取在控制台里完成后面会给出具体路径。整个改造不需要动 client 端的extension.ts所有工作都集中在 server 目录。这里有个容易踩的坑LSP server 进程默认没有网络请求的依赖你需要自己装一个 HTTP 客户端。用 Node 内置的fetch也行但要注意 LSP server 的 Node 版本可能和编辑器宿主不一致。稳妥起见我在 server 的package.json里显式加了node-fetch避免版本差异导致的fetch is not defined。这个报错在 LSP 工程里很常见因为 server 进程的运行时是编辑器拉起来的不一定是你终端里的那个 Node。另外要提醒的是补全请求是高频操作。用户每敲一个字符都可能触发一次onCompletion如果每次都同步等模型返回编辑器会明显卡顿。所以真实工程里通常要做防抖或者缓存但这一节先聚焦链路打通把请求发出去、结果能回来、编辑器能显示这三步走通之后再谈优化。链路不通的时候谈性能没有意义。2. TaoToken 统一 Key 接入把 endpoint 和鉴权收口到一处这一节讲怎么把模型接入配置收口。核心思路是LSP server 不直接读环境变量里的散装 Key而是通过一个统一的配置模块拿到baseURL、apiKey、model三件套。这样以后换模型或者换 Key只改一个地方。先说 TaoToken 的接入信息。API 地址是https://taotoken.net/api注意这个地址不带任何查询参数直接作为 OpenAI 兼容接口的 base。官网在https://taotoken.net/控制台里可以创建和管理 API Key。如果你还没建 Key进控制台后找到 API Keys 页面新建一个复制出来备用。文档页有完整的接口说明遇到参数不确定的时候可以对照。为什么要在 LSP 工程里做「统一 Key」这件事因为一个 VS Code 插件工程往往不止一个语言服务器。你可能同时有 JS 的 server、Python 的 server、还有 Markdown 的 server如果每个 server 各自读一份 Key配置就会散落。更麻烦的是 client 端有时候也需要调模型比如做命令面板的 AI 功能如果 client 和 server 各维护一份轮换 Key 的时候必然漏掉一个。我的做法是在工程根目录放一个config/ai.jsonclient 和 server 都从这个文件读。但 LSP server 进程的工作目录不一定等于工程根目录所以路径要用context.asAbsolutePath或者环境变量传进去。更稳的方式是通过initializationOptions把配置从 client 传给 server这样 server 启动时就拿到了配置不用自己去找文件。具体来说client 端在创建LanguageClient的时候clientOptions里可以带initializationOptions。server 端在onInitialize回调的params.initializationOptions里就能读到。这条通道是 LSP 协议原生支持的不需要额外开文件或者环境变量非常适合传这种启动期就确定的配置。配置的结构建议长这样一个baseURL指向 TaoToken 的 API 地址一个apiKey放密钥一个model指定默认模型再加一个timeout控制请求超时。model 字段很重要因为不同任务适合不同模型补全这种场景通常用响应快的模型而代码解释可以用能力更强的。把这些都放在配置里切换的时候不用改代码。安全方面要注意apiKey不要提交到 git。在工程里加.gitignore排除config/ai.json然后提供一个config/ai.example.json作为模板。团队协作时每个人复制一份填自己的 Key。如果你要把插件发布到市场更不能把 Key 打包进去这种情况应该让用户在自己的设置里填 Key插件通过workspace.getConfiguration读取。这一节先按本地开发场景走发布场景后面再展开。还有一个细节TaoToken 的接口是 OpenAI 兼容的所以请求体格式和 OpenAI 的/v1/chat/completions一致。但 base URL 的拼法要注意https://taotoken.net/api后面接/v1/chat/completions不要重复拼/v1。我见过有人写成https://taotoken.net/api/v1/v1/chat/completions结果 404。这个在排障章节会再提。3. 可复制的 LSP 初始化配置片段这一节给出可以直接抄的配置。先看 server 端的package.json在原有依赖基础上加node-fetch{ name: lsp-demo-server, description: demo language server with ai completion, version: 1.0.0, license: MIT, engines: { node: * }, dependencies: { vscode-languageserver: ^8.1.0, vscode-languageserver-textdocument: ^1.0.8, node-fetch: ^2.7.0 }, scripts: {} }注意vscode-languageserver的版本老教程里常见的是 4.x但 4.x 的 API 和现在差别较大。如果你是从头写建议用 8.xTextDocuments的用法更清晰。node-fetch用 2.x 是因为 3.x 是 ESM only在 CommonJS 的 LSP server 里引入会报错。这个坑我踩过require(node-fetch)在 3.x 下会抛ERR_REQUIRE_ESM。然后是配置模块server/src/aiConfig.tsexport interface AiConfig { baseURL: string; apiKey: string; model: string; timeout: number; } export const defaultAiConfig: AiConfig { baseURL: https://taotoken.net/api, apiKey: , model: gpt-4o-mini, timeout: 8000 }; export function resolveAiConfig( initOptions: any ): AiConfig { const fromInit initOptions?.ai ?? {}; return { baseURL: fromInit.baseURL || defaultAiConfig.baseURL, apiKey: fromInit.apiKey || process.env.TAOTOKEN_API_KEY || , model: fromInit.model || defaultAiConfig.model, timeout: fromInit.timeout || defaultAiConfig.timeout }; }这个模块做了两件事定义默认配置以及从initializationOptions里解析出实际配置。注意apiKey的兜底顺序是先看初始化参数再看环境变量。这样本地开发时你可以用环境变量团队协作时用初始化参数两种方式都支持。client 端的extension.ts里创建LanguageClient时把配置塞进initializationOptionsimport * as path from path; import { workspace, ExtensionContext } from vscode; import { LanguageClient, LanguageClientOptions, ServerOptions, TransportKind } from vscode-languageclient/node; let client: LanguageClient; export function activate(context: ExtensionContext) { const serverModule context.asAbsolutePath( path.join(server, out, server.js) ); const serverOptions: ServerOptions { run: { module: serverModule, transport: TransportKind.ipc }, debug: { module: serverModule, transport: TransportKind.ipc, options: { execArgv: [--nolazy, --inspect6009] } } }; const clientOptions: LanguageClientOptions { documentSelector: [{ scheme: file, language: javascript }], initializationOptions: { ai: { baseURL: https://taotoken.net/api, apiKey: process.env.TAOTOKEN_API_KEY || , model: gpt-4o-mini, timeout: 8000 } } }; client new LanguageClient( DemoLanguageServer, Demo Language Server, serverOptions, clientOptions ); client.start(); } export function deactivate(): Thenablevoid | undefined { if (!client) { return undefined; } return client.stop(); }这里initializationOptions里的ai对象就是 server 端resolveAiConfig读的那个。三件套baseURL、apiKey、model都在这里出现缺一不可。如果你用的是 Codex 的auth.json或者 Cline 的 MCP 配置思路是一样的把这三个值填到对应的配置位置。CC Switch 这类工具也是同样的逻辑切换的就是这三件套。server 端的server.ts里onInitialize回调要保存配置import { createConnection, TextDocuments, ProposedFeatures, InitializeParams, CompletionItem, CompletionItemKind, TextDocumentPositionParams } from vscode-languageserver; import { TextDocument } from vscode-languageserver-textdocument; import { resolveAiConfig, AiConfig } from ./aiConfig; let connection createConnection(ProposedFeatures.all); let documents new TextDocuments(TextDocument); let aiConfig: AiConfig; connection.onInitialize((params: InitializeParams) { aiConfig resolveAiConfig(params.initializationOptions); return { capabilities: { textDocumentSync: 1, completionProvider: { resolveProvider: true, triggerCharacters: [., ] } } }; });textDocumentSync: 1表示全量同步简单场景够用。triggerCharacters里加.和空格这样用户敲这两个字符时会主动触发补全。注意resolveProvider: true要保留因为后面onCompletionResolve还要用。到这里配置部分就齐了。baseURL是https://taotoken.net/apiapiKey从环境变量或初始化参数来model指定模型 ID。这三个值在 client 和 server 之间通过 LSP 协议传递不需要额外的文件读写。4. 用一次补全请求验证链路是否生效配置写完了现在要验证链路真的通了。这一节给出完整的onCompletion实现以及怎么确认请求确实发到了 TaoToken。先看 server 端的补全回调。核心是把用户当前行的上下文拼成 prompt发给模型再把返回的文本包装成CompletionItemimport fetch from node-fetch; connection.onCompletion( async ( textDocumentPosition: TextDocumentPositionParams ): PromiseCompletionItem[] { const doc documents.get(textDocumentPosition.textDocument.uri); if (!doc) { return []; } const line doc.getText({ start: { line: textDocumentPosition.position.line, character: 0 }, end: textDocumentPosition.position }); if (!aiConfig.apiKey) { connection.window.showWarningMessage( TaoToken API Key 未配置补全走本地兜底 ); return localFallback(textDocumentPosition); } try { const controller new AbortController(); const timer setTimeout( () controller.abort(), aiConfig.timeout ); const resp await fetch( ${aiConfig.baseURL}/v1/chat/completions, { method: POST, headers: { Content-Type: application/json, Authorization: Bearer ${aiConfig.apiKey} }, body: JSON.stringify({ model: aiConfig.model, messages: [ { role: system, content: 你是代码补全助手只返回补全的代码片段不要解释。 }, { role: user, content: 补全下面这行 JavaScript 代码\n${line} } ], max_tokens: 64, temperature: 0.2 }), signal: controller.signal } ); clearTimeout(timer); if (!resp.ok) { const errText await resp.text(); connection.console.error( TaoToken 请求失败 ${resp.status}: ${errText} ); return localFallback(textDocumentPosition); } const data: any await resp.json(); const content data?.choices?.[0]?.message?.content?.trim() ?? ; if (!content) { return localFallback(textDocumentPosition); } return [ { label: content, kind: CompletionItemKind.Text, detail: AI 补全 (${aiConfig.model}), documentation: 由 TaoToken 统一 Key 通道生成, data: 100 } ]; } catch (e: any) { connection.console.error(补全请求异常: ${e.message}); return localFallback(textDocumentPosition); } } ); function localFallback( pos: TextDocumentPositionParams ): CompletionItem[] { return [ { label: console.log, kind: CompletionItemKind.Snippet, data: 1 }, { label: function, kind: CompletionItemKind.Keyword, data: 2 } ]; }这段代码有几个关键点。第一fetch的 URL 是${baseURL}/v1/chat/completionsbaseURL 是https://taotoken.net/api拼出来就是https://taotoken.net/api/v1/chat/completions。第二鉴权用Authorization: Bearer key这是 OpenAI 兼容接口的标准写法。第三加了AbortController做超时避免模型响应慢的时候编辑器一直转圈。第四任何异常都走localFallback保证补全功能不会因为网络问题完全不可用。onCompletionResolve也要补上因为resolveProvider: true时编辑器会二次调用connection.onCompletionResolve( (item: CompletionItem): CompletionItem { if (item.data 100) { item.detail AI 补全结果; item.documentation 该补全由语言服务器通过 TaoToken 统一 Key 通道请求模型生成。; } return item; } ); documents.listen(connection); connection.listen();现在验证链路。在终端里设置环境变量然后按 F5 启动调试export TAOTOKEN_API_KEY你的KeyVS Code 会打开一个新的扩展开发宿主窗口。在里面新建一个.js文件输入const arr [1,2,3]; arr.等一两秒补全列表应该出现模型生成的内容detail 显示AI 补全 (gpt-4o-mini)。如果出现了说明链路通了。想确认请求真的发出去了可以在 server 端加一行日志或者直接看 TaoToken 控制台的用量记录。控制台里能看到每次请求的模型、token 数和时间戳。如果控制台有记录但编辑器没显示补全问题就在返回值的包装上如果控制台没记录问题在请求发出之前检查 Key 和 URL。还有一个更直接的验证方式在onCompletion里加connection.console.log把resp.status打出来。VS Code 的输出面板里选「Demo Language Server」就能看到日志。200 表示成功401 表示 Key 有问题404 表示 URL 拼错了。5. 本篇常见错排查401、local proxy failed 与 reading choices链路跑不通的时候报错信息往往很含糊。这一节把几个高频错误对照着讲清楚。401 Unauthorized。这是最常见的。原因通常是 Key 没传进去或者传错了。先检查aiConfig.apiKey是不是空字符串。如果 client 端用process.env.TAOTOKEN_API_KEY读要确认启动 VS Code 的终端里确实export了这个变量。注意如果你是从桌面图标启动 VS Code它继承的是系统环境变量不是终端里的。调试场景下按 F5 启动的宿主窗口继承的是当前终端的环境所以export之后按 F5 是有效的。另一个可能是 Key 复制的时候带了空格或者换行Bearer后面多一个空格就会 401。建议在代码里apiKey.trim()一下。local proxy failed。这个报错通常出现在企业网络环境里系统配了 HTTP 代理但 Node 的 fetch 没走代理或者代理配置和实际网络不匹配。LSP server 进程默认不读系统的代理设置。如果你确实需要走代理要在 fetch 里显式配置 agent。但更常见的情况是这个报错和 TaoToken 无关是本地网络栈的问题。先确认curl https://taotoken.net/api/v1/models能不能通如果 curl 通而插件不通就是 Node 进程的网络配置问题。检查HTTP_PROXY/HTTPS_PROXY环境变量必要时在 server 启动时清掉。Cannot read properties of undefined (reading choices)。这个报错说明data.choices是 undefined也就是返回的 JSON 结构不符合预期。原因通常是 URL 拼错了请求打到了别的路径返回了一个错误页面的 HTML 或者别的 JSON 结构。检查baseURL后面拼的是不是/v1/chat/completions。如果 baseURL 已经带了/v1再拼一次就变成/v1/v1/chat/completions会 404。TaoToken 的 base 是https://taotoken.net/api不带/v1所以拼一次是对的。另外如果返回的是流式响应stream: truedata.choices的结构也不一样这一节用的是非流式别混用。OAuth 相关报错。如果你在配置里误用了 OAuth 的鉴权方式会看到invalid_grant或者unsupported_grant_type。TaoToken 的 API 用的是 Bearer Token不是 OAuth 流程。把Authorization头改成Bearer key就行。Codex 的auth.json里如果配的是 OAuth 凭据也要换成 API Key 模式。Cline 的 MCP 配置里同理鉴权字段填 API Key。补全不触发。配置都对但补全列表不弹出来检查documentSelector里的language是不是javascript以及文件后缀是不是.js。如果你在.ts文件里测试要把language改成typescript或者加一条。另外triggerCharacters里如果没有.手动按 CtrlSpace 也能触发。server 进程起不来。按 F5 后新窗口里没有任何反应看「输出」面板的 server 日志。常见原因是server/out/server.js不存在也就是 TypeScript 没编译。检查tsconfig.json的outDir和rootDir以及有没有跑tsc -b。如果用了 project references根目录的tsconfig.json要正确引用 client 和 server 两个子项目。改了代码不生效。LSP server 是独立进程改了 server 代码要重启宿主窗口才生效。client 代码改了也要重启。调试时用tsc -b -w开 watch改完保存后重启窗口。6. 把统一 Key 通道接到你的下一个 LSP 工程链路打通之后接下来可以做的事就多了。最直接的是把补全从「单行」扩展到「多行上下文」把光标前后的代码都拼进 prompt让模型给出更准确的建议。这时候要注意 token 消耗补全场景下 prompt 不宜太长通常取当前行加上面几行就够了。另一个方向是加缓存。同样的前缀不应该重复请求模型可以在 server 端用一个 Map 缓存line - completion的映射命中就直接返回。缓存要设过期时间否则内存会涨。这个优化对编辑器流畅度提升很明显。如果你打算把这个插件发布出去Key 的管理方式要改。不能把 Key 打包进插件而是让用户在 VS Code 的设置里填。用workspace.getConfiguration(yourPlugin).get(apiKey)读取然后在package.json的contributes.configuration里声明这个设置项。这样每个用户用自己的 Key插件本身不含任何凭据。TaoToken 的 Coding Plan 适合长期做编码类插件的场景因为补全请求量大按量计费的模式更灵活。如果你只是偶尔测试用 API Keys 页面建的 Key 就够了。模型对话页面可以用来快速验证某个模型对代码补全的效果不用每次都改插件代码。接入文档里有完整的接口参数说明遇到不确定的字段可以对照。控制台里能看用量和余额调试阶段建议盯着点避免 Key 泄露导致意外消耗。最后说一个实际经验LSP server 里的网络请求一定要有超时和兜底。模型服务再稳也有抖动的时候如果补全请求卡住整个编辑器的补全功能都会受影响。我现在的做法是超时设 8 秒超时后返回本地静态补全同时给用户一个不打扰的提示。这样即使模型不可用基本的编码体验还在。链路打通只是第一步让它稳定可用才是工程化的开始。
返回列表