1. 项目概述:为什么需要一套代码对接多个大模型

在AI应用开发中,我们常常面临一个选择:到底用哪家的大模型API?是OpenAI的GPT,还是Google的Gemini,或是Anthropic的Claude?很多评测文章会给你一堆基准测试分数,告诉你哪个模型在MMLU或GSM8K上得分更高。但当你真正动手写代码,想把模型能力集成到自己的产品里时,你会发现,这些分数远不是故事的全部。真正的挑战,往往藏在API的响应格式、流式传输的实现细节、系统提示的处理方式,乃至错误码的返回逻辑里。

我最近就深陷其中。我开发的是一个浏览器端的开发者工具,核心功能需要调用大模型。为了让用户有最大的灵活性,我决定支持多个LLM提供商——用户可以选择自己偏好的服务,填入自己的API密钥,工具则负责处理后续的所有调用。听起来是个挺优雅的方案:一套代码,兼容四方。我最初设想,无非就是封装几个不同的HTTP请求,处理一下返回的JSON。但实际做下来才发现,这里面的“坑”和细节,远比想象中多。从OpenAI、Google Gemini、Anthropic Claude,再到任何宣称“OpenAI兼容”的本地端点(比如用Ollama或LM Studio部署的模型),每个服务商都有自己的一套“方言”。

这篇文章,就是我这趟“踩坑之旅”的实战记录。我不会跟你复述那些你能在官方文档里找到的API参数列表,而是聚焦于那些文档里可能一笔带过、但实际开发中会让你调试到头疼的“魔鬼细节”。如果你也在构建需要支持多模型后端的应用,希望这些经验能帮你省下不少时间。

2. 核心差异解析:不止是URL和密钥不同

当你决定支持多个LLM API时,第一反应可能是抽象一个通用的“发送请求”函数,根据用户选择替换 baseURL apiKey 。这没错,但这只是万里长征第一步。真正的复杂性,始于你收到服务器返回的那个JSON对象。

2.1 响应格式:五花八门的JSON结构

各家API返回的成功响应,结构差异之大,堪称“八仙过海,各显神通”。你的下游业务逻辑——比如把AI回复显示在网页上、存入数据库、或者进行后续处理——都依赖于从一个固定的路径里提取出纯文本。如果处理不当,一个 undefined 错误就能让整个流程崩溃。

OpenAI (Chat Completions API): 这是目前最广泛被模仿的格式。它的核心响应体嵌套在 choices 数组里。

{
  "choices": [{
    "message": {
      "content": "这里是模型生成的文本内容..."
    }
  }]
}

提取文本的路径是: response.choices[0].message.content 。注意, choices 是一个数组,理论上可以返回多个候选结果,但默认情况下只有一个。

OpenAI (新的Responses API): OpenAI推出了更新的Responses API(端点通常是 /v1/responses ),它的结构有了显著变化,更倾向于一种“事件流”式的扁平结构。

{
  "output": [{
    "type": "message",
    "content": [{
      "type": "output_text",
      "text": "这里是模型生成的文本内容..."
    }]
  }]
}

这里,文本藏在 response.output 数组里,你需要先过滤出 type "message" 的对象,再从其 content 数组里找出 type "output_text" 的项,最后拿到 text 字段。这个设计显然是为了更好地兼容多模态输出(比如文本和图像混合),但对于纯文本场景,提取逻辑变复杂了。

Anthropic Claude: Claude的API结构相对简洁明了,专注于内容块(Content Block)。

{
  "content": [{
    "type": "text",
    "text": "这里是模型生成的文本内容..."
  }]
}

路径是 response.content ,这是一个数组。你需要遍历它,找出所有 type "text" 的块,然后把它们的 text 拼接起来。这种设计同样为未来混合类型内容(如工具调用结果)留了空间。

Google Gemini: Gemini的响应结构有它自己的命名风格,核心概念是“候选人”(candidate)。

{
  "candidates": [{
    "content": {
      "parts": [{
        "text": "这里是模型生成的文本内容..."
      }]
    }
  }]
}

提取路径是: response.candidates[0].content.parts[0].text parts 也是一个数组,理论上可以包含多种类型的部分。

实操心得:先写“提取器”,再写业务逻辑 我的教训是,在写第一行调用API的代码之前,就应该先为每个提供商写好一个健壮的文本提取函数。我把它叫做 normalizeResponse extractText 。这个函数要能处理各种边界情况:响应体为空、期望的路径不存在、数组越界等。下面是我最终使用的JavaScript函数的一个简化版:

