ASP.NET Core 簡單給 Phi 模型封裝一個服務

lindexi發表於2024-12-08

我師弟跑路了,留給我一臺特別好的裝置,一臺帶 NVIDIA GeForce RTX 3090 Ti 顯示卡的裝置。我在這臺裝置上可以輕鬆透過 DirectML 跑起來 Phi 模型。既然已經跑起來模型了,我就想著能否用這個模型給更多的應用賦能。其中我想到的第一步就是搭建一個 Http 服務,讓其他裝置可以呼叫到這臺好裝置,從而在這臺好裝置上跑模型,得到的結果透過 http 返回給到其他裝置

這樣一來,其他裝置也就能享受到 Phi 模型帶來的智慧化

在 ASP.NET Core 框架的幫助下,給 Phi 模型呼叫封裝一個服務是非常簡單的事情,我甚至在一個 Program.cs 檔案裡面不到 200 行程式碼就完成了

首先是基於 dotnet 基於 DirectML 控制檯執行 Phi-3 模型 部落格提供的方法用 DirectML 跑起來 Phi-3 模型。如何用 DirectML 跑起來 Phi-3 模型部分本文就一筆帶過,感興趣的夥伴還請閱讀我之前的部落格。放心,全部的程式碼都可以在本文末尾找到下載的方法

準備的程式碼如下

var folder = @"C:\lindexi\Phi3\directml-int4-awq-block-128\";
if (!Directory.Exists(folder))
{
    folder = Path.GetFullPath(".");
}

Model model = new Model(folder);

還請大家將資料夾路徑更換為自己的模型資料夾

儘管 NVIDIA GeForce RTX 3090 Ti 顯示卡已經很強大了,但這臺裝置日常還有其他活。為了不被點爆。我這裡新增了一個 SemaphoreSlim 用來控制併發量,程式碼如下

var semaphoreSlim = new SemaphoreSlim(initialCount: 1, maxCount: 1);

每次進入請求的時候,都會等待一下 SemaphoreSlim 從而控制進入到模型計算裡,每次最多隻有一次請求。其他請求就依次進行排隊。為什麼能依次呢?因為這是利用了 SemaphoreSlim 帶來的額外功能,詳細請看 C# dotnet 的鎖 SemaphoreSlim 和佇列

再以下就是通用的初始化 ASP.NET Core 主機的程式碼

var builder = WebApplication.CreateBuilder(args);
builder.WebHost.UseUrls("http://0.0.0.0:5017");
builder.Services.AddLogging(loggingBuilder => loggingBuilder.AddSimpleConsole());

var app = builder.Build();

我約定了其他裝置的請求採用的是 HTTP 的 POST 請求方式,為了接受和約定請求,我就定義了名為 ChatRequest 的型別。這個型別只有 Prompt 提示詞一個引數,因為這正是模型所需的必備引數

record ChatRequest(string Prompt)
{
}

完成請求類的定義之後,接下來即可宣告 POST 的路徑對映了,程式碼如下

app.MapPost("/Chat", async (ChatRequest request, HttpContext context, [FromServices] ILogger<ChatSessionLogInfo> logger) =>
{
     ... // 忽略其他程式碼
});
app.Run();

以上程式碼傳入的委託裡面,只有 ChatRequest 是從其他裝置傳送過來的,其他兩個引數都是框架提供的

先設定 http 響應,包括設定狀態碼和設定開始,程式碼如下

app.MapPost("/Chat", async (ChatRequest request, HttpContext context, [FromServices] ILogger<ChatSessionLogInfo> logger) =>
{
    var response = context.Response;
    response.StatusCode = (int) HttpStatusCode.OK;
    await response.StartAsync();

     ... // 忽略其他程式碼
});

設定狀態碼一定要在 StartAsync 之前,呼叫 StartAsync 時,客戶端將會收到 HTTP 的響應頭了。在 StartAsync 和 CompleteAsync 之間,就可以慢慢傳送 Body 過去

進入 StartAsync 之後,再等待訊號量。如此可以先讓客戶端收到 HTTP 的頭,不會讓客戶端等得太無聊

