← 返回资讯

怎么同时接多家 API:多供应商接入架构的抽象层该画在哪

2026-08-07

接第二家 API 那天,改动看起来小得不值一提:复制一份 client 初始化的代码,把 base_url 换掉,key 换掉,本地跑通,合并,收工。三个月后主通道出故障,值班同事按预案把开关拨到备用,拨完发现全线报错——模型 ID 还是老那家的写法。备用通道从上线那天起就没真正能用过,只是没人有机会发现。

这不是某个人不细心的问题。它是抽象层画错位置的必然结果:当”换一家供应商”这件事在代码里表现为”多一份几乎相同的文件”,那么每次上游有任何差异,你都得指望人记得同步。人不会记得。

多接一家,动机通常只有三个

我见过的团队里,决定接第二家的理由跑不出这三条:

防单点。 上游挂了、限流了、某个模型下线了,业务不能跟着停。这是最常见的动机,也是最容易做成”假的”的一个——因为备用通道平时不跑流量,坏了没人知道。

比价。 同一个开源模型在不同平台上挂牌价不同,把非关键流量挪到便宜的一家,账单能明显下来。这条动机的隐藏成本是:你从此需要一套能按用途分流的机制,否则省下来的钱会被排查成本吃掉。

能力互补。 有的平台适合跑长任务和出图,有的适合跑对话补全;有的平台除了 OpenAI 兼容端点还额外提供 Anthropic SDK 兼容端点(Fireworks AI 的文档里明确写了这一点),存量代码的迁移路径就多一条。这类动机往往指向”两家长期共存”,而不是”一主一备”。

问题出在执行上。很多团队接第二家时选的路子是”复制一份代码改地址”,于是维护成本翻倍,而上面三个收益一个都没兑现:防单点没兑现,因为备用路径没人验证;比价没兑现,因为没有分流开关,只能全量切;能力互补没兑现,因为两套代码各自演化,谁也不敢在关键路径上依赖另一套。

抽象层画在哪一层

多供应商接入架构的全部难点,浓缩成一句话就是:“供应商”这个概念,应该在你系统的哪一层被消化掉? 往下有三种常见答案。

位置一:SDK 层封装

在应用代码里包一层薄薄的客户端工厂,按配置返回不同上游的 client。因为主流平台大多提供 OpenAI 兼容端点,这一层往往只需要处理 base_url、key 和模型 ID 的三元组替换。

  • 适用规模:上游 1 到 2 家,单一服务或少数几个服务,团队十人以内。
  • 代价:语言绑定。你用 Python 写的封装,Node 那边的服务用不上,于是同样的逻辑写第二遍——第二遍就开始漂移。另外计费归属、用量统计这些横切需求,只能各服务自己埋点。
  • 判据:如果调用方全都是同一种语言、同一个仓库,这一层足够用,不要过度设计。

位置二:自建网关

把所有对上游的调用收敛到一个内部服务,业务方只认这个服务的地址和你自己发的 key。

  • 适用规模:上游 3 家以上,调用方跨语言、跨团队,需要按团队/项目做用量归属和额度控制。
  • 代价:你多了一个必须自己保活的关键路径组件。它挂了,所有 AI 功能一起挂——所以它必须比任何单个上游都可靠,这个要求比想象中高。
  • 判据:真正逼你走到这一步的通常不是技术,是计费归属。当财务开始问”上个月这笔钱是哪个业务花的”,而你答不上来,网关就该建了。

位置三:用现成网关

LiteLLM Proxy、New API 这类开源项目已经把多上游、虚拟 key、用量统计做完了,自建的边际收益不高。

  • 适用规模:介于前两者之间,且你的需求没有特别古怪的定制。
  • 代价:你要接受它的模型抽象和配置范式;版本升级、字段变更得跟着它走。所有配置字段都要以官方文档当次为准,别照着两年前的博客抄。
  • 判据:先把你的需求列成清单去对它的功能,能对上八成就用它,不要为了剩下两成自己重写。两者的差别与选型维度,可以参考聚合网关方案对比

三个位置不是递进关系,是权衡。判据我总结成三条:团队规模决定你养不养得起一个自建组件;上游数量决定配置复杂度会不会失控;是否需要计费归属决定这层必须是进程内的还是网络上的。前两条能容忍模糊,第三条一旦为真,SDK 层封装就注定不够。

通道模型:把三件事绑成不可分割的对象

