你读到的每篇网关文章都会给出一个可用性百分比。本文没有,因为我们不发布 SLA,而且我们更愿意描述一个你可以检查的机制,而不是一个你无法验证的数字。下面是从上游返回错误到客户端看到错误之间的完整路径:四层、四个常量,以及每个常量取此数值的原因。
为什么单个上游不够
模型提供商出现糟糕的二十分钟很平常。区域性事故、限流器收紧、新发布模型的容量紧张——这些都不罕见,而且在你自己的错误率上升之前对你完全不可见。如果应用只连接一个端点,你的可用性就是该端点的可用性;从客户端能做的只有重试,但到那时用户已经等待过了。
替代方案是准备另一个去处。Kunavo 会为每个模型解析一条路由链——最多三个能够提供该模型的独立通道,按成本排序——下面四层机制让请求沿着这条链移动,而无需你编写任何重试逻辑。
第 1 层:熔断器(发送请求之前)
最便宜的故障转移是完全不产生往返的故障转移。每个网关进程都会按通道保留滚动五分钟的失败计数。在该时间窗口内发生三次失败后,通道被视为打开:接下来的六十秒内,它会在请求调度之前从链中移除。
// Layer 1, simplified from lib/providers/provider-health.ts.
// A channel that has been failing is skipped BEFORE we send anything,
// so the cost of avoiding it is zero — no request, no timeout, no wait.
const WINDOW_MS = 300_000; // failures older than 5 minutes are forgotten
const FAIL_THRESHOLD = 3; // 3 failures inside that window opens the circuit
const COOLDOWN_MS = 60_000; // and the channel is bypassed for 60 seconds
function isOpen(channel: string, now = Date.now()): boolean {
const s = state.get(channel);
return s != null && s.openUntil > now;
}
function recordFailure(channel: string, now = Date.now()) {
const s = state.get(channel) ?? { failures: [], openUntil: 0 };
s.failures = s.failures.filter((t) => now - t < WINDOW_MS);
s.failures.push(now);
if (s.failures.length >= FAIL_THRESHOLD) s.openUntil = now + COOLDOWN_MS;
}这是唯一真正即时的一层,准确说明原因很重要:并不是正在快速测量什么。该决定是在最多五分钟前由一项替你承担失败的较早请求作出的。状态按进程独立保存,因此每台机器分别学习——在真实流量下,故障通道会在相差几秒内触发所有机器的熔断;短暂异常的通道则会在冷却时间结束后自行恢复。
第 2 层:请求内故障转移(最多三跳)
当看似健康的通道仍然返回错误时,请求会转到链中的下一个通道。上限是三跳,ROUTING_CHAIN_MAX,并且链按成本排序,因此第一次尝试始终是能够提供该模型的最便宜通道。
// Layer 2. The routing chain is resolved from the catalog, cost-ordered,
// and capped at ROUTING_CHAIN_MAX hops (3). It is static configuration, not
// a learned weighting: you can read the exact chain for any model in the
// admin UI, and it is the same chain for every customer.
async function dispatch(req: ChatRequest) {
const chain = routingChain(req.model) // up to 3 channels, cheapest first
.filter((c) => !isOpen(c.id)); // layer 1 removes the known-bad
let lastError: UpstreamError | null = null;
for (const channel of chain) {
try {
return await callUpstream(channel, req);
} catch (err) {
if (!isRetryable(err)) throw err; // a 400 is yours, not ours
recordFailure(channel.id, Date.now());
lastError = err;
}
}
throw lastError ?? new Error("all channels exhausted");
}客户端错误不会触发故障转移。格式错误请求的 400 或不存在模型的 404 会从每个通道得到相同答案,因此重试三次只会让你为同一条消息等待三倍时间。只有可重试的上游故障会沿链移动。
这一层的真实代价是每跳一次失败的往返。如果通道接受连接后在两秒后返回 503,你必须先等待这两秒,第二次尝试才会开始。这正是第 1 层重要的原因:它避免一个持续失败的通道让每个请求都支付两秒的通行费。
第 3 层:流门控(30 秒)
流式传输使故障转移变得更困难,而这一点很容易被忽略。上游可以返回 200 OK,打开流,然后才发出错误事件。事件到达时,简单代理已经向客户端发送了标头和一些字节,再也无法继续转移;请求已经提交给不会响应的通道。
// Layer 3, from lib/providers/stream-gate.ts. An upstream can answer 200 OK
// and then put the error inside the stream. Committing that to your client
// means you get a truncated answer and we cannot fail over any more, so the
// gateway holds the first frames back until it knows which it is.
export const STREAM_GATE_TIMEOUT_MS = 30_000;
// Outcomes:
// a decisive content frame -> commit, stream the rest through untouched
// a decisive error frame -> discard, fall through to the next channel
// nothing decisive in 30s -> commit anyway (a slow model is not an error)因此,网关不会根据状态码提交。它会保留开头的帧,直到看到明确结果:如果是真实内容,就提交流并从此原样传递;如果是错误,则你不会收到任何内容,请求会继续沿链移动。三十秒是该决定的上限,之所以设置这个时长,是为了适应在发出第一个令牌前确实需要长时间思考的推理模型。超过此时间我们就提交,因为速度慢的模型不等于损坏的模型。
第 4 层:标头超时(240 秒)
最后一层是没人喜欢的一层。如果上游接受连接后完全不返回响应标头,请求就会挂起,唯一能结束它的是超时——UPSTREAM_HEADERS_TIMEOUT_MS,四分钟。
等待四分钟很长,这是有意为之。视频和图像生成确实可能需要几分钟;对聊天来说足够快的超时时间,会取消原本能够成功的视频任务。与明确失败的上游相比,挂起的上游也很少见,而明确失败的情况已经由第 1 至第 3 层处理。我们宁愿在罕见情况下变慢,也不愿在常见情况下出错。
如果你希望自己的应用具有比这更紧的边界,请设置客户端超时。我们的超时是后备机制,不是延迟目标。
我们不声称的内容
- 没有毫秒级故障转移数据。第 1 层不会产生往返成本,因为该决定早于你的请求。第 2 至第 4 层的耗时恰好等于失败上游从开始到失败所用的时间。这两者都不是我们能够诚实地放在营销页面上的数字。
- 没有可用性百分比。不存在 SLA,也没有信用补偿方案,因此数字只是装饰。状态页面显示我们实际观察到的成功率,包括成功率不佳的时间窗口。
- 不会静默替换模型。故障转移只会在提供同一模型的通道之间移动请求。我们绝不会用比你请求的模型更便宜的模型回答——这是值得直接询问任何网关的问题,因为从响应中看不出来。
这对基于它构建应用意味着什么
主要意味着你可以删除重试循环。可重试的上游错误会在你看到失败之前,跨独立通道完成重试,因此客户端对我们返回的 5xx 进行重试,实际上是在重试一个已经尝试过三次的请求。保留超时,删除退避。
你唯一值得自行处理的是 402 insufficient balance,它根本不是故障转移情况——再多重试也无法为钱包充值。请参阅错误参考,了解哪些状态值得再次尝试,哪些状态是最终结果。