function extractText(provider, response) {
  // 安全地访问嵌套属性,避免undefined错误
  const safeGet = (obj, path, defaultValue = '') => path.split('.').reduce((acc, key) => acc?.[key], obj) ?? defaultValue;

  switch (provider) {
    case 'openai':
      return safeGet(response, 'choices.0.message.content');
    case 'openai-responses':
      // 处理新的Responses API格式
      const messages = safeGet(response, 'output', []).filter(item => item.type === 'message');
      const textContents = messages.flatMap(msg => safeGet(msg, 'content', []))
                                   .filter(content => content.type === 'output_text')
                                   .map(content => content.text);
      return textContents.join('\n');
    case 'claude':
      const textBlocks = safeGet(response, 'content', []).filter(block => block.type === 'text');
      return textBlocks.map(block => block.text).join('\n');
    case 'gemini':
      return safeGet(response, 'candidates.0.content.parts.0.text');
    default:
      // 默认为OpenAI兼容格式,覆盖Ollama等本地模型
      return safeGet(response, 'choices.0.message.content');
  }
}

把这个函数放在一个独立的适配器模块里。你的业务代码永远只调用 extractText('openai', apiResponse) ,而不用关心内部具体怎么解析。这极大地提升了代码的健壮性和可维护性。

2.2 流式传输:数据流里的“方言”差异

对于需要实时显示模型生成结果的场景(比如聊天应用),流式传输(Streaming)是必备功能。幸运的是,这四家主流提供商都支持Server-Sent Events(SSE)方式的流式响应。不幸的是,它们发送的数据块(chunk)格式,又是各说各话。

共通点与机制: 它们都使用HTTP流,通过 Content-Type: text/event-stream 头部来推送一系列事件。每个事件以 data: 开头,后跟一个JSON对象。关键区别在于:1. 标识流结束的信号;2. 每个数据块中文本内容所在的字段路径。

OpenAI及兼容端点:

  • 数据块格式 :每个 data: 行包含一个JSON对象,其中新增的文本位于 choices[0].delta.content 字段。注意是 delta (增量),而不是完整的 message
    data: {"choices":[{"delta":{"content":"这是"}}]}
    data: {"choices":[{"delta":{"content":"一段"}}]}
    data: {"choices":[{"delta":{"content":"流式文本。"}}]}
    
  • 结束信号 :流以一个特殊的 data: [DONE] 行结束。你的客户端代码需要监听这个信号来关闭连接。

Anthropic Claude:

  • 数据块格式 :Claude使用了更复杂的事件类型。文本增量出现在 content_block_delta 事件中,具体路径是 delta.text
    event: content_block_delta
    data: {"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": "这是"}}
    
    此外还有 message_start content_block_start 等事件来标识结构开始。你需要根据 event 类型来解析 data
  • 结束信号 :流以 event: message_stop 事件结束。

Google Gemini:

  • 数据块格式 :Gemini的数据块结构类似其非流式响应,文本增量在 candidates[0].content.parts[0].text
    data: {"candidates":[{"content":{"parts":[{"text":"这是"}]}}]}
    
  • 结束信号 :流结束时,通常会发送一个包含完成原因(如 FINISH_REASON_STOP )的特定数据块,但实践中也需要判断连接是否正常关闭。

避坑指南:统一流式解析器 如果你自己从零解析这些SSE流,会非常繁琐。我的建议是:

  1. 使用成熟的库 :在浏览器端,可以考虑使用 eventsource-parser 等库来稳健地分割SSE事件行。
  2. 构建Provider-Specific的解析器 :为每个提供商写一个小的解析函数,输入是原始的 data 字符串,输出是本次块中的文本(可能为空)以及一个是否结束的布尔标志。
  3. 示例(Claude解析逻辑)
function parseClaudeChunk(line) {
  if (line.startsWith('event: ')) {
    const eventType = line.replace('event: ', '');
    if (eventType === 'message_stop') return { text: '', done: true };
  }
  if (line.startsWith('data: ')) {
    const data = JSON.parse(line.replace('data: ', ''));
    if (data.type === 'content_block_delta' && data.delta?.type === 'text_delta') {
      return { text: data.delta.text, done: false };
    }
  }
  return { text: '', done: false }; // 忽略其他事件
}
  1. 尽早实现流式 :即使你初期觉得不需要,也强烈建议在架构设计初期就为流式传输留好接口。后期从同步请求重构为支持流式,往往意味着对数据流处理逻辑的大改。

2.3 系统提示处理:指令放在哪里?

