Eric TechBlog
AIAI Assistant

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 回應可能包含:

  1. 推理過程(思考中...)
  2. 工具呼叫(正在搜尋 email...)
  3. 工具結果(找到 5 封相關信件)
  4. 文字回應(根據搜尋結果,John 在信裡提到下週五是截止日)
  5. 引用來源(來自哪封 email)
  6. HITL 請求(需要你確認是否把 deadline 加到行事曆)
  7. 前端指令(刷新側邊欄)

如果把這些全部塞進一個 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渲染方式
textMarkdown 渲染器
reasoning可折疊的思考過程區塊
tool-search搜尋結果卡片
data-approval-requestHITL 確認按鈕
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 對話來說,重連後無法續傳之前的串流。更好的做法是讓前端發現連線斷開後,重新載入訊息歷史(因為伺服器已經持久化了)。

錯誤處理

串流中的錯誤分兩類:

  1. 串流建立前的錯誤(驗證失敗、HITL 決定缺失):返回普通的 HTTP 400/500
  2. 串流中的錯誤(LLM 出錯、工具執行失敗):透過串流推送錯誤事件,前端在 UI 中展示

原則:串流一旦開始,就不用 HTTP 狀態碼了,所有事件(包括錯誤)都走串流。


重點總結

設計決策選擇原因
傳輸協議SSE單向推送、Serverless 友好
訊息格式Message Parts一條串流承載多種資料類型
Transient 標記區分持久化 vs 一次性避免控制指令汙染對話歷史
Payload 優化只傳最新訊息恆定大小,不隨對話增長
工具狀態離散狀態機精確的 UI 狀態轉換
錯誤分類建立前 vs 串流中不同階段用不同機制

Last updated on

On this page