2023年7月18日
用 SignalR 串流做出 ChatGPT 的「逐字出現」:IAsyncEnumerable、取消與多實例部署
問題:等待時間不變,但感受差很多
大型語言模型是一個 Token 一個 Token 生成回答的。一段 800 字的回答,模型可能要花 15~20 秒才全部生成完。
- 一般 HTTP 請求:後端等 OpenAI 全部生成完、拿到完整回答後才回傳。總時間 20 秒,第一個字也在 20 秒才出現
- 串流:OpenAI 每生成一小段就送出一小段,後端立刻轉給瀏覽器。總時間一樣是 20 秒,但第一個字在 1 秒內就出現,使用者看著文字一段一段長出來,感覺系統一直在回應
對使用者體驗來說,關鍵指標不是總時間,而是第一個字出現的時間(Time To First Token)。
為什麼選 SignalR
要讓伺服器「連續、主動」把資料推給瀏覽器,常見有三種做法:
| 做法 | 特性 | 適合 |
|---|---|---|
| Server-Sent Events(SSE) | 單向:伺服器 → 瀏覽器;基於一般 HTTP | 只需要伺服器推送,例如通知、單一問答的串流 |
| WebSocket | 雙向、全雙工,連線建立後雙方隨時可以傳送 | 需要雙向即時互動,例如聊天、協作 |
| SignalR | ASP.NET Core 的即時通訊框架,優先使用 WebSocket,不支援時自動降級為 SSE 或 Long Polling | .NET 後端、需要雙向通訊,不想自己處理連線管理 |
聊天室除了串流回覆,還有「使用者中途按停止」「其他分頁同步更新聊天室列表」這些雙向需求。後端是 ASP.NET Core 的情況下,SignalR 幫忙處理了連線建立、重新連線、訊息序列化、降級傳輸等細節,而且直接支援串流。
後端:Hub 方法回傳 IAsyncEnumerable
SignalR 的 Hub 方法只要回傳 IAsyncEnumerable<T>,就自動變成串流方法:每 yield return 一個值,就推一個值給前端。
public class ChatHub : Hub
{
private readonly IChatService _chatService;
public ChatHub(IChatService chatService) => _chatService = chatService;
public async IAsyncEnumerable<string> SendMessage(
Guid chatRoomId,
string message,
[EnumeratorCancellation] CancellationToken cancellationToken)
{
var answer = new StringBuilder();
await foreach (var chunk in _chatService.StreamCompletionAsync(chatRoomId, message, cancellationToken))
{
answer.Append(chunk);
yield return chunk;
}
// 串流結束後才寫入資料庫,而不是每一段都寫一次
await _chatService.SaveAnswerAsync(chatRoomId, answer.ToString(), CancellationToken.None);
}
}
StreamCompletionAsync 呼叫 OpenAI API 時設定 stream: true,OpenAI 會以 SSE 格式一段一段回傳,每一段解析出 delta.content 後再 yield return 出去。整條管線從 OpenAI 到瀏覽器都是串流,中間沒有任何地方需要「等全部收齊」。
幾個設計重點:
- 串流中不寫資料庫:每一段文字只推給前端,等整段回答結束才一次寫入完整訊息。一段回答可能有幾百段,每一段都寫一次資料庫完全沒有必要
[EnumeratorCancellation]一定要加:這個屬性讓前端取消串流時,cancellationToken會被觸發。沒有加的話,前端取消了,後端還會繼續跑完
前端:connection.stream()
前端用 @microsoft/signalr 的 stream() 呼叫串流方法,每收到一段就接在目前的回答後面:
const connection = new signalR.HubConnectionBuilder()
.withUrl("/hubs/chat", { accessTokenFactory: () => getAccessToken() })
.withAutomaticReconnect()
.build();
await connection.start();
let answer = "";
const subscription = connection.stream("SendMessage", chatRoomId, message)
.subscribe({
next: (chunk) => {
answer += chunk;
renderMarkdown(answer); // 每次重新渲染,表格、程式碼區塊會逐步成形
},
complete: () => setStatus("done"),
error: (err) => setStatus("error", err),
});
// 使用者按下「停止生成」
stopButton.onclick = () => subscription.dispose();
注意 next 收到的是片段,可能只是半個 Markdown 語法(例如表格只出現了一半)。每次都把「目前為止的完整文字」重新渲染一次,比試著增量渲染片段簡單得多,也不會出現語法錯亂。
使用者按停止:取消要一路傳到底
使用者按下「停止生成」時,subscription.dispose() 會通知伺服器取消串流,Hub 方法的 cancellationToken 被觸發。這個 Token 要一路傳下去:
subscription.dispose()
→ Hub 方法的 cancellationToken
→ StreamCompletionAsync(..., cancellationToken)
→ HttpClient 呼叫 OpenAI 的請求被中斷
如果中間任何一層沒有把 Token 傳下去,畫面上雖然停了,後端仍然在背景繼續向 OpenAI 收資料,Token 費用照算。關掉瀏覽器分頁、網路斷線時,SignalR 連線中斷也會觸發同一個取消流程。
被取消的回答要不要存?比較好的做法是把「已經生成的部分」存下來並標記為中斷,使用者回到聊天室時,還看得到停在哪裡。
多台伺服器時要注意的事
服務只有一台的時候一切都很單純,但一開始做水平擴展,SignalR 有兩個常見的問題:
1. 連線協商需要黏著(Sticky Session)
SignalR 建立連線時會先發一個 negotiate 請求,再用拿到的連線 ID 建立正式連線。如果這兩個請求被負載平衡器分到不同的伺服器,連線就會失敗。解法有兩種:
- 負載平衡器(或 Kubernetes Ingress)開啟 Sticky Session
- 確定所有用戶端都支援 WebSocket 時,前端設定
skipNegotiation: true並指定只用 WebSocket,就不需要黏著
2. 推播給「其他連線」需要 Backplane
串流回覆是在同一條連線上一問一答,不受影響。但如果要做到「使用者在 A 分頁新增了聊天室,B 分頁的列表也同步更新」,而兩個分頁剛好連到不同的伺服器,伺服器 A 就推不到 B 分頁。這時需要 Backplane(例如 Redis)讓所有伺服器之間轉送訊息:
builder.Services.AddSignalR().AddStackExchangeRedis(redisConnectionString);
使用情境:企業內部 GPT 平台
在公司內部的 GPT 平台中,聊天室的對話就是用這套方式實作:
- 前端仿照 ChatGPT 的介面,使用者送出訊息後,回答透過 SignalR 串流逐字出現
- 後端以串流模式呼叫 GPT-4,每一段都立即推給前端,整段結束後才把完整回答與 Token 用量寫進聊天室
- 使用者按下停止或關閉分頁時,取消會一路傳到 OpenAI 的請求,避免在背景繼續消耗 Token
一句話總結
使用者感受到的速度取決於第一個字什麼時候出現,而不是總時間;用 SignalR Hub 回傳 IAsyncEnumerable<T>,就能把 OpenAI 的串流回覆一段一段推到瀏覽器,記得加上 [EnumeratorCancellation] 讓取消一路傳到底、串流結束後才寫資料庫,多台部署時處理好 Sticky Session 與 Backplane。