iT邦幫忙

2026 iThome 鐵人賽

DAY 28
0
AI Engineering

從 LLM 到 Harness: 打造隱私與可信任的繁中進階 OCR Agent系列 第 28 篇

Day 28 - LLM Router:為什麼現成方案不夠用

  • 分享至 

  • xImage
  •  

昨天設計稽核表,卡在一個欄位:「這個區塊為什麼被送到這個模型」。

記錄層知道結果,不知道理由。

我的第一反應是去找現成的 LLM Router。這東西這兩年很紅,開源的、商用的都有,宣傳詞大多是「自動把請求送到最適合的模型,省錢又不掉品質」。聽起來剛好就是我缺的那一塊。

結果翻了一輪,我得到的結論有點掃興:它們在解的問題,跟我要解的問題,幾乎不在同一條軸上。


先把兩種 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 已經用速度跟覆蓋率算過了,那是版面決定的,不是問題難度決定的。


Fallback 是最危險的那一行

這件事值得單獨講。

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 的能力,要不要設、設到哪裡,是使用者的責任。但一個專門為了「讓請求成功」而設計的工具,它的文件、範例、預設值都會自然地往那個方向推你,這個推力要自己意識到。


有 log,不等於有決策紀錄

看到上面那張表,可能有人會說: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 或啟動流程,每次設定檔變動都跑。路由設定是那種「半年沒人碰、一碰就出事」的檔案,靠人記得檢查是不可能的。


Router 看得到所有東西

還有一個角度,是我開始寫這篇之前完全沒想過的。

Day 12 花了一整天審模型的供應鏈:訓練資料揭露多少、授權有沒有地雷、公司在哪裡、有沒有政府的測試報告。那一天我砍掉了一個在 CJK OCR 上表現很好的模型。

但回頭想,模型只看得到送給它的區塊。Router 看得到全部。

每一個請求、每一段內容都會先經過 Router。它是整條鏈路上權限最高的一個元件,比任何一顆模型都高。結果我花了一天審模型,卻差點直接 pip install 一個 Router 就接上去。

這個順序是反的。

一個有上百個依賴套件的 proxy server,每一個套件都是供應鏈的一環。它有沒有遙測、預設開不開、會不會送匿名使用統計,這些我都沒查(未查證),但光是「我沒查」這件事,就已經說明了問題。


所以要自建嗎?

要,但我想講清楚「自建」的範圍,因為很多人聽到自建就想到從頭寫一個 HTTP proxy,那不是我的意思。

我要自建的是兩層:

一層是政策層。收到一個區塊,先判斷它的敏感等級,再決定它可以去哪些端點、失敗時能不能換路。這一層的邏輯要短到我能一行一行讀完,而且每一條規則都要有版本號。

另一層是紀錄層。每一次路由決策都寫成一筆紀錄:看到了什麼、依據哪版規則、試了哪些路、為什麼跳過、最後去了哪。這一筆會接進 Day 26 的 JSONL,也就是 Day 27 稽核表那個填不出來的欄位。

真正送出 HTTP 請求的那一段,我不排斥用現成工具。LiteLLM 當傳輸層是可以考慮的,前提是它前面有我的政策層擋著,而且它自己的 fallback 全部關掉,失敗時把錯誤原樣丟回給政策層決定。

說穿了就是把「決定去哪」跟「負責送過去」拆開。前者一定要自己握著,後者可以借。

我對現成 Router 沒有敵意,它們在自己的戰場上很好用。只是我的戰場上,最重要的問題不是「送去哪裡最划算」,是「哪裡不能送」。

只是說得輕鬆。政策層要長什麼樣,我心裡目前只有一個模糊的輪廓,其中有一題我到現在還答不出來:GB10 半夜掛掉的時候,一份沒人標過敏感等級的文件卡在佇列裡,它該等,還是該走?


上一篇
Day 27 - 2-5% 抽樣人工稽核:統計設計與流程
下一篇
Day 29 - Router 補強:fail-open/fail-closed、資料敏感分級、可追溯性
系列文
從 LLM 到 Harness: 打造隱私與可信任的繁中進階 OCR Agent 共 30 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言