厂商差异与 Provider 抽象
厂商差异与 Provider 抽象
第 02、03 章我们把 OpenAI 的 chat/completions 协议(请求、响应、SSE 流式)啃了一遍。但现实世界的 Agent 不止有一个厂商:很多团队要在 OpenAI 和 Anthropic 之间切换,甚至同时对接。第 03 章结尾我说过「流式是 Provider 接口里最难啃的一块」。但啃它之前,得先有这个接口。这一章我们先把「接口长什么样」定下来,流式的坑留到后面章节再补。
本章目标
读完本章并做完配套练习后,你应该能够:
- 说出 OpenAI 与 Anthropic 在端点 / 鉴权 / 消息结构 / system 位置 / content 结构 / 响应 / 用量字段 / max_tokens 必填这八处差异;
- 明白「没有抽象会怎样」:同一段逻辑写两遍、换厂商要改穿所有调用点的反例;
- 手写一个最小
Provider接口(chat(messages) → {text, stopReason, usage})并实现 OpenAI、Anthropic 两家; - 说清「统一规范化的消息与结果」到底统一了什么、保留了什么(厂商的
stop_reason与finish_reason语义不同,就不强行统一成同一个值); - 判断什么情况下才值得引入官方 SDK,以及为什么「理解协议才能用得明白 SDK」。
开场反例:没有抽象会怎样
假设你要做一个小工具:给模型发一段消息、拿到回答。你现在会写了,无非是第 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 结构不同、取结果的路径不同、字段名不同。这些不同,就是「厂商差异」的本体。
现在感受一下没有抽象会怎样:
- 同一段逻辑写两遍。 你的业务代码里每有一个「调用模型」的地方,就要 copy 一份这样近乎重复、细节又处处不同的大段 fetch。两遍还能忍,等接了第三家(Gemini 的
generateContent、DeepSeek、本地 Ollama 的/api/chat……)就是三四五六遍。 - 换厂商要改穿所有调用点。 你今天用 OpenAI,明天老板说换 Claude,就得找出每一个调用点,逐一把 URL、鉴权头、body、取结果路径、字段名全部改掉。改漏一个,跑起来就是
undefined或者静默拿错字段。 - 两套心智负担。 你必须在脑子里同时记着
choices[0].message.content和content[0].text、prompt_tokens和input_tokens,还得时刻提醒自己「给 Anthropic 的 messages 里不能有 system,要抽出来」。
抽象解决的就是这个问题:把「每家厂商各自叫法不同」的部分收进一个薄薄的一层里,让上面的业务代码只面对一个统一的形状。这一层在业界叫 Provider 抽象(OpenAI SDK 生态里也叫 LLM Provider、model provider)。
两家格式逐项对照
动手写之前,先把差异摊在桌面上。下表是两家消息与响应格式的全部关键差异(工具 tools 的差异见本章结尾预告,第 06 章展开):
| 维度 | OpenAI | Anthropic |
|---|---|---|
| 默认 baseUrl | https://api.openai.com/v1 | https://api.anthropic.com |
| 端点 | POST {baseUrl}/chat/completions | POST {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.content | content[0].text |
| 结束原因 | choices[0].finish_reason | stop_reason |
| 用量 | usage.prompt_tokens / usage.completion_tokens | usage.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>;
}
然后就是每家一层的「翻译官」:createOpenAIProvider 和 createAnthropicProvider。它们都返回同一个 { 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 } }
注意三个收益点:
- 换厂商只改一行。
createOpenAIProvider(...)换成createAnthropicProvider(...),业务代码一行都不用动,只要它也吃{apiKey, model, baseUrl?}这组参数。 - system 对上层永远是「一条消息」。 在 Anthropic 里抽到顶层、在 OpenAI 里留在数组,都是 Provider 内部的事,上层不关心。
- 测试就藏在
fetchImpl里。 这个参数是练习判题的关键:判题时注入一个脚本化的 mock fetch(记录请求、返回固定响应),就能不联网、不用 API key 地验证你的翻译对不对。所以我们刻意把fetchImpl塞进 opts,而不是用魔法全局变量。
这套接口刻意最小:没有重试、没有超时、没有流式。那些是第 02、03 章讲过的独立关注点,别一次性堆进来。接口会随着后面章节演进(第 06 章加工具、流式加 streamChat),但「统一形状 + 每家一层的翻译」这个骨架不变。
何时才值得引入官方 SDK
你可能会问:官方不是给了 @anthropic-ai/sdk、openai 这些现成的包吗?为什么还要手写?这正好是本章要辨析的:手写 Provider 与引入官方 SDK 不是二选一,而是先后关系。
官方 SDK 值得引入的理由是实打实的:
- 类型完备。 官方 SDK 自带完整的请求/响应类型、错误类型,还有自动重试、超时等策略。手写映射意味着这些你都得自己维护。
- 新特性跟进。 厂商出新能力(新的消息类型、新的工具格式、新的流式事件),官方 SDK 会同步;手写的映射要自己盯着 changelog 改。
- 维护成本。 你的 Provider 层越复杂(错误处理、流式、重试、token 核算),自维护的成本越高。
但有两件事 SDK 替代不了:
- SDK 只是把同一个 wire protocol 包了层糖。 它内部发出的请求、收的响应,还是这一章你手写的那些 JSON。出 bug 时(为什么 400?为什么取到
undefined?)你必须能读懂它在发什么——这一章练的就是这个手艺。用不明白协议的人,SDK 报错也看不懂。 - 官方 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 本来就带 /v1(https://api.openai.com/v1),Anthropic 的不带。这也让「统一 baseUrl 语义」成了 Provider 各自内部的事。
小结
- 两家 LLM 的差异集中在请求构造与响应解析两端:端点、鉴权头、system 位置、content 结构、结束原因、用量字段、
max_tokens是否必填。业务语义相同,叫法各不相同; - 没有抽象时,同一段逻辑要写两遍,换厂商要改穿所有调用点;Provider 抽象把「厂商各自叫法不同」收进一层翻译官,上层只面对统一形状;
- 最小接口三件套:规范化消息
{role, content}、规范化结果{text, stopReason, usage:{inputTokens, outputTokens}}、chat(messages)方法;fetchImpl注入让抽象可测试; - 统一的是形状,不是语义:
finish_reason与stop_reason的枚举值不强行一一映射; - 官方 SDK 值得引,但解决的是「维护成本」而不是「协议理解」;先手写吃透协议,再用 SDK 替换 Provider 内部那一层。
下一章(ch05)先把「模型返回怎么变成可靠数据」讲透:结构化输出与 JSON 校验,第 06 章我们做 Tool Calling 手动全流程。先剧透一句差异:工具在两家的格式也不一样。OpenAI 把 tools 挂在请求体顶层、用 function 对象的 JSON Schema 描述,模型返回 tool_calls;Anthropic 同样有顶层 tools 数组但结构不同,模型返回 tool_use block。本章的 Provider 骨架到那时会扩展出「带工具的 chat」。现在先把「翻译官」练熟,第 05、06 章才有地基。
延伸阅读
- OpenAI: Chat Completions API:请求/响应完整字段,
messages结构与usage的权威定义。 - Anthropic: Messages API:
system顶层字段、content block 结构、max_tokens必填与usage.input_tokens/output_tokens的官方说明。 - Anthropic: Messages API 迁移指南:Anthropic 官方对「和 OpenAI 的 chat/completions 有何不同」的对照说明。
- openai-node 与 anthropic-sdk-typescript:官方 SDK 源码。想验证「它内部到底发了什么请求」,直接读源码比看文档更准。