昨天 Day 22 把答案的素材湊齊了:兩路查詢用 zip 並行壓低延遲、不該查的條件式擋下來,最後兩路匯齊、上下文組好,模型開始生成回答。問題是,模型「開始生成」到「生成完」之間,可能要好幾秒。一段附了解鎖步驟又帶營業時間的長答案,在後端慢慢吐字的這幾秒,小林那邊看到的是一片空白。今天就講這最後一段路怎麼走得不難受——讓答案邊生成邊回、像打字機一樣一個字一個字浮現,以及為什麼 reactive 這套架構天生就適合做這件事。
本篇結構:
complete 對 stream:兩種回法reactive 為什麼天生適合先把問題講清楚:它不是「系統很慢」,而是「系統讓人覺得很慢」。
LLM 生成一段話,是一個 token 接一個 token 逐步產出的過程。一段三五百字的回答,從第一個字到最後一個字,後端其實是分好幾秒陸續產出的。如果我們的做法是「等模型把整段都生成完,再一次性回給前端」,那麼在這幾秒裡,使用者面對的就是一個完全沒有反應的畫面——送出鍵按下去了,轉圈圈轉著,什麼都沒有。
這幾秒的空白,體感上比實際的耗時更糟。人對「沒有任何回饋的等待」特別不耐,會懷疑是不是當掉了、要不要重按一次。但同樣這幾秒,如果第一個字在半秒內就浮出來、後面的字陸續跟上,使用者的感受完全不同——他知道系統在動、答案正在來,於是願意等。實際總耗時可能一模一樣,差別只在於「有沒有讓人看見進度」。
所以這一站要解的,並不是把生成變快(那是模型的事,Portal 管不著),重點是把「已經生成出來的部分」儘早送到使用者眼前,別等整段湊齊。
其實你正在讀的這個連載就是同一回事:動漫不會等一整季做完才一次上架,是一集一集邊做邊播,你追著追著就把整季看完了;我也沒等 30 天寫完才一次丟給你,而是每天先給你看得到的那一篇。串流回應對使用者就是這種體感——不必等全部完成,先把已經好的部分送到眼前。
complete 對 stream:兩種回法Portal 那個「能對話的 LLM 介面」,為這件事準備了兩種回法。同一個介面對外其實開了兩個方法:
| 方法 | 回傳形態 | 行為 | 後處理時機 | 代價/好處 |
|---|---|---|---|---|
complete(request) |
一個完整答案 | 等模型整段生成完,才一次回傳 | 拿到完整答案後從容地做(遮罩、整理引用) | 簡單好處理,但有開頭那段空白 |
stream(request) |
片段流 chunk → chunk → … |
邊生成邊吐,一塊一塊到達 | 必須邊串邊處理(見下節衝突) | 打字機般逐字浮現,體感好 |
complete 是「一次回完」:呼叫出去,等模型把整段答案生成完畢,拿到一個完整的字串,再回給前端。簡單、好處理,後處理(遮罩、整理引用)都能在「拿到完整答案」之後從容地做。代價就是前面說的那段空白。
stream 則是「串流」:它回傳的是一連串逐塊到達的片段(chunk),而不是一個最終結果。模型每吐出一小段,這一小段就立刻沿著串流往前端送,前端收到就顯示。於是畫面上的答案是一塊一塊長出來的,正是打字機那種逐字浮現的體感。
從前端的角度,這兩種模式對應兩種不同的傳輸方式。complete 就是一次普通的 HTTP 回應;stream 走的是 SSE(Server-Sent Events)——一條從伺服器單向推送到瀏覽器的長連線,伺服器有一塊新內容就推一塊,瀏覽器這端持續接收、持續把畫面補上,直到伺服器送出結束訊號。一個請求換一條 SSE 連線,把陸續產出的 chunk 順著推下去:
瀏覽器 ──(POST /chat,要求串流)──▶ Portal
│ 向模型發起 stream(request)
◀── SSE: data: 請 ◀────┤ 模型吐第 1 塊
◀── SSE: data: 依下列 ◀────┤ 模型吐第 2 塊
◀── SSE: data: 步驟解鎖… ◀────┤ …
◀── SSE: data: [DONE] ◀────┘ 結束訊號,連線收尾
這裡補一個瀏覽器端的實作細節,免得把「SSE」直接等同於 EventSource:瀏覽器原生的 EventSource 只支援 GET,而上面走的是 POST /chat 帶串流,所以前端其實是以 fetch 讀 text/event-stream 回應、自己處理分塊、中止與重連,並不是靠 EventSource。若真要用 EventSource,端點就得改成 GET,或改走「先 POST 建立、再 GET 訂閱」的兩階段協定。
對應 Day 14 講過的逐請求覆寫,要不要走串流也是請求層級能決定的。這一樣是設定或 header 的事,底層走的還是同一套依賴反轉撐起的供應商抽換,串流只是同一個介面上多開的一個方法。
reactive 為什麼天生適合這裡值得停下來想一個問題:為什麼 Portal 做串流,感覺不太費力?
答案要回到 Day 5、Day 6 那套非阻塞模型。我們從第二章一路講到現在的 reactive,它的底層心智模型,從來就不是「呼叫一個函式、等它回傳一個值」;它想的是「資料是一條流(stream),元素一個一個到達,你掛上處理邏輯,誰到了就處理誰」。整套框架的型別、運算子、思考方式,本來就是為「持續吐出元素的串流」設計的。
而 LLM 的串流回應,就是「一段持續吐出 token 的串流」。這兩者的形狀完全吻合——模型那端一塊一塊地產出,reactive 這端一塊一塊地接、一塊一塊地往下游推,中間不需要把它「收攏成一個完整結果」再處理。stream(request) 回傳的就是框架的原生串流型別,前端要的 SSE 也是一條把元素逐個推出去的流;從模型到瀏覽器,整條路上資料都以「流」的形態存在,沒有一個地方需要先卡住等整段湊齊。
對照之下,complete 反而是「逆著 reactive 的天性」做的——它得把一條本來在流動的串流,硬生生收集(collect)成一個完整字串,等於主動製造了一次等待。換句話說,在這套架構裡,串流不是額外加的特技,反倒比「一次回完」更貼合底層的資料流模型。這就是「天生適合」的意思:與其說 reactive 對串流多友善,不如說串流本來就是 reactive 看世界的方式。
也正因為 Day 6 那條鐵律——事件迴圈上絕不能阻塞——串流才不會出事。模型那端慢慢吐字的這幾秒,事件迴圈並沒有被某條請求佔住空轉,它只是掛著這條串流,誰有新 chunk 就推一下,空檔去推進別人的請求。換成傳統「一條請求佔一條執行緒」(thread-per-request)的世界,一條連線開著等模型逐字吐幾秒,那條執行緒就被佔著幾秒,並行量一上來連線就爆了。是非阻塞讓「同時掛著大量慢吞吞的串流連線」這件事在資源上划算。
串流這塊,有兩個現實的邊界得講清楚,免得讀起來像已經全部完工。
| # | 邊界 | 核心問題 | 目前的處理 |
|---|---|---|---|
| 1 | 覆蓋範圍 | 哪些供應商支援串流 | 以 spring-ai 為底的實作已接通;openai-http、gemini-http 尚未補上,且明確回報「未支援串流」而非假裝退化 |
| 2 | 時序衝突 | 串流要邊吐、出境守門要等整段 | 遮罩可增量(緩衝視窗);但整則攔截在串流下結構上無解(只能退回 complete 或事後補救),語意 PII 也守不住;尚未串成可正式上線的完整流程 |
第一個是覆蓋範圍。 目前 Portal 的串流能力,是在以 spring-ai 為底的那條實作上接通的;而直連 openai-http、gemini-http 的那兩個 HTTP 實作,串流還沒補上。重點在它的處理方式——這兩條路不會假裝自己會串流、偷偷退化成「等整段生成完再一次吐出來」蒙混過去;它們會明確回報「未支援串流」。這是刻意選的做法:寧可清楚地說「這家供應商現在不支援」,也不要給出一個看起來像串流、其實是假的體驗。換供應商不影響主流程(這是 Day 12 那套抽換的好處),但「這家供應商支不支援串流」是各實作各自的能力邊界,不會被抽象層自動補齊。
第二個更棘手,是時序衝突,而且它直接牽動我們前面整章講的守門。 回想 Day 19 的出境守門:模型的輸出要先做 PII(即個資)遮罩、不當內容要整則攔截;還有引用來源(citation)要整理好附在答案後面。這些後處理有一個共同前提——它們得看到「完整的輸出」才做得準。一個信用卡號可能跨兩個 chunk 才吐完,你怎麼在只看到前半截的當下就遮對?引用來源得等模型整段講完、知道它到底引了哪幾段知識,才整理得出來。
問題就在這裡:串流的本意是「邊生成邊吐、別等整段」,但安全的後處理偏偏「要等整段才做得準」。這兩個需求是正面打架的。
complete 模式:模型整段 ──▶ 出境守門(遮罩 / 攔截 / 整理引用)──▶ 一次回給前端 ✓ 時序乾淨
stream 模式: 模型 chunk ──▶ ??? 還沒遮就推出去?等整段才遮就失去串流意義?──▶ 前端 ✗ 衝突未解
complete 模式裡這個時序是乾淨的:等整段、做完所有後處理、再回。但串流模式下,這件事得拆成兩個難度天差地別的子問題——混在一起講「邊串邊處理就好」,會把真正難的那半個藏起來:
chunk 吐完,只要視窗夠大就接得回來補遮。這半個難寫,但有解。complete;事後補救——已經顯示完才說「剛剛那段不該給你看」,已經沒意義。Day 19 分的「可遮蔽 vs 必攔截」,到串流這裡裂成了「一個能增量、一個根本做不到」。把兩個子問題放上同一條 chunk 時間線,難度的差距就顯形了:

