【大模型 API 中转站】上游接口不稳定怎么办?从优先级、权重到自动重试的多渠道容灾实战
---
## 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 兼容中转入口统一接入,再根据实际模型、渠道和业务幂等性要求配置容灾策略。涉及图片生成、视频任务、工具调用等可能产生副作用的请求时,应优先评估重复执行风险。
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐

所有评论(0)