系统提示(System Prompt)是引导模型行为的关键。但你会发现,这个看似简单的概念,在不同API里配置的位置截然不同。

提供商 参数名 放置位置 示例
OpenAI (Chat) messages 中的 role: "system" 放在请求体的 messages 数组最前面 {messages: [{role: "system", content: "你是一个助手"}, {role: "user", content: "你好"}]}
OpenAI (Responses) instructions 请求体的顶级参数,与 input 并列 {instructions: "你是一个助手", input: [...]}
Anthropic Claude system 请求体的顶级参数 {system: "你是一个助手", messages: [...]}
Google Gemini system_instruction 请求体的顶级参数,结构为 {parts: [{text: "..."}]} {system_instruction: {parts: [{text: "你是一个助手"}]}, contents: [...]}

实现策略: 你需要在发出请求前,对消息数组进行预处理。假设你的应用内部统一使用OpenAI Chat的格式(即 messages 数组包含 system 角色),那么你的适配层需要做如下转换:

function buildRequest(provider, messages, model) {
  const systemPrompt = messages.find(m => m.role === 'system')?.content || '';
  const userMessages = messages.filter(m => m.role !== 'system');

  switch (provider) {
    case 'openai':
      return { model, messages }; // 原样发送
    case 'claude':
      return {
        model,
        system: systemPrompt, // 提取到顶级
        messages: userMessages // 只发送用户/助理消息
      };
    case 'gemini':
      return {
        model,
        system_instruction: { parts: [{ text: systemPrompt }] },
        contents: userMessages.map(m => ({ role: m.role === 'user' ? 'user' : 'model', parts: [{ text: m.content }] }))
      };
    // ... 其他提供商
  }
}

这样,你的业务层可以始终用同一种方式构造对话历史,由适配层负责转换成目标API所需的格式。

3. 成本与稳定性考量:看不见的变量

除了技术实现,商业因素和模型行为本身也会带来显著差异。

3.1 令牌计数与成本计算

各家定价模型都是按令牌(Token)收费,但单价和计费方式有细微差别。

  • OpenAI & Claude :清晰地区分输入令牌和输出令牌,分别计价。例如,GPT-4o的输入可能比输出便宜。你需要从API响应头或响应体中的 usage 字段获取准确的令牌数。
  • Gemini :有免费配额,超出后按请求收费。其API响应中可能不直接提供令牌数,需要你使用相应的客户端库(如 @google/generative-ai )来估算,或根据文本长度粗略计算。

一个容易被忽略的事实:相同提示词,不同输出长度。 即使你向不同模型发送完全相同的系统提示和用户问题,它们生成的回答长度(令牌数)也可能大相径庭。有的模型倾向于简洁,有的则更“健谈”。这意味着, 即使输入成本相同,你的输出成本也会因模型的选择而浮动 。如果你的应用允许用户自选模型,最好能在发送请求前给一个基于历史平均值的成本预估,或者在UI上显示本次调用消耗的令牌数,让信息更透明。

3.2 错误处理:不是所有的429都一样

网络请求总会出错,但错误信息的呈现方式也是百花齐放。

  • OpenAI :比较符合RESTful惯例,使用标准的HTTP状态码。 429 表示速率限制, 401 表示API密钥无效, 400 通常是请求体格式错误。错误详情在响应体的JSON中,通常包含一个 error.message 字段。
  • Claude :也会使用HTTP状态码,但其响应体中的错误对象结构更规范,包含 type message 。例如,速率限制错误可能是 {“type”: “rate_limit_error”, “message”: “...”}
  • Gemini :这里有个“坑”。有时即使HTTP状态码是 200 OK ,响应体里也可能包含一个 error 对象。这意味着 你不能只检查 response.ok ,还必须解析JSON,看看里面有没有错误信息。

健壮的错误处理示例:

async function makeRequest(provider, url, options) {
  const response = await fetch(url, options);
  const data = await response.json(); // 总是尝试解析JSON

  // 首先检查是否是“成功的错误响应”(如Gemini的200 with error)
  if (data.error) {
    throw new Error(`[${provider}] API Error: ${data.error.message}`);
  }

  // 然后检查HTTP状态码
  if (!response.ok) {
    // 尝试从data中获取信息,或回退到statusText
    const errorMsg = data.message || response.statusText;
    throw new Error(`[${provider}] HTTP ${response.status}: ${errorMsg}`);
  }

  // 最后,才是真正的成功数据
  return data;
}

