返回部落格
工程·2026年5月24日·閱讀約 9 分鐘

Kunavo 如何容錯:上游錯誤與你的用戶端之間的四層機制

完整容錯路徑,以及每個數字背後的固定機制:在送出請求前跳過故障通道的斷路器、單一請求內最多三次且依成本排序的跳轉、拒絕提交先回傳 200 後轉為錯誤的串流閘門,以及為永遠不回應的上游提供的四分鐘後備機制。

你讀到的每篇閘道文章都會引用可用性百分比。本文沒有,因為我們不發布 SLA,而且與其描述一個你無法驗證的數字,我們更願意說明一套你可以檢查的機制。以下涵蓋從上游回傳錯誤到你的用戶端看到錯誤之間的完整路徑:四個層級、四個常數,以及每個常數採用該數值的原因。

為什麼單一上游不夠

模型供應商出現二十分鐘的故障很平常。區域性事件、收緊速率限制器、新推出模型的容量緊張——這些都不罕見,而在你自己的錯誤率上升之前,你完全看不到它們。如果應用程式只連線至一個端點,可用性就等於該端點的可用性;從用戶端能做的只有重試,但到了那時使用者已經等待過了。

替代方案是準備其他去處。Kunavo 會為每個模型解析一條路由鏈——最多三個可提供該模型的獨立通道,並依成本排序;以下四個層級讓請求能沿著這條鏈移動,而無需你撰寫任何重試邏輯。

第 1 層:斷路器(傳送請求前)

最便宜的故障切換,是完全不產生往返成本的那種。每個閘道程序都會為每個通道保留滾動五分鐘的失敗次數。在該時間窗內發生三次失敗後,通道便會被視為開啟;接下來六十秒內,它會在請求派送前從鏈中移除。

provider-health.ts
// 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,而鏈依成本排序,因此第一次嘗試永遠是能提供該模型的最低成本通道。

dispatch.ts
// 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、開啟串流,然後才發出錯誤事件。等事件抵達時,天真的代理伺服器早已將標頭和一些位元組傳給你的用戶端,已經無法再轉到其他通道——請求已提交給不會回應它的通道。

stream-gate.ts
// 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,這根本不是故障切換情況——再多次重試也不會補回錢包餘額。請參閱錯誤參考,了解哪些狀態值得再次嘗試,以及哪些狀態已是最終結果。