app.MapPost("/Chat", async (ChatRequest request, HttpContext context, [FromServices] ILogger<ChatSessionLogInfo> logger) =>
{
    var response = context.Response;
    response.StatusCode = (int) HttpStatusCode.OK;
    await response.StartAsync();
    await semaphoreSlim.WaitAsync();

    try
    {
         ... // 忽略其他程式碼
    }
    finally
    {
        semaphoreSlim.Release();
        await response.CompleteAsync();
    }
});

完成基礎框架邏輯之後,咱就可以開始讓模型處理其他裝置傳送過來的提示詞資訊,核心程式碼如下

        var prompt = request.Prompt;

        logger.LogInformation($"Session={sessionName};TraceId={traceId}\r\nPrompt={request.Prompt}");

        var generatorParams = new GeneratorParams(model);

        using var tokenizer = new Tokenizer(model);
        var sequences = tokenizer.Encode(prompt);

        generatorParams.SetSearchOption("max_length", 1024);
        generatorParams.SetInputSequences(sequences);
        generatorParams.TryGraphCaptureWithMaxBatchSize(1);

        using var tokenizerStream = tokenizer.CreateStream();
        using var generator = new Generator(model, generatorParams);

        var stringBuilder = new StringBuilder();

        while (!generator.IsDone())
        {
            generator.ComputeLogits();
            generator.GenerateNextToken();

            // 每次只會新增一個 Token 值
            // 需要呼叫 tokenizerStream 的解碼將其轉為人類可讀的文字
            // 由於不是每一個 Token 都對應一個詞,因此需要根據 tokenizerStream 壓入進行轉換,而不是直接呼叫 tokenizer.Decode 方法,或者呼叫 tokenizer.Decode 方法,每次都全部轉換

            var text = Decode();

            // 有些時候這個 decodeText 是一個空文字,有些時候是一個單詞
            // 空文字的可能原因是需要多個 token 才能組成一個單詞
            // 在 tokenizerStream 底層已經處理了這樣的情況,會在需要多個 Token 才能組成一個單詞的情況下,自動合併,在多個 Token 中間的 Token 都返回空字串,最後一個 Token 才返回組成的單詞
            if (!string.IsNullOrEmpty(text))
            {
                stringBuilder.Append(text);
            }

            await streamWriter.WriteAsync(text);
            await streamWriter.FlushAsync();


            string? Decode()
            {
                // 這裡的 tokenSequences 就是在輸入的 sequences 後面新增 Token 內容
                ReadOnlySpan<int> tokenSequences = generator.GetSequence(0);
                // 取最後一個進行解碼為文字
                var decodeText = tokenizerStream.Decode(tokenSequences[^1]);

                //// 當前全部的文字
                //var allText = tokenizer.Decode(tokenSequences);

                return decodeText;
            }
        }

對於服務化來說,以上程式碼最核心的就是當模型每吐出一個字的時候,就呼叫一次 await streamWriter.WriteAsync(text) 將其傳送出去

由於我的業務需求都在一個區域網內,我就無視 await streamWriter.WriteAsync(text) 的耗時了。如果大家準備跨網傳送,則可以再使用 Channel 進行最佳化,確保讓模型跑得不間斷,不會由於網路速度影響而讓模型沒有全速跑

以上就是使用 ASP.NET Core 簡單給 Phi 模型封裝一個服務的方法

本文程式碼放在 githubgitee 上,可以使用如下命令列拉取程式碼。我整個程式碼倉庫比較龐大,使用以下命令列可以進行部分拉取,拉取速度比較快

先建立一個空資料夾,接著使用命令列 cd 命令進入此空資料夾,在命令列裡面輸入以下程式碼,即可獲取到本文的程式碼

git init
git remote add origin https://gitee.com/lindexi/lindexi_gd.git
git pull origin 006b67e95fb0e1aa832c5a63c27b78a3fd33ee0a

以上使用的是國內的 gitee 的源,如果 gitee 不能訪問,請替換為 github 的源。請在命令列繼續輸入以下程式碼,將 gitee 源換成 github 源進行拉取程式碼。如果依然拉取不到程式碼,可以發郵件向我要程式碼

git remote remove origin
git remote add origin https://github.com/lindexi/lindexi_gd.git
git pull origin 006b67e95fb0e1aa832c5a63c27b78a3fd33ee0a

獲取程式碼之後,進入 Bp/JearnugurgelrurkawdoBeacecidem 資料夾,即可獲取到原始碼

更多技術部落格,請參閱 部落格導航

相關文章