LLM API 只有 3 种协议,但走同一种协议的服务商可以有无数个。把协议和身份拆开——协议是代码,Provider 是数据——复杂度从 N×M 变成 N+M。300 行 TypeScript,零依赖。
问题不是"功能不工作",是"它不会告诉你它挂了"
五月,我写了一个 Claude Code skill 叫 unblind。我用 DeepSeek 当底座但它不会看图,于是让 unblind 把图片转发给 Mimo 和 OpenAI 的视觉 API。
MVP 阶段只有两个 Provider。几十行 if-else,能跑。
但很快我发现了一个更让人不安的问题:API Key 过期——没提示。网络抖了一下——没重试。权限丢了——静默跳过。这个工具不会报错,它会在你完全不知道的情况下安静地失效。
我给它加了 Phase 0 自愈、熔断重试、持久化缓存、安全沙箱。做完这些,unblind 不会悄无声息地挂了。
然后我注意到另一件事。熔断器不关心你调的是视觉 API 还是翻译 API。缓存策略不关心返回的是图片描述还是 OCR 文本。错误归一化不关心对面是 Mimo 还是 OpenAI。
一套通用的 Provider 基础设施,被写死在一个视觉 skill 的肚子里。
第一次尝试:跟着生态做,撞到天花板
生态里最大的同类项目是 vision-support,19 个 Provider。模式很标准——基类 + 子类,GoF 模板方法。我照着做了 v2.0。
class BaseProvider {
async analyzeImage({ image, prompt, options }) {
const { url, body, headers } = this._buildRequest(image, prompt, options);
const res = await apiRequest(url, { body, headers });
return { content: await this._parseResponse(res), model: this._model };
}
}
class MimoProvider extends BaseProvider { ... } // 54 行
class OpenAIProvider extends BaseProvider { ... } // 45 行
class GeminiProvider extends BaseProvider { ... } // ~50 行
每个 Provider 一个子类。unblind 从 3 个扩到 7 个——加了 Groq、Together、Fireworks、Ollama。
然后 Groq、Together、Fireworks 让我卡住了。它们走的是 OpenAI 一模一样的 Chat Completions API。和 OpenAIProvider 的唯一区别是 baseUrl 和 model 名字。
写三个新类?几乎完全重复。偷懒共用 OpenAIProvider?差异藏在 build 函数里,代码读不出 Groq 和 OpenAI 的关系。两难。
而后来发现,Mimo 可以同时走 OpenAI 和 Anthropic 两种协议——同一家厂商,两个协议入口。如果按子类的路子,这又是两倍复杂度。
停下来想清楚
我把两个不该放在一起的东西塞进了一个类。
| 概念 | 是什么 | 变化频率 | 该用什么 |
|---|---|---|---|
| 协议 | "发请求的格式"——Anthropic Messages / OpenAI Chat / Google Gen AI | 极低(行业验证了 3 年才收敛到 3 个) | 代码 |
| Provider | "连到哪"——哪个厂商、哪个 Key、哪个 baseUrl | 随时加新的 | 数据 |
OpenAI Chat Completions 这一个协议下面已经有 5 家——OpenAI、Groq、Together、Fireworks、Ollama。Anthropic Messages 下面目前只有 Mimo,但某个代理网关明天就可以把 GPT-4o 用 Anthropic 协议包装一层。Google 协议下面只有 Gemini,但以后可能有别的。
一家厂商 × 一个协议 = 一条注册表数据。两个协议 = 两条。五个厂商 = 五行。 这在子类方案下是 N×M 的类爆炸,在协议方案下只是 N 条数据 + M 个协议。
这个认知来自数据库行业 30 年前的经验。MySQL 和 PostgreSQL 需要不同的 SQL Dialect,但 100 个 MySQL 实例只是连接串不同。Groq 就是一个 MySQL 实例,OpenAI 是另一个——我给每个实例写了一套 Dialect。
协议是代码,Provider 是数据
// 协议 — 代码。整个项目只有 3 个协议对象。
const PROTOCOLS = {
'openai-chat-completions': {
endpoint: '/chat/completions',
auth: (key) => ({ Authorization: `Bearer ${key}` }),
buildContent: (inputs, prompt) => {
const content = [];
for (const inp of inputs) {
if (inp.type === 'image') content.push({ type: 'image_url', image_url: { url: inp.data } });
if (inp.type === 'text') content.push({ type: 'text', text: inp.data });
}
content.push({ type: 'text', text: prompt });
return content;
},
buildBody: (model, content, opts) => ({
model, max_tokens: opts.maxTokens || 2048, messages: [{ role: 'user', content }]
}),
extractContent: (data) => data.choices?.[0]?.message?.content,
parseError: (data, status) => { /* → 4 种 category */ },
},
'anthropic-messages': { /* 6 个函数,各有不同 */ },
'google-generative-ai': { /* 6 个函数,各有不同 */ },
};
// Provider — 纯数据。新增只需加一行。模型是字段值,不占条目。
const REGISTRY = [
{ name: 'openai', protocol: 'openai-chat-completions', baseUrl: 'https://api.openai.com/v1', model: 'gpt-4o' },
{ name: 'groq', protocol: 'openai-chat-completions', baseUrl: 'https://api.groq.com/openai/v1', model: 'llama-4-vision' },
{ name: 'mimo', protocol: 'anthropic-messages', baseUrl: 'https://api.xiaomimimo.com/anthropic', model: 'mimo-v2.5' },
{ name: 'gemini', protocol: 'google-generative-ai', baseUrl: 'https://generativelanguage.googleapis.com/v1beta', model: 'gemini-2.5-flash' },
];
// Mimo 同时支持 Anthropic 和 OpenAI 协议?加一行即可,不写新代码:
// { name: 'mimo-openai', protocol: 'openai-chat-completions', baseUrl: 'https://api.xiaomimimo.com/v1', model: 'mimo-v2-omni' }
// GenericProvider — 唯一类,零子类
const provider = new GenericProvider({
name: 'openai',
protocol: PROTOCOLS['openai-chat-completions'],
baseUrl: 'https://api.openai.com/v1',
apiKey: process.env.OPENAI_API_KEY!,
model: 'gpt-4o',
});
await provider.execute({ inputs, prompt });
| v2.0 模板方法 | v3.0 协议驱动 | |
|---|---|---|
| 复杂度 | N × M(N 个 Provider 类 × M 个 build 函数) | N + M(N 行数据 + M 个协议对象) |
| 类数量 | 7 个 | 1 个 |
| 新增 OpenAI 兼容 Provider | 写 build 函数 | Registry 加 1 行 |
| 新增协议家族 | 写 Provider 类 | PROTOCOLS +1,Registry +1 |
| 同一厂商多协议接入 | 新子类 | 加 1 行数据 |
| 代码行数 | ~347 | ~290 (-16%) |
| 协议逻辑可单测 | ❌ 依赖 Key | ✅ 零依赖纯函数 |
四个关键设计
1. 不用类,用纯函数——为了可测试性
interface Protocol {
readonly endpoint: (model: string) => string;
readonly auth: (apiKey: string) => Record<string, string>;
readonly buildContent: (inputs: Input[], prompt: string) => unknown[];
readonly buildBody: (model: string, content: unknown, opts: ExecuteOptions) => unknown;
readonly extractContent: (data: Record<string, unknown>) => string;
readonly parseError: (data: Record<string, unknown>, status: number) => ParsedError;
}
没有 extends,没有 abstract。协议就是一个装了 6 个纯函数的普通对象。
// 零依赖单测——不需要 mock fetch,不需要构造 Provider 实例
it('extractContent — OpenAI 响应', () => {
assert.equal(
PROTOCOLS['openai-chat-completions'].extractContent({
choices: [{ message: { content: 'A cat on a table' } }]
}),
'A cat on a table'
);
});
子类方案做不到这件事——你必须构造完整实例,有 Key、有 URL、有超时。协议方案下 80% 的测试零依赖。
2. overrides 而不是子类——差异是数据
Groq 的 max_tokens 上限 4096,OpenAI 协议没有这个限制。差异太小,不配建类:
{ name: 'groq', protocol: 'openai-chat-completions',
overrides: {
buildBody(proto, model, content, opts) {
const body = proto.buildBody(model, content, opts);
body.max_tokens = Math.min(body.max_tokens, 4096); return body;
},
},
}
灵感来自 Kubernetes strategic merge patch——默认在 spec,覆盖在 patch。
3. 三种错误格式 → 四种 category
Anthropic 返回 { type: "error", error: { type: "invalid_request_error" } }。OpenAI 返回 { error: { type: "..." } }。Gemini 返回 { error: { code: 400, status: "INVALID_ARGUMENT" } }。
完全不兼容。每个协议提供 parseError(data, status),归一化为一套类型:
type ParsedError =
| { category: "auth" } // 不重试
| { category: "rate_limit" } // 退避
| { category: "server" } // 降级下一个
| { category: "client" }; // 报错
上游熔断器只读 category——不需要知道对面是哪家 API。
4. TypeScript 把运行时错误变成编译错误
export type Input = TextInput | ImageInput | AudioInput | DocumentInput;
export interface ImageInput {
readonly type: "image"; readonly data: string; readonly mimeType: string;
}
export interface TextInput {
readonly type: "text"; readonly data: string;
}
之前 mimeType 是可选字符串——忘了传,运行时炸。现在 type 决定哪些字段必需——忘了传,编译就过不去。
N×M → N+M:量化
协议驱动不优化"换模型"——v2.0 改字段也是零代码。它消灭的是 Provider 和协议之间的耦合:
| 操作 | v2.0 | v3.0 |
|---|---|---|
| 换模型 | 改字段(零代码) | 改字段(零代码) |
| 加一个同协议厂商(Groq 加入) | 写 build 函数 | 加一行数据 |
| 加一个新协议家族(Google 加入) | 写 Provider 子类 | 加一个协议对象 |
| 同一厂商双协议接入(Mimo → Anthropic + OpenAI) | 新子类 + 新 build | 加一行数据 |
真正被消除的是跨维度耦合——协议进化时 Provider 不受影响,Provider 增减时协议不需要改。N 和 M 终于独立演化了。
落地
Provider 层拆出来后,unblind 代码减少 16%。拆出来的 package 可被任何 Agent 工具复用,命名为 zeshim:
npm install zeshim
完整设计文档:unblind/display/provider-optimization.md。
但是——"把 Provider 层拆出来"这个动作,不只改变了代码。它还逼我定下了 zeshim 的四条原则。这四条在拆的时候不觉得重要,现在回头看,它们才是稳定性的来源。
附:拆出来之后才想明白的四条原则
1. 零依赖是品类定义,不是偏好
调研了 52 个框架和 Coding Agent 之后,我发现了一件有意思的事:全行业没有一个零依赖的多 Provider 抽象。 零依赖本身就是品类。加了第一个依赖,zeshim 和 LangChain 的区别就从"架构不同"降级为"代码量不同"——而代码量不同不是护城河。
代价:不能用 tiktoken 做 token 计数、不能用 Zod 做校验、不能用 undici 做 HTTP 优化。这些全部放 user-space。
2. Protocol 接口永不膨胀
LangChain 的 BaseChatModel 从 3 个方法膨胀到 15 层调用栈。Vercel AI SDK 的 LanguageModelV1 需要 10+ 个方法。zeshim 当前 6 个,加 stream?(+1),预留 countTokens?(+1),封顶 8 个。
需要第 9 个能力不是加函数——是 new Protocol。
3. Core ≤ 500 LOC
300 LOC → 462 LOC → ... 如果没有上限,"核心不膨胀"的承诺是空话。500 LOC 等于任何人 30 分钟可以读完。超过就拆成独立包。
4. Scheduler 不进 Core
Provider 健康状态暴露为 status(): "healthy"|"degraded"|"down"。调度逻辑(延迟统计、成本累计、动态路由)在独立包里,不 import Protocol,不知道 API 细节。Provider 不知道 Scheduler 存在。Scheduler 不知道 Protocol 细节。
你觉得 LLM API 的 Provider 层应该做在客户端还是服务端?欢迎讨论。
English version: dev.to