iT邦幫忙

2026 iThome 鐵人賽

DAY 4
0

昨天我們把 Portal 跟 LiteLLM 那類純 AI Gateway 擺在一起比,結論是:Portal 不只是轉發流量的閘道,它是一個內建身分、守門、路由、稽核的 Agent 平台 BFF。但「內建很多治理能力」這句話,要落到程式裡是有代價的——你得選一套撐得住這些能力的技術底座。今天這篇是第一章的收束,我想在開始逐項拆解之前,先把整張總圖攤開:Portal 的三大技術選型是什麼、為什麼是這三個、它們又是怎麼互相撐住彼此的。先看見整片森林,後面 26 天進到樹林裡才不會迷路。

本篇結構:

  • 為什麼要先給總圖
  • 跑在哪裡:GCP Cloud Run
  • 三個選型,怎麼互相撐住
  • 一張設定檔看懂「宣告式」
  • 這組選擇的代價,以及它不是免費的

為什麼要先給總圖

接下來的這幾天,我們會一站一站往下鑽:非阻塞怎麼運作、身分怎麼可信、守門怎麼分層、答案怎麼生成。每一站單獨看都成立,但很容易看到後面忘了前面——讀者會問「等等,為什麼稽核要 fire-and-forget(交辦即忘)?」「為什麼身分不放 ThreadLocal?」「為什麼換供應商真的不用改程式碼?」

這些問題的答案,其實都指回同一組根部選擇。如果一開始沒講清楚這組選擇,每一篇都得重新交代背景,連載會變得很碎。所以今天的任務很單純:把三個根部選型的輪廓講完,當成後面各章的索引——之後每次遇到「為什麼這樣設計」,你都能回頭對到今天的某一格。

這三個選型是:

  1. reactive WebFlux——用少數執行緒撐高並行的非阻塞執行模型;
  2. 無持久化(persistence)——Portal 自己不存業務狀態、不存對話歷史,DB 是可選配件;
  3. 宣告式抽換——換 LLM/檢索器/守門供應商,只改設定、不動業務碼。

重點不是它們各自多厲害,而是它們是「一組」的。拆開看,每一個都只是一個尋常的工程決定;合起來,才湊出 Portal 想成為的那種東西。

跑在哪裡:GCP Cloud Run

不過在看這三者怎麼咬合之前,得先交代一個貫穿全系列的前提——這套系統部署在 GCP 上、跑在 Cloud Run(一個 serverless 的容器平台)。後面很多「為什麼這樣設計」,其實都是在回應 Cloud Run 的幾個特性:

  • 按流量自動擴縮(0→N 個實例):沒流量時可縮到零,一來就拉起多個實例。所以「多實例同時在跑」是常態,不是罕見情況。
  • 實例是 ephemeral 的:實例隨時可能被回收、重啟、換一台,寫在本機檔或行程記憶體裡的東西不保證留得住。
  • 閒置連線約 300 秒就被切:撐太久的長連線扛不住平台的閒置逾時(idle timeout)。
  • workload identity:平台幫每個服務配發一個可驗證的身分(透過 metadata server),服務不必自己保管長期金鑰。

把這個基座放在心裡,後面好幾個設計就會從「為什麼要這樣」變成「不這樣不行」:

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 這個基座下最省事、也最順的做法。

三個選型,怎麼互相撐住

先用一張圖把關係擺出來,再逐條講為什麼它們綁在一起:

https://ithelp.ithome.com.tw/upload/images/20260805/20183385uiEiHWfFD3.png
第一條線:reactive 撐起「整天在等別人」這件事。 Portal 的工作性質很特別——它自己幾乎不做運算,時間幾乎都花在等 Engine、等 LLM、等 KM、等回覆。如果走傳統 thread-per-request,一個請求就佔住一條執行緒站著乾等,請求一多執行緒就被等到耗光,整個塞住。reactive 的 event loop 解掉這個難題:執行緒交辦完外部呼叫就回頭服務別人,等外部回覆到了再回來接手。少數幾條執行緒,就能同時掛著等一大堆請求。Day 5 會把這套「不空等」講透,今天先記住一個重點——Portal 是個 I/O 密集的閘道,這個性質決定了它非走 reactive 不可,不是隨手選的。

第二條線:無持久化讓 reactive 不必揹包袱。 reactive 最怕的就是在 event loop 上做會卡住的事——而寫資料庫,正是典型的會卡操作。Portal 乾脆把問題從源頭拿掉:它本身無狀態,不存對話歷史,業務知識留在 EngineKM。唯一會碰 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

把這份設定整套換成全 mockPortal 不帶任何金鑰、不連任何外部服務,就能完整離線跑起來做 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 自己什麼都不記得。 想做跨請求的對話記憶、想查歷史,對不起,那是 EngineKM 的事,Portal 只能轉發。好處是它輕、它無狀態、它好擴展;代價是任何「需要記住」的需求都不能順手塞進 Portal,得想清楚該放哪一層。

宣告式抽換的代價是:抽象介面一旦定下來就很難改。 所有供應商都依賴同一組介面,介面設計得不夠穩,每加一家新供應商就得回頭動介面,連鎖影響所有既有實作。這也是為什麼第四章會花時間講「抽象介面怎麼設計得夠穩定」——可抽換的彈性,是用前期把介面想清楚的紀律換來的。

把三個代價並排,更看得清「天下沒有白拿的選型」:

選型 代價 用什麼換來的
reactive WebFlux 寫法門檻高、心智負擔重,一個阻塞呼叫就拖垮整條執行緒 整個團隊都要懂非阻塞
無持久化 Portal 自己什麼都不記得,需要記憶的需求塞不進來 想清楚每個狀態該放哪一層
宣告式抽換 抽象介面一旦定下就很難改,動介面會連鎖影響所有實作 前期把介面想清楚的紀律

還有一個全局性的誠實分界,跟今天的總圖直接相關:

Agent 工具安全層(工具結果守門、步數限制、工具政策那些)多數已經接進 Agent 軌、每次工具呼叫實際生效,僅完整計畫驗證等少數元件已寫好、尚未接進對話流程。所以這 30 天裡,凡是講到這塊,我都會明說哪些已接線在跑、哪些還沒接,不會把未接的講成已上線、也不會把已接的講成空架子。

把今天收個尾:Portal 的三大選型——reactive、無持久化、宣告式抽換——不是三個獨立決定,是「想當一個高並行、無狀態、隨時換零件的對外閘道」這個定位逼出來的一組答案,彼此咬合。

明天,我們從這張圖最底層那一格開始往下挖——非阻塞與事件迴圈,看少數幾條執行緒到底是怎麼一邊等外部回應、一邊還能撐住高並行的。


上一篇
第參天、大平台與LiteLLM的異同
系列文
轉生到全端工程師沒多久就要負責公司的大平台??4
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言