Streaming Protocol
串流協議設計,涵蓋 SSE 的選型考量、Message Parts 格式、工具呼叫狀態機,以及前端消費串流的最佳實踐。
設計問題
LLM 生成一段 500 字的回應可能需要 3-8 秒。如果等生成完才回傳,用戶會盯著一個空白畫面發呆。更糟的是,email AI Assistant 的回應不只是文字,還可能包含搜尋進度、snippet、工具狀態與 HITL 請求。
核心問題:如何設計一個協議,讓前端能即時接收多種類型的串流資料?
方案比較
前端每隔 N 毫秒發請求問「有新內容嗎?」
Client: GET /chat/status?id=123 → { text: "根據" }
Client: GET /chat/status?id=123 → { text: "根據搜尋結果" }
Client: GET /chat/status?id=123 → { text: "根據搜尋結果,你明天有" }- 優點:最簡單,任何 HTTP 伺服器都能支持
- 缺點:延遲高(取決於輪詢間隔)、浪費資源(大量無效請求)、不適合即時場景
建立持久連線,雙向通訊。
- 優點:真正的即時、雙向
- 缺點:Serverless 環境支持差、連線管理複雜、需要額外的心跳機制
伺服器透過一個長連線持續推送事件。
- 優點:原生 HTTP、Serverless 友好、自動重連、單向推送剛好符合需求
- 缺點:單向(但 AI 回應本來就是單向的)、瀏覽器有連線數限制
選擇 SSE
AI 助理的通訊模式是典型的「用戶發一則訊息,伺服器推送一連串回應」。這是 SSE 最擅長的場景。搭配 Vercel AI SDK 的抽象層,不需要手動管理 SSE 的底層細節。
訊息格式設計
問題:一則回應包含多種資料
一次 AI 回應可能包含:
- 推理過程(思考中...)
- 工具呼叫(正在搜尋 email...)
- 工具結果(找到 5 封相關信件)
- 文字回應(根據搜尋結果,John 在信裡提到下週五是截止日)
- 引用來源(來自哪封 email)
- HITL 請求(需要你確認是否把 deadline 加到行事曆)
- 前端指令(刷新側邊欄)
如果把這些全部塞進一個 string,前端就要自己解析,容易出錯。
解法:Message Parts
每則訊息由一個 parts 陣列組成,每個 part 有明確的 type:
Message {
id: string
role: "user" | "assistant"
parts: Part[]
}
Part = TextPart | ReasoningPart | ToolCallPart | SourcePart | DataPart
TextPart { type: "text", text: string }
ReasoningPart { type: "reasoning", text: string }
ToolCallPart { type: "tool-{name}", state: State, input: T, output: U }
SourcePart { type: "source-url", url: string }
DataPart { type: "data-{name}", data: T }前端根據 type 選擇對應的渲染元件:
| type | 渲染方式 |
|---|---|
text | Markdown 渲染器 |
reasoning | 可折疊的思考過程區塊 |
tool-search | 搜尋結果卡片 |
data-approval-request | HITL 確認按鈕 |
source-url | 引用連結 |
延伸閱讀
如果你想看 HITL 事件本身如何設計,可以接著讀 Human-in-the-Loop; 如果你想看搜尋工具怎麼提供 snippet 與全文兩段式結果,可以回到 Retrieval Pipeline。
為什麼不用不同的 endpoint?
把工具呼叫、HITL 事件、文字回應拆到不同 endpoint 會讓前端邏輯爆炸。用 parts 的設計,所有資料走同一條串流,前端只需要一個 switch(part.type) 就能處理所有情況。
串流的生命週期
1. 建立串流
伺服器建立一個可寫入的串流物件。這個串流在整個請求生命週期中保持開啟:
createUIMessageStream({
execute: async ({ writer }) => {
// writer 是唯一的寫入通道
// 所有的工具結果、文字、事件都透過 writer 推送
},
onFinish: async ({ responseMessage }) => {
// 串流結束後的後處理
}
})2. 多來源合併
一個串流可能需要合併多個來源的資料:
writer.merge(agentStream) ← Agent 的回應串流
writer.write(approvalEvent) ← 手動寫入的 HITL 事件
writer.write(frontendAction) ← 前端操作指令merge 用於接入子串流(如 Agent 的輸出),write 用於寫入離散事件。兩者可以交織使用。
3. Transient vs Persistent Parts
不是所有串流中的資料都應該被持久化:
| 類型 | Transient | 範例 |
|---|---|---|
| 文字回應 | No | 正常對話內容 |
| 工具結果 | No | 搜尋結果 |
| HITL 請求 | No | 等待用戶決定 |
| 刷新側邊欄 | Yes | 只是觸發前端行為 |
Transient parts 只在串流中出現,不會被存到訊息歷史裡。這避免了前端控制指令汙染對話記錄。
前端消費串流
useChat Hook
前端用一個 hook 管理整個對話狀態:
const { messages, sendMessage, status } = useChat({
id: chatId,
messages: initialMessages,
onData: (event) => { /* 即時事件處理 */ },
onFinish: () => { /* 串流結束 */ },
})Hook 自動處理:
- 串流中的 text part → 逐字更新到 messages 狀態
- 串流中的 tool part → 更新工具狀態(pending → running → completed)
- 串流結束 → 更新 status 狀態
狀態機
idle → submitted → streaming → idle
→ error → idle| 狀態 | 前端行為 |
|---|---|
idle | 正常,可輸入 |
submitted | 顯示載入動畫 |
streaming | 即時更新訊息,推理區塊顯示串流效果 |
error | 顯示錯誤,可重試 |
減少 Payload
傳統做法:每次請求把所有訊息歷史傳給後端。 問題:對話越長,payload 越大。
優化:自定義 Transport,只傳最新一則訊息:
transport: {
prepareSendMessagesRequest: (request) => ({
body: {
id: chatId,
message: request.messages[request.messages.length - 1],
},
}),
}後端從 DB 讀取歷史,搭配語意搜尋只取需要的部分。這讓 payload 大小保持恆定,不隨對話長度增長。
工具呼叫的串流狀態
工具呼叫不像文字是連續的,它有離散的狀態轉換:
input-available → running → output-available
→ output-error串流中的表現:
Event: tool-search, state: input-available, input: { keywords: ["John"] }
→ 前端顯示「Search: Pending」+ 參數
Event: tool-search, state: running
→ 前端更新為「Search: Running」+ 動畫
Event: tool-search, state: output-available, output: { emails: [...] }
→ 前端更新為「Search: Completed」+ 結果卡片每個狀態轉換都是串流中的一個事件,前端無需輪詢。
生產考量
超時
LLM 呼叫加上工具執行可能需要較長時間。設定合理的最大串流時間:
export const maxDuration = 30; // 30 秒超過這個時間,連線會被伺服器主動關閉。前端應該能優雅處理這種情況。
背壓
如果前端消費速度跟不上伺服器推送速度(罕見但可能),SSE 的底層 TCP 會自然產生背壓。不需要應用層額外處理。
重連
SSE 原生支持自動重連。但對 AI 對話來說,重連後無法續傳之前的串流。更好的做法是讓前端發現連線斷開後,重新載入訊息歷史(因為伺服器已經持久化了)。
錯誤處理
串流中的錯誤分兩類:
- 串流建立前的錯誤(驗證失敗、HITL 決定缺失):返回普通的 HTTP 400/500
- 串流中的錯誤(LLM 出錯、工具執行失敗):透過串流推送錯誤事件,前端在 UI 中展示
原則:串流一旦開始,就不用 HTTP 狀態碼了,所有事件(包括錯誤)都走串流。
重點總結
| 設計決策 | 選擇 | 原因 |
|---|---|---|
| 傳輸協議 | SSE | 單向推送、Serverless 友好 |
| 訊息格式 | Message Parts | 一條串流承載多種資料類型 |
| Transient 標記 | 區分持久化 vs 一次性 | 避免控制指令汙染對話歷史 |
| Payload 優化 | 只傳最新訊息 | 恆定大小,不隨對話增長 |
| 工具狀態 | 離散狀態機 | 精確的 UI 狀態轉換 |
| 錯誤分類 | 建立前 vs 串流中 | 不同階段用不同機制 |
Last updated on