昨天 Day 14 我們把切換粒度縮到最小:「這一筆」請求帶個 header 臨時換家供應商,下一筆照舊。那是行員主線(/chat)上的事。今天換一個視角:Portal 除了服務行員,還有另一張臉——管理面。後台管理員(或管理前端)透過 Portal 做的兩件事,都不走 /chat:① 在執行期查/換/停供應商(控制面);② 讀寫知識庫(KM 反向代理)。這兩條合起來,就是第四章「可抽換」這條線延伸到執行期、再延伸到後台的收尾。
本篇結構:
/sdk-registry 的開關面板/sdk-registry 的開關面板前面三天講的「可抽換」其實都停在啟動期:Day 13 那本名冊是開機收集好的,Day 14 的 header 覆寫也只在「查表那一刻」插手挑哪個。但真實營運不會這麼安分,想想兩個很具體的場景:
這些都是「執行期」才會發生、又帶著「不能停機」硬約束的事。Day 14 的 header 覆寫解不了(粒度是單筆請求)。需要的是一個能在執行期讀寫供應商生命週期的控制面。Portal 把它開成一組 HTTP 端點 /sdk-registry——接著 Day 13 那塊配電箱的比喻,這是裝在配電箱上、能不斷電操作的一排開關:
| 方法 | 路徑 | 作用 |
|---|---|---|
GET |
/sdk-registry |
查現況:接了哪些供應商、各自健康狀態 |
POST |
/sdk-registry/{category}/{name}/reload |
重載單一供應商(換 key、刷新連線) |
POST |
/sdk-registry/{category}/{name}/disable |
執行期停用(流量自動退回該類預設) |
POST |
/sdk-registry/{category}/{name}/enable |
重新啟用 |
category 是 Day 13 那張分類表裡的插槽(llm、guardrail、maps…),name 是供應商自報的名字(openai-http、gemini-http)。這組端點就是「對著名冊的某一格動手」。
GET 回的是一張現況快照,每個供應商除了名字,還自報一個健康狀態,列舉只有四種:HEALTHY(健康)、DEGRADED(降級,能用但表現不佳)、UNHEALTHY(不可用)、UNKNOWN(還沒探測、或這實作根本沒做健康檢查)。這狀態靠 Day 13 講的介面預設值(default method)長出來:沒做健康檢查的舊插件預設回 UNKNOWN,完全不會壞;接外部 HTTP 的供應商啟動時探測一次端點可達性,據此回 HEALTHY/UNHEALTHY。一張快照大概長這樣:
{
"llm": [
{ "name": "openai-http", "enabled": true, "health": "HEALTHY" },
{ "name": "gemini-http", "enabled": false, "health": "DEGRADED" },
{ "name": "mock", "enabled": true, "health": "UNKNOWN" }
]
}
enabled 跟 health 是兩件事:health 是供應商「自己覺得自己行不行」(自報,disable 動不了它);enabled 是「營運上要不要讓流量走它」(由 disable/enable 控制)。上面 gemini-http 被人手動停掉了(enabled: false),即使它只是 DEGRADED 還沒完全掛——這就是有人主動把它拿下來止血的樣子。
寫操作(reload/disable/enable)不是誰都能打。它們全部受管理員權杖保護,這是管理面的第一道門,呼應 Day 10 的不對稱原則:行員提問低風險、fail-open;「停掉一家全公司在用的供應商」是高風險管理動作,採 fail-closed。沒帶對權杖一律拒絕,而且連權杖根本沒設定好都算失敗、照樣回 503。權杖比對走常數時間——比對耗時不隨內容變,讓人無法從回應快慢逐字猜出權杖(計時攻擊,timing attack)。而且每個寫操作都寫進稽核紀錄(audit log),循 Day 9 那組 correlation id(追蹤碼)就能還原「誰、在何時、停了哪一家」:
2026-06-22 09:15:03.412 INFO [req=a1b2c3d4] SdkRegistry : disable category=llm name=gemini-http by=admin reason=upstream-5xx
2026-06-22 09:15:03.418 INFO [req=a1b2c3d4] AuditLog : emit action=provider.disable target=llm/gemini-http actor=admin result=ok
止血之後,disable 把 gemini-http 標成不可用,原本要挑它的請求自動退回該類預設供應商(設定檔 portal.llm.provider 指的那家),整條對話鏈一筆不斷;等對方恢復,打一發 enable 放回清單。
管理面的第二條路,是後台管理介面要直接讀寫知識庫(KM)的時候。Day 2 講過 Portal 不碰 KM 的「業務」——真正的檢索鏈是後端引擎連 KM;但「管理操作」(新增、修改、查列表那類)是另一回事,它走 Portal 開的一條反向代理:
/api/v1/km/** 的請求,驗完身分後原樣轉發給 KM。它刻意寫成通用的——KM 那邊加新端點,Portal 這個代理一行都不用改(呼應 Day 13 的「0 行不動」精神,只是這次不動的是代理)。401,不放行匿名。X-Request-Id,也就是 Day 9 的追蹤碼),其餘客戶端自帶的 header 一律剝掉;回來的回應也只放行白名單 header(Content-Type、ETag 那類),Set-Cookie 這種一律不回傳,把攻擊面縮到最小。Portal 在這條路上只是個驗過身分的轉接站,不插手知識庫的內容——跟 Day 2「業務知識不在 Portal 身上」一致。這條路有兩個誠實要講的邊界:
| 邊界 | 現況 | 正式版方向 |
|---|---|---|
| 整包載記憶體 | 請求與回應內容整包讀進記憶體再轉發(緩衝上限拉到 50MB)。一般查詢沒事,但大型批次上傳(CSV、Excel 動輒幾十 MB)有撐爆記憶體的風險 | 改成邊收邊轉的串流式代理(跟 Day 23 串流同一類課題,reactive 這類非同步框架天生擅長) |
| 對 KM 的 s2s 認證還沒接 | Portal 對後端引擎的 s2s 已走 Day 11 那套 OIDC token,但對 KM 這條目前是 TODO;本機開發時 KM 跑在 localhost,不需要 s2s 認證 |
正式上 Cloud Run、KM 也要求驗身分時補上,同樣走向 metadata server 取 ID token 那套 |
把兩條路擺一起,會發現 Portal 的管理面跟它的客戶端主線是同一種哲學——薄。它只做三件事:認證(SSO 單一登入/管理員權杖)、轉發(控制面動名冊、KM 代理轉請求)、留痕(寫操作寫 audit)。它刻意不做的同樣關鍵:
Portal。它只確立「你是誰」(authn),「你能做什麼」(authz)交給後端的管理服務。這呼應 Day 10 的分工——Portal 管身分,不管業務權限。Portal 一概不碰。管理面最容易長歪的地方,就是「順手在代理層多做一點判斷」。一旦開始在
Portal塞授權規則或業務邏輯,它就會慢慢長成第二個後台——這正是 Day 2 那條「該放Portal還是後端」的判準,在管理面再走一次。
控制面能「線上隨時查、隨時改、不停機」,但「執行期可改的狀態」本身就有代價,得把 PoC(概念驗證)階段的妥協講清楚,免得把示意級的開關當成正式營運的防線。
最該講的:disable 的停用狀態只活在記憶體裡,服務一重啟就清空、回到設定檔的預設。 你緊急停掉的那家 gemini-http,只要 Portal 因任何原因重啟(部署、當機、自動擴縮),就又被當成 enabled 復活。這在 PoC 能接受——熱控制當下解的是「此刻先止血」,不是「永久下線」。但它意味著現階段的 disable 還稱不上正式營運開關,只是個應急閥。要撐得住,停用狀態得外移到一個重啟不丟、多實例共享的地方(GCP 上如 Memorystore for Redis,或寫進 Cloud SQL)。這跟 Day 11「不在程式裡塞長期狀態」、用量閘門那本「只活在單機記憶體的帳本」是同一類課題。
而且多實例下還藏著一個比「重啟復活」更早發作的問題:Portal 一旦自動擴縮成多台,一發 POST .../disable 只會打到負載平衡當下選中的那一台——從你下令的當刻起,它就只在 N 台裡的 1 台生效,其餘 N−1 台對那家壞掉的供應商照樣放行。換句話說,這個緊急止血閥在多實例下是不完整的止血,不必等重啟就漏。停用狀態鎖在單機記憶體裡,控制面的指令既無法跨實例生效,也無法保證一致。這也是為什麼「外移到共享儲存」不只是個優化,而是這道閘要能真正發揮作用的前提。
「N 台只中 1 台」畫出來最清楚:

另外兩個小一點的取捨:熱重載與「正在進行的請求」有時序縫隙(reload 換 key 的那一瞬間,已經在飛的請求還用舊連線,得自己跑完或逾時);生命週期掛鉤目前是同步的(供應商若在重載掛鉤裡做網路探測,這次 reload 會卡著等它探完;改非同步又會破壞 Day 12 的「0 行不動」承諾,介面簽名(signature)一動所有實作得跟著改,所以還沒解)。
回到 Day 3 那把尺。當時把 Portal 跟 LiteLLM 並排,熱控制那格兩邊都打了勾:
| LiteLLM | Portal | |
|---|---|---|
| 動態金鑰管理 | /key/update 那一套 |
/sdk-registry |
| 治理思路 | RBAC + 稽核 | 權限管得住、全部留痕 |
| 成熟度 | 狀態有持久化、多實例一致 | 「停用」目前只在單機記憶體 |
LiteLLM 是成熟產品,狀態有持久化、多實例一致;Portal 這套目前的「停用」還只在單機記憶體,骨架立起來了,但離正式營運開關還差一段工:把狀態外移到重啟不丟、跨實例共享的儲存(Memorystore/Cloud SQL)。
把每一格的真實狀態如實交代清楚,正是這三十天的任務之一。
第四章到這裡收束。從 Day 12 的「換廠商 0 行 Java 不動」,到 Day 13 拆開那本名冊、Day 14 的逐請求覆寫,再到今天把控制權延伸到執行期、把後台讀寫知識庫的那條代理也收進管理面——整套「可抽換」其實都在回答同一個命題:讓既有的東西不必改,就能長出新能力,而且每一條對外的路都先過身分這道關。 明天 Day 16,我們翻開第五章守門與安全,先從入境的第一道關講起:當有人在訊息裡夾帶「ignore previous instructions」想覆寫系統指令,這道提示注入防禦怎麼在第一時間把它識破、短路擋下。