此外,要为每种典型的错误(如速率限制、认证失败、模型过载)设计重试逻辑和友好的用户提示。例如,遇到 429 错误,可以尝试指数退避重试;遇到 401 ,则直接提示用户检查API密钥。

4. 架构设计与实现策略

面对这些差异,一个清晰的架构至关重要。目标是将提供商特定的代码隔离在最小范围内,让核心业务逻辑保持干净、可测试。

4.1 适配器模式:你的最佳盟友

我强烈推荐使用 适配器模式(Adapter Pattern) 。为每个LLM提供商(包括“OpenAI兼容”这一类)创建一个独立的适配器类或模块。这个适配器对外提供统一的接口,对内处理所有脏活累活。

统一的接口设计:

// 这是所有适配器都要实现的接口
class LLMProviderAdapter {
  constructor(apiKey, baseURL, model) {
    this.apiKey = apiKey;
    this.baseURL = baseURL;
    this.model = model;
  }

  // 1. 标准化请求
  async createChatCompletion(messages, options = {}) {
    // options 可包含 temperature, max_tokens, stream 等
    throw new Error('Not implemented');
  }

  // 2. 标准化响应解析(同步)
  parseResponse(apiResponse) {
    throw new Error('Not implemented');
  }

  // 3. 标准化流式响应处理
  async *createChatCompletionStream(messages, options = {}) {
    throw new Error('Not implemented');
  }

  // 4. 估算令牌成本(可选)
  estimateCost(inputTokens, outputTokens) {
    throw new Error('Not implemented');
  }
}

具体适配器示例(OpenAI):

class OpenAIAdapter extends LLMProviderAdapter {
  async createChatCompletion(messages, options) {
    const response = await fetch(`${this.baseURL}/v1/chat/completions`, {
      method: 'POST',
      headers: {
        'Authorization': `Bearer ${this.apiKey}`,
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({
        model: this.model,
        messages: messages, // OpenAI Chat格式,直接使用
        stream: false,
        ...options
      })
    });
    return this.handleResponse(response);
  }

  parseResponse(apiResponse) {
    // 使用前面提到的 extractText 逻辑
    const text = extractText('openai', apiResponse);
    const usage = apiResponse.usage; // { prompt_tokens, completion_tokens, total_tokens }
    return { text, usage };
  }

  async *createChatCompletionStream(messages, options) {
    const response = await fetch(`${this.baseURL}/v1/chat/completions`, {
      method: 'POST',
      headers: { /* ... */ },
      body: JSON.stringify({ ...options, stream: true })
    });

    const reader = response.body.getReader();
    const decoder = new TextDecoder();
    let buffer = '';

    try {
      while (true) {
        const { done, value } = await reader.read();
        if (done) break;

        buffer += decoder.decode(value, { stream: true });
        const lines = buffer.split('\n');
        buffer = lines.pop(); // 最后一行可能不完整,留回缓冲区

        for (const line of lines) {
          if (line.startsWith('data: ')) {
            const data = line.slice(6);
            if (data === '[DONE]') return;
            try {
              const parsed = JSON.parse(data);
              const chunkText = parsed.choices?.[0]?.delta?.content || '';
              if (chunkText) yield chunkText;
            } catch (e) {
              console.warn('Failed to parse stream chunk:', e);
            }
          }
        }
      }
    } finally {
      reader.releaseLock();
    }
  }
}

这样,在你的业务逻辑中,你只需要:

const provider = new OpenAIAdapter(apiKey, 'https://api.openai.com', 'gpt-4o-mini');
const result = await provider.createChatCompletion(messages);
console.log(result.text);

切换提供商时,只需更换适配器实例,业务代码无需改动。

4.2 开发与测试技巧

  1. 从最便宜的模型开始 :在开发测试阶段,不要直接用最贵的旗舰模型(如GPT-4 Turbo、Claude 3.5 Sonnet)。充分利用各家提供的轻量级、低成本模型:

    • OpenAI: gpt-4o-mini
    • Anthropic: claude-3-haiku
    • Google: gemini-1.5-flash 这些模型响应快、成本极低,非常适合进行集成测试和功能验证。
  2. 拥抱“OpenAI兼容”生态 :这是降低复杂性的关键策略。许多开源模型部署方案(如Ollama、vLLM、LM Studio)以及一些云服务商提供的API,都直接兼容OpenAI的Chat Completions API格式。这意味着, 你为OpenAI编写的适配器,可以不经修改或仅做微小调整(如修改 baseURL ),就直接用于这些服务 。这能覆盖你80%以上的集成场景。

