cd ../back to blog
$Engineering//June 4, 2026//9 min read

99.95% SLO 的工程實作:閘道可靠性的真相

拆解 Brievio 聊天 API 達成每月 99.95% 可用性背後的工程:加權候選路由、50ms 首位元組看門狗、計費算盤,以及更重要的維運瑣事。

Brievio 對外公開的 SLO 是 聊天 API 每月 99.95% 的可用性。這聽起來像個行銷數字, 但它是真的 — 它把我們的錯誤預算上限壓在每月 21 分鐘,而且自上線以來 我們每個月都達標。這篇文章談的是它背後實際上的東西:上游容錯移轉、 首位元組看門狗、計費算盤,以及那些比上述任何一項都更重要的、無聊的 維運瑣事。

現實是:每一個上游都有狀況差的時候

我們在幕後會跟約 12 個上游通訊。在任何一個月份,我們都會看到它們各自 至少出現一次 5 到 30 分鐘的局部劣化。有時是某個區域的中斷 (Anthropic 的 us-east-1 這一季就有兩次事故)。有時是悄悄的權杖桶 擠壓(Vertex AI 的速率限制器一到週一就很小氣)。有時則是整個供應商 全面中斷(kie.ai 在三月斷線了 47 分鐘)。

如果單一上游掛了、而你又沒有容錯移轉,那你的 SLO 就等於它的SLO — 再扣掉你的傳輸開銷。這會在 99.5% 附近劃下一道硬性天花板。要突破 99.9%,前提是任何單一上游故障都不能把你拖垮;而 99.95% 則要求就連 兩個同時發生的故障也不能。

第 1 層:加權候選路由

我們託管的每一個模型,都有 1 到 4 條上游路徑。派發器會依加權順序逐一 走過它們,而權重會根據滾動成功率即時衰減(每個上游、每個區域取最近 100 次呼叫)。當 us-east-1 開始回 5xx 時,它的權重會在約 5 秒內掉到 us-west-2 之下,新進的請求會在客戶察覺之前就停止流向那裡。

dispatch.ts
// 上游派發器的虛擬碼。實際實作
// (TypeScript、僅限伺服器端)的結構與此相當接近。
async function dispatch(req: ChatRequest): Promise<ChatResponse> {
  const candidates = pickCandidates(req.model);
  // candidates = [{provider: "anthropic-direct", weight: 100},
  //               {provider: "google-vertex",   weight:  80},
  //               {provider: "kie-wholesale",   weight:  60}]
  // 權重越低 = 越偏備援。初始權重來自滾動的成功率。

  const deadline = Date.now() + 540_000;          // 硬性的 540 秒牆鐘
  let lastError: Error | null = null;

  for (const candidate of candidates) {
    const budgetMs = chunkBudget(deadline, candidates.length);
    try {
      return await Promise.race([
        callUpstream(candidate, req),
        timeout(budgetMs),
      ]);
    } catch (err) {
      if (!isRetryable(err)) throw err;          // 4xx → 不做容錯移轉
      recordFailure(candidate, err);              // 權重即時衰減
      lastError = err;
    }
  }
  throw lastError ?? new Error("all upstreams exhausted");
}

這裡有兩件事很容易做錯:

  • 不要在 4xx 時做容錯移轉。上游回 400 代表這個請求本身 就有問題 — 把它重送到另一個上游,只會換來同樣的 400,而且更慢。只有 408、429、5xx 與網路錯誤才會觸發容錯移轉。
  • 限定每個候選的預算。如果一個 540 秒的截止時限要分給 3 個候選,就給每個約 150 秒,讓它要嘛開始串流、要嘛死掉。別把整整 540 秒都給第一個 — 萬一它卡住,你就沒有時間留給備援了。

第 2 層:首位元組看門狗(50ms)

上游事故有一半並不是硬性故障 — 連線開了、請求送出了,然後就沒了。 沒有回應,也沒有錯誤。就只是一片靜默。天真的重試會傻等到逾時為止, 再去試下一個,把使用者感受到的延遲整整翻倍。

我們的派發器會在請求送上線路後,啟動一個 積極的 50ms 首位元組計時器。如果在那個視窗內連一個 回應位元組都沒看到,我們就中止並落到下一個。50ms 在感知門檻之下, 所以在順利的路徑上,客戶感受不到任何延遲。在故障的路徑上,客戶的 請求會在單一個人類可感知的影格內容錯移轉到備援上游。

ttfb-watchdog.ts
// 首位元組偵測 — 若上游卡住,就「快速失敗」。
async function callUpstream(c: Candidate, req: ChatRequest) {
  const ac = new AbortController();
  // 請求送上線路後,若在 50ms 內連一個回應位元組都沒看到,
  // 就把這個上游視為靜默,直接落到下一個。
  const firstByteWatchdog = setTimeout(() => ac.abort("ttfb-50ms"), 50);

  const res = await fetch(c.url, {
    method: "POST",
    body: JSON.stringify(req),
    signal: ac.signal,
  });

  clearTimeout(firstByteWatchdog);
  if (!res.body) throw new Error("no-body");

  // 現在已拿到首位元組。把串流交回去;用戶端會在每個區塊
  // 抵達的當下就收到 — 完全不緩衝。
  return new Response(res.body, {
    headers: { "content-type": "text/event-stream" },
  });
}