遮罩那半個也還藏著一個限制,接回 Day 17:滑動視窗對「有形狀」的 PII(信用卡、員編)才管用,對沒形狀的(中文姓名、地址)基本無效——你連在完整字串上都抓不到「王小明」是姓名,在逐塊到達的串流裡更不可能靠緩衝視窗補回來。所以串流下的出境守門,不只是「比較難寫」的問題——其中一整類風險(整則攔截、語意 PII)在這個模式下結構性地守不住。
至於引用來源(citation),得和上面分開講,這是兩件事:擷取與整理引用這件事本身是接好、會動的——在 complete 模式下,等模型整段講完,就能把它引了哪幾段知識整理出來附在後面。難的只是「在串流模式下、邊吐邊把引用對齊到正確的句子」,那跟整則攔截一樣得等資訊夠完整才準。所以別把「citation 的串流版還沒解」誤讀成「citation 沒做」:做了,只是它的串流版本跟出境守門的串流版本,卡在同一道時序牆上。
所以今天這一站的狀態要說準確:串流機制通了、
reactive的契合度也驗證了,但它跟出境守門的合流還欠一塊——而且欠的不只是「工」:其中「整則攔截」與「語意 PII」這兩類,在串流模式下是結構性難題,不是補一段緩衝邏輯就能收乾淨的。
答案串完、最後一個 chunk 推到小林眼前,這次互動在使用者這端就算結束了。但對一家企業內部系統來說,故事還差最後一筆:這一秒鐘到底誰來過、問了什麼、系統怎麼回的,得留下可追溯的紀錄。明天 Day 24,我們進第七章,講稽核紀錄(audit log)——一次互動寫一列、userId 只認 SSO 確立的身分而非本次輸入,把「誰來過、做了什麼」封存成法遵查得到的足跡。