前端无缝切换大模型 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,基本可以忽略。