AI 学习笔记(六):多 Provider API 接入怎么做,OpenAI、Claude、DeepSeek 差异与统一封装

上一篇把单一 OpenAI 调用收口成了可复用服务层。
但项目只要再往前走一步,问题就会变成:如果我要同时接 OpenAI、Claude、DeepSeek,还能不能继续只写一套代码?

答案是:可以统一,但不能硬抹平。
这三家都有文本生成、工具调用、结构化输出能力,但请求入口、消息格式、上下文状态和高级特性并不完全一样
真正稳的做法不是追求“一个巨大的通用请求体”,而是:统一你的业务接口,在 provider 边界做适配。

1. 先说结论:统一业务契约,不要统一底层细节

接多 provider 时,最容易走偏的一步,是先做一个“万能 messages 数组”,试图把所有平台都塞进同一个字段结构里。

短期看很省事,长期通常会出问题:

  • OpenAI 现在更推荐走 Responses API
  • Claude 原生接口是 Messages APIsystem 放在顶层,不在消息数组里
  • DeepSeek 对 OpenAI 兼容度很高,但某些能力只在特定模型或 Beta base URL 生效

所以更稳的分层应该是:

  1. 业务层只关心:
    • 我要文本还是结构化对象
    • 需要哪些工具
    • 超时、重试、日志、限流怎么统一
  2. Provider Adapter 关心:
    • 具体请求长什么样
    • 如何解析响应
    • 哪些能力支持,哪些能力要降级

一句话总结:你要统一的是“我想让模型做什么”,不是“每家 SDK 长得一模一样”。

2. 三家最值得先看清的差异

维度 OpenAI Claude DeepSeek
主推荐接口 responses.create messages.create OpenAI 兼容 chat.completions.create
system 放哪 作为 input 里的 system 消息 顶层 system 字段 和 OpenAI 兼容,放 messages
会话状态 可用 previous_response_id 串联响应 默认无状态,历史消息由你自己带 官方明确说明是无状态,需自行传历史
结构化输出 text.format 支持 json_schema 原生可做结构化输出,但接口形态不同 JSON Output 可用;Strict function calling 仅 Beta
工具调用 有 function calling,也有内建工具 tools + tool_use / tool_result OpenAI 兼容 function calling
SDK 策略 官方 openai 官方 @anthropic-ai/sdk 可直接复用 openai 包配 baseURL

这里有两个特别容易踩坑的点:

2.1 Claude 不要按 OpenAI 思维硬套

如果你直接用 Claude 原生 API,system 不是 messages 里的一个 role,而是单独字段。
另外它的工具调用响应里会出现 tool_use 内容块,回传工具结果时要发 tool_result,跟 OpenAI/DeepSeek 那种 function calling 心智模型并不完全一样。

2.2 DeepSeek “兼容 OpenAI”不等于“完全等同”

用 OpenAI SDK 配 DeepSeek 的 baseURL,大多数文本生成场景会很顺手。
但像 strict function calling、reasoner 模型限制、上下文缓存等细节,就不能默认按 OpenAI 的行为去猜。

3. 一套更稳的封装思路

我更建议把多 provider 封装成两层:

3.1 通用能力层

这层定义项目真正需要的最小接口,例如:

  • generateText
  • generateObject
  • callTools
  • streamText

再配上统一的:

  • timeout
  • maxRetries
  • traceId/requestId
  • 错误分类
  • provider/model 维度日志

3.2 Provider Adapter 层

每家只实现自己那部分差异:

  • OpenAIAdapter
    • responses.create
    • 解析 output_text
    • 需要结构化输出时走 text.format
  • ClaudeAdapter
    • messages.create
    • content 块中提取 text
    • 工具调用按 tool_use / tool_result 处理
  • DeepSeekAdapter
    • 继续用 openai SDK
    • 但固定好 baseURL
    • 对 Beta 才支持的能力做显式开关

这套设计的关键不是“高级”,而是避免最低公分母抽象
比如 Claude 有 Prompt Caching,DeepSeek 有默认开启的 Context Caching,OpenAI 有更偏原生的 Responses 能力。
这些都不应该被你的通用接口直接抹掉,而应该通过 providerOptions 这类扩展字段保留出口。

4. 一个可直接复用的最小示例

下面这个例子只统一“文本生成”,足够说明封装方向:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
import OpenAI from 'openai';
import Anthropic from '@anthropic-ai/sdk';

