更新于

多厂商故障切换落地:从设计图到能跑的代码


多厂商 fallback 的架构设计——熔断阈值、权重调度、重试策略——LLM 多提供商 fallback 路由设计 已经讲得比较完整,故障演练的组织流程也有 多模型供应商故障演练 覆盖。这两篇解决的是”该怎么设计”和”怎么定期验证设计有没有生效”,但团队真正把方案写成代码时,往往卡在几个设计图上不会画出来的实现细节上。本文只讲这些细节。

一、流式响应中途切换:不能简单”从头重试”

最容易被设计阶段忽略的场景:请求已经在流式输出,输出到一半时上游厂商连接中断或返回错误。这时候直接对新厂商发起一次全新请求、从头流式输出,会导致用户在界面上看到内容”跳变”——前半段是 A 厂商的文风和已经生成的内容,重试后 B 厂商完全不知道前面输出过什么,大概率会给出结构和风格都不同的另一份内容,直接拼接会非常突兀。

实用的处理方式有两种,按产品对体验的容忍度选择:

方式一:吞掉已输出内容,整体重来(简单但体验有跳变)

1. 检测到流式中断
2. 前端清空已渲染内容,展示"正在重新生成"提示
3. 向 fallback 厂商发起全新请求(不携带已生成的部分内容)
4. 重新流式渲染

实现成本最低,适合对话类产品——用户对”重新生成一次”的心理预期本身就存在,跳变不会显得意外。

方式二:已输出部分保留,用 fallback 厂商续写

1. 检测到流式中断,记录已输出的完整文本
2. 向 fallback 厂商发起请求时,把"已输出内容"作为 assistant 角色的前缀塞进消息历史,
   prompt 追加类似"请从上面内容自然地继续,不要重复也不要另起话题"的指令
3. 新厂商的流式输出直接追加渲染在已有内容后面

这种方式用户感知上更流畅,但续写质量依赖 fallback 厂商能不能很好地衔接一段不是自己生成的内容——必须提前用真实文本测试续写的自然度,不同厂商在”续写他人文本”这个任务上的表现差异不小,不能假设都一样好用。如果测试下来续写效果生硬,宁可退回方式一。

关键实现细节:无论哪种方式,都需要在服务端记录”这次请求已经消耗了多少 token / 调用了哪个厂商”,因为切换后可能产生两倍的计费(A 厂商已输出的部分 + B 厂商重新生成的部分),成本核算和用户侧计费逻辑要能正确处理这种”一次请求实际打了两次 API”的情况,不能简单按”一次请求”计费, 否则会出现成本对不上账的问题。

二、跨厂商会话状态对齐:message 格式不是唯一的坑

多轮对话场景下发生 failover,需要把已有对话历史转换成新厂商能接受的格式。字段名转换(role: "assistant" 这类通用字段基本没问题)不是难点,真正容易出错的是这几类”格式兼容但语义不对齐”的情况:

  • 工具调用历史:如果对话历史里包含之前几轮的工具调用和结果,不同厂商对”如何在历史消息里表示一次已完成的工具调用”的格式差异很大(参考 OpenAI、Anthropic、Gemini 工具 Schema 兼容层设计 里第一节的对比)。转换时如果只转换最新一轮请求的工具定义、却没处理历史消息里的工具调用记录格式,新厂商很可能直接报格式错误拒绝请求,而不是”优雅降级”。
  • 系统提示词位置:部分厂商把 system prompt 作为独立字段,部分要求拼进第一条 user 消息,跨厂商切换时如果模板没有做好适配,容易出现 system prompt 重复插入或者完全丢失的问题。
  • 多模态内容引用:如果历史消息里包含图片,不同厂商对图片的引用方式(URL、base64、文件 ID)不同,file ID 这种方式意味着”图片已经上传到该厂商服务器”,切换厂商后这个 ID 在新厂商那里完全无效,必须在切换逻辑里判断是否需要重新上传图片资源,这一步经常被遗漏,导致 failover 之后历史图片直接”消失”。