不管抽象层画在哪,有一个数据结构必须先定死:通道(channel)。

一个通道 = base_url + key 的环境变量名 + 模型 ID。这三件事必须绑成一个对象,任何一处都不能单独配置、单独覆盖、单独切换。

channels:
  primary:
    base_url: "https://api.together.ai/v1"
    api_key_env: "TOGETHER_API_KEY"
    model: "MiniMaxAI/MiniMax-M3"
  backup:
    base_url: "https://api.fireworks.ai/inference/v1"
    api_key_env: "FIREWORKS_API_KEY"
    model: "accounts/fireworks/models/deepseek-v3p1"

最常见的事故就是这里出的。 切通道的时候只切了地址和 key,模型 ID 没跟着切,于是备用通道全线失败。这种事故有两个恶劣属性:一是平时测不出来——只在主通道故障时才走这条路径,而那正是你最不希望出新问题的时刻;二是报错具有误导性,模型 ID 不对时返回的可能是 404、400,也可能看起来像鉴权或权限问题,值班的人第一反应是去查 key,方向从一开始就偏了。

为什么模型 ID 这么容易漏?因为各家的命名形式根本不是一套东西。把已核实的几家排开看:

平台base_url鉴权环境变量模型 ID 形式
Groqhttps://api.groq.com/openai/v1GROQ_API_KEY扁平名,如 llama-3.3-70b-versatile
Cerebrashttps://api.cerebras.ai/v1CEREBRAS_API_KEY扁平名,如 gpt-oss-120b
DeepInfrahttps://api.deepinfra.com/v1/openaiDEEPINFRA_TOKEN两段式,如 deepseek-ai/DeepSeek-V3
Novita AIhttps://api.novita.ai/openai文档写 ${API_KEY}两段式,如 deepseek/deepseek-r1
Together AIhttps://api.together.ai/v1TOGETHER_API_KEY两段式,如 MiniMaxAI/MiniMax-M3
Nebius Token Factoryhttps://api.tokenfactory.nebius.com/v1/NEBIUS_API_KEY两段式带日期版本,如 deepseek-ai/DeepSeek-R1-0528
Fireworks AIhttps://api.fireworks.ai/inference/v1FIREWORKS_API_KEY三段式,如 accounts/fireworks/models/deepseek-v3p1
Replicatehttps://api.replicate.com/v1/predictionsREPLICATE_API_TOKEN非模型名,用 version + input 结构

这张表里藏着好几处会咬人的细节。路径尾巴的顺序各家不同:Groq 是 /openai/v1,DeepInfra 是 /v1/openai,两个词序颠倒过来,肉眼扫过去很难发现。Fireworks 比多数平台多一段 inference,抄漏了得到的是 404 而不是鉴权错误,排查方向又要跑偏一次。Nebius 官方写法带末尾斜杠,如果你的代码自己也拼一个斜杠上去,路径就多一层。Together 的模型 ID 只填后半截会失败,且报错看起来像权限问题。

命名形式的差异更值得单独盘:扁平名、两段式、带日期版本后缀的两段式、三段式,四种形式混在一个配置文件里,而它们在 YAML 里长得都像”一个字符串”。这类命名坑我在推理平台模型 ID 命名规则里展开过。

所以通道对象不只是为了整洁,它是一个结构性的防错:只要代码里没有任何地方能单独设置 base_url,模型 ID 漏切这件事就在物理上做不到了。做法上有两条硬规定:

  1. 切换的最小单位是整个通道对象,不提供”只改某个字段”的接口。
  2. 通道定义集中在一个文件,禁止在调用点用参数覆盖其中任何一项。

兼容端点不等于功能一致

“OpenAI 兼容”这四个字最容易被读成”完全一样”。它的实际含义只是:请求路径、请求体结构和响应结构对得上,标准 SDK 能直接用。它不保证每个参数都被支持,也不保证每个功能的行为一致。

有个已核实的例子很能说明问题。Groq 的官方文档明确列出了它不支持的 OpenAI 参数:logprobslogit_biastop_logprobsmessages[].nameN(若传必须等于 1);另外 temperature 传 0 会被转换成 1e-8,音频输出格式不支持 vttsrt。这些差异单看每一条都很小,但如果你的业务恰好靠 logit_bias 约束输出,或者靠 temperature=0 来求可复现,切过去之后行为就变了,而且不会报错——它只是安静地返回了不一样的结果。

