---

## 1. 单渠道接入为什么容易出问题

很多项目第一次接入模型时,代码大概是这样的:

```python

from openai import OpenAI

client = OpenAI(

    base_url="https://api.example.com/v1",

    api_key="sk-xxx",

)

response = client.chat.completions.create(

    model="your-model",

    messages=[

        {"role": "user", "content": "帮我总结这段会议记录"}

    ],

)

```

只要上游正常,这段代码没有问题。但上线后通常会遇到:

- 单个 API Key 触发 RPM 或 TPM 限流;

- 上游返回 `429`、`500` 或连接错误;

- 账户余额不足、Key 失效;

- 某个地区节点临时不可用;

- 同一个模型在不同供应商处价格和稳定性不同;

- 高峰期请求全部压到一个渠道。

如果业务只配置一个上游,任何一次渠道故障都会直接暴露给用户。更常见的临时解决方案,是在业务代码里写一个备用地址:

```python

try:

    return call_provider_a()

except Exception:

    return call_provider_b()

```

这种写法看似简单,却会迅速带来新问题:

- 所有异常都重试,参数错误也会被重复发送;

- 每个业务服务都维护一套切换逻辑;

- 不知道请求最终由哪个渠道完成;

- 多次尝试可能导致重复扣费或重复执行;

- 新增第三、第四个上游时,代码越来越难维护。

更合理的做法,是把渠道选择、失败判断和计费处理集中到 API 网关层。

---

## 2. 一次请求在网关中如何选渠道

多渠道调度可以分成三个步骤:

```text

客户端请求

    │

    ▼

匹配分组与模型

    │

    ▼

选择当前优先级

    │

    ▼

在同优先级渠道中按权重选择

    │

    ▼

请求上游

    │

    ├─ 成功:返回结果并结算

    │

    └─ 失败:判断是否允许重试

                   │

                   ▼

             按重试索引选择后续优先级

```

