前端无缝切换大模型 API:一个请求层的抽象思路
有经验的团队都不会在前端业务代码里直接调大模型 API,而是在中间加一层请求抽象——这就是今天要讲的核心思路。抽象得好,切换供应商几乎不碰业务代码;抽象得差,每个页面都要改一遍,改到怀疑人生。
具体怎么做?我会从协议定义、Provider 实现、错误处理到流式响应,把一个生产可用的请求层完整拆开。
先定义一套统一的协议,而不是直接对接供应商格式
最关键的决策就一个:定义一套你自己的请求/响应格式,让所有供应商的差异全部关在实现层里。别让业务代码知道你用的是 OpenAI、Claude 还是 DeepSeek。
// types/llm.ts
export interface LLMMessage {
role: 'system' | 'user' | 'assistant';
content: string;
}
export interface LLMRequest {
messages: LLMMessage[];
temperature?: number;
maxTokens?: number;
stream?: boolean;
}
export interface LLMResponse {
id: string;
content: string;
usage?: {
promptTokens: number;
completionTokens: number;
};
}
export interface LLMStreamChunk {
content: string;
done: boolean;
}
这套协议只保留所有大模型共有的核心字段。不要试图把某个供应商的特殊能力(比如 OpenAI 的 top_p 或 Claude 的 stop_sequences)直接暴露到协议里——那样就绑死了。如果确实需要高级参数,用 extra 字段透传,但业务代码不应该依赖它。
反过来想,如果你现在用的是 OpenAI 的 chat/completions 格式,你的业务代码里到处是 response.choices[0].message.content,那换供应商就是灾难。用统一协议,业务代码只看到 response.content,舒服得多。
Provider 模式:一个抽象类 + 多个实现
// providers/base.ts
export abstract class LLMProvider {
abstract readonly name: string;
abstract chat(request: LLMRequest): Promise<LLMResponse>;
abstract chatStream(request: LLMRequest): AsyncGenerator<LLMStreamChunk>;
// 可选:模型列表查询
abstract listModels(): Promise<string[]>;
}
抽象类定了三个能力:同步对话、流式对话、模型列表。每个供应商各实现一份。
来看 OpenAI 的实现:
// providers/openai.ts
import OpenAI from 'openai';
export class OpenAIProvider extends LLMProvider {
readonly name = 'openai';
private client: OpenAI;
constructor(apiKey: string, baseURL?: string) {
super();
this.client = new OpenAI({
apiKey,
baseURL: baseURL || 'https://api.openai.com/v1',
dangerouslyAllowBrowser: true, // 前端场景需要
});
}
async chat(request: LLMRequest): Promise<LLMResponse> {
const response = await this.client.chat.completions.create({
model: 'gpt-4o',
messages: request.messages,
temperature: request.temperature ?? 0.7,
max_tokens: request.maxTokens,
});
return {
id: response.id,
content: response.choices[0]?.message?.content || '',
usage: response.usage ? {
promptTokens: response.usage.prompt_tokens,
completionTokens: response.usage.completion_tokens,
} : undefined,
};
}
async *chatStream(request: LLMRequest): AsyncGenerator<LLMStreamChunk> {
const stream = await this.client.chat.completions.create({
model: 'gpt-4o',
messages: request.messages,
temperature: request.temperature ?? 0.7,
max_tokens: request.maxTokens,
stream: true,
});
for await (const chunk of stream) {
yield {
content: chunk.choices[0]?.delta?.content || '',
done: chunk.choices[0]?.finish_reason !== null,
};
}
}
async listModels(): Promise<string[]> {
const models = await this.client.models.list();
return models.data.map(m => m.id);
}
}
再看 Claude 的实现。Anthropic 的 SDK 和 OpenAI 完全不同,但通过统一协议,差异被完全封装:
// providers/anthropic.ts
import Anthropic from '@anthropic-ai/sdk';
export class AnthropicProvider extends LLMProvider {
readonly name = 'anthropic';
private client: Anthropic;
constructor(apiKey: string) {
super();
this.client = new Anthropic({ apiKey });
}
async chat(request: LLMRequest): Promise<LLMResponse> {
// 注意:Claude 要求 role 只能是 'user' 或 'assistant'
// system prompt 需要单独提出来
const systemMsg = request.messages.find(m => m.role === 'system');
const messages = request.messages.filter(m => m.role !== 'system');
const response = await this.client.messages.create({
model: 'claude-3-5-sonnet-20241022',
max_tokens: request.maxTokens || 4096,
system: systemMsg?.content,
messages: messages.map(m => ({
role: m.role as 'user' | 'assistant',
content: m.content,
})),
});
return {
id: response.id,
content: response.content[0]?.type === 'text'
? response.content[0].text
: '',
usage: {
promptTokens: response.usage.input_tokens,
completionTokens: response.usage.output_tokens,
},
};
}
async *chatStream(request: LLMRequest): AsyncGenerator<LLMStreamChunk> {
const systemMsg = request.messages.find(m => m.role === 'system');
const messages = request.messages.filter(m => m.role !== 'system');
const stream = await this.client.messages.create({
model: 'claude-3-5-sonnet-20241022',
max_tokens: request.maxTokens || 4096,
system: systemMsg?.content,
messages: messages.map(m => ({
role: m.role as 'user' | 'assistant',
content: m.content,
})),
stream: true,
});
for await (const event of stream) {
if (event.type === 'content_block_delta' && event.delta.type === 'text_delta') {
yield {
content: event.delta.text,
done: false,
};
} else if (event.type === 'message_stop') {
yield { content: '', done: true };
}
}
}
async listModels(): Promise<string[]> {
// Anthropic 不提供公开的 model list API
return ['claude-3-5-sonnet-20241022', 'claude-3-opus-20240229'];
}
}
关键差异全部在 Provider 内部消化了:Claude 的 system prompt 处理方式、role 限制、流式事件结构,都与 OpenAI 不同,但对外暴露的接口完全一致。
工厂函数:把 Provider 选择集中管理
业务代码不应该知道当前用的是哪个 Provider,也不该直接 new Provider。用一个简单的工厂函数或者注册表来管理:
// providers/factory.ts
import { LLMProvider } from './base';
import { OpenAIProvider } from './openai';
import { AnthropicProvider } from './anthropic';
type ProviderType = 'openai' | 'anthropic';
const providerRegistry = new Map<ProviderType, () => LLMProvider>();
export function registerProvider(type: ProviderType, factory: () => LLMProvider) {
providerRegistry.set(type, factory);
}
export function createProvider(type: ProviderType): LLMProvider {
const factory = providerRegistry.get(type);
if (!factory) throw new Error(`Unknown provider: ${type}`);
return factory();
}
// 应用启动时注册
registerProvider('openai', () => new OpenAIProvider(
import.meta.env.VITE_OPENAI_API_KEY,
import.meta.env.VITE_OPENAI_BASE_URL,
));
registerProvider('anthropic', () => new AnthropicProvider(
import.meta.env.VITE_ANTHROPIC_API_KEY,
));
这样切换供应商只需改一个环境变量或配置项。甚至可以在运行时动态切换,比如做 A/B 测试对比不同模型的效果。
流式响应:用 AsyncGenerator 统一
前端场景里流式响应是刚需——用户不可能等 10 秒看空白页面。AsyncGenerator 是处理流式数据最自然的方式,调用方用 for await...of 消费,和数组遍历一样直观:
// hooks/useChat.ts
import { useState, useCallback } from 'react';
import { LLMProvider, LLMMessage, LLMStreamChunk } from '../types/llm';
import { createProvider } from '../providers/factory';
export function useChat(providerType: ProviderType) {
const [messages, setMessages] = useState<LLMMessage[]>([]);
const [streaming, setStreaming] = useState(false);
const sendMessage = useCallback(async (content: string) => {
const provider = createProvider(providerType);
const newMessages: LLMMessage[] = [
...messages,
{ role: 'user', content },
];
setMessages(newMessages);
// 先插入一个空的 assistant message 用于流式更新
const assistantIndex = newMessages.length;
setMessages([...newMessages, { role: 'assistant', content: '' }]);
setStreaming(true);
let fullContent = '';
try {
const stream = provider.chatStream({
messages: newMessages,
maxTokens: 4096,
});
for await (const chunk of stream) {
if (chunk.done) break;
fullContent += chunk.content;
setMessages(prev => {
const updated = [...prev];
updated[assistantIndex] = {
role: 'assistant',
content: fullContent,
};
return updated;
});
}
} catch (error) {
// 错误处理见下一节
console.error('Stream error:', error);
} finally {
setStreaming(false);
}
}, [messages, providerType]);
return { messages, streaming, sendMessage };
}
这个 Hook 完全不知道底层是 OpenAI 还是 Claude,它只依赖 LLMProvider 抽象。
错误处理:统一错误类型,别让上层去猜
不同供应商的错误格式天差地别。OpenAI 返回 error.message,Anthropic 返回 error.error.message,网络错误又是另一套。必须在 Provider 层做归一化:
// types/errors.ts
export class LLMError extends Error {
constructor(
message: string,
public readonly code: 'rate_limit' | 'auth' | 'timeout' | 'server' | 'unknown',
public readonly statusCode?: number,
public readonly providerRaw?: unknown,
) {
super(message);
this.name = 'LLMError';
}
}
在 Provider 实现里统一捕获和转换:
// providers/openai.ts 的 chat 方法中添加
async chat(request: LLMRequest): Promise<LLMResponse> {
try {
// ... 正常逻辑
} catch (error: any) {
if (error.status === 429) {
throw new LLMError('请求过于频繁,请稍后重试', 'rate_limit', 429, error);
}
if (error.status === 401) {
throw new LLMError('API Key 无效', 'auth', 401, error);
}
if (error.code === 'ECONNABORTED') {
throw new LLMError('请求超时', 'timeout', undefined, error);
}
throw new LLMError(
error.message || '未知错误',
'unknown',
error.status,
error,
);
}
}
上层只需要 catch (e) 然后判断 e instanceof LLMError,根据 e.code 决定是重试、提示用户还是切换备用 Provider。
多 Provider 自动切换:加上重试和降级
生产环境里单点依赖一个供应商不靠谱。可以在请求层加一个带降级逻辑的 wrapper:
// providers/fallback.ts
export class FallbackProvider extends LLMProvider {
readonly name = 'fallback';
private providers: LLMProvider[];
constructor(...providers: LLMProvider[]) {
super();
this.providers = providers;
}
async chat(request: LLMRequest): Promise<LLMResponse> {
let lastError: Error | null = null;
for (const provider of this.providers) {
try {
return await provider.chat(request);
} catch (error) {
lastError = error as Error;
console.warn(`Provider ${provider.name} failed, trying next...`);
// 只对 server、timeout 类错误降级,auth 错误不应重试
if (error instanceof LLMError && error.code === 'auth') {
throw error;
}
continue;
}
}
throw lastError || new Error('All providers failed');
}
async *chatStream(request: LLMRequest): AsyncGenerator<LLMStreamChunk> {
// 流式场景下降级更复杂,因为已经输出了一部分内容
// 这里简化处理:失败就抛错,不做流内切换
let lastError: Error | null = null;
for (const provider of this.providers) {
try {
yield* provider.chatStream(request);
return;
} catch (error) {
lastError = error as Error;
if (error instanceof LLMError && error.code === 'auth') {
throw error;
}
}
}
throw lastError || new Error('All providers failed');
}
}
使用时就一行:
const provider = new FallbackProvider(
new OpenAIProvider(apiKey1),
new AnthropicProvider(apiKey2),
);
如果 OpenAI 挂了,自动切到 Claude,用户无感知。
API Key 安全:前端调用必须走反向代理
最后必须提一个安全问题。大模型的 API Key 绝不能直接暴露在前端代码里——即使你用环境变量,打包后也会出现在浏览器端。正确做法是在你自己的后端加一个轻量代理:
// 前端这样初始化
const provider = new OpenAIProvider(
'', // API Key 留空
'/api/llm/openai', // 指向你自己的后端代理
);
你的后端代理(比如用 Cloudflare Workers 或 Next.js API Route)负责注入真实的 API Key 并转发请求。这样 Key 永远不出现在前端。
常见问题
如果用同一个 openai npm 包调不同供应商的兼容 API(比如 DeepSeek、通义千问),还需要写多个 Provider 吗?
不一定。很多国产大模型提供了 OpenAI 兼容接口,你只需要改 baseURL 就能复用 OpenAIProvider。但要注意两点:一是这些兼容接口可能不支持所有参数(比如 DeepSeek 不支持 temperature 为 0),你需要针对性地调整;二是流式响应的 SSE 格式可能有细微差异。建议还是独立写一个 Provider,哪怕大部分代码从 OpenAIProvider 复制过来,调整起来更自由。
流式响应在中途失败,已经输出的内容怎么处理?
这是流式场景下最棘手的问题。已输出的内容已经从 AsyncGenerator 里 yield 出去了,没法撤回。实际做法是:在 useChat 层面捕获错误后,保留已经输出的部分内容,同时在消息末尾追加一条错误提示,比如「[回复中断,请重试]」。不要清空整个消息,那样用户体验更差。多 Provider 降级在流式场景下也建议不做中途切换,因为切换后的 Provider 没有上下文,输出会不连贯。
这个抽象层会增加多少请求延迟?
几乎为零。抽象层本身没有网络开销,只是做了数据格式转换。真正的延迟来自你选择的反向代理方案——如果是同机房的 Worker 转发,额外延迟通常在 10-50ms,基本可以忽略。