厂商差异与 Provider 抽象

厂商差异与 Provider 抽象

第 02、03 章我们把 OpenAI 的 chat/completions 协议(请求、响应、SSE 流式)啃了一遍。但现实世界的 Agent 不止有一个厂商:很多团队要在 OpenAI 和 Anthropic 之间切换,甚至同时对接。第 03 章结尾我说过「流式是 Provider 接口里最难啃的一块」。但啃它之前,得先有这个接口。这一章我们先把「接口长什么样」定下来,流式的坑留到后面章节再补。

本章目标

读完本章并做完配套练习后,你应该能够:

开场反例:没有抽象会怎样

假设你要做一个小工具:给模型发一段消息、拿到回答。你现在会写了,无非是第 02 章那套 fetch。现在我们要求它同时支持 OpenAI 和 Anthropic,于是「发消息拿回答」这段逻辑你写了两遍

// === OpenAI 版 ===
const res = await fetch('https://api.openai.com/v1/chat/completions', {
  method: 'POST',
  headers: {
    authorization: `Bearer ${process.env.OPENAI_API_KEY}`, // Bearer 鉴权
    'content-type': 'application/json',
  },
  body: JSON.stringify({ model: 'gpt-4o', messages }),      // system 在 messages 里
});
const data = await res.json();
const text = data.choices[0].message.content;               // 取文本的路径
const stop = data.choices[0].finish_reason;                 // 结束原因的名字
const input = data.usage.prompt_tokens;                     // 用量的名字
const output = data.usage.completion_tokens;
// === Anthropic 版:一模一样的功能,第二遍写法 ===
const res = await fetch('https://api.anthropic.com/v1/messages', {
  method: 'POST',
  headers: {
    'x-api-key': process.env.ANTHROPIC_API_KEY,             // 换个鉴权头
    'anthropic-version': '2023-06-01',                      // 还多了个版本头
    'content-type': 'application/json',
  },
  body: JSON.stringify({
    model: 'claude-sonnet-4-5',
    max_tokens: 1024,                                      // ← 必填
    system,                                                 // system 抽成顶层字段
    messages: messages.map((m) => ({                       // content 要包成 block 数组
      role: m.role,
      content: [{ type: 'text', text: m.content }],
    })),
  }),
});
const data = await res.json();
const text = data.content[0].text;                          // 取文本的路径
const stop = data.stop_reason;                              // 结束原因的名字
const input = data.usage.input_tokens;                      // 用量的名字
const output = data.usage.output_tokens;

这两段代码做的逻辑完全相同:发消息 → 拿文本 → 拿结束原因 → 拿用量。但它们的 URL 不同、鉴权头不同、body 结构不同、system 位置不同、content 结构不同、取结果的路径不同、字段名不同。这些不同,就是「厂商差异」的本体。

现在感受一下没有抽象会怎样

  1. 同一段逻辑写两遍。 你的业务代码里每有一个「调用模型」的地方,就要 copy 一份这样近乎重复、细节又处处不同的大段 fetch。两遍还能忍,等接了第三家(Gemini 的 generateContent、DeepSeek、本地 Ollama 的 /api/chat……)就是三四五六遍。
  2. 换厂商要改穿所有调用点。 你今天用 OpenAI,明天老板说换 Claude,就得找出每一个调用点,逐一把 URL、鉴权头、body、取结果路径、字段名全部改掉。改漏一个,跑起来就是 undefined 或者静默拿错字段。
  3. 两套心智负担。 你必须在脑子里同时记着 choices[0].message.contentcontent[0].textprompt_tokensinput_tokens,还得时刻提醒自己「给 Anthropic 的 messages 里不能有 system,要抽出来」。

抽象解决的就是这个问题:把「每家厂商各自叫法不同」的部分收进一个薄薄的一层里,让上面的业务代码只面对一个统一的形状。这一层在业界叫 Provider 抽象(OpenAI SDK 生态里也叫 LLM Provider、model provider)。

两家格式逐项对照

动手写之前,先把差异摊在桌面上。下表是两家消息与响应格式的全部关键差异(工具 tools 的差异见本章结尾预告,第 06 章展开):

