昨天我們把 Portal 跟 LiteLLM 那類純 AI Gateway 擺在一起比,結論是:Portal 不只是轉發流量的閘道,它是一個內建身分、守門、路由、稽核的 Agent 平台 BFF。但「內建很多治理能力」這句話,要落到程式裡是有代價的——你得選一套撐得住這些能力的技術底座。今天這篇是第一章的收束,我想在開始逐項拆解之前,先把整張總圖攤開:Portal 的三大技術選型是什麼、為什麼是這三個、它們又是怎麼互相撐住彼此的。先看見整片森林,後面 26 天進到樹林裡才不會迷路。
本篇結構:
接下來的這幾天,我們會一站一站往下鑽:非阻塞怎麼運作、身分怎麼可信、守門怎麼分層、答案怎麼生成。每一站單獨看都成立,但很容易看到後面忘了前面——讀者會問「等等,為什麼稽核要 fire-and-forget(交辦即忘)?」「為什麼身分不放 ThreadLocal?」「為什麼換供應商真的不用改程式碼?」
這些問題的答案,其實都指回同一組根部選擇。如果一開始沒講清楚這組選擇,每一篇都得重新交代背景,連載會變得很碎。所以今天的任務很單純:把三個根部選型的輪廓講完,當成後面各章的索引——之後每次遇到「為什麼這樣設計」,你都能回頭對到今天的某一格。
這三個選型是:
reactive WebFlux——用少數執行緒撐高並行的非阻塞執行模型;Portal 自己不存業務狀態、不存對話歷史,DB 是可選配件;重點不是它們各自多厲害,而是它們是「一組」的。拆開看,每一個都只是一個尋常的工程決定;合起來,才湊出 Portal 想成為的那種東西。
不過在看這三者怎麼咬合之前,得先交代一個貫穿全系列的前提——這套系統部署在 GCP 上、跑在 Cloud Run(一個 serverless 的容器平台)。後面很多「為什麼這樣設計」,其實都是在回應 Cloud Run 的幾個特性:
把這個基座放在心裡,後面好幾個設計就會從「為什麼要這樣」變成「不這樣不行」:
| Cloud Run 特性 | 逼出的設計 | 哪天細講 |
|---|---|---|
| 實例 ephemeral+自動擴縮 | Portal 必須無狀態(正是下一節的第二個選型);稽核不落本機檔,改走 Cloud Logging/Cloud SQL |
Day 24 |
| 自動擴縮成多實例 | 用量帳本、供應商停用這類「活在記憶體的狀態」,多實例下各自記數會對不上,得外移到共享儲存(如 Memorystore,GCP 的 Redis 服務) | Day 15、Day 28 |
| 閒置連線約 300 秒逾時 | MCP(Model Context Protocol,讓模型呼叫外部工具的協定)軌從 SSE(Server-Sent Events)改成無狀態的 Streamable HTTP;語音長連線的逾時得特別調大 | Day 27、Day 29 |
| workload identity | 服務間認證向 metadata server 取短效 OIDC(OpenID Connect)token,程式裡零長期金鑰 | Day 11 |
所以下一節那個「無持久化」選型,與其說是偏好,不如說是 Cloud Run 這個基座下最省事、也最順的做法。
先用一張圖把關係擺出來,再逐條講為什麼它們綁在一起:

第一條線:reactive 撐起「整天在等別人」這件事。 Portal 的工作性質很特別——它自己幾乎不做運算,時間幾乎都花在等 Engine、等 LLM、等 KM、等回覆。如果走傳統 thread-per-request,一個請求就佔住一條執行緒站著乾等,請求一多執行緒就被等到耗光,整個塞住。reactive 的 event loop 解掉這個難題:執行緒交辦完外部呼叫就回頭服務別人,等外部回覆到了再回來接手。少數幾條執行緒,就能同時掛著等一大堆請求。Day 5 會把這套「不空等」講透,今天先記住一個重點——Portal 是個 I/O 密集的閘道,這個性質決定了它非走 reactive 不可,不是隨手選的。
第二條線:無持久化讓 reactive 不必揹包袱。 reactive 最怕的就是在 event loop 上做會卡住的事——而寫資料庫,正是典型的會卡操作。Portal 乾脆把問題從源頭拿掉:它本身無狀態,不存對話歷史,業務知識留在 Engine 和 KM。唯一會碰 DB 的地方是稽核,而稽核刻意 offload 出去、還 fire-and-forget——交辦完就不等結果,寫失敗也只記一筆警告,絕不反噬回給行員的答案(這條規則 Day 6 會專講)。無狀態同時帶來水平擴展的好處:沒有 session 黏在某台機器上,任何一台都能服務任何請求,前面擺幾台就擴幾台。reactive 想要的「主線別卡」,剛好被「不存狀態」這個選擇成全了。
第三條線:宣告式抽換,是前兩條的自然延伸。 既然 Portal 的本質是「把請求接出去給別人做」,那「別人」是誰、用哪一家,就應該是能換的。Day 3 對照 LiteLLM 時講過,多供應商治理是這類平台的核心;Portal 的做法是讓 application 層的業務碼只認一個抽象的「能對話的 LLM」概念,不認得任何具體供應商。每家供應商各做一個符合介面的零件,開機時收進一本名冊,業務碼透過固定介面去拿,至於名冊實際給的是哪一家,它根本不在乎。因為 Portal 不自己算、只負責接出去,所以「接給誰」天生就該是宣告式可換的——這不是額外加的花俏功能,是它定位的直接結果。
把三條線整理一下:Portal 想當一個高並行、無狀態、隨時換零件的對外閘道。reactive 解決高並行,無持久化成全 reactive 又順手給了水平擴展,宣告式抽換則是「我只負責接出去」這個定位的必然長相。少了任何一個,另外兩個都會卡。整理成一張表:
| 選型 | 定位 | 解決什麼 | 與另外兩個的關係 |
|---|---|---|---|
reactive WebFlux |
少數執行緒撐高並行的非阻塞模型 | 高並行、I/O 密集時不空等 | 需要主線別卡 → 由無持久化成全 |
| 無持久化 | Portal 不存業務狀態與對話歷史 |
成全 reactive 不卡,順手給水平擴展 |
不存狀態 → 任何一台都能服務任何請求 |
| 宣告式抽換 | 換供應商只改設定、不動業務碼 | 「只負責接出去」的必然長相 | 因為只接出去 → 接給誰天生可換 |
前兩個選型偏執行模型,比較抽象;第三個最容易用一個具體東西讓你一看就懂,就是設定檔。Portal 把「現在這套要接哪些供應商」全部攤在 YAML 上,業務碼一個字都不在裡面:
portal:
llm:
provider: openai-http # 可選 mock / openai-http / gemini-http / spring-ai
guardrail:
provider: regex # 可選 mock / regex / openai-moderation
maps:
provider: google # 可選 mock / google
audit:
provider: slf4j # 可選 slf4j / postgres
把這份設定整套換成全 mock,Portal 不帶任何金鑰、不連任何外部服務,就能完整離線跑起來做 demo——這對開發和展示太重要了。要從 mock 切到真的 OpenAI,改的是 portal.llm.provider 這一行,Java 程式碼一個字都不動。
這裡有個務必誠實的分界:
不是所有能力都用同一種方式切換。 常見的誤解是以為每一類插槽都能逐筆熱抽換,其實分兩種。
| 切換方式 | 涵蓋的插槽 | 行為 | 後續章節 |
|---|---|---|---|
| 執行期查表 | LLM、地圖、入境守門 | 所有零件先全部裝上,每次請求再依設定或 header 臨時挑一個,甚至能逐筆切換、執行中熱啟停 | Day 14、Day 15 |
| 開機時定下來 | 登入、稽核、送 LLM 前的預檢、MCP 認證、檢索器 | 照設定選定一個裝上,跑起來就不換了 | 第四章 |
判準很單純:需要逐筆切換的走查表,啟動就固定的走開機選定。 這個區別後面第四章會反覆用到,今天先在腦裡標個記號。
把三大選型講成「彼此完美支撐」,聽起來太順了,得補上代價這一面,否則就是業配。
reactive 的代價最直接:寫法門檻高、心智負擔重。 event loop 上一個不小心的阻塞呼叫,就能凍住整條執行緒、拖垮後面所有排隊的請求——這是 reactive 的生死線,後面好幾天都會回頭引用。而且因為請求會在執行緒之間流轉,傳統放在 ThreadLocal 的身分和 correlation id 會斷掉,得改放框架的 request context 隨請求帶著走(Day 7 專講這個坑)。換句話說,高並行不是白拿的,是用「整個團隊都要懂非阻塞」換來的。
無持久化的代價是:Portal 自己什麼都不記得。 想做跨請求的對話記憶、想查歷史,對不起,那是 Engine 和 KM 的事,Portal 只能轉發。好處是它輕、它無狀態、它好擴展;代價是任何「需要記住」的需求都不能順手塞進 Portal,得想清楚該放哪一層。
宣告式抽換的代價是:抽象介面一旦定下來就很難改。 所有供應商都依賴同一組介面,介面設計得不夠穩,每加一家新供應商就得回頭動介面,連鎖影響所有既有實作。這也是為什麼第四章會花時間講「抽象介面怎麼設計得夠穩定」——可抽換的彈性,是用前期把介面想清楚的紀律換來的。
把三個代價並排,更看得清「天下沒有白拿的選型」:
| 選型 | 代價 | 用什麼換來的 |
|---|---|---|
reactive WebFlux |
寫法門檻高、心智負擔重,一個阻塞呼叫就拖垮整條執行緒 | 整個團隊都要懂非阻塞 |
| 無持久化 | Portal 自己什麼都不記得,需要記憶的需求塞不進來 |
想清楚每個狀態該放哪一層 |
| 宣告式抽換 | 抽象介面一旦定下就很難改,動介面會連鎖影響所有實作 | 前期把介面想清楚的紀律 |
還有一個全局性的誠實分界,跟今天的總圖直接相關:
Agent 工具安全層(工具結果守門、步數限制、工具政策那些)多數已經接進 Agent 軌、每次工具呼叫實際生效,僅完整計畫驗證等少數元件已寫好、尚未接進對話流程。所以這 30 天裡,凡是講到這塊,我都會明說哪些已接線在跑、哪些還沒接,不會把未接的講成已上線、也不會把已接的講成空架子。
把今天收個尾:Portal 的三大選型——reactive、無持久化、宣告式抽換——不是三個獨立決定,是「想當一個高並行、無狀態、隨時換零件的對外閘道」這個定位逼出來的一組答案,彼此咬合。
明天,我們從這張圖最底層那一格開始往下挖——非阻塞與事件迴圈,看少數幾條執行緒到底是怎麼一邊等外部回應、一邊還能撐住高並行的。