复杂度从 N×M 降到 N+M:一个零依赖的 LLM Provider 基座方案

60 阅读7分钟

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.0v3.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