建议的落地方式是维护一个和厂商无关的内部会话表示(不一定要多复杂,把上面几类差异点都用统一字段记录),failover 时从这个内部表示重新编译成目标厂商格式,而不是直接拿上一个厂商的原始请求体做字段替换——原始请求体里混杂了大量厂商特定的格式细节,直接改字段容易漏改。

三、副作用幂等:failover 不能让同一个操作执行两次

如果 Agent 任务链路里有写操作(下单、发消息、扣款这类有副作用的工具调用),failover 时最危险的情况是:请求已经在 A 厂商侧触发了工具调用并且执行成功,但由于网络问题,客户端没有收到成功响应就判定为超时,转而向 B 厂商发起重试,B 厂商生成的结果又触发了一次同样的工具调用——最终副作用执行了两次。

这个问题不能靠”厂商切换逻辑写得更谨慎”来解决,而是必须在工具调用层面本身做幂等设计:

每次工具调用附带一个幂等键(idempotency key),
生成方式建议用"会话 ID + 本轮请求序号 + 工具名"的组合哈希,
而不是每次调用随机生成——
随机生成的幂等键在 failover 重试时会是一个新值,起不到去重作用。

工具执行端收到调用请求后,先查幂等键是否已处理过:
- 已处理过 → 直接返回上次的执行结果,不重复执行副作用
- 未处理过 → 正常执行,并记录该幂等键与结果的映射(保留时间窗口视业务而定,通常 24~72 小时足够覆盖 failover 场景)

这一层幂等设计的价值不仅限于 failover 场景——模型本身重复调用同一工具(比如没有正确判断已经完成过某个操作)也会触发同样的问题,所以这不是 failover 专属的额外成本,而是任何有写操作的 Agent 系统本来就该有的基础设施,只是在做多厂商切换时会更早暴露这个缺口,倒逼团队提前把它补上。

四、健康检查:主动探测和被动熔断要分开实现

设计文档里通常会提到”根据错误率触发熔断”,但落地时容易把”根据用户真实请求的错误率被动统计”和”主动探测健康状态”这两件事混在一个模块里实现,导致两个问题互相干扰:被动统计需要积累一定的样本量才能得出可信的错误率,样本不足时容易误判;主动探测的探测请求本身如果设计成本过高(比如探测请求也调用一次完整的模型推理),会额外消耗成本和配额。

实际落地建议:

  • 被动熔断:基于真实业务请求的滑动窗口错误率(比如最近 100 次请求里失败超过 30%),触发临时摘除该厂商,一段冷却时间后允许少量流量试探性恢复(半开状态),这部分逻辑和标准的熔断器模式(circuit breaker)基本一致。
  • 主动探测:用极低成本的请求做健康检查(比如只要求返回一个固定短语,或者直接调用厂商的账户余额/状态接口而不是模型推理接口),频率可以设置得比被动统计更高(比如每 30 秒一次),作为被动熔断的补充信号,能更快发现”厂商完全不可达”这类明显故障,不必等业务流量自然触发。

两者的结果汇总到同一个厂商健康状态存储(比如一个简单的 Redis 键值,记录每个厂商当前是否可用),路由决策只读这个汇总状态,不直接耦合两套检测逻辑各自的实现细节。

五、上线前必须过一遍的落地检查项

设计和演练都做完之后,实际上线前建议再过一遍这份清单,这些都是本文提到的、容易在”方案评审通过”和”真正线上跑起来没出问题”之间产生落差的点:

  • 流式中断的处理方式(整体重来 or 续写)已经用真实文本测试过衔接效果
  • failover 后的计费/成本核算逻辑能正确处理”一次请求打了两次 API”的情况
  • 内部会话表示已经覆盖工具调用历史、system prompt 位置、多模态引用这三类跨厂商差异
  • 所有有副作用的工具调用都已经接入幂等键机制,且幂等键不是随机生成的
  • 主动探测请求的成本已经核算过,不会因为探测频率过高产生不必要的账单
  • 被动熔断的半开恢复逻辑经过实际测试,确认不会在厂商刚恢复时被瞬间打满导致二次熔断

六、相关阅读

如果你不想自己维护跨厂商的会话格式转换和健康检查这套基础设施,YoTradeApi 提供统一网关和内置故障切换能力,可以把这部分落地成本直接省掉。