Engineering
LLM 備援策略:ViralArc 的模型備援 DSL 與斷路器設計
用一行字串宣告 LLM 備援策略:ViralArc 的模型備援 DSL 與斷路器設計
想像兩個 ViralArc 每週都會遇到的場景。
第一個是 demo 現場:台下數十個人同時拿出手機註冊、連帳號、按下「生成」。平常分散在一整天的流量,此刻壓縮進同一分鐘。
第二個更日常:排程觸發。ViralArc 的自動化大量依賴排程——海巡代理定時掃描貼文、自動回覆定時發送、內容生成定時執行。排程的本質就是「約好同一時間做事」,所以每到觸發點,幾十個帳號的 pipeline 會在同一個時間窗裡一起醒來,各自對模型發出請求。
這兩種場景有個共同點:尖峰是平時的數十倍,而且說來就來。這篇文章分享我們怎麼用一個一行字串的備援 DSL,加上一個 per-pipeline、per-model 的斷路器,讓這種流量形狀跑在多家模型供應商之上而不翻車。
問題:LLM 配額是為「平滑流量」設計的,我們的流量不平滑
模型供應商的 rate limit(RPM/TPM)本質上假設你的流量是平滑的。面對尖峰型流量,單一供應商的配額怎麼配都尷尬:
- 按尖峰配:為了每週幾次的 demo 和每小時幾分鐘的排程高峰,常態性持有大量閒置配額,成本難看。
- 按均值配:每次 demo 現場就是一場 429 風暴,排程一觸發就集體限流——而且掛掉的偏偏都是最不能掛的時刻(demo 就是給人看的,排程回覆晚了就錯過留言熱度)。
再加上我們的 pipeline 種類很多——留言過濾、回覆生成、關鍵字擴充、策略規劃、貼文撰寫——每一條對模型的要求不同。高頻低價值的呼叫(過濾一則留言)不值得動用旗艦模型;替客戶品牌帳號發聲的生成則不能省。單一模型、單一供應商,兩頭都照顧不了。
所以我們接了多家:OpenAI、Google、GCP Vertex、AWS Bedrock、OpenRouter、Together 等。多供應商解決了配額和單點故障,但立刻帶來新問題:每條 pipeline 該用哪個模型?掛了退到哪裡?誰來決定什麼叫「掛了」?
一行字串,宣告完整的備援策略
我們不想用 YAML 檔或資料庫表來管理模型路由——那會讓「調整某條 pipeline 的模型」變成一次部署或一次資料遷移。最後的設計是把整個策略壓進一個字串。語法只有兩個規則:
- 逗號是優先層級:先用第一層,第一層不可用才往後退。
- 括號加
|是負載均衡組:(A|B)表示 A、B 同一層,每次請求隨機挑一個。
舉一個示意的例子(模型名稱是虛構的,實際配置不在本文範圍)。一條高頻的輕量 pipeline 可能長這樣:
(官方A_小模型|官方B_小模型),聚合器_開源模型
讀作:「平常在兩家官方端點的小模型之間隨機分流,把流量攤在兩份配額上;兩個都出狀況時,退到聚合器上的開源模型。」而一條品質敏感的生成 pipeline 則可能排出四、五層深的退路,並且讓同一個開源模型出現在不同供應商的託管端點上——這一手等於把「模型壞了」和「供應商壞了」兩種故障分開對沖:模型本身有問題,換模型;只是某家託管商抖動,同一個模型在別家還活著,輸出品質完全不變。
幾個讓這個 DSL 好用的小設計:
- 環境變數熱覆寫:每條 pipeline 對應一個
MODEL_<PIPELINE_NAME>環境變數,值就是同一套的 DSL。當線上某個模型出問題,而且需要手動快速修改時,只要一個環境變數就能把整個服務導到別的供應商,不用改 code。 - 別名表:字串裡寫供應商的原始模型 ID 或我們的內部名稱都可以,parser 透過 alias map 統一解析。
- 防呆:解析時自動去重、跳過空組、認不得的模型名印警告但不炸掉——設定寫錯一個名字,其他備援仍然生效。
為什麼官方端點在前、聚合器在後
細看我們的層級排序,會發現一個一致的模式:第一層幾乎都是官方端點(OpenAI、Google、GCP 這類第一方 API),聚合器(如 OpenRouter)排在後面接溢出的流量。這不是隨手排的,背後的推理是:
官方端點是「可預測的容量」。 直連第一方 API,配額是自己的、延遲是穩定的、模型版本是明確的,行為可以被壓測和調參。日常的基載流量走這裡,品質和延遲都有底。
聚合器是「彈性的緩衝」。 聚合器的價值在於它背後是一整池供應商——你打過去,它找有餘裕的一家幫你跑。這正是尖峰時最需要的東西:demo 現場把官方配額打滿的那幾分鐘,溢出的請求自動流向聚合器,被那個大池子吸收。但代價是多一跳網路、路由到的底層主機可能不同,延遲和行為的變異數都比較大——所以它適合當緩衝,不適合當基載。
一句話總結這個排法:平時把流量放在最可控的地方,尖峰時把流量倒進最有彈性的地方。 配額的問題也順帶解了——官方配額只需要 cover 基載加一點餘裕,尖峰的差額由備援鏈往下溢出,不需要為了每週幾次的高峰去養一份閒置的頂配額度。
斷路器:什麼叫「這個模型掛了」?
有了退路,還需要知道何時退。天真的做法是「這次失敗,下次換一個」,但那會讓暫時抖動的模型被反覆重試,也讓真正掛掉的模型每次都先吃一發逾時。我們要的是斷路器語意:出問題就把它從候選名單暫時拿掉,過一段時間自動放回來。
狀態粒度是 (pipeline, model) 一組,不是全域的 model 一組——同一個模型可能在長輸出的 pipeline 上一直逾時、在短輸出的 pipeline 上活得好好的,不該連坐。每組狀態維護一個 15 分鐘滑動視窗的嘗試/失敗記錄、一個連續 429 計數、一個熔斷到期時間。觸發熔斷的條件是兩個其一:
| 條件 | 閾值 | 直覺 |
|---|---|---|
| 連續 429 | ≥ 2 次 | 兩發 rate limit 就是明確的「我現在不想理你」,不用等統計顯著 |
| 視窗失敗率 | 樣本 ≥ 5 且失敗率 ≥ 30% | 一般錯誤要有足夠樣本才熔斷,避免單次抖動誤殺 |
兩個門檻刻意不同,而且這個不對稱正是為尖峰場景調的:429 是供應商主動告訴你「退後」,在 demo 或排程齊發的當下,反應慢一拍就是幾十個請求排隊撞牆,所以連兩次就熔斷、立刻把後續流量導向下一層;一般 5xx 或逾時則可能只是單次網路抖動,要 5 個樣本、30% 失敗率才動手。任何一次成功都會把連續 429 歸零。
熔斷後暫停 15 分鐘,到期自動恢復——沒有 half-open 狀態、沒有探測請求,因為滑動視窗本身就是探測:放回來之後如果還是不行,幾個樣本內就會再被熔斷。
重試機制:重試和備援是同一個迴圈
斷路器要發揮作用,重試的「位置」很重要。常見的重試是在請求層做的:失敗了,對同一個模型再打一次。對 429 來說這是最糟的行為——對方已經在限流了,你還在原地加壓。所以我們把請求層的重試整個關掉,把重試提升到模型選擇之上:每個 pipeline step 最多嘗試 3 次,流程變成:
- 每次嘗試都重新選模。走一次「過濾熔斷中的模型 → 層內隨機挑選」的完整流程,而不是黏著上一次的選擇。
- 每次嘗試結束都記帳。成功或失敗都寫進滑動視窗;失敗時用一個遞迴的 status code 萃取器把各家 SDK 藏在不同位置的 HTTP 狀態碼(
statusCode、status、response.status、包在cause或errors[]裡的)挖出來,判斷是不是 429。 - 記帳的結果立刻影響下一次選模。如果這次失敗剛好觸發熔斷,下一次嘗試時這個模型已經不在候選裡了。
實際跑起來像這樣:第一次嘗試抽到官方 A,吃到 429,記帳;第二次重新選模,可能抽到同組的官方 B,又 429——此時 A、B 都可能觸發連續 429 熔斷;第三次選模時第一層已經全滅,自然落到下一層的聚合器上,成功。三次重試橫跨了兩層備援、三個供應商,而 pipeline 的呼叫端對此完全無感。
三次都失敗,才會把錯誤拋給呼叫端——而呼叫端之上還有各自的保底機制(例如 webhook 通知本身就是先落地再處理的,失敗的通知可以重放)。每一層都只負責自己能救的部分。
選模型時的最後一條規則:fail open
選擇器有一條我們最堅持的規則:走完所有層級、發現每一個模型都在熔斷中時,不是拒絕服務,而是回到第一層隨機挑一個照樣發請求。
熔斷名單是輔助訊號,不是硬牆。如果所有模型同時被熔斷,最可能的解釋不是「全世界的 LLM 都掛了」,而是我們自己的網路或某個共同依賴出了問題——這時 fail closed 只會把小故障放大成全面停擺。發出去也許會失敗,但也許不會;失敗了上面還有重試。
附贈的難題:每個模型的「個性」不一樣
多模型備援有個常被低估的成本:模型之間不只是價格和速度不同,行為也不同——輸出格式、思考過程、對指令的服從度,家家有自己的脾氣。如果 fallback 到另一個模型後輸出跑掉,備援等於沒備。所以備援鏈上的每一個模型,我們都花了不少時間逐一調校、對齊行為,讓呼叫端完全不用知道這次抽到的是誰。這部分的細節值得另開一篇來寫。
結語
回頭看,這個系統沒有任何一個部件是新發明——斷路器是教科書 pattern,DSL 只是個 30 行的字串 parser。真正花時間的是幾個「邊界上的決定」:官方端點當基載、聚合器當緩衝;狀態粒度切在 (pipeline, model) 而不是 model;429 和一般錯誤走不同的熔斷門檻;重試提升到模型選擇之上、讓兩者共用同一個迴圈;以及全滅時 fail open。
這些決定共同服務一個目標:demo 現場幾十個人同時按下按鈕、排程整點齊發的那一刻,使用者不在乎背後是哪家供應商——他只在乎結果有沒有出來。剩下的,是我們的事。