维度OpenAIAnthropic
默认 baseUrlhttps://api.openai.com/v1https://api.anthropic.com
端点POST {baseUrl}/chat/completionsPOST {baseUrl}/v1/messages
鉴权头Authorization: Bearer <key>x-api-key: <key>
版本头anthropic-version: 2023-06-01
system 消息messages 数组里的一条 role: 'system'顶层 system 字段;messages不允许 system
content 结构字符串(content: '...'block 数组(content: [{type:'text', text:'...'}]
max_tokens可选必填
响应正文choices[0].message.contentcontent[0].text
结束原因choices[0].finish_reasonstop_reason
用量usage.prompt_tokens / usage.completion_tokensusage.input_tokens / usage.output_tokens

逐项读一遍这张表,你会注意到一个规律:差异集中在「请求怎么构造」和「响应怎么解析」两端,而中间的业务语义(发消息、拿文本、拿结束原因、拿用量)是一样的。所以 Provider 抽象要做的,正是构造解析这两件事的翻译,外加一个统一的数据形状。

还要注意:统一数据形状 ≠ 强行把语义不同的东西捏成一个值。比如 finish_reason 可能返回 'length'(撞上 token 上限被截断)、'tool_calls'(模型要调工具),而 Anthropic 的 stop_reason'end_turn''max_tokens''tool_use'。这两个枚举值本身含义不同'stop''end_turn' 都是「正常说完」,但 'tool_calls''tool_use' 是不一样的存在)。所以规范化时,我们只保证 stopReason 这个字段存在、是「厂商说它为什么停了」的字符串,而不承诺它俩的取值一一映射。抽象的职责是「形状统一」,不是「语义抹平」。

手写最小 Provider 接口

先定义统一的形状——这是整个抽象的地基:

// 规范化消息:不管哪家厂商,进到我们代码里的对话都是这个形状。
// system 是 role 的一种,跟在 OpenAI 里一样摆在数组里;
// 至于 Anthropic 要求抽到顶层,那是 Provider 内部翻译的事。
interface NormalizedMessage {
  role: 'system' | 'user' | 'assistant';
  content: string;
}

// 规范化结果:把两家各自叫法的三样东西,翻译成统一的三个字段。
interface ProviderResult {
  text: string;                            // 模型回答的文本
  stopReason: string;                      // 厂商说它为什么停(原值透传)
  usage: { inputTokens: number; outputTokens: number };
}

interface ProviderOptions {
  apiKey: string;
  model: string;
  baseUrl?: string;        // 缺省用厂商官方地址
  fetchImpl?: typeof fetch; // 注入用(测试/演示里替换成 mock,平时忽略)
}

interface Provider {
  chat(messages: NormalizedMessage[]): Promise<ProviderResult>;
}

然后就是每家一层的「翻译官」:createOpenAIProvidercreateAnthropicProvider。它们都返回同一个 { chat },区别只在内部怎么构造请求、怎么解析响应。

// OpenAI 实现:几乎是把第 02 章的裸调原封不动搬进来,
// 只是外面包了一层统一入口。system 留在 messages 里,原样透传。
export function createOpenAIProvider(opts: ProviderOptions): Provider {
  const { apiKey, model, fetchImpl = fetch } = opts;
  const baseUrl = opts.baseUrl ?? 'https://api.openai.com/v1';

  return {
    async chat(messages) {
      const res = await fetchImpl(`${baseUrl}/chat/completions`, {
        method: 'POST',
        headers: {
          authorization: `Bearer ${apiKey}`,
          'content-type': 'application/json',
        },
        body: JSON.stringify({ model, messages }),
      });
      const data = await res.json();
      return {
        text: data.choices[0].message.content,
        stopReason: data.choices[0].finish_reason,
        usage: {
          inputTokens: data.usage.prompt_tokens,
          outputTokens: data.usage.completion_tokens,
        },
      };
    },
  };
}
// Anthropic 实现:翻译工作量集中在两点——
// ① system 从 messages 里抽出来放到顶层(多个 system 消息用空行拼接);
// ② 每条消息的 content 字符串包成 [{type:'text', text}] block 数组。
// 还要补上必填的 max_tokens。
export function createAnthropicProvider(opts: ProviderOptions): Provider {
  const { apiKey, model, fetchImpl = fetch } = opts;
  const baseUrl = opts.baseUrl ?? 'https://api.anthropic.com';

  return {
    async chat(messages) {
      const system = messages
        .filter((m) => m.role === 'system')
        .map((m) => m.content)
        .join('\n\n');
      const body: Record<string, unknown> = {
        model,
        max_tokens: 1024,                     // 必填;这里先给固定默认值
        messages: messages
          .filter((m) => m.role !== 'system') // messages 里不许有 system
          .map((m) => ({ role: m.role, content: [{ type: 'text', text: m.content }] })),
      };
      if (system) body.system = system;

      const res = await fetchImpl(`${baseUrl}/v1/messages`, {
        method: 'POST',
        headers: {
          'x-api-key': apiKey,
          'anthropic-version': '2023-06-01',
          'content-type': 'application/json',
        },
        body: JSON.stringify(body),
      });
      const data = await res.json();
      return {
        text: data.content[0].text,
        stopReason: data.stop_reason,
        usage: {
          inputTokens: data.usage.input_tokens,
          outputTokens: data.usage.output_tokens,
        },
      };
    },
  };
}

到此,上层代码的体验是:

const provider = createOpenAIProvider({ apiKey, model: 'gpt-4o' });
// 或者 createAnthropicProvider({ apiKey, model: 'claude-sonnet-4-5' })

const result = await provider.chat([
  { role: 'system', content: 'You are a helpful assistant.' },
  { role: 'user', content: '用一句话介绍什么是 Agent。' },
]);
// result: { text: '...', stopReason: '...', usage: { inputTokens: n, outputTokens: n } }

注意三个收益点:

这套接口刻意最小:没有重试、没有超时、没有流式。那些是第 02、03 章讲过的独立关注点,别一次性堆进来。接口会随着后面章节演进(第 06 章加工具、流式加 streamChat),但「统一形状 + 每家一层的翻译」这个骨架不变。

何时才值得引入官方 SDK

你可能会问:官方不是给了 @anthropic-ai/sdkopenai 这些现成的包吗?为什么还要手写?这正好是本章要辨析的:手写 Provider 与引入官方 SDK 不是二选一,而是先后关系

官方 SDK 值得引入的理由是实打实的:

但有两件事 SDK 替代不了:

  1. SDK 只是把同一个 wire protocol 包了层糖。 它内部发出的请求、收的响应,还是这一章你手写的那些 JSON。出 bug 时(为什么 400?为什么取到 undefined?)你必须能读懂它在发什么——这一章练的就是这个手艺。用不明白协议的人,SDK 报错也看不懂。
  2. 官方 SDK 之间也不统一。 openai 包的方法签名和 @anthropic-ai/sdk 完全不同。就算接了 SDK,你的业务代码仍然需要自己的 Provider 抽象来统一它们,只不过把「手写 fetch」换成「包 SDK」而已,翻译层该写还是得写。

所以正确的姿势是:先手写,把协议吃透;等接第三家、或者要上重活(流式 + 重试 + 并发)时,再用官方 SDK 替换 Provider 内部那一层——因为你的业务只面向 { chat },换内部实现不伤筋动骨。本教程「先 from-scratch、再谈封装」的方法论,说的就是这个。

常见坑

坑一:把 system 消息塞进 Anthropic 的 messages。 Anthropic 的 messages 只允许 user/assistant(还有后续章节的 tool_result 等),system 必须抽到顶层 system 字段。照抄 OpenAI 的 messages: [...all] 直接吃 400。翻译时记得 filter(m => m.role !== 'system'),多个 system 消息用 \n\n 拼成一个字符串。

坑二:content 是字符串还是 block 数组。 OpenAI 的 content 是裸字符串,Anthropic 的 content 是数组([{type:'text', text:'...'}]),响应端还反过来:OpenAI 从 choices[0].message.content 取字符串,Anthropic 从 content[0].text 取 block 里的文本。两头各一套,别只记一头。

坑三:用量字段名不同,拿错就是 undefined OpenAI 是 usage.prompt_tokens / usage.completion_tokens,Anthropic 是 usage.input_tokens / usage.output_tokens。拿错字段不会报错,只会静默得到 undefined,后续 inputTokens + outputTokens 一算就是 NaN。这种 bug 在演示里很难暴露(因为 demo 只打一家),所以练习 stage5 专门用「同一份断言同时打两家」来压出这类不对称。

坑四:baseUrl 末尾的斜杠。 https://api.anthropic.com/ 拼上 /v1/messages 会得到 //v1/messages 双斜杠,不少网关会路由失败或 404。要么文档约定「baseUrl 不带尾斜杠」,要么在 Provider 里 baseUrl.replace(/\/$/, '') 兜底。注意 OpenAI 的默认 baseUrl 本来就带 /v1https://api.openai.com/v1),Anthropic 的不带。这也让「统一 baseUrl 语义」成了 Provider 各自内部的事。

小结

下一章(ch05)先把「模型返回怎么变成可靠数据」讲透:结构化输出与 JSON 校验,第 06 章我们做 Tool Calling 手动全流程。先剧透一句差异:工具在两家的格式也不一样。OpenAI 把 tools 挂在请求体顶层、用 function 对象的 JSON Schema 描述,模型返回 tool_calls;Anthropic 同样有顶层 tools 数组但结构不同,模型返回 tool_use block。本章的 Provider 骨架到那时会扩展出「带工具的 chat」。现在先把「翻译官」练熟,第 05、06 章才有地基。

延伸阅读

完成阅读,去做练习 →