上游 LLM 厂商又抽风了我们的多渠道故障转移是怎么做的摘要本文详细介绍了AI平台中多渠道故障转移系统的设计与实现。通过拆分渠道与厂商表结构、建立三态健康状态机、精准判断故障切换条件网络异常/5xx切4xx/429不切、实现failover主循环与后台探活机制、优化缓存策略及错误信息脱敏构建了一个稳定可靠的故障转移系统。文章还分享了实际开发中遇到的坑点与解决方案为构建高可用LLM网关提供了实践参考。接入过多个 LLM 厂商的人都知道一个痛苦的事实——它们时不时会挂。移动云抽个风、智谱限个流、某个 Key 突然失效这些都是家常便饭。如果只接一个渠道用户体验就是时好时坏随时可能 502。我们这个 AI 平台接了好几个厂商同一个模型往往配了多个上游渠道比如 GLM-5.2 既可以从移动云走也可以直连智谱。需求很明确主渠道挂了网关得自动切到备用渠道用户无感知。这篇讲讲这个故障转移系统怎么做的重点聊聊什么错误该切、什么错误不该切这个容易踩坑的判断。先把数据模型理清楚做故障转移第一步得知道有哪些渠道可以切。我们用了两张表渠道和厂商是拆开的。一开始没拆渠道表里直接存upstream_url和upstream_api_key。结果发现一个问题同一个厂商账号同一套 URLKey会被多个模型引用。比如移动云的一个账号既调 GLM 又调 Qwen 又调 DeepSeek那这套 URLKey 就得在三个渠道记录里各填一遍。这带来的麻烦是——Key 要轮换的时候得到每个渠道记录里去改十几个地方漏一个就是隐患。而且重复数据本身就难维护。所以后来拆了-- 厂商表一个厂商账号一份配置CREATETABLEt_provider(idBIGINTPRIMARYKEY,nameVARCHAR(100),-- 移动云-生产1号upstream_urlVARCHAR(500),-- 上游 URLupstream_api_keyVARCHAR(500),-- Keytimeout_secINTDEFAULT60);-- 渠道表模型 × 厂商的关联一个模型可挂多个厂商CREATETABLEt_model_channel(model_idVARCHAR(100),-- 对外模型 IDglm-5.2provider_idBIGINT,-- 关联厂商账号upstream_modelVARCHAR(100),-- 上游要求的模型名ZHIPU/GLM-5.2priorityINTDEFAULT0,-- 0主1/2备用weightINTDEFAULT100,health_statusVARCHAR(20)-- HEALTHY/DEGRADED/DOWN);拆完清爽了渠道只管模型和厂商怎么关联、用什么路由策略连接配置全归厂商表。Key 轮换改一处所有引用的渠道自动生效。这个拆分虽然只是个表结构调整但省了无数维护麻烦。健康状态机把坏渠道踢出轮询光有多渠道不够。如果某个渠道已经挂了每次请求还去试一遍它那就是白白浪费时间和用户体验。得有个机制把确认坏掉的渠道临时踢出去。每个渠道有个health_status字段三态连续失败 ≥ 3 次 HEALTHY ─────────────────→ DEGRADED降级仍参与路由但标记 ↑ │ │ │ 连续失败 ≥ 5 次 │ 探活成功 ▼ └──────────────────────── DOWN宕机从路由剔除 │ │ 后台探活成功 ▼ 恢复 HEALTHY记录调用结果的逻辑defrecord_channel_call(channel_id,success,error_msgNone):withget_conn()asconn:withconn.cursor()ascursor:ifsuccess:# 成功清零连续失败恢复健康cursor.execute(UPDATE t_model_channel SET total_calls total_calls 1, consecutive_failures 0, health_status HEALTHY WHERE id %s,(channel_id,))else:# 失败连续失败 1到阈值自动降级/下线cursor.execute(UPDATE t_model_channel SET total_calls total_calls 1, consecutive_failures consecutive_failures 1, health_status CASE WHEN consecutive_failures 1 5 THEN DOWN WHEN consecutive_failures 1 3 THEN DEGRADED ELSE health_status END WHERE id %s,(channel_id,))阈值为什么是 3 和 5这是经验值。3 次降级是给运维一个早期信号——“这渠道开始不稳定了”5 次下线是因为连续失败 5 次基本可以确定是真挂了不是偶发抖动。如果只失败 1 次就下线网络抖动一下就把渠道踢了太激进。查询可用渠道时DOWN 的直接过滤defget_channels(model_id):withget_conn()ascursor:cursor.execute(SELECT ... FROM t_model_channel mc LEFT JOIN t_provider p ON p.id mc.provider_id WHERE mc.model_id %s AND mc.status ACTIVE AND mc.health_status IN (HEALTHY, DEGRADED) # DOWN 不参与ORDER BY mc.priority ASC, mc.weight DESC,(model_id,))最关键的判断什么错误该切什么不该切这是整个设计里想得最久的地方。不是所有错误都应该切换渠道。先看代码里的判断函数很短但很关键def_should_failover(status_code,exc):判断是否该切到备用渠道。ifexcisnotNone:returnTrue# 网络异常/超时/连接拒绝 → 切ifstatus_codeisnotNoneandstatus_code500:returnTrue# 上游 5xx → 切returnFalse# 4xx 和 429 都不切逻辑看着简单但背后的考量挺多。网络异常/超时exc→ 切。这是最明确的该切的情况——主渠道连不上、响应超时换一个试。5xx → 切。上游服务器内部错误换个渠道可能就好。4xx → 不切。这个要展开说。4xx 是客户端错误意味着你的请求有问题。比如400 参数错误换一个渠道参数还是错的照样 400。401/403 鉴权失败是这个渠道的 Key 有问题。理论上该切——但实际场景里如果这个 Key 失效悄悄切到备用反而掩盖了问题备用可能也用同厂商同账号。我们选择报错让人看到让运维去修 Key而不是静默切走。404 模型不存在上游路由配错了切了也一样。所以 4xx 的处理是不切把错误返回给上层让用户/运维看到真实问题。429限流→ 不切。这个最容易引起争论。429 是这个账号触发了厂商限流理论上切到另一个账号能缓解。但我们选择不切原因有二限流通常是账号维度的切到同厂商的备用渠道一样限流。如果是真·突发流量切走只是把限流传染到备用渠道没解决问题。429 应该靠前端退避重试解决而不是网关悄悄切渠道。当然如果你的场景是不同厂商账号互为备份429 也可以做成可切换的——我们把它做成策略可配但默认不切。这个该不该切的判断是整个故障转移设计的灵魂。切得激进会掩盖真实问题切得保守用户体验差。得根据业务场景权衡。failover 主循环判断逻辑定了主循环就好写了——按优先级逐个渠道试失败就切asyncdefcall_with_failover(model,body,streamFalse):channelsget_channels(model)# 已按优先级排序DOWN 已过滤ifnotchannels:returnNone,None# 没有多渠道配置回退旧路由clientawait_get_client()last_errorNoneforidx,chinenumerate(channels):req_bodybody.copy()ifch.get(upstream_model)!model:req_body[model]ch[upstream_model]# 映射上游模型名headers{Authorization:fBearer{ch[upstream_api_key]},Content-Type:application/json,}role主渠道ifidx0elsef备用渠道{idx}logger.info(fFailover [{role}]{model}-{ch[upstream_url][:50]})try:reqclient.build_request(POST,ch[upstream_url],jsonreq_body,headersheaders,timeoutch.get(timeout_sec,60))responseawaitclient.send(req,streamstream)# 2xx 或 4xx不切换4xx 换渠道也一样ifresponse.status_code500:returnresponse,ch# 返回给上层处理# 5xx记录失败切下一个logger.warning(fFailover [{role}] 返回{response.status_code}切换)_record_fail(ch,fHTTP{response.status_code})awaitresponse.aclose()# ← 别忘了关否则 FD 泄漏last_errorException(fHTTP{response.status_code})exceptExceptionase:# 网络异常记录失败切下一个logger.warning(fFailover [{role}] 异常{e}切换)_record_fail(ch,str(e)[:500])last_errore logger.error(fFailover 所有渠道挂了{model}, last_error{last_error})returnNone,None这里有个血泪教训——5xx 响应的response.aclose()不能漏。流式响应拿到后无论成功失败都要关。之前有版本切换渠道时忘了关 5xx 的响应导致连接不回收、FD 泄漏就是那篇 502 排查的锅之一。现在每次切换前都老老实实 close。失败记录是异步的不阻塞请求def_record_fail(ch,error_msg):cidch.get(channel_id)ifcid:asyncio.create_task(asyncio.to_thread(record_channel_call,cid,False,error_msg))用create_task丢到后台线程写库不拖慢用户的这次请求。渠道挂了不能一直不管后台探活渠道被标记 DOWN 后不能永久拉黑——上游可能只是临时抽风过会儿就好了。所以得有个后台循环定期去戳一下DOWN 的渠道活了就恢复。asyncdefprobe_down_channels():# 查所有 DOWN 渠道rowsquery(SELECT ... WHERE health_status DOWN)clientawait_get_client()forchinrows:# 发最小探针max_tokens1几乎不花钱test_body{model:ch[upstream_model],messages:[{role:user,content:ping}],max_tokens:1,}try:respawaitclient.send(client.build_request(POST,ch[upstream_url],jsontest_body,...))# 任何非 5xx 都算上游可达恢复健康ifresp.status_code500:restore_health(ch[channel_id])logger.info(f渠道{ch[channel_id]}探活成功恢复 HEALTHY)exceptException:pass# 探活失败就保持 DOWN下轮再试asyncdefstart_probe_loop(interval_sec180):whileTrue:awaitprobe_down_channels()awaitasyncio.sleep(interval_sec)# 每 3 分钟一轮探针设计有几个讲究max_tokens: 1让上游只生成 1 个 token 就停几乎不花钱。如果发完整请求探活成本扛不住。判定标准是 500而不是 200。这个改过——最早判 200但有些厂商对余额不足返回 200 body 里带错误码探活会误判健康。改成 500只要上游系统可达就算活把业务错误排除在健康判定外。401 可能只是 Key 临时问题429 是限流这些是软故障不该让渠道长期下线。探活恢复后主动刷缓存让路由查询立刻看到ifrestored0:refresh_cache()# 清空渠道缓存强制下个请求重查缓存别每次请求都查库故障转移每次都要查t_model_channel JOIN t_provider这是个 JOIN 查询每个对话请求都查一遍太浪费。做了两级缓存_single_cache{}# 单渠道TTL 5 分钟_list_cache{}# 渠道列表TTL 30 秒defget_channel(model_id):cached_single_cache.get(model_id)ifcachedandtime.time()-cached[0]300:# 5 分钟returncached[1]# 未命中查 DB写缓存...defget_channels(model_id):cached_list_cache.get(model_id)ifcachedandtime.time()-cached[0]30:# 30 秒returncached[1]...TTL 的取舍单渠道配置很少变5 分钟无所谓渠道列表涉及健康状态渠道下线/恢复要快一点生效30 秒是个平衡。探活恢复时主动refresh_cache()让刚恢复的渠道立刻被看到不等 30 秒。错误信息脱敏顺带提一个细节。上游 LLM 厂商的错误消息经常带商业信息比如请到 ecloud.10086.cn 订购资源包。这种东西直接透传给用户既不专业也可能暴露商业关系。所以做了个错误脱敏SENSITIVE_WORDS[订购,ecloud.10086.cn,订阅,资源包,签约]defsanitize_error_response(content,status_code):# 脱敏商业信息 → 请求失败# 按错误码给友好中文429→请求频繁、401→认证失败、502→服务异常...用户看到的是统一的中文友好提示而不是上游的英文技术串和订购引导。这种小细节对用户体验的提升比想象中大。踩过的坑坑一5xx 响应不关闭 → FD 泄漏。前面提过了failover 切换时response.aclose()不能漏否则就是那篇 502 的事故。坑二探活把余额不足误判为健康。最早判status_code 200但厂商对余额不足也返 200导致探活把实际不可用的渠道恢复回去。改成 500解决。坑三并发写健康状态丢更新。record_channel_call用consecutive_failures consecutive_failures 1这种读改写高并发下可能两个请求同时失败但只 1。后来改成短连接 单条 UPDATE 原子操作。坑四缓存没及时刷新。管理后台改了渠道 Key但缓存 5 分钟没过期请求还在用老 Key 失败。后来管理后台改配置后主动调refresh_cache()。最后多渠道故障转移看着是个路由 重试的简单事做细了涉及的点不少表设计拆分、健康状态跟踪、切换判断精准、探活自愈、缓存平衡、错误脱敏。核心的设计思想就一条不乱切。网络异常/5xx 果断切4xx/429 不切——因为换了也一样甚至更糟。配合前面那篇计费协议整个网关做到了故障自动转移、扣费准确、用户透明。上游抽风的时候用户基本无感地切到了备用渠道这就是这套系统的价值。