在玩 ChatGPT 的時候,對於如何將聊天機器人的回應,即時的一字字依序輸出在網頁上,這樣的互動體驗非常有沉浸感,讓人真的有在和 AI 對話的感覺。當我們想要使用 ASP.NET Core WebAPI 以及 JavaScript 來呈現這樣的體驗時,如何讓後端 API 串流的輸出內容,並讓 JavaScript 在呼叫該 API 後能依序接受這樣的資料流,這時候問題就來了,兩者之間要如何串流資料呢?這篇提供一種處理方法。

API 端

在 ASP.NET Core 5 的時候,MVC 框架就開始支援輸出 IAsyncEnumerable<T> 類別,到了 ASP.NET Core 6 的時候,開始使用 System.Text.Json 格式化,從這時候開始,ASP.NET Core 可以用較少的記憶體使用量來處理串流資料。

由於 ASP.NET Core 已經在內部完成了相對應的處理,因此要在 WebAPI 中使用 IAsyncEnumerable<T> 並不困難,只要在回傳值的地方,使用 yield return 就可以了。

這裡透過 Task.Delay 來模擬依序回傳資料,在真實案例中,這段程式碼可以是你使用 Azure.AI.OpenAI 套件呼叫 GetChatCompletionsStreamingAsync(),取得 GPT 的串流回應。下面是個簡單的模擬範例:

[ApiController]
[Route("[controller]")]
public class StreamController : ControllerBase
{
    [HttpGet]
    [Route("object")]
    public async IAsyncEnumerable<Demo> GetObject()
    {
        for (int i = 0; i < 10; i++)
        {
            await Task.Delay(TimeSpan.FromSeconds(1));
            yield return new Demo { Id = i, Name = Guid.NewGuid().ToString() };
        }
    }
}

JavaScript 端

至於前端該如何呼叫這個 API 並接收串流的資料呢?

這裡我使用 fetch 來呼叫 API,這部分的操作方式和以前一樣。不同的是,接收到回傳值的時候,由於是資料流的形式,因此要使用 getReader() 方法來取得 response.body 的可讀取資料流 ReadableStream(詳參考 MDN - ReadableStreamUsing readable streams),在依序接收串流資料時,我們可以根據每一段收到的資料,額外進行處理。

至於要做甚麼處理,這裡我寫成 callback 的方式,讓使用者可以自行定義處理方式,這樣就可以達到更大的彈性。

function stream(callback) {
    const myHeaders = new Headers();
    myHeaders.append('Content-Type', 'application/json');
    const requestOptions = {
        method: 'GET',
        headers: myHeaders,
        redirect: 'follow',
    };
    fetch('http://localhost:5213/stream/object', requestOptions)
        .then((response) => {
            const reader = response.body.getReader();
            // 處理資料流中的每個資料區塊
            reader.read().then(function pump({ done, value }) {
                // "done" 是布林值,代表該資料流是否結束
                // "value" 是 Uint8Array,代表每個資料區塊的內容值
                // 當收到資料流結束的訊號,則關閉此資料流
                if (done) return;

                // 將 Uint8Array 轉成 string,然後交給 callback 函式處理
                let data = new TextDecoder().decode(value);
                callback(tolerantParse(data));

                // 讀取下一段資料區塊
                return reader.read().then(pump);
            });
        })
        .catch((error) => console.log('error', error));
}

眼尖的你可能會發現,在執行 callback 的時候,有先將每個區塊的資料通過 tolerantParse() 方法做處理,這是因為 ASP.NET Core WebAPI 在 IAsyncEnumerable<T> 的回傳值中,整體來說是一個陣列,因此第一筆區塊會是以 [ 開頭,中間的區塊則會以 , 開頭,最後一筆還用 ] 做結尾,你可以想像成下面的資料範例每一行就是一個區塊:

[{"id":0,"name":"8acab337-5065-4879-be66-01a3ca46fb3d"}
,{"id":1,"name":"8ba7d07b-aff6-4aaf-9de5-483c9797fab1"}
,{"id":2,"name":"e87fa3b0-9e6b-47f5-b8ea-c964d7bc5eef"}
,{"id":3,"name":"b17e4839-2a71-4699-a483-c6cb0565ca2f"}]

因此必須對這些區塊值做處理,才能正確的轉成正確的 JSON 格式,再交給 callback 函式處理。

function tolerantParse(str) {
    // 移除開頭的逗號和 [ 符號
    if (str.startsWith(',') || str.startsWith('[')) str = str.slice(1).trim();
    // 移除結尾的 ] 符號
    if (str.endsWith(']')) str = str.slice(0, -1);
    return `[${str}]`;
}

最後則是前端 JavaScript 的呼叫使用,看你要怎麼對這些資料做處理,這裡我只是單純的將資料印在網頁上。

stream((data) => {
    console.log(data);
    document.getElementById('response').innerHTML += data + '<br>';
});

執行效果

本篇完整範例程式碼請參考 poychang/demo-AsyncEnumerableApi

後記

這篇的作法為了處理 IAsyncEnumerable<T> JSON 序列化後的格式,在前端做了一些妥協,不得不額外做特別的處理。後來針對這件事情重新思考了一下,在針對「模擬聊天情境,即時的一字字依序輸出在網頁上」這個目標下,重新設計 API 的回傳方式,讓前端可以更容易的處理串流資料。

關於這件事,請詳閱在 ASP.NET Web API 使用 IAsyncEnumerable 並串流至 JavaScript - PART 2的內容及範例程式碼,請先參考這篇文章。


參考資料:


Poy Chang

Trial and Error