iT邦幫忙

2026 iThome 鐵人賽

DAY 15
0
Vibe Coding

轉生到全端工程師沒多久就要負責公司的大平台??系列 第 15

Day 15|Portal 的管理面:控制面與 KM 反向代理

  • 分享至 

  • xImage
  •  

昨天 Day 14 我們把切換粒度縮到最小:「這一筆」請求帶個 header 臨時換家供應商,下一筆照舊。那是行員主線(/chat)上的事。今天換一個視角:Portal 除了服務行員,還有另一張臉——管理面。後台管理員(或管理前端)透過 Portal 做的兩件事,都不走 /chat:① 在執行期查/換/停供應商(控制面);② 讀寫知識庫(KM 反向代理)。這兩條合起來,就是第四章「可抽換」這條線延伸到執行期、再延伸到後台的收尾。

本篇結構:

  • 控制面:一個叫 /sdk-registry 的開關面板
  • KM 反向代理:後台讀寫知識庫的那條路
  • 管理面的本分:只認證、只轉發、只留痕
  • 取捨:彈性的代價是狀態一致性

控制面:一個叫 /sdk-registry 的開關面板

前面三天講的「可抽換」其實都停在啟動期:Day 13 那本名冊是開機收集好的,Day 14 的 header 覆寫也只在「查表那一刻」插手挑哪個。但真實營運不會這麼安分,想想兩個很具體的場景:

  1. 例行輪換:安全要求 API key 定期換,新 key 已經發下來。若換 key 的唯一辦法是改設定檔、重啟服務,等於為一次例行輪換讓所有人對話中斷幾十秒——不可接受。
  2. 緊急止血:某家供應商後端掛了,走到它的請求都在空等、逾時、回錯。你想立刻把它從「可用清單」拿掉、讓流量退回該類預設供應商,等它恢復再放回——這一切都不該需要重啟。

這些都是「執行期」才會發生、又帶著「不能停機」硬約束的事。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 那張分類表裡的插槽(llmguardrailmaps…),name 是供應商自報的名字(openai-httpgemini-http)。這組端點就是「對著名冊的某一格動手」。

GET 回的是一張現況快照,每個供應商除了名字,還自報一個健康狀態,列舉只有四種:HEALTHY(健康)、DEGRADED(降級,能用但表現不佳)、UNHEALTHY(不可用)、UNKNOWN(還沒探測、或這實作根本沒做健康檢查)。這狀態靠 Day 13 講的介面預設值(default method)長出來:沒做健康檢查的舊插件預設回 UNKNOWN,完全不會壞;接外部 HTTP 的供應商啟動時探測一次端點可達性,據此回 HEALTHYUNHEALTHY。一張快照大概長這樣:

{
  "llm": [
    { "name": "openai-http", "enabled": true,  "health": "HEALTHY" },
    { "name": "gemini-http", "enabled": false, "health": "DEGRADED" },
    { "name": "mock",        "enabled": true,  "health": "UNKNOWN" }
  ]
}

enabledhealth 是兩件事:health 是供應商「自己覺得自己行不行」(自報,disable 動不了它);enabled 是「營運上要不要讓流量走它」(由 disableenable 控制)。上面 gemini-http 被人手動停掉了(enabled: false),即使它只是 DEGRADED 還沒完全掛——這就是有人主動把它拿下來止血的樣子。

寫操作(reloaddisableenable)不是誰都能打。它們全部受管理員權杖保護,這是管理面的第一道門,呼應 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

止血之後,disablegemini-http 標成不可用,原本要挑它的請求自動退回該類預設供應商(設定檔 portal.llm.provider 指的那家),整條對話鏈一筆不斷;等對方恢復,打一發 enable 放回清單。

KM 反向代理:後台讀寫知識庫的那條路

管理面的第二條路,是後台管理介面要直接讀寫知識庫(KM)的時候。Day 2 講過 Portal 不碰 KM 的「業務」——真正的檢索鏈是後端引擎連 KM;但「管理操作」(新增、修改、查列表那類)是另一回事,它走 Portal 開的一條反向代理

  • 通吃的路由:任何打到 /api/v1/km/** 的請求,驗完身分後原樣轉發給 KM。它刻意寫成通用的——KM 那邊加新端點,Portal 這個代理一行都不用改(呼應 Day 13 的「0 行不動」精神,只是這次不動的是代理)。
  • 先驗身分,匿名擋下:這些端點是 user-scoped(誰改了什麼要記進 audit),所以沒有有效身分的請求直接回 401,不放行匿名。
  • header 白名單,雙向都管:轉發出去時只放行少數白名單 header(使用者身分、呼叫端類別、X-Request-Id,也就是 Day 9 的追蹤碼),其餘客戶端自帶的 header 一律剝掉;回來的回應也只放行白名單 header(Content-TypeETag 那類),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)。它刻意不做的同樣關鍵:

  • 不做細緻授權(RBAC,角色權限控管):「這個身分能不能改這個設定、能不能改這篇知識」這種逐資源的權限判斷,不在 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 台」畫出來最清楚:

https://ithelp.ithome.com.tw/upload/images/20260816/201833857RIDnmOU0W.png
另外兩個小一點的取捨:熱重載與「正在進行的請求」有時序縫隙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」想覆寫系統指令,這道提示注入防禦怎麼在第一時間把它識破、短路擋下。


上一篇
Day 14|逐請求覆寫
下一篇
Day 16|入境守門壹:誰敢亂問問題
系列文
轉生到全端工程師沒多久就要負責公司的大平台??16
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言