← 回文章列表

2023年7月18日

用 SignalR 串流做出 ChatGPT 的「逐字出現」:IAsyncEnumerable、取消與多實例部署

SignalRWebSocketASP.NET CoreOpenAI即時通訊

問題:等待時間不變,但感受差很多

大型語言模型是一個 Token 一個 Token 生成回答的。一段 800 字的回答,模型可能要花 15~20 秒才全部生成完。

一般 HTTP 與串流的比較:一般 HTTP 要等全部生成完才一次回傳,第一個字大約在 20 秒才出現;串流則是產生一段就推一段,第一個字大約 1 秒就出現。下方是串流的管線:OpenAI API 以 stream 模式回傳,SignalR Hub 以 IAsyncEnumerable 逐段 yield return,透過 WebSocket 推到瀏覽器的 connection.stream

  • 一般 HTTP 請求:後端等 OpenAI 全部生成完、拿到完整回答後才回傳。總時間 20 秒,第一個字也在 20 秒才出現
  • 串流:OpenAI 每生成一小段就送出一小段,後端立刻轉給瀏覽器。總時間一樣是 20 秒,但第一個字在 1 秒內就出現,使用者看著文字一段一段長出來,感覺系統一直在回應

對使用者體驗來說,關鍵指標不是總時間,而是第一個字出現的時間(Time To First Token)。

為什麼選 SignalR

要讓伺服器「連續、主動」把資料推給瀏覽器,常見有三種做法:

做法特性適合
Server-Sent Events(SSE)單向:伺服器 → 瀏覽器;基於一般 HTTP只需要伺服器推送,例如通知、單一問答的串流
WebSocket雙向、全雙工,連線建立後雙方隨時可以傳送需要雙向即時互動,例如聊天、協作
SignalRASP.NET Core 的即時通訊框架,優先使用 WebSocket,不支援時自動降級為 SSE 或 Long Polling.NET 後端、需要雙向通訊,不想自己處理連線管理

聊天室除了串流回覆,還有「使用者中途按停止」「其他分頁同步更新聊天室列表」這些雙向需求。後端是 ASP.NET Core 的情況下,SignalR 幫忙處理了連線建立、重新連線、訊息序列化、降級傳輸等細節,而且直接支援串流。

後端:Hub 方法回傳 IAsyncEnumerable

SignalR 的 Hub 方法只要回傳 IAsyncEnumerable<T>,就自動變成串流方法:每 yield return 一個值,就推一個值給前端。

C#
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() 呼叫串流方法,每收到一段就接在目前的回答後面:

javascript
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 要一路傳下去:

text
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)讓所有伺服器之間轉送訊息:

C#
builder.Services.AddSignalR().AddStackExchangeRedis(redisConnectionString);

使用情境:企業內部 GPT 平台

在公司內部的 GPT 平台中,聊天室的對話就是用這套方式實作:

  • 前端仿照 ChatGPT 的介面,使用者送出訊息後,回答透過 SignalR 串流逐字出現
  • 後端以串流模式呼叫 GPT-4,每一段都立即推給前端,整段結束後才把完整回答與 Token 用量寫進聊天室
  • 使用者按下停止或關閉分頁時,取消會一路傳到 OpenAI 的請求,避免在背景繼續消耗 Token

一句話總結

使用者感受到的速度取決於第一個字什麼時候出現,而不是總時間;用 SignalR Hub 回傳 IAsyncEnumerable<T>,就能把 OpenAI 的串流回覆一段一段推到瀏覽器,記得加上 [EnumeratorCancellation] 讓取消一路傳到底、串流結束後才寫資料庫,多台部署時處理好 Sticky Session 與 Backplane。