这里有条纪律要守住:不要凭印象断言”某家不支持某个功能”。 上面这份清单之所以能写,是因为它来自 Groq 的官方文档。其他平台我没有对应的官方清单,那就不写。同理,Fireworks 的官方文档列出的功能清单里包含流式、函数调用、结构化输出、推理,这是它自己声明的,不能反过来推断别家有没有。

不能假设通用的能力,按我的经验集中在这四类:

  • 流式事件格式:分块的切分粒度、结束标记、用量统计出现在哪一个 chunk,这些细节各家不一定一致,而解析器往往是照着某一家写死的。
  • 函数调用 / 工具调用:参数结构、并行调用、拒绝调用时的返回形态。
  • 结构化输出:是”尽力而为”还是”强制约束”,失败时的表现差别很大。
  • 推理类参数:思考过程放在哪个字段、是否计入输出、是否可关闭。

迁移或新增一家上游后,这份清单必须逐条重测:

  1. 一次普通的非流式补全,核对响应字段齐不齐。
  2. 一次流式补全,核对分块解析器不报错、结束标记能识别、用量字段能取到。
  3. 一次带工具定义的调用,核对工具被正确触发和参数结构。
  4. 如果业务依赖结构化输出,用真实 schema 跑一次,并测一次故意难以满足的 schema,看失败形态。
  5. 把你业务实际会传的全部参数原样发一次——不是简化版,是线上真会发的那一套,专门用来暴露”某个参数不被支持”。
  6. 一次超长输入,观察超限时返回的错误类型(这一步的结果会直接决定下一节的回退配置)。
  7. 一次会被内容策略拦截的输入,同样是为了看失败形态。

第 5 条最容易被跳过,也最容易出事。很多人测的是教科书式的最小请求,线上发的却是带了七八个参数的请求。

路由和回退是两件事,别混在一个策略里

这两件事经常被塞进同一个配置,然后谁也说不清某次请求为什么走了那条路。

路由是主动的:按用途决定这次请求该去哪。成本敏感的批量任务走便宜通道,需要长上下文的走大窗口通道,交互式场景走延迟更友好的通道。路由的输入是业务语义,在请求发出之前就已经决定了。这部分的策略设计我在网关路由策略里单独讲过。

回退是被动的:请求已经发出去并且失败了,现在要决定补救方式。回退的输入是失败类型

把两者混在一起的典型症状是:一个”低成本通道”因为上下文超限失败,被回退规则换成了另一个同样装不下的低成本通道,重试几轮全灭,最后返回给用户的错误还是上下文超限。

LiteLLM Proxy 在这件事上的字段设计很值得借鉴——它把 fallback 拆成了三种,字段名逐字如下:

字段作用
fallbacks失败时路由到备选模型
content_policy_fallbacks内容策略拒绝时的回退
context_window_fallbacks超上下文长度时回退到大窗口模型
default_fallbacks兜底(模型配错时)

配套的可靠性字段还有 num_retries(每个模型的重试次数)、request_timeout(单次调用最长时间)、allowed_fails(触发冷却的失败次数阈值)、cooldown_time(冷却时长,之后该模型重新启用)、enable_pre_call_checks(发送前校验请求)。后两个合起来就是熔断器语义:连续失败到阈值就冷却掉这个模型,过一段时间再放回来。官方文档里这些字段带的示例数值只是示例,既不是默认值也不是推荐值,具体取值要按你自己的流量特征定,字段名与用法以官方文档当次为准。

设计上真正的启发是那句拆分:普通失败、内容策略拒绝、上下文超限,是三类完全不同的失败,配同一套回退目标必然有一类是错的。哪怕你不用 LiteLLM,自己写回退逻辑时也该按这个维度分叉——先判断失败类型,再决定回退目标。具体到 LiteLLM 的配置写法,可以看LiteLLM fallback 配置;整体的容灾链路设计见网关容灾与自动降级

还有一条边界要划清:回退不是无限重试。上游返回 4xx 里的语义错误(请求体本身不合法)时,换一家通常也是同样的结果,重试只是把一次失败变成 N 次失败。真正值得回退的是”这一家现在不行”,不是”这个请求本身不行”。

非兼容平台要单独适配,别硬塞进同一个抽象