以 [4SAPI](https://4sapi.com) 这类 OpenAI 兼容中转服务为例,客户端仍然只需要维护一个 `base_url` 和一把 API Key。具体选择哪个上游渠道,由中转层完成:

```python

from openai import OpenAI

client = OpenAI(

    base_url="https://4sapi.com/v1",

    api_key="sk-你的中转站Key",

)

response = client.chat.completions.create(

    model="your-model",

    messages=[

        {"role": "user", "content": "解释一下什么是向量数据库"}

    ],

)

print(response.choices[0].message.content)

```

业务侧不需要知道本次请求最终走的是官方接口、云厂商渠道还是其他兼容上游。

---

## 3. 优先级和权重不是一回事

渠道配置中最容易混淆的两个字段是:

- **优先级(priority)**:决定先尝试哪一层渠道;

- **权重(weight)**:决定同一优先级内的流量分配比例。

假设同一个模型配置了四个渠道:

| 渠道 | 优先级 | 权重 | 用途 |

|---|---:|---:|---|

| A | 100 | 80 | 主渠道 |

| B | 100 | 20 | 同级分流 |

| C | 50 | 100 | 第一备用 |

| D | 10 | 100 | 最后兜底 |

正常请求只会在优先级 `100` 的 A、B 之间选择。忽略实现中的平滑修正时,`80/20` 表达的是大致按 `8:2` 分流的配置意图:

```text

A:80%

B:20%

```

只有当前请求失败并满足重试条件时,调度器才会按重试索引尝试优先级 `50` 的 C,之后再考虑优先级 `10` 的 D。

这里有一个重要细节:A 和 B 是同级分流渠道。一次请求如果随机选中 A 并失败,下一次重试通常会进入更低优先级,而不是保证先尝试同级的 B。因此,同优先级权重解决的是“正常流量怎么分”,低优先级才负责“失败后往哪里退”。

因此:

- 想做日常流量分摊,应配置相同优先级、不同权重;

- 想让某个渠道只在故障时启用,应配置更低优先级;

- 不要把“备用渠道”与主渠道设成同一优先级,否则它平时也会获得流量。

核心选择逻辑可以简化成:

```go

priorities := sortDescending(uniquePriorities)

targetPriority := priorities[retryIndex]

candidates := channelsWithPriority(targetPriority)

channel := weightedRandom(candidates)

```

实际实现还需要处理权重全部为 `0` 的情况。启用内存渠道缓存时,这套实现会将同级渠道视为等权;直接查询数据库的选择路径则会为每个渠道增加基础权重。两条路径都能避免全零权重导致无法选择,但低权重下的实际比例可能存在差异。因此,权重更适合看作相对分流参数,上线前应以当前部署模式做压测验证。

当重试次数大于可用优先级层数时,当前实现会继续使用最低优先级,而不是因为“所有层级已经走过”就自动停止。重试次数仍然是最终边界。

---

## 4. 自动重试不能只看“请求失败”

不是所有失败都值得换渠道再试一次。

### 适合重试的情况

一般包括:

- 上游限流;

- 部分服务器错误;

- 临时网络错误;

- 渠道自身状态异常;

- 无法建立连接或响应状态不合法。

这些问题可能只影响当前渠道,切换上游后有机会恢复。

### 不适合重试的情况

一般包括:

- 请求 JSON 无法解析;

- 必填参数缺失;

- 请求体超过网关限制;

- 用户额度不足;

- 模型参数本身不被支持;

- 已指定必须使用某一个渠道时发生的普通上游错误;

- 已经向客户端输出了部分流式内容。

例如,客户端提交了错误参数:

```json

{

  "model": "",

  "messages": []

}

```

换十个渠道也不会成功。继续重试只会增加延迟,还会制造更多无意义日志。

因此,错误对象最好携带明确的控制信息:

```go

return NewError(

    err,

    ErrorCodeInvalidRequest,

    WithSkipRetry(),

)

```

调度器判断重试时,会综合错误类型、`skip_retry`、剩余次数、指定渠道和 HTTP 状态码决定是否切换。判断顺序也很重要:在当前实现中,被归类为“渠道级错误”的异常会优先进入重试分支;普通状态码错误才会继续检查 `skip_retry`、剩余次数和指定渠道限制。

---

## 5. 用状态码规则控制重试范围

实际生产环境中,不同团队对重试范围的要求并不相同。比起把状态码写死在代码里,更适合提供可配置规则:

```text

429,500-503,505-523,525-599

```

这是一份偏保守的示例,不是项目的默认值。当前项目默认范围更宽,还包括部分 `1xx`、`3xx` 和 `4xx`;生产环境不建议在不了解上游语义的情况下直接照搬默认范围。

解析后可以得到多个状态码区间:

```go

type StatusCodeRange struct {

    Start int

    End   int

}

```

请求失败后,依次判断:

```go

func shouldRetry(err *APIError, retriesLeft int) bool {

    if err == nil {

        return false

    }

    if err.IsChannelError {

        return true

    }

    if err.SkipRetry {

        return false

    }

    if retriesLeft <= 0 {

        return false

    }

    if requestUsesSpecificChannel() {

        return false

    }

    return retryStatusCodeMatched(err.StatusCode)

}

```

上面的伪代码只展示错误判断顺序;总尝试次数仍由外层重试循环限制。即使渠道级错误返回“允许重试”,超过全局次数后也不会无限循环。

这里有两个容易忽略的边界:

1. **不要把所有 `4xx` 都理解为客户端错误**  

   某些上游会用 `401`、`403` 或其他 `4xx` 表示渠道 Key、权限或余额异常。对于聚合网关,这可能属于“当前渠道不可用”,切换另一渠道仍然有意义。

2. **不要把所有 `5xx` 都自动重试**  

   某些网关超时状态可能意味着请求已经在上游执行,只是结果没有及时返回。此时重试存在重复生成、重复工具调用或重复扣费的风险。

当前实现还将 `504`、`524` 和“响应体无法正确解析”列为固定跳过重试的情况,不会被普通状态码配置重新打开。重试状态码应该结合具体上游行为调整,而不是机械套用固定名单。

---

## 6. 为什么流式请求更难重试

非流式请求在失败时通常还没有向客户端返回正文,网关可以相对安全地更换渠道。不过,如果上游已经执行成功,只是在回传阶段超时,仍然存在重复执行风险。

流式请求则不同:

```text

data: {"choices":[{"delta":{"content":"今天"}}]}

data: {"choices":[{"delta":{"content":"天气"}}]}

```

如果这时上游断开连接,客户端已经收到了“今天天气”。网关若切换到另一个渠道重新生成,新渠道可能从头返回:

```text

data: {"choices":[{"delta":{"content":"根据"}}]}

```

最终内容就会拼成“今天天气根据……”,无法保证语义正确。

所以从设计上看,流式请求要区分两个阶段:

- **首个有效数据写出前失败**:通常仍有机会换渠道;

- **已经向客户端写出内容后失败**:通常不应透明重试。

这也是为什么“给所有请求统一套三次重试”并不是一个可靠方案。实现层还应在重试前检查响应是否已经开始写出;不能只依赖状态码判断。

---

## 7. 重试与计费必须在同一条链路中设计

多渠道重试最危险的问题之一,是重复扣费。

一个更稳妥的计费流程是:

```text

请求开始

  │

  ├─ 预扣额度

  │

  ├─ 尝试渠道 A

  │      └─ 失败但允许重试

  │

  ├─ 尝试渠道 C

  │      └─ 成功

  │

  └─ 按最终结果结算差额

```

重试过程应属于同一个请求和同一个计费会话,而不是每切换一次渠道就重新向用户预扣。

如果所有渠道最终都失败,则需要检查本次请求是否真的发生过预扣:

```go

defer func() {

    if finalError != nil && billingSession != nil {

        billingSession.Refund(ctx)

    }

}()

```

但“失败就退款”也不是绝对规则。对于某些已经触发上游执行的请求,平台可能已经产生真实成本;违规请求还可能存在单独计费策略。因此,退款逻辑应该根据业务和上游结算方式设计,不能只依据客户端是否拿到结果。

---

## 8. 永久故障应自动隔离

如果某个渠道已经出现以下错误:

- API Key 无效;

- 账户被停用;

- 余额不足;

- 权限被取消;

- 明确的认证失败;

仅仅让当前请求切换渠道还不够。后续请求继续选中它,只会重复失败。

网关可以在确认属于渠道级永久错误后,将该渠道标记为自动禁用:

```go

if channel.AutoBan && shouldDisableChannel(err) {

    disableChannel(channel.ID, err.Error())

    notifyAdministrator(channel.ID)

}

```

这里至少需要设置三道限制:

- 全局自动禁用功能已经开启;

- 只有启用了 `AutoBan` 的渠道才允许自动禁用;

- 参数错误、用户额度不足等非渠道故障应通过 `skip_retry` 或错误分类避免误伤渠道。

如果一个渠道包含多把 Key,还可以只禁用出错的 Key,而不是直接关闭整个渠道。这样能减少单个凭证故障带来的影响。

---

## 9. 不要忽略“渠道粘性”

有些业务不能每次请求都随机选择渠道。例如:

- 提示词缓存与上游账号绑定;

- 同一会话希望复用相同地区或节点;

- 某些任务需要回到创建任务时的原渠道查询;

- 不同渠道的模型版本或输出风格存在细微差异。

这时可以为会话、用户或缓存标识建立“渠道粘性”:

```text

user_123 + model_x

        │

        ▼

优先命中上次成功渠道

```

粘性渠道失效后,再决定是否允许回退到普通调度。如果业务要求严格一致,也可以配置“粘性渠道失败后不重试”。

渠道粘性与负载均衡并不矛盾:第一次请求仍可按优先级和权重选择,成功后在一定时间内复用该选择。

---

## 10. 一套可落地的配置思路

假设业务有三个兼容同一模型的上游,可以这样设计:

### 主渠道 A

```text

priority = 100

weight   = 80

auto_ban = true

```

### 主渠道 B

```text

priority = 100

weight   = 20

auto_ban = true

```

### 备用渠道 C

```text

priority = 50

weight   = 100

auto_ban = true

```

同时配置:

```text

retry_times = 2

retry_status_codes = 429,500-503

```

这套配置表达的是:

1. 正常流量主要在 A、B 之间按照配置权重分配;

2. 当前请求发生可重试错误后,尝试较低优先级的 C;

3. 明确的渠道永久故障可以自动隔离;

4. 参数错误、额度错误等请求级问题直接返回,不浪费重试次数。

在当前实现中,`retry_times = 2` 表示最多增加两次重试,加上首次调用,单个客户端请求最多可能尝试三次。由于示例只有两个优先级层级,第三次尝试仍可能落到最低优先级 C,而不是自动寻找一个不存在的新层级。具体后台字段的含义应以部署版本为准,配置前应通过测试环境确认总尝试次数和渠道顺序。

需要注意,权重代表随机选择概率,不保证任意一个很短的时间窗口都严格符合比例。

---

## 11. 上线前至少做这几组测试

- 同优先级渠道的权重分布是否大致符合预期;

- 主渠道返回 `429` 后是否切换到备用渠道;

- 参数错误是否跳过重试;

- 指定渠道调用发生普通状态码错误时是否禁止自动切换,并单独验证渠道级错误的处理;

- 流式输出开始后断流是否避免重复生成;

- 多次尝试是否只产生一条用户侧计费记录;

- 所有渠道失败后,预扣额度是否正确退回;

- 无效 Key 是否触发渠道或单 Key 自动禁用;

- 自动禁用后是否发送管理员通知;

- 请求日志能否看出实际使用过的渠道顺序。

测试时不要只验证“最终成功”。还要确认成功之前发生了什么,否则可能出现请求虽然返回了结果,却重复调用了多个上游并产生额外成本。

---

## 12. 总结

多渠道容灾不是简单地准备两个 API 地址。真正可靠的方案至少要同时解决:

- 用优先级区分主渠道和备用渠道;

- 用权重完成同级流量分配;

- 根据错误类型决定是否重试;

- 避免流式输出后的危险重试;

- 将重试、预扣、结算和退款放在同一计费链路;

- 对确定失效的渠道进行自动隔离。

**一句话总结:优先级决定“先用谁”,权重决定“同级怎么分”,错误策略决定“失败后要不要换”。这三部分组合起来,才是一套完整的 AI API 多渠道容灾方案。**

如果业务不希望自行维护渠道选择和协议适配,可以通过 [4SAPI](https://4sapi.com) 这类 OpenAI 兼容中转入口统一接入,再根据实际模型、渠道和业务幂等性要求配置容灾策略。涉及图片生成、视频任务、工具调用等可能产生副作用的请求时,应优先评估重复执行风险。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