  3. 记录原始请求与响应 :在开发调试阶段,务必记录下每次API调用的原始请求体、响应头和响应体。可以将这些信息输出到控制台,或写入一个开发专用的日志文件。当出现解析错误、意外输出或令牌计数不符时,这些原始数据是定位问题最快的方式。可以考虑写一个简单的请求拦截/日志中间件。

  4. 为“未知”做好准备 :LLM领域变化飞快,新的模型、新的API版本、新的功能会不断出现。你的适配器架构应该易于扩展。考虑使用配置文件来定义不同提供商的端点、参数映射和响应解析规则,而不是将逻辑硬编码在代码里。

5. 未来展望与功能演进

当前的多模型集成已经不只是处理文本生成了。各大提供商都在快速推进更高级的功能,而这些功能的实现差异可能更大。

1. 函数调用/工具使用: OpenAI有 tool_calls ,Claude有 tool_use 块,Gemini有 functionCall 。虽然概念相似,但JSON结构完全不同。如果你的应用需要此功能,适配器需要额外处理:将通用的工具定义转换成提供商特定格式,并将模型返回的工具调用参数解析成统一结构。

2. 结构化输出: 让模型返回固定的JSON格式。OpenAI有 response_format: { "type": "json_object" } 参数,Claude和Gemini也有各自的实现方式(如Claude的 response_format 参数)。适配器需要确保请求配置正确,并验证返回的JSON是否合规。

3. 多模态输入: 处理图像、PDF等文件。差异体现在:

  • 如何传文件 :OpenAI和Claude支持通过URL或 base64 编码的数据直接传入消息体。Gemini则需要先通过文件上传API将文件上传到Google的服务器,获得一个 File URI ,再在请求中引用该URI。
  • 如何引用文件 :在消息结构中,指代文件的方式也各不相同。

应对策略: 对于这些高级功能,建议采用“能力检测”和“渐进增强”的策略。在你的统一接口中,可以为这些功能提供可选的方法,并在适配器内部判断当前提供商是否支持。如果不支持,则优雅地降级或向用户抛出清晰的错误信息。

class UnifiedLLMClient {
  constructor(adapter) {
    this.adapter = adapter;
  }

  async generateText(messages, options) {
    return this.adapter.createChatCompletion(messages, options);
  }

  // 可选的高级功能
  async generateStructuredOutput(messages, jsonSchema) {
    if (this.adapter.supportsStructuredOutput) {
      return this.adapter.createStructuredCompletion(messages, jsonSchema);
    } else {
      // 降级方案:在系统提示中要求输出JSON,然后尝试解析
      console.warn('Provider does not natively support structured output, using fallback.');
      // ... 实现降级逻辑
    }
  }
}

6. 总结与核心建议

回顾整个多模型API的集成过程,最大的收获不是学会了某一家API的调用方式,而是建立起一种 抽象和隔离的思维 。在AI技术快速迭代的当下,模型的“最佳选择”可能每季度都在变。今天你可能因为成本选择A,明天可能因为某项新功能选择B。

因此,构建应用的核心优势,不在于绑定某个“最强”的模型,而在于构建一个 干净、可插拔的抽象层 。这个抽象层能让你:

  • 快速切换 :用最小的成本将后端从GPT换成Claude或Gemini。
  • 降低成本 :轻松接入更便宜或本地部署的OpenAI兼容模型。
  • 提升可靠性 :当某个服务出现故障或限流时,可以故障转移到备用提供商。
  • 面向未来 :当新的LLM提供商出现时,你只需要为其编写一个新的适配器,核心业务代码纹丝不动。

所以,如果你正准备开始一个需要集成LLM的项目,我的建议顺序是:

  1. 定义清晰的内部分层 :业务逻辑层 -> 统一服务层 -> 提供商适配层。
  2. 从OpenAI兼容接口开始 :优先实现这个最通用的适配器,它能让你立即用上大量开源和商业模型。
  3. 尽早处理流式响应 :即使UI最初不需要,也在数据流处理层做好支持。
  4. 把差异关在“笼子”里 :所有解析响应、构造请求、处理错误的特殊逻辑,都严格限制在对应的适配器文件中。
  5. 为变化而设计 :假设一切都会变——API版本、功能、定价。让代码保持灵活。

最后,别忘了在开发过程中保存好那些原始的请求和响应日志。它们不仅是调试的利器,也是理解这些API如何演变的宝贵资料。当你下次再需要集成一个新玩家时,翻看这些记录,你会感谢当初那个细心记录的自己。

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