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 之下,新進的請求會在客戶察覺之前就停止流向那裡。
// 上游派發器的虛擬碼。實際實作
// (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 在感知門檻之下, 所以在順利的路徑上,客戶感受不到任何延遲。在故障的路徑上,客戶的 請求會在單一個人類可感知的影格內容錯移轉到備援上游。
// 首位元組偵測 — 若上游卡住,就「快速失敗」。
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。解法是一個寫入時的預留:
// 「計費算盤」— 事先預留估算成本,這樣兩個同時進來的
// 大型請求就不會雙雙通過餘額檢查、接著一起透支。
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,或在 我們的錯誤文件裡查看完整的錯誤分類。