function joinClaudeText(blocks = []) {
return blocks
.filter((item) => item.type === 'text')
.map((item) => item.text)
.join('\n');
}

export function createProvider(provider) {
if (provider === 'openai') {
const client = new OpenAI({
apiKey: process.env.OPENAI_API_KEY,
});

return {
async generateText({ system, user, model = 'gpt-5' }) {
const resp = await client.responses.create({
model,
input: [
{ role: 'system', content: system },
{ role: 'user', content: user },
],
});

return {
text: resp.output_text || '',
raw: resp,
};
},
};
}

if (provider === 'claude') {
const client = new Anthropic({
apiKey: process.env.ANTHROPIC_API_KEY,
});

return {
async generateText({ system, user, model = 'claude-sonnet-4-5' }) {
const resp = await client.messages.create({
model,
system,
max_tokens: 2048,
messages: [{ role: 'user', content: user }],
});

return {
text: joinClaudeText(resp.content),
raw: resp,
};
},
};
}

if (provider === 'deepseek') {
const client = new OpenAI({
apiKey: process.env.DEEPSEEK_API_KEY,
baseURL: 'https://api.deepseek.com',
});

return {
async generateText({ system, user, model = 'deepseek-chat' }) {
const resp = await client.chat.completions.create({
model,
messages: [
{ role: 'system', content: system },
{ role: 'user', content: user },
],
});

return {
text: resp.choices?.[0]?.message?.content || '',
raw: resp,
};
},
};
}

throw new Error(`Unsupported provider: ${provider}`);
}

这段代码刻意没做太多抽象,只做两件事:

  • 把三家的入口收敛成同一个 generateText
  • 保留 raw,方便后面拿 request id、usage、provider 特有字段继续扩展

这比一开始就设计“万能 SDK”更稳。

5. 真正上项目时,别只盯着调用成功

多 provider 接入里,真正决定稳定性的通常不是“能不能返回文本”,而是下面这些工程细节:

5.1 错误分类要带上 provider 维度

同样是 429,不同 provider 的限流节奏和恢复方式不一样。
日志里至少要带:

  • provider
  • model
  • requestId
  • durationMs
  • retryable
  • errorType

5.2 结构化输出不要假设三家完全等价

OpenAI 的 Responses API 可以直接用 json_schema
Claude 原生也能做结构化输出,但如果你走它的 OpenAI compatibility layer,有些 OpenAI 字段会被忽略或做兼容转换。
DeepSeek 的严格 function calling 目前又带 Beta base URL 限制。

所以这块更合理的做法通常是:

  • 通用接口叫 generateObject
  • 每家单独实现
  • 不支持 strict 的 provider 明确降级,而不是静默伪装支持

5.3 会话管理不要偷懒

OpenAI 已经有更强的响应链路能力,但 Claude 和 DeepSeek 这边,你仍然经常要自己管历史消息。
如果系统未来要做 Agent 或多轮工作流,建议尽早把“上下文拼装”从 provider 实现里拆出来,做成单独模块。

6. 什么时候该选哪一家

如果你只是想先跑通一套最小 AI 服务层,我的建议是:

  1. OpenAI 优先
    Responses API、结构化输出、工具调用路线更完整,适合先把工程骨架搭起来。
  2. Claude 第二个接
    它最能逼你把“不要把 provider 细节揉进业务层”这件事做对。
  3. DeepSeek 适合补成本和国产可用性维度
    OpenAI 兼容入口上手快,但特性边界一定要单独测,不要只靠想当然。

总结

从单 provider 走到多 provider,真正的分水岭不是“多写几个 API Key”,而是你有没有把封装层次想清楚。

最值得记住的只有一句话:

统一业务能力接口,接受 provider 差异存在,在边界做适配,而不是强行把三家揉成一个假想标准。

这样做的好处是:

  • 后续接第四家模型时改动最小
  • 降级、路由、A/B 对比更容易做
  • 结构化输出、工具调用、流式输出这些高级能力不会被最低公分母限制死

下一篇我会继续沿着这条线往前走:
把多 provider 差异进一步收口成一个可扩展的路由/降级服务层。

参考资料

本文永久链接: https://www.mulianju.com/learning-notes/ai-learning-notes-multi-provider-api-integration-openai-claude-deepseek/