Server-sent events 筆記
什麼是 Server-sent events (SSE)
Server-sent events (簡稱 SSE) 是一種利用持久連線,讓用戶端可以透過送出一個請求後,持續得到伺服器端回應的模式。
用戶端會在請求標頭中使用 Accept: text/event-stream 來發起 SSE 連線,接著伺服器端透過在回應標頭加上 "Content-Type: text/event-stream" 來預告接下來的回應會是串流的形式。
看到 "Content-Type: text/event-stream" 可能會想說「啊!這不就是 streaming response 嗎?」,沒錯,事實上 SSE 不是什麼新技術,它可以說是一個有固定格式的 streaming response。
直接看 raw bytes
與其看規格書,不如直接抓一個真實的 SSE 回應下來看。這裡打自建 Gateway,因為 LLM 的 streaming API 就是一個最適合的應用場景:
1 | |
來看回應標頭:
1 | |
重點來了:content-type: text/event-stream。伺服器端靠這個 Content-Type 告訴用戶端「接下來的 body 請用 SSE 的格式去解析,而且不要等我關閉連線」。
再來看後面的回應:
1 | |
這就是 SSE 的全部了,可以看到每個 SSE 回應都是 data 開頭的 object。
事件內容
事件串流是個簡易的文字資料串流,內容必須以 UTF-8 格式編碼。在事件串流中,不同的訊息以一對換行符號做區隔。
SSE 規格只定義了四個欄位,全部都是選用的:
| 欄位 | 用途 |
|---|---|
data: |
事件內容。同一個事件可以有多行 data:,用戶端會用 \n 把它們接起來 |
event: |
事件類型。用戶端可以針對不同類型註冊不同的 handler,不寫的話預設是 message |
id: |
事件 ID。用戶端會記住最後一個 ID,斷線重連時自動帶在 Last-Event-ID 標頭裡 |
retry: |
告訴用戶端斷線後要等幾毫秒才重連,單位 ms |
另外可以用 : 開頭的當作是註解,會被用戶端忽略。實務上常拿來當 heartbeat,每隔一段時間送一個 : ping\n\n 避免中間的 proxy 因為 idle timeout 把連線砍掉。
它要解決什麼問題
一般的 Request-Response 模式是,用戶端和伺服器端一來一回,在某些情況下伺服器需要耗時回傳大量的內容,這種傳統模式就會讓用戶端等太久造成體驗不佳。
SSE 就是為了解決這種問題而設計的,利用 streaming 的形式,只要內容已經準備好就可以先送回用戶端,而 AI Chat 的模式就是最適合使用的情境:
使用者輸入 prompt,而 LLM 會持續產生內容,透過 SSE 把內容輸出到用戶端,搭配前端使用打字機效果,看起來就很像有個人在電腦後面和你對話。
除此之外 SSE 還有一個好處:斷線重連。
一般的 streaming response 斷了就是斷了,用戶端不是認分重來,就是要自己實作一套續傳機制。SSE 規定伺服器用 id: 標記每個事件,用戶端記住最後一個 ID,斷線後自動重連並在 Last-Event-ID 標頭帶回去,伺服器就能知道要從哪裡接下去送回應。
和 Request、WebSocket、Polling 這些模式有什麼區別
一般 Request / Response
一來一回,連線用完就關。問題是使用者在伺服器處理的整段時間內什麼都看不到。
Polling 輪詢
用戶端定時去問。實作最簡單,但先天有兩個問題:延遲取決於輪詢間隔(間隔 3 秒代表最慢 3 秒才看得到),而大部分請求都是空手而回,白白消耗連線與伺服器資源。
Long Polling 長輪詢
伺服器收到請求後不馬上回,一直 hold 到有資料(或 timeout)才回,用戶端收到後立刻再發一個。延遲比 polling 好很多,但每收一次資料就要重建一次連線,而且伺服器要 hold 住大量未完成的請求。這是 SSE 普及前的主流做法。
WebSocket
透過 101 Switching Protocols 把 HTTP 連線升級成 WebSocket 協定,之後就是全雙工的雙向通道。功能最強,可以讓用戶端和伺服器端即時地交換資訊。
SSE
一個請求換來持續不斷的回應。單向(只有伺服器能推),但斷線重連與續傳是內建的。
綜合比較
| Request | Polling | Long Polling | SSE | WebSocket | |
|---|---|---|---|---|---|
| 方向 | 單次一問一答 | 用戶端拉 | 用戶端拉 | 伺服器推(單向) | 雙向 |
| 底層協定 | HTTP | HTTP | HTTP | HTTP | 從 HTTP 升級成 ws/wss |
| 連線數 | 每次一條 | 每次一條 | 每次一條 | 一條,長期持有 | 一條,長期持有 |
| 即時性 | 無 | 差(取決於間隔) | 中等 | 好 | 最好 |
| 無效請求 | — | 多 | 少 | 無 | 無 |
| 自動重連 | — | 天生就是重複請求 | 天生就是重複請求 | 瀏覽器內建 | 要自己寫 |
| 斷線續傳 | — | 自己帶游標 | 自己帶游標 | Last-Event-ID 內建 |
要自己寫 |
| 資料格式 | 任意 | 任意 | 任意 | 只能是 UTF-8 文字 | 文字或二進位 |
| 瀏覽器 API | fetch |
fetch + timer |
fetch + 迴圈 |
EventSource |
WebSocket |
| 自訂標頭 | 可以 | 可以 | 可以 | EventSource 不行 |
不行(握手時) |
| 連線數上限 | — | — | — | HTTP/1.1 同網域 6 條 | 不受此限 |
| 適合場景 | 一般 API | 低頻更新 | 過渡方案 | LLM 串流、通知、進度、股價 | 聊天、遊戲、協同編輯 |
小結
學習 SSE 最大的收穫是又更了解 HTTP、TCP 等運作的方式了。之前一直搞不懂:TCP 本來就會把內容切成好幾段持續傳送,這不就是一種 streaming 嗎?那為什麼還要分 application/json 和 text/event-stream?
原來差異只是用戶端針對不同 Content-Type 的處理不同,看是要立即處理,還是結束後才處理的差別。