做 Code Agent 相关工作的朋友应该都有过这种体验:模型底座一换,整个 Agent 的上下文构建、工具调用、输出解析全都要跟着重新过一遍。有人觉得接一个新 LLM Provider 不就是改个 base_url 和 api_key 吗?真上手就会发现,问题全藏在那些"看起来差不多"的细节里。
最近我在解剖 Continue 这个开源 AI Code Agent 的源码,正好把"从零接入一个新 Provider"这条链路完整走了一遍。这篇是系列第 21 篇,我打算用实战的方式,把 Provider 抽象层到底该怎么设计、新 Provider 怎么接进去、哪些地方最容易翻车,一次讲透。内容以 Continue 为参照系,但思路适用于任何自研的 Code Agent——你只要手里有一个基于 LLM 的编码助手,这篇文章就能帮你少走弯路。
1. 为什么"接 Provider"是 Code Agent 解剖系列里绕不开的一课
1.1 模型底座是 Code Agent 唯一的外脑
市面上叫得上名字的 Code Agent,不管 UI 长什么样、前缀是 Continue 还是 Copilot 还是 Cursor,内核都一样:把用户的自然语言意图、当前编辑器上下文、仓库里的相关代码片段拼装成 Prompt,扔给一个 LLM,拿到回复后要么直接展示,要么解析成工具调用继续执行。
这意味着 LLM Provider 就是 Code Agent 的"外脑"。换外脑不是换个脑子那么简单,因为不同"脑子"的说话方式不一样:
- OpenAI 系的 API 用
chat/completions接口,工具调用叫tools/tool_calls。 - Anthropic 系用
messages接口,工具调用叫tools/tool_use,而且消息轮次结构里多了一个tool_use块的拆分逻辑。 - 本地跑的 Ollama、LM Studio,接口是 OpenAI 兼容的,但模型名、参数项、是否支持流式工具调用,每家又有自己的脾气。
所以"接入新的 LLM Provider"这件事,本质上是在做一层翻译:把 Code Agent 内部的统一诉求,翻译成某个 Provider 听得懂的方言,再把 Provider 的回话翻译回 Agent 的统一格式。这层翻译做得好不好,直接决定这个 Agent 能不能在不改上层逻辑的前提下快速拥抱新模型。
1.2 Continue 这个开源参照系为什么值得看
我选 Continue 当参照系,不是因为它功能最全,而是因为它把 Provider 抽象做得足够典型,而且是个可以随便翻源码的开源项目。它支持几十个 Provider,从 OpenAI、Anthropic、Google 到 Ollama、vLLM、各种国内厂商的兼容端点,覆盖面广,抽象层就必然被磨得比较薄、比较实用。
更关键的是,Continue 的 Provider 接入不是写死在一坨 if-else 里,而是有明确的接口定义、注册机制和配置映射。你去看它的core/llm/llms/目录,会发现每个 Provider 一个文件,继承同一个基类,重写几个关键方法就完事。这个结构本身就是一份很好的教材:它告诉你哪些东西应该抽象到基类里,哪些东西应该留给子类去实现。
对我这种喜欢"抄作业"的人来说,直接读一个生产级开源项目的扩展点,比看十篇理论文章都管用。下面我就沿着 Continue 的设计思路,把整条链路拆开讲。
2. Provider 抽象层:接口边界画在哪,决定了你能走多远
2.1 最小必要接口:一个可用 Provider 最少要暴露什么
接入新 Provider 之前,最重要的决策是:抽象层到底要抽象到什么程度。抽象太粗,上层代码全是 if-else;抽象太细,接一个小众 Provider 要写 200 行空实现。
以 Continue 的实践来看,一个可用的 Provider 最少需要回答三个问题:
- 你能列出哪些模型?(决定用户在下拉框里能看到什么)
- 你能做非流式的补全吗?(决定简单问答和测试能不能跑通)
- 你能做流式补全吗?(决定真实编码场景里的逐字输出体验)
对应到代码上,就是这样一个接口轮廓:
interface LLMProvider { id: string; listModels(): Promise<ModelInfo[]>; chatComplete(params: ChatParams): Promise<ChatResponse>; streamChat(params: ChatParams): AsyncGenerator<StreamEvent>; } interface ChatParams { messages: ChatMessage[]; model: string; tools?: ToolDefinition[]; temperature?: number; maxTokens?: number; signal?: AbortSignal; } interface ChatResponse { content: string; toolCalls?: ToolCall[]; usage?: TokenUsage; } type StreamEvent = | { type: "text"; delta: string } | { type: "tool_call"; delta: Partial<ToolCall> } | { type: "done"; usage?: TokenUsage } | { type: "error"; message: string };这个设计里最重要的不是方法签名,而是StreamEvent这个联合类型。它把"任意 Provider 的流式输出"统一成了三种事件:文本增量、工具调用增量、结束。上层 UI 和 Agent 循环只需要消费这三种事件,完全不关心底层是 SSE 还是 WebSocket,是 OpenAI 格式还是 Anthropic 格式。
接口画到这个边界,后面接任何 Provider 都只是实现三个方法的事。如果你发现某个方法在某个 Provider 上天然不支持,比如一个纯补全模型不支持工具调用,那就在基类里提供默认抛错实现,子类按需覆盖。
2.2 比接口更重要的:请求/响应的"方言"归一化
接口只是外壳,真正的工作量在"方言归一化"。
举个最典型的例子:OpenAI 和 Anthropic 对"模型返回工具调用"的表达方式完全不同。
OpenAI 的响应里,工具调用长这样:
{ "choices": [{ "message": { "role": "assistant", "content": null, "tool_calls": [{ "id": "call_123", "type": "function", "function": { "name": "read_file", "arguments": "{\"path\": \"/src/main.py\"}" } }] } }] }Anthropic 的响应里,同样的事情长这样:
{ "content": [ { "type": "text", "text": "我来读取文件" }, { "type": "tool_use", "id": "toolu_123", "name": "read_file", "input": { "path": "/src/main.py" } } ] }注意区别:OpenAI 的arguments是字符串,需要你 JSON.parse;Anthropic 的input直接是对象。OpenAI 的content是 null,文本和工具调用分开;Anthropic 的content是数组,文本块和工具块混在一起。
如果你不做归一化,上层 Agent 循环就得同时处理两套逻辑,每接一个新 Provider 就多一套分支。归一化之后,上层只看到统一的ToolCall { id, name, arguments },至于底层哪种格式,Provider 实现类自己消化。
StreamEvent的tool_call增量事件也是为这个设计的。OpenAI 流式返回tool_calls数组里的 delta 是逐步拼出来的,Anthropic 流式返回content_block_delta里的input_json_delta,它们拼接方式不同,但归一化到Partial<ToolCall>后,上层只管累积arguments字符串,最后统一解析。
2.3 配置体系的设计:让新 Provider 只改配置文件
接口和归一化解决的是代码层的问题,配置层一样有讲究。Continue 的用户配置里,一个模型长这样:
{ "models": [ { "title": "My Custom Model", "provider": "custom", "model": "my-model-name", "apiKey": "sk-xxx", "baseUrl": "https://api.example.com/v1", "apiVersion": "2024-02-01" } ] }注意provider字段对应的是注册在代码里的 Provider id,model字段是要发给服务端的模型名,apiKey、baseUrl这些是可选参数。这种设计的好处是:接新 Provider 时,如果它只是某个已有协议的变体,你甚至不用写新代码,配一个provider: "openai"加上自定义baseUrl就能用。
真正需要写代码的情况只有两种:一种是这个 Provider 的协议跟所有现有 Provider 都不同,另一种是你需要在请求里塞一些只有它才有的特殊参数。后一种情况,Continue 的处理方式是在配置里允许透传自定义参数,Provider 实现里读出来塞进请求体。这样既保持了配置的灵活性,又不需要为了个别参数去改抽象接口。
3. 实战:从零接入一个新 Provider(以 Continue 为例)
3.1 摸清仓库结构和扩展点
先说清楚,这里不是教你改 Continue 源码然后提 PR,而是教你理解它的扩展点,然后可以把它平移到你自己的 Code Agent 上。Continue 的仓库结构里,核心逻辑在core/目录,Provider 相关代码集中在core/llm/llms/。每个 Provider 一个文件,比如OpenAI.ts、Anthropic.ts、Ollama.ts。
它们的共同特征是:继承一个BaseLLM类,重写_streamChat或_complete这类内部方法。基类负责公共逻辑,比如组装请求头、处理 AbortSignal、统计 token 用量,子类只负责协议差异。
接入一个新 Provider 的第一步,就是在这个目录里新建一个文件,然后去注册表里登记。这个"登记"动作看似不起眼,其实是整个扩展机制的核心:注册表是一个把字符串 id 映射到 Provider 构造函数的地方,上层代码只跟 id 打交道。
export const llmProviders: Record<string, new () => BaseLLM> = { openai: OpenAI, anthropic: Anthropic, ollama: Ollama, custom: CustomProvider, };加了这一行,配置里写"provider": "custom"就能被正确识别。注册机制的意义在于:上层模块不需要 import 每一个 Provider,只需要查这张表。这也是为什么 Continue 能保持核心逻辑和 Provider 实现解耦。
3.2 实现 Provider 类:从 Model 列表到 Chat Completion
假设我们要接一个虚构的 Provider,叫example,它提供了一个 OpenAI 风格的接口,但有一些私有改动。最稳妥的实现路径是:先照着OpenAI.ts抄骨架,再逐段替换成新服务的协议。
一个最小的实现长这样:
import { BaseLLM } from "../base"; import { ChatParams, StreamEvent, ToolCall } from "../types"; interface ExampleChatResponse { choices: Array<{ message?: { content?: string; tool_calls?: any[] }; delta?: { content?: string; tool_calls?: any[] }; finish_reason?: string; }>; } export class ExampleProvider extends BaseLLM { async listModels(): Promise<ModelInfo[]> { const resp = await fetch(`${this.baseUrl}/models`, { headers: { Authorization: `Bearer ${this.apiKey}` }, }); const data = await resp.json(); return data.data.map((m: any) => ({ name: m.id, title: m.id })); } async *streamChat(params: ChatParams): AsyncGenerator<StreamEvent> { const body = { model: params.model, messages: params.messages, stream: true, temperature: params.temperature, max_tokens: params.maxTokens, }; const resp = await fetch(`${this.baseUrl}/chat/completions`, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${this.apiKey}`, }, body: JSON.stringify(body), signal: params.signal, }); if (!resp.ok) { const text = await resp.text(); throw new Error(`Example API error ${resp.status}: ${text}`); } const reader = resp.body!.getReader(); const decoder = new TextDecoder(); let buffer = ""; while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); const lines = buffer.split("\n"); buffer = lines.pop()!; for (const line of lines) { const trimmed = line.trim(); if (!trimmed.startsWith("data:")) continue; const payload = trimmed.slice(5).trim(); if (payload === "[DONE]") { yield { type: "done" }; continue; } const json: ExampleChatResponse = JSON.parse(payload); const delta = json.choices[0]?.delta; if (delta?.content) { yield { type: "text", delta: delta.content }; } if (delta?.tool_calls) { yield { type: "tool_call", delta: normalizeToolCall(delta.tool_calls[0]) }; } } } } }这段代码的核心逻辑就三步:拼请求、读 SSE 流、把每一行data:载荷解析后归一化成StreamEvent。注意TextDecoder一定要开{ stream: true },否则多字节字符(比如中文注释)在跨 chunk 时会被截断成乱码。这个坑我在第一次写的时候踩过,后面细讲。
3.3 非流式路径:测试和兜底都靠它
流式接口是生产环境的主角,但非流式接口一样不能少。原因有两个:
第一,单元测试和集成测试用非流式接口最方便。你不需要起一个 SSE 解析器,直接await provider.chatComplete(...)拿到完整响应,断言内容是否符合预期。
第二,某些场景下流式就是不可用的。比如一些批处理任务、CI 里的静默执行、或者 Provider 端临时禁用了 stream。这时候一个能用的非流式兜底,比报错强得多。
实现非流式接口其实就是把stream: true改成stream: false,然后从响应体里直接取choices[0].message。很多新手会忽略这个路径,结果一接进去,测试全挂,因为测试用例走的是非流式。建议在实现 Provider 时,两条路径一起实现,别偷懒。
4. 工具调用与结构化输出:Provider 兼容性的真正分水岭
4.1 各家 Function Calling 的"同与不同"
如果说流式文本输出是 Provider 接入的及格线,那工具调用就是分水岭。Code Agent 之所以叫 Agent,就是因为它能调用工具:读文件、跑命令、搜索代码、改代码。没有工具调用的模型,充其量是个高级补全插件。
各家对"工具调用"的实现思路其实大同小异,都是让模型输出一个结构化的"我想调用某个函数"的声明,然后由 Agent 循环去执行,再把结果送回模型。但落到协议层,差异就大了:
| 维度 | OpenAI | Anthropic | Google Gemini |
|---|---|---|---|
| 请求字段 | tools/tool_choice | tools(格式略有不同) | tools/function_declarations |
| 响应字段 | message.tool_calls | content[].tool_use | candidates[].content.parts[].functionCall |
| 参数格式 | arguments是 JSON 字符串 | input是 JSON 对象 | args是 JSON 对象 |
| 多工具并行 | 支持(数组) | 支持(多个 tool_use 块) | 支持(多个 functionCall) |
最坑的是参数格式这一行。OpenAI 把arguments设计成字符串,是为了让模型在输出时不用保证 JSON 合法性,只需要生成文本,到了客户端再解析。Anthropic 则让模型直接输出 JSON 对象。如果你在归一化层不处理这个差异,上层拿到一个字符串一个对象,JSON.parse会直接炸。
4.2 把工具定义转换成 Provider 方言
归一化的另一面是请求方向的转换:Agent 内部的工具定义,要翻译成 Provider 能理解的格式。
Agent 内部的工具定义通常长这样:
interface ToolDefinition { name: string; description: string; parameters: JSONSchema; }OpenAI 接受的就是这个格式,直接透传。但Anthropic 的请求里,工具描述的 prompt 越长越好,参数格式对 JSON Schema 的支持也有限。Google 那边又不一样,它要求parameters必须是type: "object"开头的 schema,否则会报错。
所以一个健壮的 Provider 实现,应该包含一个convertTools的内部函数,把统一的ToolDefinition[]转成目标 Provider 的请求结构。这个函数虽然代码不多,但是最容易出 bug 的地方,尤其是处理 JSON Schema 里的$defs、$ref、anyOf这些高级特性时,各家支持程度天差地别。
我的建议是:第一版只支持 JSON Schema 的基础子集(type、properties、required、enum、description、嵌套object、array),够用就好。等跑通了,再按需求渐进式支持高级特性。一上来就想把完整 JSON Schema 全实现,只会把自己耗死在兼容性泥潭里。
4.3 流式工具调用:增量解析的通用方案
流式工具调用是另一个折磨人的点。非流式时,模型一次性把整个工具调用返回,你解析一次就行。流式时,工具调用的arguments是被切成一小段一小段吐出来的,你需要自己拼。
OpenAI 的流式增量格式是每 chunk 返回delta.tool_calls[0].function.arguments的一个片段,你需要累积字符串。Anthropic 的流式增量是content_block_delta.delta.partial_json,也是一段 JSON 字符串片段,但它的 content block 结构意味着你要自己跟踪当前正在累积哪个 tool_use 块。
这里有一个小技巧:不要在每个增量到达时都尝试JSON.parse完整字符串,那样解析器会疯狂报错,因为中间状态本来就是非法的 JSON。正确的做法是只累积arguments字符串,等流结束时(收到finish_reason: "tool_calls"或[DONE])再统一解析。如果在中间想实时展示工具参数的进度,可以用一个"尽量解析,失败就忽略"的容错函数:
function tryParseJsonLoose(str: string): any { try { return JSON.parse(str); } catch { return null; } }这个函数的作用仅限于"预览",最终以流结束后的完整解析结果为准。这样既不会因为中间态报错,又能给用户反馈进度。
5. 踩坑实录:认证、模型名、并发与超时的那些坑
5.1 认证方式不是只有 API Key 一种
绝大多数 Provider 认证都是一个 API Key 塞在Authorizationheader 里,但真接起来你会发现世界远比这复杂:
- 某些企业网关要求 API Key 放在自定义 header 里,比如
X-API-Key。 - 某些服务要求
Authorization: Bearer <token>,但 token 需要先从另一个接口换取,而且有有效期。 - 某些本地服务(比如 Ollama 默认跑在 localhost)压根不需要认证,但你代码里如果硬塞一个空
Authorizationheader,反而可能触发 CORS 或网关拒绝。 - Azure OpenAI 的认证是
api-keyheader 加 URL 里的api-version参数,跟标准 OpenAI 完全不同。
处理这些差异的正确姿势是:Provider 实现类内部封装一个buildHeaders()方法,把所有认证逻辑收拢到一个地方。配置层面给用户暴露apiKey、apiVersion、customHeaders这几个字段,其中customHeaders是对象类型,允许用户自由透传额外 header。这样你就不需要为了某个特殊网关去改代码,用户配一下就完事。
还有一个我反复强调的细节:永远不要用console.log打印完整请求头。我在调试时不止一次看到有人把包含 API Key 的 header 打进了日志,提交到仓库,然后被爬虫扫到,Key 直接裸奔。Provider 实现里如果要打印调试信息,一定要做脱敏处理,只打印 header 的 key 名,不打印 value。
5.2 模型名映射:用户写的"短名"和 API 的"全名"
模型名是 Code Agent 集成里最容易忽略、也最容易出问题的地方。
用户视角的模型名是"gpt-4o"这种短名,但某些 Provider 的 API 里,你可能必须传完整的部署名或带版本号的 model id,比如 Azure 的部署名是你在资源里自己起的,跟 OpenAI 官方模型名没有任何对应关系。更麻烦的是,有些 Provider 的模型名大小写敏感,传错一个字符就 404 或 400。
解决思路是在配置层加一个可选的modelAlias字段。如果用户填了,就以它为准;如果没填,就把配置里的model原样传出。这样用户可以自己解决短名和全名的映射,不需要动代码。
另一个相关问题是对listModels返回结果的处理。有些 Provider 的模型列表接口一次返回几百个模型,其中一半是废弃的、不能实际调用的。如果你把它们全部塞进下拉框,用户体验会很差。我建议在listModels里做一次过滤,只保留id包含实际可用前缀的模型,或者至少按字母排序 + 去重。这个小优化,实测能显著降低用户选错模型的比例。
5.3 超时、重试与限流:Provider 挂了不能拖垮整个 Agent
接 Provider 最怕的不是接口报错,而是接口"既不报错也不返回",一直挂在那里。Code Agent 是交互式工具,用户等一次补全最多忍耐几十秒,超过这个时间就该果断放弃,给人反馈。
实现上有几个通用做法:
fetch请求必须带AbortSignal,把用户取消和超时统一挂到一个AbortController上。- 超时时间建议分层:连接超时 10 秒,首字节超时 30 秒,整体空闲超时 60 秒。不要只设一个总超时,否则慢启动的流式服务会被误杀。
- 重试策略要区分错误类型。
429(限流)和5xx(服务端错误)可以重试,400(请求格式错误)、401(认证失败)、404(模型不存在)重试一万次也是白搭,直接抛错。 - 重试要带指数退避和抖动。最简单的实现是
delay = min(2^attempt * 1000, 8000) + random(0, 500)毫秒。
这些逻辑看着繁琐,但属于那种"一次实现,所有 Provider 受益"的公共能力,建议放在基类里,而不是每个子类写一遍。Continue 的基类就做了大量这类工作,这也是为什么它的几十个 Provider 子类都很简洁。
6. 验证与回归:怎样才算"接好了"
6.1 先跑通最小链路,再叠加能力
一个新 Provider 接入后,最忌讳的就是一次性联调所有功能,然后在新功能出问题时不知道是哪一环的问题。我的习惯是分四步走:
第一步,只验证listModels。发一个请求,看返回的模型列表是否符合预期。这一步能快速暴露认证、baseUrl、网络路径这些基础问题。
第二步,验证非流式文本补全。不传工具,只发一个"你好",看能不能拿到完整回复。这一步能验证请求体组装、响应解析、消息格式这些核心逻辑。
第三步,验证流式文本补全。在终端里用脚本消费AsyncGenerator,观察text增量事件是否按预期到达。这一步重点测中英文混合内容、换行符、特殊字符这些边缘 case。
第四步,验证工具调用。给一个简单的工具定义,比如get_current_time,让模型回答"现在几点",然后看工具调用事件是否能被正确归一化。这个场景能覆盖请求方向的工具格式转换、响应方向的 tool_call 解析、以及流式增量拼接。
每一步通过后再走下一步,出了问题能立刻定位到具体模块,不用从头排查。
6.2 用配置化测试覆盖 Provider 差异
Provider 接入的回归测试,说白了就是"同样的输入,不同的 Provider,都要得到符合预期的结构化结果"。但你不能在 CI 里真的调外部 API,那样又慢又不稳定。我推荐两层测试策略:
第一层是 mock 测试。用msw或者简单的fetchmock,模拟 Provider 的 HTTP 响应,只测你的代码能否正确解析。把 OpenAI、Anthropic、自定义 Provider 的典型响应都 mock 一遍,每个 Provider 一个 fixture 文件。这一层跑得快,适合在任何一次代码变更后全量跑。
第二层是冒烟测试。用真实 API Key,只跑几个最小用例,标记为@smoke,不在每次 CI 里跑,而是在发版前手动触发。冒烟测试用例要写得很克制,比如只发一条短消息、只调用一次工具,控制成本。
我还习惯在 fixture 里专门造一些"恶意输入",比如超长的工具参数、含 unicode 转义的内容、嵌套很深的 JSON Schema。这些边缘 case 才是 Provider 差异集中爆发的地方,比正常用例值钱得多。
6.3 兼容性检查清单:接任何一个 Provider 前过一遍
最后分享一个我手头一直维护的检查清单。每次往 Code Agent 里接新 Provider,我都会逐条过一遍,确认没有遗漏:
- 配置项是否完整覆盖:
apiKey、baseUrl、apiVersion、模型名、自定义 header。 - 流式和非流式两条路径是否都实现,测试是否都通过。
- SSE 解析是否处理了
[DONE]、多行data:、注释行、空行、chunk 截断。 - 多字节字符(中文、日文、emoji)在流式传输中是否不乱码。
- 工具调用请求格式是否正确,响应是否归一化成统一
ToolCall。 - 流式工具调用增量是否正确累积,结束时能否正确解析完整 JSON。
- 认证失败的报错是否友好,能否提示用户检查 API Key。
- 限流、超时、服务端错误是否区分处理,重试策略是否生效。
- 模型列表是否过滤、排序、去重,用户选错模型时是否有明显报错。
- 日志是否脱敏,API Key 不会被打进日志文件。
这个清单看着长,但每一条都是用真实事故换来的教训。比如"chunk 截断"那条,我第一次接流式接口时,中文注释被切成两半,解码出来一堆乱码,排查了半天才发现是TextDecoder的问题。现在我把这条写进清单,再也没有在这个问题上浪费过时间。
接入 LLM Provider 这件事,本质上是给 Code Agent 装一个"可替换的发动机"。发动机的品牌可以换,但油路、电路、仪表盘得是统一的规格。只要抽象层设计得干净、归一化做得彻底、踩坑经验沉淀成清单,你的 Agent 就能在模型快速迭代的时代始终保持"想换就换"的灵活性。这也是我解剖这一系列源码后最深的一点体会:真正决定一个 Code Agent 能走多远的,往往不是它当前用的那个模型有多强,而是它的架构能不能在下一个更强的模型出现时,用最小的成本接上去。