昨天設計稽核表,卡在一個欄位:「這個區塊為什麼被送到這個模型」。
記錄層知道結果,不知道理由。
我的第一反應是去找現成的 LLM Router。這東西這兩年很紅,開源的、商用的都有,宣傳詞大多是「自動把請求送到最適合的模型,省錢又不掉品質」。聽起來剛好就是我缺的那一塊。
結果翻了一輪,我得到的結論有點掃興:它們在解的問題,跟我要解的問題,幾乎不在同一條軸上。
這系列其實已經有一個 Router 了。Day 18 的 Region Routing 做的是「這一塊是純文字、表格還是混排,要交給哪條處理路線」。它看的是版面。
今天講的 LLM Router 是另一層:同一個請求,可以送到哪幾個模型端點,挑哪一個,挑的那個掛了怎麼辦。
市面上的 LLM Router 大致都在回答同一個問題:這個請求值不值得用貴的模型?簡單的丟給便宜的小模型,難的才送大模型,目標是在品質差不多的前提下把帳單壓低。
我這邊的情況完全不同。全部的模型都跑在自己的機器上,沒有 API 帳單可以省。我在意的是:
成本跟品質是它們的主軸,合規跟可追溯是我的主軸。今天整篇其實都在講這個錯位。
先講清楚範圍,免得誤導:我沒有在這個 repo 裡部署任何一套現成 Router,也沒有對它們做過任何實測。底下的描述來自各專案公開說明的定位與設計目標。我不確定的功能細節一律標「未查證」,版本號一個都不寫,因為這類專案改版很快,寫了大概也會過期。
LiteLLM 是我最熟的一個。它把上百種模型供應商包成統一的 OpenAI 相容介面,可以當 Python SDK 用,也可以當成一台 proxy server 自己架。設定檔裡列一串 model_list,每個模型可以指定 api_base,所以要指向我自己 GB10 上的 vLLM 端點是做得到的。它有 fallback 機制、負載平衡、花費追蹤。本質上它是一個「統一入口 + 基本路由」的閘道。
RouteLLM 是 LMSYS 發表的研究型框架。它的想法比較學術:用人類偏好資料訓練一個路由器,判斷一個問題交給「強模型」和「弱模型」各自會贏的機率,再用一個門檻值決定送哪邊。門檻調高就省錢,調低就保品質。它是開源的,可以本機跑。
OpenRouter 是一個託管服務,你打它的 API,它幫你轉送到後面各家模型供應商。
另外還有一些商用路由服務,例如 Not Diamond、Martian,定位是「幫你挑最適合的模型」。它們的部署方式、有沒有地端版本、資料怎麼處理,我查得不夠深,全部算「未查證」。
我把自己需要的東西列成七條,一條一條對。
| 需求 | LiteLLM(自架 proxy) | RouteLLM | OpenRouter | 商用路由服務 |
|---|---|---|---|---|
| 資料不出機房 | 可以,前提是每個模型都指向內網端點 | 可以本機跑;但它路由的對象要你自己設定 | 做不到,請求會經過它的服務 | 未查證(是否有地端部署) |
| 依資料敏感度決定去向 | 沒有內建的敏感分級概念;是否能靠標籤類功能做到,未查證 | 不是它的目標,它看的是問題難度 | 有供應商層級的資料政策選項,細節未查證 | 未查證 |
| 失敗時的預設行為 | fallback 由使用者設定,設定了就會往下一個模型送 | 未查證 | 有自動換供應商的機制,細節未查證 | 未查證 |
| 決策理由可回放(規則版本、候選、跳過原因) | 有 logging callback,能記請求與花費;是否記錄「為什麼選這個」與規則版本,未查證 | 路由分數可以拿到,是否有完整決策紀錄,未查證 | 未查證 | 未查證 |
| 目的地白名單 | 需要自己在設定檔管;沒有看到「不在名單就拒絕」的強制機制,未查證 | 不適用 | 不適用(目的地由它管) | 未查證 |
| 紀錄本身不外洩內容 | 取決於你開了哪些 callback;接第三方觀測平台就會送出去 | 未查證 | 不適用 | 未查證 |
| 本身的供應鏈審查 | 開源、可讀原始碼,但依賴套件很多,要照 Day 12 的清單自己審 | 開源,依賴較少,未逐一審 | 不適用 | 不適用 |
這張表「未查證」很多,我知道。但也正是這樣,這張表才有意思:我需要的七條裡,沒有任何一條是這些方案宣傳時的主打。它們主打的是省錢、統一介面、高可用。
OpenRouter 第一條就出局。資料要先經過第三方服務,對一個標題寫著「隱私」的系列,這不用討論。
LiteLLM 是最接近能用的。它能架在內網,能指向我自己的端點。但它的設計預設是「讓請求盡量成功」,而我要的預設是「不確定的時候寧可失敗」。這兩個預設會在 fallback 那一行正面相撞。
RouteLLM 的思路很漂亮,但它回答的問題我根本沒有。我不需要判斷「這題值不值得用貴的模型」,我的表格區、純文字區該送哪顆模型,Day 10 已經用速度跟覆蓋率算過了,那是版面決定的,不是問題難度決定的。
這件事值得單獨講。
Router 的 fallback 設計初衷是可用性:主要端點掛了,自動換一個,使用者不會看到錯誤。對聊天機器人來說這完全合理。
放在我的情境,想像這個設定:
model_list:
- model_name: ocr-main
litellm_params:
model: openai/gemma-4-26b
api_base: http://10.0.0.220:8004/v1
- model_name: ocr-backup
litellm_params:
model: openai/gpt-4o-mini
fallbacks:
- ocr-main: ["ocr-backup"]
這份設定長得很像 LiteLLM 的格式,但我沒拿真的 LiteLLM 跑過,欄位名稱請對照你手上版本的文件。
問題在第二個模型。它沒寫 api_base,而 openai/ 前綴的模型沒指定端點時,一般會打到 OpenAI 的官方 API(實際行為以你用的版本文件為準)。平常完全不會出事,因為 GB10 好好的,所有請求都走 ocr-main。
直到哪天晚上 GB10 被重開,或者像 Day 3 那樣兩個服務搶記憶體 crash 掉。
那一刻起,年報的區塊開始安安靜靜地往外送。沒有錯誤訊息,因為 fallback 成功了。服務的可用率報表還會很好看。
這就是我說「兩個預設相撞」的意思。可用性優先的系統,失敗的樣子是「悄悄換一條路」;隱私優先的系統,失敗的樣子應該是「大聲停下來」。
我不是說 LiteLLM 設計錯了。它給了你 fallback 的能力,要不要設、設到哪裡,是使用者的責任。但一個專門為了「讓請求成功」而設計的工具,它的文件、範例、預設值都會自然地往那個方向推你,這個推力要自己意識到。
看到上面那張表,可能有人會說:LiteLLM 有 logging,OpenRouter 也有請求紀錄,怎麼會說追不到?
因為它們記的東西,跟稽核員要問的東西不一樣。
一般閘道記的是「發生了什麼」。大概長這樣(這是我為了說明寫的示意,不是任何一套工具的真實輸出格式):
{"ts": "2026-09-24T21:14:03", "model": "ocr-backup", "prompt_tokens": 292,
"completion_tokens": 431, "latency_ms": 9120, "status": 200}
每個欄位都有用,拿來算帳、抓效能問題都很好。但 Day 27 的稽核員拿到這一行,還是答不出他真正想問的:這個區塊為什麼去了 ocr-backup?是主模型掛了,還是規則本來就這樣寫?當時的規則是哪一版?有沒有別的路被考慮過又被跳過?
我要的是「為什麼」:
{"trace_id": "tsmc2025-p61-r03", "rule_version": "router-policy/2026-09-25.1",
"level": "L0_PUBLIC", "evidence": ["source_tag=mops_public"],
"tried": [{"route": "gemma4_26b_local", "result": "unhealthy"},
{"route": "tesseract_local", "result": "selected"}],
"outcome": "done"}
同樣是一行 JSON,第二種才接得上 Day 26 的記錄層,也才填得了 Day 27 那個空欄位。
status: 200 在第一種紀錄裡是好消息。在我的系統裡,一個不該成功的請求回了 200,是最壞的消息。
在決定自建之前,我先做了一件小事:寫一支腳本,檢查路由設定裡有沒有會讓資料出門的東西。不管最後用不用現成方案,這支都用得上。
from __future__ import annotations
import ipaddress
from urllib.parse import urlparse
ALLOWED_HOSTS = {"10.0.0.220"} # GB10 的內網位址,Day 11 提過
ALLOWED_NETS = [ipaddress.ip_network("10.0.0.0/24")]
def host_ok(api_base: str | None) -> tuple[bool, str]:
if not api_base:
return False, "沒寫 api_base:可能落到套件預設的雲端端點"
host = urlparse(api_base).hostname or ""
if host in ALLOWED_HOSTS:
return True, "allowlist"
try:
ip = ipaddress.ip_address(host)
except ValueError:
return False, f"{host} 是網域名稱,解析結果可能會變"
if any(ip in net for net in ALLOWED_NETS):
return True, "內網段"
return False, f"{host} 不在核准網段"
def lint(config: dict) -> list[str]:
problems = []
names = set()
for entry in config.get("model_list", []):
name = entry.get("model_name", "?")
names.add(name)
ok, why = host_ok(entry.get("litellm_params", {}).get("api_base"))
if not ok:
problems.append(f"[目的地] {name}: {why}")
for rule in config.get("fallbacks", []):
for src, dsts in rule.items():
for dst in dsts:
if dst not in names:
problems.append(f"[降級] {src} -> {dst}: 目的地不在 model_list")
if not config.get("decision_log"):
problems.append("[追溯] 沒有設定路由決策紀錄")
return problems
decision_log 是我自己加的欄位,不是 LiteLLM 的設定。它代表「這套路由有沒有把決策理由記下來」,沒有的話就報一條。
拿上面那份設定餵進去,本機跑出來是:
[目的地] ocr-backup: 沒寫 api_base:可能落到套件預設的雲端端點
[追溯] 沒有設定路由決策紀錄
這支檢查器有個我自己很清楚的弱點:網域名稱我一律擋掉。理由是 DNS 解析結果會變,今天指向內網的名字,明天可能被改到別的地方。代價是設定檔只能寫 IP,很醜,換機器要改一堆地方。我暫時選擇醜,因為「看起來設定正確、實際上資料出門」這種錯,比「設定檔難維護」嚴重太多。
Tip:這支檢查器最好放進 CI 或啟動流程,每次設定檔變動都跑。路由設定是那種「半年沒人碰、一碰就出事」的檔案,靠人記得檢查是不可能的。
還有一個角度,是我開始寫這篇之前完全沒想過的。
Day 12 花了一整天審模型的供應鏈:訓練資料揭露多少、授權有沒有地雷、公司在哪裡、有沒有政府的測試報告。那一天我砍掉了一個在 CJK OCR 上表現很好的模型。
但回頭想,模型只看得到送給它的區塊。Router 看得到全部。
每一個請求、每一段內容都會先經過 Router。它是整條鏈路上權限最高的一個元件,比任何一顆模型都高。結果我花了一天審模型,卻差點直接 pip install 一個 Router 就接上去。
這個順序是反的。
一個有上百個依賴套件的 proxy server,每一個套件都是供應鏈的一環。它有沒有遙測、預設開不開、會不會送匿名使用統計,這些我都沒查(未查證),但光是「我沒查」這件事,就已經說明了問題。
要,但我想講清楚「自建」的範圍,因為很多人聽到自建就想到從頭寫一個 HTTP proxy,那不是我的意思。
我要自建的是兩層:
一層是政策層。收到一個區塊,先判斷它的敏感等級,再決定它可以去哪些端點、失敗時能不能換路。這一層的邏輯要短到我能一行一行讀完,而且每一條規則都要有版本號。
另一層是紀錄層。每一次路由決策都寫成一筆紀錄:看到了什麼、依據哪版規則、試了哪些路、為什麼跳過、最後去了哪。這一筆會接進 Day 26 的 JSONL,也就是 Day 27 稽核表那個填不出來的欄位。
真正送出 HTTP 請求的那一段,我不排斥用現成工具。LiteLLM 當傳輸層是可以考慮的,前提是它前面有我的政策層擋著,而且它自己的 fallback 全部關掉,失敗時把錯誤原樣丟回給政策層決定。
說穿了就是把「決定去哪」跟「負責送過去」拆開。前者一定要自己握著,後者可以借。
我對現成 Router 沒有敵意,它們在自己的戰場上很好用。只是我的戰場上,最重要的問題不是「送去哪裡最划算」,是「哪裡不能送」。
只是說得輕鬆。政策層要長什麼樣,我心裡目前只有一個模糊的輪廓,其中有一題我到現在還答不出來:GB10 半夜掛掉的時候,一份沒人標過敏感等級的文件卡在佇列裡,它該等,還是該走?