昨天 Day 26 收掉了第七章——我們把 metrics 和日誌都串在那組 correlation id 上,讓一個分散式、非同步的系統「看得清自己在幹嘛」。觀測解決的是「事情發生之後怎麼看懂」,今天進第八章,要回頭面對一個更早就埋下、卻一直被擱著的接線問題:當你要接的供應商是一個會自己升級、自己重啟的獨立服務,根本不是一包函式庫時,怎麼接? 第四章那套「換廠商 0 行 Java 不動」的插件框架,在這種供應商面前是接不上的。今天講的,就是平台裡跟它平行存在的第二條整合路——Agent 軌,以及它走的協定 MCP。
本篇結構:
先把第四章那套機制的前提攤開來看。Day 12 到 Day 15 講的插件框架——共同的標記介面、啟動時收集成名冊、執行期查表挑一個——它能成立,靠的是一個你可能沒特別留意的假設:供應商給你的是一包 Java 函式庫(JAR),能跟 Portal 跑在同一個行程裡、被 DI 容器注入。你新接一家 LLM(寫一個供應商實作),掛上元件標記、回報自己叫 gemini-http,它就自動進名冊;要用的時候,application 層透過一個固定的入口去拿,至於拿到的是哪一家,它不在乎。這整套之所以漂亮,是因為「換零件」只是在「同一個行程裡」換一個被注入的物件而已。
可是有些「供應商」根本不是這種形態。後端的 AI 引擎、知識庫服務,都是獨立部署、獨立發版的 HTTP 服務——它們有自己的 release cycle,會在半夜自己滾版本、自己重啟。你沒辦法把一個會這樣自顧自升級的東西,塞進你的 Java 行程裡當成一個 bean 注入。硬塞的話,對方一發版你就得跟著重新編譯、重新部署,兩個服務的生命週期被焊死在一起——這恰恰是微服務拆分要避免的事。
所以這類整合得走第二條路:Agent 軌。它跟 SDK 軌平行存在,差別其實很單純,一張表就講完:
| SDK 軌 | Agent 軌 | |
|---|---|---|
| 供應商給的形態 | Java 函式庫(JAR) | 獨立 HTTP 服務 |
| 怎麼接 | 同行程,DI 容器注入 | 遠端呼叫,走網路協定 |
| 誰的程式碼排流程 | Portal 自己(先查 A 再查 B) |
交給一個能自主決策的 LLM |
| 耦合程度 | 緊(同生共死、同一次部署) | 鬆(各自發版、各自部署) |
最後一列那個「誰排流程」的差異,比形態差異更要緊,下一節展開。
Agent 軌走的協定是 MCP(Model Context Protocol),一套讓 LLM 應用去呼叫「外部工具」的標準。Portal 在這條軌上做的事,是把後端引擎的能力——查知識庫、查某位行員的個人記憶——包裝成一個個工具,交給一個 LLM 自己決定何時、要不要呼叫。
這跟 SDK 軌有個關鍵的不同,值得停下來說清楚。Day 22 講過 SDK 軌那條並行檢索:是 Portal 的程式碼把流程排死的——先偵測意圖、zip 兩路同時查知識庫和地圖、再組上下文呼叫模型,每一步都是工程師寫好的,模型只負責生成最後那段文字。Agent 軌反過來,把流程的決定權交給 LLM:模型看完問題,自己判斷「這跟 AD 帳號有關,我該去查知識庫」,然後自己發出工具呼叫;Portal 退到後面,只負責把工具遞過去、把模型發出的呼叫轉發給真正的後端服務、再把結果餵回去。
兩條軌「誰排流程」的對照:
| SDK 軌(Day 22 並行檢索) | Agent 軌(MCP) | |
|---|---|---|
| 流程由誰決定 | Portal 的程式碼排死 |
LLM 自主決策 |
| 模型的角色 | 只負責生成最後那段文字 | 看問題、決定呼叫哪個工具、看結果再決定 |
Portal 的角色 |
偵測意圖、zip 兩路、組上下文 |
遞工具、轉發呼叫、餵回結果 |
行員問題 ──▶ Portal 內的 LLM agent
│ 把「查知識庫 / 查記憶」當成工具遞給 LLM
▼
LLM 自主決策:「我要呼叫 km_search」
│ Portal 攔下這個呼叫、補上認證、覆寫操作身分
▼
Streamable HTTP ──▶ 後端 Engine(真正執行查詢)
│ 結果回給 LLM
▼
LLM 看到結果,自己決定還要不要再查,最後組出答案
實際用起來,前端不用換 endpoint,帶一個請求標頭就把這題從 SDK 軌切到 MCP 軌——這正是 Day 14 那套「逐請求覆寫」的延伸用法,只是這次換掉的是整條整合軌,不只是某一家供應商。連線名我們就取作 engine,標頭 X-Agent 的值跟著它:
curl -X POST http://localhost:8080/chat \
-H "X-Agent: engine" \
-d '{"message":"AD 帳號被鎖怎麼辦?","history":[]}'
對應的設定大致長這樣。上半段是 Portal 自己對這條軌的控制(要不要開、用哪個模型、逾時與重試),下半段是 MCP client 連到後端那個工具服務的位置——connections 底下那個 key 就是上面標頭指到的連線名 engine:
portal:
agent:
mcp:
enabled: true
name: engine
model: gpt-4o-mini
timeout-seconds: 30
retry-attempts: 2 # 暫時性錯誤自動重試,再不行回友善訊息
spring:
ai:
mcp:
client:
streamable-http:
connections:
engine:
url: ${ENGINE_MCP_URL:http://localhost:8082}
endpoint: /mcp
讓 LLM 自主呼叫工具,是要付安全代價的——它擴大了攻擊面。這就接回 Day 20 那道閘了:攻擊者可能用話術誘導模型「幫我查別人的個人記憶」,這是經典的 confused deputy。當時我們把那道核心防護單拎出來講過:
不管模型想把什麼員編傳進工具,工具呼叫的邊界一律把它覆寫成 SSO 確立的登入者本人,冒充他人身分的越權從源頭消失(身分以外的參數仍可能被話術污染,那是另一道題——見 Day 20 把防護承諾範圍收窄的那一段,以及「驗明你是誰(authn)」與「你能做什麼(authz)」之別)。
今天要補的是:那道覆寫,落點就在這條 Agent 軌上,是它真正在做、不可關閉的一層。這條軌上總共疊了三層守門:
| 防護層 | 防的是什麼 | 狀態 |
|---|---|---|
| 工具呼叫邊界覆寫操作身分 | confused deputy 越權(模型被誘導查別人的記憶) | 已上線、不可關閉 |
| 工具呼叫次數上限 | 模型陷進無窮呼叫的迴圈 | 已上線 |
| 對工具回傳內容再守一次門 | 從查詢結果裡夾帶的間接注入 | 已接進 Agent 軌、每次工具呼叫實際生效 |
Agent 軌上唯一還沒接線的是「完整計畫驗證」那一塊——元件本身在,但還沒掛進流程。
這一節有三個取捨、加一項現況要交代,先列個總覽,再逐項展開:
SSE 換成 Streamable HTTP
第一個要交代的取捨,是協定的選擇。業界其實有兩套標準可選:
| MCP | A2A(Agent-to-Agent) | |
|---|---|---|
| 把對方當成 | 工具 | 對等的另一個 agent |
| 心智模型 | request/response,最簡單 | agent 協作、任務轉派 |
| Java 生態支援 | Spring AI 已夠成熟 | 較不成熟 |
Portal 選 MCP,理由很務實——知識庫本來就是個資料工具、引擎也能當成一個智慧工具來用,request/response 的心智模型最簡單,而且 Java 這邊的生態(Spring AI)對 MCP 的支援已經夠成熟。A2A 並沒有被排除,我們為它留了明確的觸發條件:等真的出現「agent 把任務轉派給另一個 agent 協作」的需求,或業界收斂到 A2A,再導入。先不為還沒發生的需求過度設計——這跟 Day 15 那條「執行期停用只存在記憶體、不過度做成正式營運開關」是同一種克制。
第二個坑值得記下來,因為它是被部署環境硬逼出來的:這條軌的傳輸協定換過一次。最早用 SSE(Server-Sent Events)——也就是 Day 23 串流回應那套長連線推送。但 MCP 軌跑在雲端環境(Cloud Run)上時撞了牆:那邊對閒置連線有大約 300 秒的逾時限制,一條掛著等模型慢慢決策的長連線,閒著閒著就被基礎設施切掉了。解法是改成無狀態的 Streamable HTTP——每個呼叫都是獨立的一次 POST、不依賴持久連線,自然就免疫了閒置逾時,順帶還能水平擴展到多個實例。
SSE(最早用) |
Streamable HTTP(現在用) |
|
|---|---|---|
| 連線形態 | 長連線、持久推送 | 無狀態、每次獨立 POST |
| 雲端閒置逾時 | 約 300 秒就被切掉 | 免疫 |
| 水平擴展 | 受限 | 可擴展到多個實例 |
這是個很真實的教訓:協定不是在白板上挑「哪個比較優雅」挑出來的,是被部署環境倒逼著改的。(有意思的是,Day 29 要講的語音中繼會再撞同一個 300 秒的坑,但語音是真正需要長連線的,只能去把逾時上限調長——同一個限制,兩條路因為本質不同而選了相反的應對。)
第三個是要老實承認的長期成本:雙軌維護。SDK 軌的 agent 走一套程式路徑、用自家那個「能對話的 LLM」抽象介面;MCP 軌走另一套、用 Spring AI 的 ChatClient 搭 MCP client。兩條軌沒有合併,各養各的。
| SDK 軌 | MCP 軌 | |
|---|---|---|
| 程式路徑 | 自家「能對話的 LLM」抽象介面 | Spring AI 的 ChatClient 搭 MCP client |
| 要的是什麼 | Portal 自己把流程排死、可預測、可逐筆換供應商 |
把決策權讓給模型、接遠端服務 |
這是接受下來的取捨而不是疏忽——兩軌服務的場景根本不同。硬要塞進同一條程式路徑,只會把兩邊都扭曲。它們唯一共用的,是上層那個 Agent 抽象的入口,以及前面所有的守門與身分鏈,所以對 /chat 的呼叫方來說,兩軌長得一樣,差別只在一個標頭。
最後一項要交代的現況,是兩軌的引用來源(citation)怎麼接。SDK 軌那條並行檢索,知識庫回來的內容會整理成引用來源附在答案後面;MCP 軌這邊現在也接好了——工具回傳的內容會被擷取出文件、標題與片段,並在回應時一起帶回前端。
| citation 狀態 | |
|---|---|
| SDK 軌 | 知識庫內容整理成引用來源,附在答案後面 |
| MCP 軌 | 工具回傳內容會擷取文件、標題、片段,回應時帶回前端 |
所以同一題,不論走 SDK 軌還是 MCP 軌,都看得到來源。兩軌在這件事上已經對齊,拿兩邊的輸出對照不會再看到一邊有來源、一邊空白的落差。
把今天歸結起來:SDK 軌和 Agent 軌之間沒有新舊替換的關係,它們是兩種供應商形態各自的歸宿。 函式庫型的供應商走 SDK 軌、流程由 Portal 排死、緊耦合但可預測;服務型的供應商走 Agent 軌、流程交給 LLM、鬆耦合但得多守一道工具邊界。Portal 作為平台的價值,就在於它把這兩種「把外面接進來」的差異都吸收在自己這一層,讓上游的呼叫方不必感知。
明天 Day 28,我們處理另一個平台必須擋住的東西——不是身分、不是內容,而是錢:每一次模型呼叫的頻率與成本,怎麼在花出去之前就被閘門卡下來。用量閘門登場。