我們是透過量測自家上游 TTFB 的 P99 才定出 50ms 的:即使是最慢的上游, 在 99% 的情況下也能在 40ms 內吐出第一個回應位元組。任何超過 50ms 的, 都是訊號,不是雜訊。請依你自己上游的 P99 來調整這個數字。

第 3 層:計費算盤

可靠性不只關乎可用性 — 還關乎計費在高負載下能不能保持正確。早期我們 遇過一個競態:兩個同時進來的請求都通過了餘額檢查,接著雙雙提交, 於是該使用者的帳戶倒欠了 $0.12。解法是一個寫入時的預留:

reserve-balance.ts
// 「計費算盤」— 事先預留估算成本,這樣兩個同時進來的
// 大型請求就不會雙雙通過餘額檢查、接著一起透支。
async function reserveBalance(userId: string, estCents: number) {
  return await db.transaction(async (tx) => {
    const bal = await tx.balance.findUnique({ where: { userId } });
    if (bal.balanceCents - bal.reservedCents < estCents) {
      throw new InsufficientQuotaError();
    }
    await tx.balance.update({
      where: { userId },
      data: { reservedCents: { increment: estCents } },
    });
    return { release: () => releaseReservation(userId, estCents) };
  });
}

每一次呼叫都會事先估算它的最高成本(輸入權杖 × 輸入費率 + max_tokens × 輸出費率),在單一交易中於錢包上預留那個金額, 並在實際用量確定後,把沒用到的部分釋放掉。同時進來的請求會在交易層 序列化。永遠不會透支。

那些更重要的無聊事

每一位讀到這裡的工程師,看著派發器的示意圖大概都在點頭,心裡想著 「酷喔,加權重試、首位元組看門狗,我懂了。」但真正讓我們的 SLO 月復一 月撐住的,其實是些比較不刺激的東西:

  • 每個 cron 都有心跳。權杖輪替、餘額清掃、用量彙總 — 每一個在完成時都會去 ping 一個 Healthchecks.io 端點。漏掉一次心跳會 在 5 分鐘內告警。我們從漏掉的心跳裡抓到的中斷,比從明確警報抓到的 還多。
  • 一個真的準確的狀態頁。 brievio.com/status 每 90 秒對每一個模型跑一次合成檢查。當某個上游出現退步時,它會在 客戶察覺之前就轉黃。
  • 在需要 runbook 之前就先寫好 runbook。我們經歷過的 每一起事故,包含那些在影響到客戶之前就攔下來的,都產出了一筆 runbook 條目。凌晨四點的呼叫器有一份檢查清單;工程師不必動腦思考。
  • 什麼都接 Sentry,熱路徑上做剖析。派發器會把每一個 容錯移轉決策連同候選、原因與剩餘預算一起記錄下來。如果有退步偷偷 溜進來,搜尋條件就是「把這小時內 reason=ttfb-50ms 的容錯移轉,依 上游分組列給我看。」

我們不做的事

有幾件事是我們刻意避開、但你可能會預期一個 99.95% 的閘道應該會做的:

  • 跨區域的雙活資料庫。我們的資料庫是單一區域(iad)。 複製延遲會造成計費上的不一致,那是我們不想去除錯的東西。如果 iad 硬性掛掉,我們會降級成唯讀模式 — 客戶仍可呼叫模型(派發器在邊緣是 無狀態的),但在一小時內看不到用量歷史。對我們而言,這個取捨是對的。
  • 偷偷掉包模型。有些彙整商在連不上 Claude Opus 時, 會把請求路由到 Haiku。我們不這麼做 — 我們寧可讓你拿到一個 503, 也不要給你一個你沒付錢的、不一樣的模型。派發器只會容錯移轉到 另一個上游上的同一個模型
  • 搶先重試。Anthropic 的 429 是有意義的。立刻重試只會 讓問題對所有人都更糟。我們的退避是帶抖動的指數退避,上限是 5 次嘗試 與總共 30 秒的等待 — 跟我們 在錯誤指南裡建議的是同一套政策。

預算實際上花到哪裡

我們在 2026 年 5 月的錯誤預算是 21 分 36 秒。我們用掉了:

  • 3 分 12 秒:一次部署期間 us-east-1 Anthropic 的短暫 異常。容錯移轉觸發了,沒有任何請求被丟棄,但 P99 延遲在那個視窗內 飆過了目標值。
  • 1 分 50 秒:一個影片模型上的 kie.ai 5xx 爆量。所有 重試都成功了,但每次呼叫的延遲違反了 SLO。
  • 0 硬性停機。沒有任何請求在沒有重試選項的情況下回了 5xx。

這是 21:36 預算裡燒掉的 5:02 — 用了 23%,遠在目標之內。剩下的那 77%, 就是讓我們能積極部署、並吸收下一次意外的本錢。

這對你而言意味著什麼

如果你是在 Brievio 上開發,你不需要實作自己的供應商容錯移轉、自己的 TTFB 看門狗,或自己的 Anthropic 對 Vertex 路由。這正是重點所在:一個 base_url、一個 bearer token、貨真價實的模型,而 SLO 由我們來守住。

如果你正在打造另一個閘道、讀這篇是為了找靈感:派發器是 600 行 的 TypeScript。它並不是複雜的部分。複雜的部分是那些故障模式的 runbook、 心跳、狀態頁,以及那些累月積下、最終匯聚成一套可靠系統的維運小傷疤。 在你上線之前,先為這些做好規劃。

想看目前的可用性數字與各區域明細,請至 brievio.com/status,或在 我們的錯誤文件裡查看完整的錯誤分類。