你讀到的每篇閘道文章都會引用可用性百分比。本文沒有,因為我們不發布 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)因此閘道不會根據狀態碼提交。它會保留開頭的框架,直到看到明確結果:若是真實內容,便提交串流,之後原封不動地傳遞;若是錯誤,則你尚未收到任何內容,請求會繼續沿鏈移動。三十秒是這項決策的上限,因為推理模型可能確實需要很長時間才輸出第一個 Token。超過這段時間便提交,因為速度慢的模型不代表模型故障。
第 4 層:標頭逾時(240 秒)
最後一層是沒有人喜歡的那一層。如果上游接受連線後完全不回傳回應標頭,請求就會掛起,唯一能結束它的是逾時——UPSTREAM_HEADERS_TIMEOUT_MS,四分鐘。
等待四分鐘很久,但這是刻意的設計。影片和圖片生成確實可能需要數分鐘;若逾時時間短到適合聊天的即時感,就會取消原本可能成功的影片工作。與明確失敗的上游相比,掛起的上游也很少見,而明確失敗的情況已由第 1 至第 3 層處理。我們寧可在罕見情況下變慢,也不願在常見情況下出錯。
如果你的應用程式需要比這更嚴格的上限,請設定用戶端逾時。我們的逾時是最後防線,不是延遲目標。
我們不宣稱的事項
- 沒有毫秒級故障切換數字。 第 1 層不產生往返成本,因為決策早於你的請求。第 2 至第 4 層的耗時,正好等於失敗上游花費的失敗時間。這兩者都不是我們能誠實放在行銷頁上的數字。
- 沒有可用性百分比。 沒有 SLA,也沒有抵扣方案,因此數字只會是裝飾。狀態頁面會顯示我們實際觀察到的成功率,包括表現不佳的時間區段。
- 不會默默替換模型。 故障切換只會在提供同一個模型的通道之間移動請求。我們絕不會以比你要求更便宜的模型回應——這是值得直接向任何閘道詢問的做法,因為從回應中看不出來。
這對建置在其上的應用程式意味著什麼
大致上,這表示你可以刪除重試迴圈。可重試的上游錯誤會在你看到失敗前,先跨獨立通道重試,因此用戶端對我們的 5xx 進行重試,等於重試一個已經嘗試三次的請求。保留逾時,移除退避。
唯一值得你自行處理的是 402 insufficient balance,這根本不是故障切換情況——再多次重試也不會補回錢包餘額。請參閱錯誤參考,了解哪些狀態值得再次嘗試,以及哪些狀態已是最終結果。