Replicate 是个好例子。它不是 OpenAI 兼容端点,有自己的 predictions API:社区模型走 https://api.replicate.com/v1/predictions,官方模型走 https://api.replicate.com/v1/models/{model}/predictions,Deployments 走 https://api.replicate.com/v1/deployments/{deployment}/predictions;鉴权用 Authorization: Bearer $REPLICATE_API_TOKEN。请求体的关键字段是 version(模型版本 ID)和 input(输入参数对象),另外可以带 webhook(完成时回调 URL)和 webhook_events_filter(触发 webhook 的事件类型数组)。请求头里 Prefer: wait 能启用同步模式并指定等待秒数(如 wait=5),Cancel-After 设置自动取消超时(格式如 1m30s2h)。

看清楚这个结构差异:它默认是异步的——提交任务,然后轮询或等回调拿结果,Prefer: wait 只是给同步场景开的一个口子。这不是”参数不一样”,这是调用范式不一样

我见过有人试图把这种平台塞进统一的 chat completion 抽象里:在通道对象里加个 is_async 布尔值,然后在调用层写一堆分支。结果是抽象层被污染,每加一个非兼容平台就要动一次公共代码,而公共代码是所有通道共用的——改坏一次,全部通道一起遭殃。

更稳的做法是在业务语义层对齐,而不是在参数层对齐。你的业务需要的是”给我一段文本”或”给我一张图”,那就在业务层定义这个能力接口,让 Replicate 适配器自己在内部处理提交、轮询、取消超时这一整套,对外只暴露业务语义。它和 OpenAI 兼容适配器是并列的两个实现,共享的是业务接口,不是参数结构。

判断标准很简单:如果一个平台的接入差异不能靠”换三处配置”消化掉,它就不属于同一个抽象。 强行统一得到的不是复用,是一个谁也不敢改的公共分支。

演练:唯一能证明回退可用的办法

回到开头那个故障。备用通道从上线就是坏的,根因不是配置写错——配置总会写错——而是没有任何一个环节会让写错的配置暴露出来

补上这个环节只有一个办法:把主通道指向一个必然失败的地址,跑一遍完整业务流。

具体做法不复杂:在预发环境里,把主通道的 base_url 改成一个不存在的主机,或者把 key 改成一个无效值(这两种失败类型不同,最好分别跑一次),然后走完整的业务链路——不是调一次 API 看返回 200,是从用户入口进去、走到结果出来的全流程。

跑的时候盯这几件事:

  • 请求最终真的落到了备用通道,日志里能看到备用通道的地址和模型 ID,而不是靠”没报错”来推断。
  • 备用通道的模型 ID 是它自己的形式,不是主通道那一套。
  • 首次失败到成功返回之间的耗时,业务侧能不能接受。重试次数和单次超时叠加起来的总时长经常超出预期。
  • 你的监控和报警确实触发了。回退成功不该是静默的,否则主通道悄悄挂了一周你都不知道。
  • 流式场景要单独跑一次:失败发生在建立连接前和已经吐了一半 chunk 之后,处理方式完全不同。

这件事要定期做,不能只在上线时做一次。上游会改路径,模型会下线,你自己的配置会被别人改。一个从没被真正触发过的回退路径,默认应该假定它是坏的——这个假定比乐观估计更接近现实。

自查清单

  1. 通道对象是否把 base_url、key 环境变量名、模型 ID 绑成了不可分割的整体?代码里有没有任何地方能单独覆盖其中一项?
  2. 每条通道的 base_url 是否逐字核对过官方文档?路径段的顺序、多出来的段、末尾斜杠都对得上吗?
  3. 每条通道的模型 ID 形式是否与该平台一致(扁平名 / 两段式 / 带日期版本 / 三段式),有没有跨平台复制粘贴?
  4. 新增或迁移上游后,流式、函数调用、结构化输出、推理参数是否逐条重测过?测试请求带的参数是不是线上真会发的那一套?
  5. 路由(按用途选通道)和回退(按失败类型补救)是不是两套独立配置,而不是揉在一个策略里?
  6. 回退是否按失败类型分叉——普通失败、内容策略拒绝、上下文超限分别配了不同目标?请求体本身不合法的情况是否被排除在回退之外?
  7. 非 OpenAI 兼容的平台(如 Replicate 这类任务式 API)是否有独立适配器,在业务语义层对齐,而没有污染公共调用层?
  8. 最近一次把主通道指向必然失败地址、跑完整业务流的演练是什么时候?演练时是否确认了日志里出现的是备用通道的地址和模型 ID,以及报警确实触发?

相关阅读