昨天 Day 12 我們站在使用者的角度看了那個招牌甜頭:換一家 LLM 供應商,設定檔改一行字,業務 Java 程式碼一個字都不用動。但「0 行不動」這四個字,昨天是當成結論在用的:我說名冊裡換個零件就好,業務碼根本不認得 openai-http 還是 gemini-http。今天要把這個結論翻過來,看它底下到底靠什麼撐住。換句話說,「可抽換」不是喊喊的口號,它得有一套具體的機制,而且這套機制小到出乎意料——核心只有三樣東西。
本篇結構:
先把問題講清楚,免得覺得這只是套個介面那麼簡單。
Portal 要同時容納的不是一兩家供應商。光是這幾類插槽,現場就掛著這些零件:
| 插槽 | 已掛上的實作 |
|---|---|
| LLM | mock(離線用)、openai-http、gemini-http、spring-ai |
| 守門 | mock、regex、openai-moderation |
| 地圖 | mock、google |
這些零件功能不同、來源不同、各自的開發節奏也不同。真正的難處在於:你希望「加一家新供應商」這件事,是純粹的加法——只新增一個檔案,而不去動任何已經跑得好好的東西。
反例很容易想像。最樸素的做法是維護一張中央清單:每加一家 LLM,就回到某個 switch 或某張 map 裡登記一筆「gemini-http 對應到哪個實作」。這做法第一天就能動,卻會慢慢爛掉:
所謂「可抽換」要是建立在「每次都回去改同一個地方」上,那它其實是假的可抽換。所以真正要解的,是怎麼讓系統「自己」認得新零件,而不需要任何人回去登記。
撐起這件事的,是一套刻意做得很小的插件框架。它只有三個概念,我一個一個拆。
| # | 概念 | 它是什麼 | 它解決什麼 |
|---|---|---|---|
| 1 | 共同標記 | 一個幾乎是空的介面,沒有任何業務方法 | 宣告「我是一個能被平台管理的插件」,讓平台認得你是同一族、可被收集列管 |
| 2 | 分類表 | 一張寫死、封閉的歸類表 | 標明你是哪一種插件,平台才知道把你插進哪個插槽 |
| 3 | 名冊 | 啟動時掃描收集、按「類別+名字」編好的可查清單 | 讓前兩樣東西活起來,執行期報出名字就能換一個 |
這三樣東西環環相扣。共同標記是一張通用識別證,零件貼上它,平台就認得、收進來列管。分類表再把你歸進某個插槽——LLM、檢索器、地圖、守門、意圖路由,外加幾個治理用的進階插槽;這張表寫死又封閉,因為插槽代表「平台架構裡的一個位置」,是平台層級的決定,不是某個供應商能自己長出來的。名冊則讓前兩樣活起來:系統啟動時,DI(依賴注入)容器把所有貼了標記的零件一次收集起來,這正是 Day 12 講的「依賴注入」背後「依賴反轉」原則在底層真正發生的事——業務碼不去 new 任何具體供應商,改由容器把現場零件一次掃進來,框架再按「類別+名字」編成一本可查的名冊,執行期報出名字就能換一個。
下面這張圖,就是這三樣東西合起來的樣子:
┌── 所有實作都貼同一個標記,啟動時全被 DI 容器收集 ──┐
LLM: mock openai-http gemini-http spring-ai
守門: mock regex openai-moderation
地圖: mock google
└──── 名冊按「類別 + 名字」編好,執行期查表挑一個 ────┘
「0 行不動」就是這樣來的。 你新寫一家 LLM,做的事只有兩件:
gemini-http。剩下的全是自動的——它會被啟動掃描收進名冊,不必去任何中央清單登記,不必改業務流程,也不必改入口 controller。延續 Day 12 的插座比喻:標記是「這是個能插上牆的電器」的識別證,分類表是插座的規格,名冊則是配電箱。換電器不必動牆,加電器也不必重畫電路圖。
名冊建好之後,還有一個常被誤解的點要講清楚:不是所有能力都用同一種方式取用。 差別在於一個很實際的問題——這類零件需不需要「每一筆請求都能臨時換」。
| 執行期查表 | 開機時定下來 | |
|---|---|---|
| 走這條的能力 | LLM、地圖、入境守門 | 登入、稽核、送 LLM 前的預檢、MCP(Model Context Protocol)認證、檢索器 |
| 取用方式 | 所有零件啟動時全裝上、全留名冊,每筆請求依設定或 header 臨時挑 | 照設定選定一個裝上,啟動後就不換 |
| 逐筆切換 | 支援,還能執行中熱重載、甚至停用某一家 | 不支援,整個生命週期固定是它 |
| 代價 | 名冊得一直握著所有實作,多花一點記憶體,換來最大彈性 | 省記憶體,但失去逐筆彈性 |
判準其實很單純:要支援逐筆切換的,走查表;啟動就該固定的,開機選定。「逐請求換 LLM」是個合理需求——你可能想對某一題臨時試另一家模型;但「逐請求換登入方式」就很怪,身分驗證的規則在一次部署裡本來就該一致,讓它能逐筆換反而是個漏洞。明天 Day 14 會專門講「執行期查表」這條路怎麼用一個 header 做到逐請求覆寫,那整篇都建立在今天這本名冊上——能逐筆換,前提就是名冊一直握著所有零件。
portal:
llm:
provider: gemini-http # 執行期查表:改這行就換一家,業務碼不動
guardrail:
provider: regex # 同樣查表,可逐筆覆寫
audit:
provider: slf4j # 開機定下:啟動選定,執行中不換
最後這段,是我覺得這套框架最值得學的地方——它不是一次設計好的,而是一路長出來的,而且每一步都守著同一條規矩:既有的實作一行都不准改。
回頭看那個標記介面,它最早是完全空的,真的就只標記身分,什麼方法都沒有。後來平台陸續需要更多治理能力,這個介面就一件一件長出新東西。每一步的擴充,舊實作都 0 行不動:
| 版本 | 新增能力 | 介面預設值(default)行為 |
|---|---|---|
| v0 | 空介面,只標記「我是插件」 | — |
| v1 | 自報身分(版本、顯示名稱) | 回一個通用名稱與版本 |
| v2 | 自報健康狀態(分健康、降級、不可用、未知四種) | 回「未知」 |
| v3 | 生命週期掛鉤(啟動、關閉、重載) | 什麼都不做(no-op) |
| v4 | 平台開始呼叫掛鉤、開出查現況與熱重載的管理端點 | 想要新能力的零件各自覆寫 default,其餘原封不動 |
問題來了:每加一個方法到介面上,照理說所有實作那個介面的零件都得跟著補上這個方法,不然編譯就過不了。Portal 當時已經有十幾個落地的零件,難道每次擴充都要全部回去改一輪?那就完全違背了「0 行不動」的承諾。
解法是介面預設值,也就是 default method。每次擴充能力,新方法都帶一個無害的預設實作寫在介面上:自報身分的,預設回一個通用值;自報健康的,預設回「未知」;生命週期掛鉤的,預設是什麼都不做。於是已上線的那十幾個實作一個都不用動,就自動繼承了新能力的空殼——要不要把預設換成真正的實作,各個零件自己決定,沒空管的就用預設,照樣跑得好好的。
這就是為什麼治理能力一路加、卻從沒驚動過任何一個已落地的零件。它示範的其實是一個比插件框架更通用的道理——對一個會被很多人實作的介面做向後相容的擴充,這時候 default method 最派得上用場。 這也正好呼應 Day 4 鋪過的「宣告式抽換」基調:抽換的彈性,重點不在執行期到處塞 if,而在於把擴充點設計在介面這個穩定的邊界上。
代價當然有,而且全壓在「抽象介面要設計得夠穩定」這件事上。
整套框架能成立,是因為那個共同標記、還有各類插槽的介面,被當成一條穩定的邊界在守。它一旦動,連鎖反應就來了。default method 的能與不能,剛好是個鮮明的對比:
| 加一個帶預設的新方法 | 改既有方法的簽名(signature) | |
|---|---|---|
| default method 救得了嗎 | 救得了,純擴充,舊實作無感 | 救不了 |
| 對舊實作的衝擊 | 0 行不動 | 改了參數或回傳型別,所有實作都得跟著改 |
| 「0 行不動」 | 維持 | 當場破功 |
所以這條邊界的設計門檻很高——加東西可以很隨意,動既有的東西卻得非常克制,這個不對稱,是這套框架能不能長期立得住的關鍵。
PoC(概念驗證)階段也誠實留了幾筆帳:
記帳一:生命週期掛鉤目前是同步的。 萬一某個插件在「啟動」掛鉤裡做網路探測,會拖慢整個服務啟動;要改成非同步又會動到介面簽名,反過來打破「0 行不動」——這個取捨還沒解。
記帳二:幾類插槽暫時允許多家並存。 檢索器、地圖、守門其實架構上應該只能有一個實作生效,現在卻暫時允許多家並存,那是為了 demo 借框架做展示,框架自己也標了記號,正式版會收斂成單一實作。
先把這些帳記著,後面幾天會陸續還。
明天 Day 14,我們把鏡頭拉到「執行期查表」這條路上——看一個請求怎麼只憑一個 header,就在名冊裡臨時挑走另一家供應商,順帶聊聊它在 A/B 測試和線上除錯時的實際用法。