AI 如何像真人般一字一句說話?用 SSE 實現 Stream 流渲染
09 Aug 2026
2024 年底 v1.15.0 為 Apigee 補上了 Server-Sent Events(SSE,伺服器推送事件)的正式支援,讓這款 API 管理平台站到「AI 時代的閘道」的位置。本文探討 SSE 到底解決了什麼,企業如果要架 LLM 串流,哪些規則不能踩,以及 Apigee 特別拿出來的 EventFlow 究竟是什麼武器。
兩種等待,一個真相
把一個複雜問題丟給 AI 聊天機器,畫面通常是以下兩種結局之一:
┌──────────────────────────────────────┐ ┌──────────────────────────────────────┐ │ ▍ 普通 HTTP 回應 │ │ ▍ SSE 串流回應 │ │ ▍ ●●● 思考中… (10–20 秒) │ │ ▍ ▌ │ │ ──────────────────────────────── │ ▍ ▌商業企劃案 │ │ 啪! 一口氣彈出整篇 │ │ ▍ ▌企劃目的在於… │ │ 密密麻麻的長文 │ │ ▍ ▌先盤點目標客群… │ └──────────────────────────────────────┘ └──────────────────────────────────────┘
左邊那條路,等個十幾秒才有反應,使用者的手指早就移到關閉分頁上;右邊這條路,伺服器邊生成邊把字一段一段吐出來,體感像有人在對面對我們說話。追求即時、流暢,而且「像真的有人」的體驗,是現代 AI 產品的標配,這份體驗背後靠的是 SSE。
把「等待」變成看得見的進度
「等十秒才看到結果」與「第一秒就開始看到內容」,是兩種完全不同的產品體驗,而這正是前端與後端可以各出一半力的地方:
| 端 | 該做什麼 |
|---|---|
| 前端 | 送出請求後立刻顯示骨架(skeleton)與輸入游標,第一個 token 抵達就馬上渲染到畫面,「長期等待」不能全都指望一個不停轉的 loading |
| 後端 | LLM 一生成第一個 token 就立刻 flush 推送,不能為了省流量把內容累積到一大段才送 |
前端接收的前三秒大概是這樣:
┌──────────────────────────────────────┐
│ ▍ 思考中……│││ │
│ ▍ 商業企劃重點如下: │ ← 第一個 token 一到就馬上渲染到畫面
│ ▍ ▌ │
└──────────────────────────────────────┘
骨架、游標、逐字渲染到畫面這三件事,讓「等待」本身也成為產品品質。
「等待」做得好不好,可以用三個指標結帳:
| UI/UX 指標 | 怎麼量 | 前端 / 後端怎麼解 |
|---|---|---|
| TTFB(首字到達) | 送出請求到第一個事件抵達 | 後端在 LLM 算完第一個 token 就立刻推送給前端 |
| 首字渲染時間 | 螢幕第一次出現文字 | 前端收到 chunk 立刻 append,不等整段收完 |
| 字速率 | 每秒渲染到畫面的 token 數 | 前端用 requestAnimationFrame 依序渲染到畫面 |
前端規律是「收到就畫」,後端規律是「算完就送」:讓第一個字在幾百毫秒內到達,而不是十秒後看到整篇,兩者的產品力完全不同。
從驛站馬車到飛鴿傳書
傳統的 HTTP 請求與回應,像古代傳遞軍情:前線的將軍要等後方的軍師,把整本厚戰略報告全部寫完、裝訂、打包,再派一列馬車一起送出。等馬車搖搖晃晃抵達,前線的戰爭可能已經打完一半了。
SSE 打破了這個規則:雙方握手完只建立一條保持開啟的連線,伺服器一旦產出新的資料片段,就立刻作單向推送給使用者端,不需等整份報告寫完。
傳統 HTTP ── request ──────────► 伺服器算完 ── 一次回傳 ──► 等整篇
SSE ───────▶ 連線開啟 ──► token₁ → token₂ → token₃ … ► tokenₙ
這種「飛鴿傳書」的溝通方式,對大型語言模型的 API 特別關鍵(LLM)。回顧 LLM 的文字是以字(token)一個接一個透過神經網路算出來的,如果等整篇幾千字的文章全部生成完再回傳,延遲好幾秒甚至十幾秒。透過 SSE,LLM 只要生成第一個 token 就能立刻送到前端,大幅縮短體感延遲,這對必須在即時環境作業的 AI 代理(像是安撫客戶情緒的客服機器人、負責協調 workflow 的 Agent)更是生死交關:低延遲不是加分題,而是上線的必要條件。
前端要接住這條串流,用原生 API 就能做到。各家 LLM 的事件 payload 形狀不同——OpenAI 是 choices[].delta.content,Gemini 走 streamGenerateContent 時是 candidates[].content.parts[].text——先統一寫一個 parseDelta(),後面所有範例都走它:
// 各家 LLM 的事件 JSON 形狀不同,集中在這裡解析
function parseDelta(data: string): string {
const o = JSON.parse(data);
// OpenAI: choices[].delta.content;Gemini: candidates[].content.parts[].text
return o.choices?.[0]?.delta?.content ?? o.candidates?.[0]?.content?.parts?.[0]?.text ?? '';
}
// 前端:用 EventSource 直接訂閱 text/event-stream
const source = new EventSource('/v1/chat/stream');
source.onopen = () => console.log('stream open');
source.onmessage = (event) => {
appendTokenToEditor(parseDelta(event.data)); // 每個 token 到了就立刻渲染到畫面
};
// 也可以改用 fetch + ReadableStream,支援 POST 與 header 控制。
// 「讀串流 → 切事件 → 解 payload」封在 streamChat(),後面每個範例都重用:
async function streamChat(res: Response, onDelta: (t: string) => void) {
const reader = res.body!.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const chunks = buffer.split('\n\n');
buffer = chunks.pop() ?? ''; // 尾巴是不完整的事件,留著下次接
for (const c of chunks) {
const line = c.replace(/^data: /, '');
if (line === '[DONE]') return; // 結束標記:答完了
onDelta(parseDelta(line));
}
}
}
// 呼叫端:要帶 body、header 或 signal,就自己 fetch
const res = await fetch('/v1/chat/stream', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ prompt }),
});
await streamChat(res, (t) => appendTokenToEditor(t));
實際串流長這樣:SSE 的事件格式
SSE 的資料流是純文字,伺服器端必須用 Content-Type: text/event-stream 回傳,內容以 UTF-8 編碼,每一則訊息以「一組欄位 + 空白行」為單位。常見欄位有四種:
| 欄位 | 作用 |
|---|---|
data: |
訊息的內容,連續多行 data: 會被串接起來 |
event: |
事件名稱;有指定名稱要用 addEventListener() 收,沒指定的統一走 onmessage |
id: |
事件 ID;重連時瀏覽器會帶 Last-Event-ID 給伺服器續傳 |
retry: |
重連等待時間(毫秒) |
另外,以 : 開頭的那行是註解,伺服器可以定時丟一條註解當作 heartbeat,避免閒置連線被逾時切掉:
: keep-alive 註解
event: userconnect
data: {"username": "bobby"}
data: 這是純文字訊息
id: 42
retry: 5000
把上面的欄位組合起來,一條真實的 LLM 串流長這樣(以 OpenAI 慣例的 choices[].delta.content 為例;Gemini 的 candidates[...].text 形狀在前面 parseDelta 已對應):
data: {"choices":[{"delta":{"content":"你"}}]} ← 一則事件
data: {"choices":[{"delta":{"content":"好"}}]} ← 下一則事件
data: [DONE] ← 結束標記
上面這串 data: {...},跟後面「事件會被切在半路」圖裡的 chunk,是同一批資料在不同階段的樣貌:網路把它切散(chunk),前端靠 buffer 拼回以空白行結尾的完整事件,[DONE] 才代表真的答完。
連線意外中斷時,EventSource 會自動重連,不用自己寫 retry 邏輯,要真的結束才用 .close()。前一段提到的 Last-Event-ID 續傳,底層就是這樣運作——伺服器在每個事件帶上 id:,重連時瀏覽器把最新 ID 傳回去,伺服器就能接著往下推。
前端要留意的兩個坑
第一,EventSource 只支援 GET。LLM 串流(例如 Gemini 的 streamGenerateContent)多半是 POST,所以才要改用 fetch + ReadableStream 那支範例;跨網域呼叫還要記得帶 { withCredentials: true }。
第二,連線數有天花板。非 HTTP/2 時,每個瀏覽器與網域組合同時最多約 6 條 SSE 連線(Chrome、Firefox 都已標記不修),多開幾個分頁很容易撞到上限;走 HTTP/2 才由伺服器協調同時串流數(預設 100)。靠 SSE 一次開很多條推播,是會真的把連線用完的。
前端第一個字出現得多快,取決於後端把第一個 token 送得多快——後端不能等整段生成完才回傳,前端也不能等整條 stream 收完才渲染。
用 SSE 實現 Stream 流渲染
前面講的是「SSE 怎麼送」,這一段講前端怎麼把它「畫」出來。Stream 流渲染,就是把回應當成一條持續流入的字元流:收到 chunk 就更新一段畫面,而不是等整份收齊再一次 render。
事件會被切在「半路上」
TCP 不會幫我們對齊 SSE 的事件邊界。一個 data: {...}\n\n 事件可能被拆成好幾個 chunk 抵達,也可能跟下一個事件黏在同一個 chunk 裡:
網路送來的 chunk 不會挑事件邊界,事件會被「切開」或是「兩三個擠在同一個 chunk 裡」:
chunk1: data: {"content":"你"}\n\n data: {"c
chunk2: ontent":"好"}\n\n ← 跟 chunk1 的尾巴拼起來才是一個完整事件
前面 streamChat() 已經把這層藏好:它累積 buffer,只對「以空行結尾」的完整事件動手。前端要接的只剩一件事——把 onDelta 接到渲染器上,讓每個完整事件直接進到畫面(createStreamRenderer 的實作在下一節):
// 上游 streamChat() 負責 buffer、半路事件與 [DONE];
// 這裡只決定「完整事件 → 推進渲染器」
await streamChat(res, (t) => renderer.push(t));
別讓每個 token 都去碰 DOM
LLM 一秒可能吐幾十甚至上百個 token,每個都 appendChild 的話,瀏覽器會一直重排、打斷輸入,畫面反而更卡。作法是用 requestAnimationFrame(rAF)把「多個 token」合併到同一幀,一次只補一小段文字,維持 60fps 的滑順:
function createStreamRenderer(el: HTMLElement) {
let text = ''; // 完整答案
let shown = ''; // 已畫上螢幕的部分
const cursor = el.appendChild(document.createElement('span'));
cursor.className = 'cursor';
const frame = () => {
if (shown === text) return; // 沒有新內容,下一幀再來
const span = document.createElement('span');
const next = text.slice(shown.length, shown.length + 120);
span.textContent = next;
el.insertBefore(span, cursor);
shown += next.length;
requestAnimationFrame(frame); // 繼續補剩下的
};
return {
push(content: string) {
text += content;
requestAnimationFrame(frame); // 同一幀內多次 push,只排一次
},
};
}
順帶一提,cursor 的閃爍只靠一個 class 指定樣式,不需要 JS 插手——例如 animation: blink 1s step-start infinite,.cursor { opacity: 1 } 與 @keyframes blink { 50% { opacity: 0 } } 就能讓「正在生成」的游標一眼可見。
流渲染的 UI 長這樣
┌──────────────────────────────────────┐
│ ▍ 商業企劃重點如下: │
│ ▍ 先盤點目標客群,接著評估……▌ │ ← 游標停在最後,正在生成
│ ▍ 已完成 1,024 / 3,000 字 │
└──────────────────────────────────────┘
| 端 | 作法 |
|---|---|
| 前端 | buffer 接住半路事件、rAF 節流渲染、游標提示「還在生成」 |
| 後端 | 每個事件確實以 \n\n 結尾、結束送 [DONE],前端才知道「答完了」 |
後端負責「產生」,前端負責「畫出來」,兩端靠 SSE 的線格式銜接,就是 Stream 流渲染的完整閉環。
SSE 與 WebSocket:都是長連線,為什麼 LLM 串流選 SSE
講到即時通訊,最常被拿出來跟 SSE 比較的一定是 WebSocket。兩者都能維持一條長時間開啟的連線,但用途與取向完全相反(單向推播 vs 雙向互動):
| 面向 | SSE | WebSocket |
|---|---|---|
| 通訊方向 | 伺服器 → 使用者(單向) | 雙方都可主動傳(雙向) |
| 資料格式 | 純文字(text/event-stream) |
文字或二進位 |
| 連線建立 | 一般 HTTP 連線 | 先 HTTP Upgrade 握手,再走 ws:///wss:// |
| 斷線重連 | 內建,可搭配 Last-Event-ID 續傳 |
要自己寫重連邏輯 |
| 中間層相容性 | 走標準 HTTP,proxy/CDN 直通 | 部分代理、防火牆不認 Upgrade |
| 適合場景 | 伺服器單向推送 | 雙向、高頻、低延遲互動 |
「先 HTTP Upgrade 握手」這一步值得多說一點。一般 HTTP 是「要求→回應」就結束;WebSocket 想建立長連線,得先送一個帶 Upgrade: websocket 的 HTTP 要求,伺服器回 101 Switching Protocols 同意後,雙方從那一條連線轉成 ws:// 的雙向通道,之後不再是「要求/回應」,而是直接互傳 frame(封包)。也就是說,WebSocket 會先「升級握手」,把普通 HTTP 連線改造成專用的雙向隧道,然後才開始溝通;上面表格中「部分代理、防火牆不認 Upgrade」點出的正是這一步是自訂流程,中間的 proxy 如果不認得,就會卡在握手、連不上。對照 SSE,SSE 不需要升級,直接就是普通 HTTP 的回應,所以 proxy/CDN 只要支援 HTTP 就能直通。
LLM 生成式回答的本質,就是「伺服器把答案推給使用者」的單向流程:使用者開場丟一個 prompt,剩下的全是伺服器一面算、一面把 token 推出去。這種模式剛好命中 SSE 的強項——不需要雙向通道,只要一個 event 一個 event 把 token 送出去,連線中途斷掉時還能靠 Last-Event-ID 自動續傳。如果改用 WebSocket 處理這種單向推送,等於開大卡推自行車:雙向通道、keepalive、重連邏輯全部要自己顧,多出來的複雜度卻沒有換來任何使用者體驗的提升。
但 WebSocket 不是無用武之地。當使用者需要在中途喊停(中斷生成、立刻切換其他模型),或想把一些參數即時回傳回去,雙向通道就派上用場。不過這種「偶爾反過來傳一次」的需求不一定要升級成 WebSocket,實務上有兩種省事的混搭:
AbortController中斷:瀏覽器直接斷開連線,server 靠斷線偵測來停止生成——前端單方面就能完成,不需要 server 中途搞定。- SSE + 短連線:SSE 開一條推送,要喊停時另發一條普通的取消請求(例如
POST /cancel),由 server 主動停止生成。需要對應端點,但 server 可以多做一些收尾(如保留已生成的內容)。
兩者的差別在 server 要不要配合:
| 作法 | 機制 | server 要配合的事 |
|---|---|---|
AbortController 中斷 |
前端直接斷掉 socket | 不用,靠斷線偵測 |
| SSE + 短連線 | 另送取消請求,server 主動停 | 要實作取消端點 |
前端範例用 AbortController,只需掛上 signal 就能邊讀邊停:
const controller = new AbortController();
// 讀取與解析共用前面 streamChat(),這裡只多一件事:把 signal 掛上 fetch
const res = await fetch('/v1/chat/stream', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ prompt }),
signal: controller.signal, // 把取消掛在串流上
});
await streamChat(res, (t) => appendTokenToEditor(t));
// 使用者按「停止」→ 中斷整個串流(read() 會收到 abort 而中斷)
stopBtn.onclick = () => controller.abort();
Apigee 對兩種協定都支援,但架構不同:SSE 綁 EventFlow,WebSocket 則要用另一套Using WebSockets文件設定的 proxy。挑選標準其實很簡單——伺服器單向推、遇斷線要自動重續,走 SSE;雙向、高頻、低延遲互動,才考慮 WebSocket。
SSE 與 WebSocket:使用者互動方式的差異
對使用者來說,SSE 與 WebSocket 帶來的「互動默契」完全不同:SSE 是「順著往下讀」,WebSocket 是「來回即時協調」。UI 與前後端也要依此分工:
| 情境 | SSE | WebSocket |
|---|---|---|
| 使用者的感受 | 單向讀一段一段的內容 | 雙手都能動的即時協作 |
| 前端要補的 | 停止按鈕(配合 AbortController)、重連狀態提示 |
socket 狀態列、斷線重連按鈕 |
| 後端要扛的 | 幾乎不用管重連(HTTP 層自帶) | keep-alive 與斷線恢復邏輯 |
AI 一面把答案逐字吐出,使用者一面可以隨時喊「停止」,不需要等整段生成完,前端大概是這樣的介面:
┌──────────────────────────────────────┐
│ ▍ 正在補充後續背景…… │
│ ▍ ⏹ 停止生成 ○ 已連線 │
│ ──────────────────────────────── │
│ ▍ 連線不穩提示:↻ 自動重連中(2 秒) │
└──────────────────────────────────────┘
雙向、高頻、低延遲的換手場合,才輪得到 WebSocket。
企業部署先講清楚:版本與 10 MB 上限
SSE 在前端怎麼送、怎麼畫,前面都講了;現在輪到真正把它部署到企業環境時,最先卡關的不是「連線怎麼開」,而是兩道硬性規定:版本要夠新、單一事件不能太大。這兩條都是在把 Apigee 當作串流中繼時才冒出來的門檻,如果部署前沒先確認環境,等踩線了才發現就來不及-可參考這裡。
這兩條硬性規定是:
| 規定 | 內容 |
|---|---|
| 版本 | Apigee 與 Apigee hybrid 需為 v1.15.0 或以上才支援 SSE 串流(Extension Processor 也支援) |
| 上限 | 每個 response event 的資料量最高 10 MB |
10 MB 聽起來很巨大——如果拿來傳 4K 影片,一秒都不到;但如果傳的是 AI 生成的文字,10 MB 放得下約 350 萬個中文字(UTF-8 一字 3 bytes)。這個上限不是限制 AI 能講多少話,而是系統架構面的安全防線。SSE 是持續開啟的連線,若不設單一事件的緩衝上限,惡意攻擊者能用無限膨脹的串流資料,直接塞爆代理伺服器的記憶體,這就是無上限 buffering 的攻擊面。企業級服務的第一課,就是先在防禦層面上架好盾牌。
「收完」跟「答完」是兩件事
10 MB 單一事件上限存在,會打破前端一個很自然的假設:「收到串流結束」不等於「AI 回答完畢」。使用者在意的是「這題有沒有說完整」,所以前端不能拿「沒有下一個事件」當「結束」:
| 前端地雷 | 替代作法 |
|---|---|
| 用「沒收到新事件」判斷 AI 講完了 | 由後端明確定義結束標記(例如 data: [DONE]),收到才算答完 |
把一整個大事件 JSON.parse 後才渲染 |
每個 chunk 一到就解析渲染,單一大事件也拆著處理 |
而且當回應真的逼近 10 MB、被截尾時,UX 要明講,不能裝得自然結束:
┌──────────────────────────────────────┐
│ ▍ 商業企劃案: │
│ ▍ 接下來是成本結構…… │
│ ⚠ 回應過長,內容可能已被截斷 │
│ [從這裡重新生成] │
└──────────────────────────────────────┘
建立專屬通道:先用 UI 模板「Proxy with Server-Sent Events (SSE)」
要把一群 LLM 的 token 串流透過 Apigee 轉發給前端,官方 UI(Apigee 管理介面)提供了名為 Proxy with Server-Sent Events (SSE) 的範本,不需要從零開始,可參考這裡-建立 API proxy。建立 proxy 時,在 Proxy template 選項裡直接選它,Apigee 就會幫我們裝好一個內含 EventFlow 的骨架。
<ProxyEndpoint name="default">
<Description/>
<FaultRules/>
<PreFlow name="PreFlow">
<Request/>
<Response/>
</PreFlow>
<PostFlow name="PostFlow">
<Request/>
<Response/>
</PostFlow>
<Flows/>
<EventFlow name="EventFlow" content-type="text/event-stream">
<Response/>
</EventFlow>
<HTTPProxyConnection>
<Properties/>
<URL>https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:streamGenerateContent?key=GEMINI_API_KEY&alt=sse</URL>
</HTTPProxyConnection>
</ProxyEndpoint>
官方模板把金鑰直接寫在
<URL>的 query string,那只是範本圖方便的作法。正式環境別讓機密長佇在 proxy 設定裡——把 API key 改放 Apigee 的 KVM(Key/Value Map)或 Target Server 設定,<URL>只留主機位置,執行期再由 Apigee 注入。跟前端一樣,不要把 API key 直接寫在程式碼裡。
搭好之後,接下來是最容易踩入卻最致命的一關:端點設定。
第一條警戒線:SSE 與非 SSE 端點,絕不能混用
文件裡有一句非常強烈警告(原文 Important):
Use separate target endpoint definitions for SSE targets. Mixing SSE and non-SSE target endpoints together might result in inconsistent behavior such as empty
response.contentflow variables.
翻譯:SSE 目標端點要跟非 SSE 目標端點完全分開。混在一起,最常見的災難就是流程變數 response.content 會變成空白。
原因是底層對兩種連線的生命週期處理完全不一樣:
- 傳統請求:接收 → 處理 → 關閉
- SSE:接收 → 保持開啟 → 持續轉發
系統底層的開啟與路由邏輯完全不同,硬混在一起,路由機制會產生嚴重的混淆——對系統而言像是「伺服器什麼都沒回傳」,logs(日誌)連 response.content 都抓不到,分析系統直接失明、企業合規與資料處理全面受損。下面兩個配置:
<!-- ✗ 錯誤:把 SSE 與非 SSE 目標混在同一個 TargetEndpoint -->
<TargetEndpoint name="default">
<HTTPTargetConnection>
<Properties/>
<URL>https://backend.example/api/chat</URL> <!-- 普通 REST -->
<URL>https://backend.example/api/chat/stream</URL> <!-- SSE -->
</HTTPTargetConnection>
</TargetEndpoint>
<!-- ✓ 正確:SSE 用獨立端點,跟 REST 完全分離 -->
<TargetEndpoint name="sseTarget">
<EventFlow name="EventFlow" content-type="text/event-stream">
<Response/>
</EventFlow>
<HTTPTargetConnection>
<Properties/>
<URL>https://backend.example/api/chat/stream</URL>
</HTTPTargetConnection>
</TargetEndpoint>
所以第一原則是:清晰的結構分離。target 分開,EventFlow 才看得到資料。
混用端點,災難都會落在使用者身上
SSE 與非 SSE 混在同一 proxy,真正受害的是使用者體驗:同一個功能,這幾次有串流、那幾次直接空白,使用者會以為是產品壞掉。解法必須在架構與前端各站一崗:
| 端 | 作法 |
|---|---|
| 後端(Apigee) | SSE 走獨立 target 與 EventFlow,普通 REST 走自己的 target,兩條井水不犯河水 |
| 前端 | 一律檢查回應的 Content-Type 分流:text/event-stream 走逐 token 渲染,其餘走整包渲染 |
前端分流邏輯長這樣:
收到回應
└─ Content-Type: text/event-stream ──► 逐 token 串流顯示
└─ 其他(JSON / text)──────────────► 整包收到再一次渲染
只要前端一律看 headers 分流,就算後端偶爾出錯,也不會把半截串流當整包答案渲染出去。
EventFlow:串流的專屬通道
當端點喬好後,就進入核心實作:EventFlow(事件流程)。傳統 API 我們習慣處理 ProxyFlow、Request 的 PreFlow、Response 的 PostFlow;但 EventFlow 是專為了攔截 SSE 即時串流而生的特殊 endpoint flow。它的啟動要件非常嚴格——該 flow 的 content-type 屬性必須精準設定為 text/event-stream,等於幫這條專屬通道掛上通行證,告訴底層系統「在這裡面跑的是不能被打斷的事件流」。
兩條很殘酷的優先順序
- 放 ProxyEndpoint 或 TargetEndpoint 都可以——但兩邊都設了?系統只執行 TargetEndpoint 裡的那個,ProxyEndpoint 的全部被視為不存在。
- 同一個端點裡寫了好幾個 EventFlow?只有最後一個生效,前面全部退位。
把四種排列組合攤開看:
| ProxyEndpoint | TargetEndpoint | 執行的 EventFlow |
|---|---|---|
| 有 | 無 | ProxyEndpoint 的 |
| 無 | 有 | TargetEndpoint 的 |
| 有 | 有 | 只有 TargetEndpoint 的 |
乍看之下,這個「只認最後一道」的規矩很像設計瑕疵:明明 ProxyEndpoint 也寫了 EventFlow,為什麼不算數?但如果從即時串流的底層限制看,就會懂 Apigee 為什麼寧可做得這麼不留情面——串流流動中的資料,停不下來做規則合併。
傳統 API 是「請求 → 回應」一對一:請求進來,系統可以花個幾毫秒,把 ProxyEndpoint、TargetEndpoint 各層的政策合併成一套完整的規則再執行。幾毫秒的合併時間,在傳統 API 裡完全無感。
SSE 就不是這個節奏。資料像開著的水龍頭,一串 event 一個接一個流過去,每一個都不能等。如果系統每過一個 event,都要停下來「計算不同區塊的政策衝突、決定要不要合併」,等於每一滴水都要過安檢再放行——幾毫秒看起來很微量,但串流是連續的,幾毫秒 × 千百個事件,直接打斷「邊生成邊送出」的初衷。
因此 Apigee 的取捨是要低延遲,就放棄在串流上做規則合併——流量只認「最後一道關卡的規則」,其他區塊寫了也等於沒寫。用自由度換延遲,這是串流閘道在效能上不得不做的取捨。
傳統 API: 收到請求 → 花幾毫秒「合併」各層級政策 → 回傳
SSE: 資料像水龍頭一直流,停不下來合併
→ 只認「最後一道關卡」= 保住毫秒級延遲
在傳統 API 中,系統可以花幾毫秒把不同層級的規則優雅整合在一起。但 SSE 的世界裡,資料像開著的水龍頭,如果每一個 event 流過時都要停下來計算、解決不同區塊之間的規則衝突,會產生無法忍受的延遲——直接違背了用 SSE 追求極致低延遲的初衷。所以 EventFlow 是「架構上的紀律」:它要求架構師必須清楚知道,最後一道關卡(TargetEndpoint 或最後一個區塊)就是唯一守門人。要部署時,所有必要的政策都要集中,而且正確地設定在最後生效的 EventFlow 裡,系統不會幫我們擦屁股。這不是缺陷,這是用鐵腕逼出全域觀。
EventFlow 武器庫:能掛哪些政策
EventFlow 的「Response」元素中,一次最多可以放 四種 政策,且「能放的類型受到嚴格限制」——其他類型的政策一概不允許:
| 政策 | 作用 |
|---|---|
DataCapture |
從串流萃取資料,例如 token 數,送進 Apigee Analytics |
JavaScript |
在 flow 裡跑腳本,細緻修改事件內容 |
LLMTokenQuota |
對串流做 token 計數與配額控管,超支直接切斷 |
MessageLogging |
把事件寫入 log,供後續追溯 |
PublishMessage |
把事件推到外部(例如 Cloud Pub/Sub)做監控 |
RaiseFault |
主動丟出錯誤,攔截非預期內容 |
SanitizeModelResponse |
呼叫 Model Armor,對回應內容做即時安全過濾 |
1. 成本:LLMTokenQuota
呼叫 LLM 計費本就按 token,AI 說越多話帳單就越驚人。EventFlow 裡掛上 LLMTokenQuota,可在資料流通的當下即時計算 token,一旦使用者額度用畢,立刻在串流中切斷連接。否則惡意使用者下一個無限迴圈的提示詞,就能讓 AI 吐出幾百萬字,隔天信用卡刷爆。
<EventFlow name="EventFlow" content-type="text/event-stream">
<Response>
<Step>
<Name>LTQ-enforce-quota</Name>
</Step>
</Response>
</EventFlow>
LLMTokenQuota 在 EventFlow 中只有「事件裡偵測到 token 用量 metadata(通常來自 LLM 回應的最後一筆 event)時才執行」;其餘事件直接視為 no-op,不影響串流:
<LLMTokenQuota continueOnError="false" enabled="true" name="LTQ-enforce-quota">
<DisplayName>LLM token quota enforcement</DisplayName>
<Quota>
<Identifier ref="verify_api_key.verify_api_key.apikey"/>
<Allow>10000</Allow>
<Interval>1</Interval>
<TimeUnit>month</TimeUnit>
</Quota>
<LLMTokenUsageSource>response.event.current.data</LLMTokenUsageSource>
<LLMModelSource>response.event.current.data</LLMModelSource>
</LLMTokenQuota>
注意:只要在 EventFlow 內使用
LLMTokenQuota,<LLMTokenUsageSource>與<LLMModelSource>就必須讀取目前事件的資料(response.event.current.*),跟一般 Proxy 中的讀法不同。詳見這裏。
講一半卡住,要讓使用者知道為什麼
token 配額用盡時,最糟的體驗是「AI 講到一半,畫面直接轉圈」。使用者分不清是配額、斷線還是服務壞掉。前後端各留一手即可:
- 後端:配額用盡不要默默斷線——先送一個明確訊號(例如帶
event: error的命名事件,或明確終止),再接掉連線。 - 前端:收到這個訊號就換成「配額用盡」的說明,而不是留在轉圈狀態。
┌──────────────────────────────────────┐
│ ▍ 本次對話已用 998 / 1,000 token │
│ ⚠ 已達配額上限,後續內容未生成。 │
│ [額度已用盡] [重新生成] │
└──────────────────────────────────────┘
2. 資料:DataCapture 抓取 token 用量與 ROI
使用 DataCapture 可以在每次 API 呼叫中精準萃取 token 計算,把資料放進 analytics data collector,供我們做商業分析、評估投資報酬率——既要擋下超支的使用者,也要看得見成本,缺一不可。
<EventFlow name="EventFlow" content-type="text/event-stream">
<Response>
<Step>
<Name>JS-parse-token</Name>
</Step>
<Step>
<Name>DC-capture-tokencount</Name>
</Step>
</Response>
</EventFlow>
資料收集器可定義 dc_tokenCount 與 dc_thoughtsTokenCount 兩欄(資料收集器名稱以 dc_ 為前綴),例如把解析出來 token 計數塞進 flow variable,再對照 analytics 查詢。這樣不管一次呼叫吐出幾千個 token,都能在上線時用同一份 pipeline 追出成本曲線,而不是把明細散落在各家 LLM 公司的帳單裡。
成本看得見,訂價才做得準
DataCapture 的 UI/UX 影響不在使用者畫面,而在營運儀表板:token 成本抓不到,就不知道每輪對話到底賺不賺錢。前後端各自分工:
| 端 | 作法 |
|---|---|
| 後端(Apigee) | DataCapture 把 token 數寫進 Analytics,用 dc_ 欄位持續累積 |
| 前端(內部工具) | 管理儀表板按使用者 / 模型 / 時段畫出成本曲線,超支當下顯示告警 |
┌──────────────────────────────────────┐
│ 成本儀表板(本月) │
│ Gemini 3,204,556 tokens $32.0 │
│ Groq 890,331 tokens $8.9 │
│ GPT-4o 45,002 tokens $0.9 │
│ ───────────────────────────────── │
│ ⚠ user_1987 已用 98% 配額 │
└──────────────────────────────────────┘
把計費的資料放在營運後台一眼可見,成本控制就不只是財務部門的事。
3. 防護:Model Armor 在「行進間」過濾內容
AI 模型再強,也逃不掉 prompt injection、Jailbreak 或幻覺產出的偏見、歧視或違規內容。把 Model Armor 掛在 EventFlow 裡,就等於在使用者看到內容之前加了一道隱形即時過濾網,在毫秒衝向使用者的過程中完成安全審查。注意,在 Apigee hybrid,Model Armor 政策需要 v1.15.1 以上的版本才支援。
<EventFlow name="EventFlow" content-type="text/event-stream">
<Response>
<Step>
<Name>SMR-sanitize-response</Name>
</Step>
</Response>
</EventFlow>
被遮住,也要讓使用者看得懂
被 Model Armor 過濾掉的片段,如果前端只是「那幾個字沒出現」,使用者會以為是模型壞掉或自己的網路問題。好的 UX 是「有遮,而且明講」:
- 後端:被過濾的事件用命名事件送出(例如
event: filtered,或帶filtered: true的資料點),前端才認得出來。 - 前端:收到過濾事件,就在原位置渲染一行「此段按企業安全政策未顯示」的說明,而不是直接消失。
┌──────────────────────────────────────┐
│ ▍ 先從競品分析開始…… │
│ ████████████████████████████ │
│ ⚠ 此段落含違規內容,依安全政策未顯示 │
└──────────────────────────────────────┘
4. 錯誤:攔截非預期回應並通報
真實網路環境中後端隨時可能當機或回傳亂碼。EventFlow 也能攔截這些非預期的回應,用 RaiseFault 處理錯誤,再用 MessageLogging/PublishMessage 傳到外部監控(例如 Pub/Sub),讓維運團隊第一時間收到警報。
<EventFlow name="EventFlow" content-type="text/event-stream">
<Response>
<Step>
<Name>RF-handle-error</Name>
<Condition>fault.name equals "invalid_access_token"</Condition>
</Step>
</Response>
</EventFlow>
<RaiseFault name="RF-handle-error">
<IgnoreUnresolvedVariables>false</IgnoreUnresolvedVariables>
<Set>
<Payload type="text/plain">SSE 串流發生錯誤,已攔截。</Payload>
<StatusCode>502</StatusCode>
</Set>
</RaiseFault>
斷線當下,給「重試」一條路
後端當機、回傳亂碼時,前端最差的作法是只剩一個「連線中斷」的轉圈。作法是:後端把錯誤資訊(fault name、status code)包在事件前推,前端收到後保留已收內容,並提供「重試/重新開始」,把「斷線」變成流程的一部分,而不是死路:
┌──────────────────────────────────────┐
│ ▍ 已經生成的前 20 行 … │
│ ⚠ 串流發生錯誤(fault: timeout) │
│ [從這裡重試] [重新開始] │
└──────────────────────────────────────┘
整套運作長這樣:移動中的高速海關
把這四種策略全部湊在一起,運作就像國際機場裡專為重要旅客設計的「高速海關通道」:
- AI 伺服器像是不斷把 token「旅客」送下飛機的班機
- 這些旅客走進 EventFlow,什麼都不用停(行進間一次通過所有站)
- 過 X 光機 → Model Armor 當下檢查內容有沒有「違禁品」
- 感應墊掃 QR 扣款 → LLMTokenQuota 持續計算
- 看到可疑分子 → 錯誤處理直接降下閘門、通報外部警備
┌─────────────────────────────────────────────────────┐
│ EventFlow(高速海關通道) │
│ [X光安檢] [扣款掃碼] [閘門控制] [通報警衛] │
│ Model Armor LLMTokenQuota RaiseFault MessageLogging│
└─────────────────────────────────────────────────────┘
▲ token 串流在行進間完成整段安檢 ▼
LLM 後端(機場) 使用者(大廳)
每個 token 都在毫秒之間完成攔截運算——在資料像湍流一樣衝向使用者的過程中,閘道端已經完成了安全審查、計費、錯誤處理。這就是 API 閘道在 AI 時代的定位轉變:從純路由/驗證,進化到「夾在即時資料流中的運算點」。
計費真相:EventFlow 走的是 Extensible 計費
使用 EventFlow 部署的 API proxy 會以 Extensible 計費。這跟底層資源消耗有關——傳統 API 請求是伺服器處理完就關閉連接,佔用資源時間極短;SSE 則必須長時間保持連線開啟,佔住底層的 TCP 連接與記憶體。就像搭出租車:
| 模式 | 譬喻 | 計費特性 |
|---|---|---|
| 傳統 API | 計程車 | 到達就下車,算里程 |
| SSE / EventFlow | 包車留一天 | 佔用司機時間與車輛,邏輯自然不同 |
所以架構評估 LLM 串流時,要把「長期佔用基礎設施」的帳也算進去,才能設計出同時兼顧效能與成本效益的系統。
未來:多模態串流來了,AI 閘道怎麼進化
本文主要在探討「文字事件流」的掌控上。但技術不會停步,AI 正迅速走向多模態:未來的 AI 可能即時串流生成語音、甚至動態影片。那時需要在毫秒級「行進間」審查與計費的,不再是一個個輕量的文字 token,而是每秒數十幀的高畫質影片、或帶複雜情緒轉折的合成語音。同一個串流裡的事件也會變成多型態:語音、圖片、影片——UI 的職責從「逐字渲染」升級成「依事件型態長出對應的元件」,後端 EventFlow 再把安檢與計費分布到各種 media 上。
| 端 | 作法 |
|---|---|
| 前端 | 依事件型態分流渲染:audio_chunk 長出聲波、image_chunk 長出圖片卡片、文字持續逐字顯示 |
| 後端 | EventFlow 依媒體型態把每個事件分別安檢與計費,確保串流仍是單一管道 |
畫面會愈來愈接近「混合式的媒體回應」:
┌──────────────────────────────────────┐
│ ▍ 語音 ═▁▂▃▄▃▂▁═ (合成語音) │
│ ▍ 圖片 [ ◄◄◄ ] 逐幀顯示 │
│ ▍ 文字 ▼ 重點整理如下:… │
└──────────────────────────────────────┘
EventFlow 這座「高速海關」該怎麼進化?要在使用者看不出任何延遲的前提下,邊串流 4K 影片、邊做內容安全審查(例如判斷畫面是否變成暴力內容),又得同時處理巨量的運算資源計費——架構複雜度會是現在的數十倍,甚至數百倍。這正是值得每個技術人都好好思考的題目:文字的高速海關,能不能升級成多媒體的全方位高速海關?到那時「高速海關」四個字,才真正名副其實。
總結
把全文的痛點整理如下表,問題與解法一一對應:等待太久用 SSE 逐 token 送出解決,記憶體被無限串流塞爆就用 10 MB 上限擋,response.content 空白要靠 target 分離與 EventFlow 單獨設定來修,token 超支交給 LLMTokenQuota 即時控配,違規或幻覺內容由 Model Armor 即時過濾,而後端故障則靠 RaiseFault + MessageLogging 留下證據。
| 問題 | 解法 |
|---|---|
| 等整份回應太慢,體感延遲高 | SSE 讓 LLM 每個 token 產生就送出 |
| 無上限的串流資料塞爆記憶體 | 每 event 10 MB 上限加上政策攔截 |
混用的 target 端點讓 response.content 空白 |
target 分離、EventFlow 單獨設定 |
| token 超支、惡意無限輸出 | LLMTokenQuota 即時控配 |
| 內容有違規 / 幻覺 | Model Armor / SanitizeModelResponse 即時過濾 |
| 後端故障、錯誤無跡可循 | RaiseFault + MessageLogging 外部警戒 |
這些解法有一個共同的背景:全都長在 EventFlow 那條「停不下來」的即時通道上。傳統 API 可以慢慢合併規則、出了錯再事後補救;SSE 的資料是水龍頭,一開就停不下來,所以每一道關卡都得在零停頓之間完成——計費、安檢、錯誤處理,全部一邊流動一邊搞定。
回過頭看,這整串決策最終都落在同一個 UI/UX 願景上:讓使用者感受到「AI 正在陪我們聊」,核心是「立刻有反應」與「意外不會被卡住」。SSE 逐 token 推送,把「首字出現」從十幾秒縮到幾百毫秒;骨架、游標、逐字渲染,把等待變成看得見的進度;停止按鈕、重連提示、斷線可重試,讓中斷與錯誤從一堵牆變成可以被處理的岔路;10 MB 上限與 token 計費,則保證這條暢通通道在企業規模下不會因為塞爆或超支而突然斷掉——前端負責把每次回應渲染得即時又明白,後端 EventFlow 負責讓通道又快又穩,兩邊合起來,才是使用者感受得到的真正流暢。
SSE 改變的不是「怎麼送」,而是「送得多快、多被看管」。把這套 EventFlow 的高速海關模式摸熟,未來會思考、會說話的 AI 應用,從閘道這一層開始就站得穩。
推薦閱讀
- A2A 協定:打破 AI 溝通的高牆:當 AI 之間要互相對話,協定走的是事件導向通訊,探討 A2A 如何讓來自不同系統的代理用同一種語言協作。
- 讓 AI 代理聰明更要精明:資源感知最佳化:AI 越用越貴,這篇談的是如何在掌握預算的前提下一路動態決策,把成本壓下來又不犧牲回應品質。
參考資料
-
[Streaming server-sent events Apigee Google Cloud](https://cloud.google.com/apigee/docs/api-platform/develop/server-sent-events)(中文版:串流伺服器傳送的事件) -
[使用 server-sent 事件 MDN](https://developer.mozilla.org/zh-TW/docs/Web/API/Server-sent_events/Using_server-sent_events) - Apigee release notes — server-sent events 與 EventFlow
- Get started with LLM token policies
- Get started with Model Armor policies
- Collecting data with the DataCapture policy data