13

ChatGPT 聊天程式練習 - 使用 .NET + Azure OpenAI API

 1 year ago
source link: https://blog.darkthread.net/blog/chatgpt-console-chat/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

使用 .NET + Azure OpenAI API-黑暗執行緒

半年過去,大家已學會平常心看待 ChatGPT,了解它的長處跟弱點,不再過度神化,什麼都問再靠北它瞎扯。我認為這才是面對 ChatGPT 的正確心態,認知到生成式 AI 的產出從來就不保證正確,需自負查核複檢之責,方能善用新科技提升競爭力,而不是亂用搞到可能飯碗不保

現在才開始學寫 ChatGPT 程式,明顯慢半拍,不過倒也符合我的風格 - 等技術成熟點再開始用,功能成熟穩定,學習資源多好上手,適合已跑不快的老人。舉個明顯的例子:Azure OpenAI Studio 在幾個月前還只有 JSON、Python、curl 範例,不提供 C#,現在已經有了 SDK 也有現成範例。

Fig1_638278830793134643.png

關於 Azure OpenAPI Service 的概念、申請、費用、Token 計算、開發... 大家若跟我一樣剛要下水,MVP 黯雲有一系列文章是不錯的入門,建議可從這篇 Azure OpenAI Service - Azure OpenAI Service 概觀開始,裡面有所有文章的連結。

基本上使用 ChatGPT API,可以接 OpenAI 提供的 API 也可以接 Azure OpenAI API,基本上兩個 API 規格相容,改 URL 跟 Api Key 就可切換來源。Azure 版本的好處是若企業已導入 Azure,Azure OpenAPI Service 在 SLA 保證、多區域備援、AD 整合、虛擬網路整合、合規要求上較符企業需要。至於個人使用者,若你手上有學生方案或 Visual Studio 訂閱,有一些免費額度可以玩(學生 100 USD/年、VS Enterprise 訂閱 150 USD/月、或有信用卡註冊前 30 天 200 USD 額度,等於可以免費玩玩 ChatGPT API,這些是值得試試 Azure OpenAI Service 的理由。

不過 Azure OpenAI Service 採取申請審核制(理由是要管控 AI 技術不被誤用濫用或惡意使用),需用組織、公司信箱(不接受 Hotmail、Outlook、Gmail 等免費個人信箱)提出申請,網路上成功分享蠻多的,感覺不難通過申請,學生用學校名義即可,總之試試無妨。(不知是否因為 AI 熱潮已退,現在核准很快,我不到一個工作天便收到回覆)

但有個壞消息,申請核准後只會有 ChatGPT 3.5,想用 ChatGPT 4 需另外再申請,得乖乖排隊。
(David 老師前幾天開心分享他排超久終於輪到了,登楞! 上週才提申請的我應該還有得等...)

Fig2_638278830797421959.png

ChatGPT 3.5 或 4 只是模型不同,程式寫法一樣,不妨礙學習,那就來個簡單練習:用 C# 呼叫 Azure OpenAI API 寫一個可以跟 ChatGPT 聊天的 .NET Console 程式。

我開了一個 .NET 7 Console 專案,用 dotnet add package Azure.AI.OpenAI --prerelease 加入 Beta 版 OpenAI API 程式庫。參考 Azure OpenAI Studio 遊樂場給的 C# 範例,先寫好服務類別。

要跟 ChatGPT 聊天時,若要 ChatGPT 記得先前的對話內容繼續延伸,需將先前雙方的對話內容以 ChatMessage 物件形式當參數傳給 ChatGPT。每個 ChatMessage 用 ChatRole 識別為 System (初始化提示,定義 ChatGPT 扮演角色)、User (使用者)、Assistant (ChatGPT 的回應內容)、Function Calling (完成函式)。

由於呼叫傳入的所有對話內容會拆分成 Token 參考,而一次所能傳入的 Token 數量有上限且要算錢,故不能無限累積所有對話內容,一般常見做法是取最近的 N 則。我採用的實作方法是宣告一個自訂類別 ChatRecord 儲存對話內容,使用 Queue<ChatRecord> 保存對話過程,當數量大於 10 筆時,捨棄最早的內容直到筆數等於 10。(更精緻的做法應是請 ChatGPT 摘要先前對話濃縮成簡短大綱)

ChatGPT 生成內容有很多參數可以調,例如:回應字數上限、溫度、頂端 P (類似溫度可控制隨機性)、頻率罰則 及 目前狀態罰則 (用來減少文字重複性)... 等,剛開始學走路就都用預設值吧。

生成結果回傳方式有兩種,GetChatCompletionsAsync() 是全部生成好再一次回傳、GetChatCompletionsStreamingAsync() 則是產生過程以串流方式傳回。

至於結果,型別為 ChatCompletions,Choices 為多個聊天回覆選項的集合,從中擇一(我用 .First()) 取 .Message.Content 即為回覆文字。

我先從簡單的同步回傳開始,以下是一次取回結果的版本:

using Azure;
using Azure.AI.OpenAI;

public class ChatRecord
{
    public DateTime Time { get; set; }
    public string Role { get; set; } // U-User, A-Assistant
    public string Message { get; set;}
    public ChatRecord(string msg, string role = "U") {
        this.Message = msg;
        this.Role = role;
    }
}

public class ChatGptService
{
    private readonly string apiUrl;
    private readonly string apiKey;
    private readonly string deployName;
    OpenAIClient client;
    public float Temperature = (float)0.7;
    public int MaxTokens = 800;
    public float NucleusSamplingFactor = (float)0.95;
    public int FrequencyPenalty = 0;
    public int PresencePenalty = 0;
    public string SystemPrompt = "你是一名 AI 助理,使用繁體中文提供解答";

    public ChatGptService(string apiUrl, string apiKey, string deployName)
    {
        this.apiUrl = apiUrl;
        this.apiKey = apiKey;
        this.deployName = deployName;
        client = new OpenAIClient(
            new Uri(apiUrl),
            new AzureKeyCredential(apiKey));

    }

    public async Task<string> Generate(IEnumerable<ChatRecord> contextMessags)
    {
        var chatMessages = new List<ChatMessage>() {
                new ChatMessage(ChatRole.System, SystemPrompt)
        };
        chatMessages.AddRange(contextMessags.Select(m => new ChatMessage(
            m.Role == "A" ? ChatRole.Assistant : ChatRole.User,
            m.Message
        )));

        Response<ChatCompletions> response = await client.GetChatCompletionsAsync(
        deploymentOrModelName: deployName,
        new ChatCompletionsOptions(chatMessages)
        {
            Temperature = Temperature,
            MaxTokens = MaxTokens,
            NucleusSamplingFactor = NucleusSamplingFactor,
            FrequencyPenalty = FrequencyPenalty,
            PresencePenalty = PresencePenalty
        });

        ChatCompletions completions = response.Value;
        return completions.Choices.First().Message.Content;
    }
}

Console 輸入流程我簡單搭一下,能動就好。裡面有用上昨天介紹的環境變數保密儲存技巧保存 API Key,ChatGPT 回應需要時間,我用了 Task.Run() + CancellationToken 技巧在等待過程印出 ... 減少使用者焦慮(謎:只有你這種急性子才需要吧?),程式範例如下:

using System.Diagnostics;
using System.Text;
using System.Security.Cryptography;
using System.Text.RegularExpressions;
Func<string, string> GetOrSetEnvVar = (varName) =>
{
    var val = Environment.GetEnvironmentVariable(varName, EnvironmentVariableTarget.User);
    if (!string.IsNullOrEmpty(val))
    {
        try
        {
            val = Encoding.UTF8.GetString(
                ProtectedData.Unprotect(Convert.FromBase64String(val), null, DataProtectionScope.CurrentUser));
            return val;
        }
        catch
        {
            Console.WriteLine("非有效加密格式,請重新輸入");
        }
    }
    Console.Write($"請設定[{varName}]:");
    val = Console.ReadLine() ?? string.Empty;
    //加密後存入環境變數
    var enc =
        Convert.ToBase64String(
            ProtectedData.Protect(Encoding.UTF8.GetBytes(val), null, DataProtectionScope.CurrentUser));
    Environment.SetEnvironmentVariable(varName, enc, EnvironmentVariableTarget.User);
    return val;
};

var apiUrl = GetOrSetEnvVar("OpenAI_Url");
var apiKey = GetOrSetEnvVar("OpenAI_Key");
var deployName = "GPT35-16K";
var chatSvc = new ChatGptService(apiUrl, apiKey, deployName);
var context = new Queue<ChatRecord>();
var sb = new StringBuilder();
Console.WriteLine("ChatGPT API 練習");
Console.WriteLine("/new 開始新對話、/quit 結束程式");
Action showPrompt = () =>
{
    Console.ForegroundColor = ConsoleColor.Yellow;
    Console.Write(">");
};
showPrompt();
while (true)
{
    var line = Console.ReadLine();
    if (line == "/quit")
    {
        Print("再見,下次再聊", ConsoleColor.Green);
        Console.ResetColor();
        break;
    }
    else if (line == "/new")
    {
        Print("清空對話,重新開始", ConsoleColor.Magenta);
        context.Clear();
    }
    else if (string.IsNullOrEmpty(line) && Regex.IsMatch(sb.ToString(), "\\S"))
    {
        // 送出內容
        context.Enqueue(new ChatRecord(sb.ToString()));
        Console.ForegroundColor = ConsoleColor.Cyan;
        Console.Write("傳送中,請稍侯");

        var cts = new CancellationTokenSource();
        var cancelToken = cts.Token;
        var progress = Task.Run(async () =>
        {
            while (!cancelToken.IsCancellationRequested)
            {
                await Task.Delay(500, cancelToken);
                Console.Write(".");
            }
        }, cancelToken);
        
        var ts = new Stopwatch();
        ts.Start();
        var res = await chatSvc.Generate(context);
        ts.Stop();

        // 將回答也納入上下文
        context.Enqueue(new ChatRecord(res, "A"));
        // 上下文內容有 Token 數上限,不能無限累加,取最新十次對話
        // TODO: 更精巧做法是將先前對話摘要成精簡版
        while (context.Count > 10) context.Dequeue();

        // 印出回答
        cts.Cancel();
        Console.WriteLine($" {ts.ElapsedMilliseconds:n0}ms");
        Print(res, ConsoleColor.White);
    }
    else
    {
        sb.AppendLine(line);
    }
}

void Print(string msg, ConsoleColor color = ConsoleColor.White)
{
    Console.ForegroundColor = color;
    Console.WriteLine(msg);
    showPrompt();
}

實測,由於我們有傳送先前對話內容,如下圖所示,在問完 JavaScript 排序後,加問一句 "那 C# 呢?",ChatGPT 便知道是同一個題目改用 C# 解:

Fig3_638278830800892212.png

但由以上測試會發現,使用者需要乾等 9 秒及 6 秒才會看到結果,而不像 ChatGPT 網站會分段輸出。而 Azure OpenAI API 程式庫也有提供串流形式的回傳方式。我們修改 ChatGptService 加入一個 版本:

    public async IAsyncEnumerable<String> StreamingGenerate(IEnumerable<ChatRecord> contextMessags)
    {
        var chatMessages = new List<ChatMessage>() {
                new ChatMessage(ChatRole.System, SystemPrompt)
        };
        chatMessages.AddRange(contextMessags.Select(m => new ChatMessage(
            m.Role == "A" ? ChatRole.Assistant : ChatRole.User,
            m.Message
        )));
        Response<StreamingChatCompletions> response = await client.GetChatCompletionsStreamingAsync(
        deploymentOrModelName: deployName,
        new ChatCompletionsOptions(chatMessages)
        {
            Temperature = Temperature,
            MaxTokens = MaxTokens,
            NucleusSamplingFactor = NucleusSamplingFactor,
            FrequencyPenalty = FrequencyPenalty,
            PresencePenalty = PresencePenalty
        });
        // https://blog.darkthread.net/blog/iasyncenumerable-in-mvc/
        await foreach (var choice in  response.Value.GetChoicesStreaming()) {
            await foreach (var msg in choice.GetMessageStreaming()) {
                yield return msg.Content;
            }
        }
    }

GetChatCompletionsStreamingAsync() 傳回的 StreamingChatCompletions 型別,Choice 是以 IAsyncEnumerable<StreamingChatChoice> 形式傳回,而其下的 Message 則是以 IAsyncEnumerable<ChatMessage> 形式傳回,幸好之前研究過 ASP.NET Core IAsyncEnumerable 與 yield return,現在派上用場,我用兩層 await foreach 加 yield return 接收,這個串流版函式的回傳型別也要改成 IAsyncEnumerable<String>,再次驗證了 async 病毒傳染性

由於 ChatGPT 的回答是以 IAsyncEnumerable<String> 形式分批傳回,呼叫端這裡也要小小改寫:

    else if (string.IsNullOrEmpty(line) && Regex.IsMatch(sb.ToString(), "\\S")) {
        // 送出內容
        context.Enqueue(new ChatRecord(sb.ToString()));
        sb.Clear();
        Console.ForegroundColor = ConsoleColor.Cyan;
        Console.WriteLine("傳送中,請稍侯...");
        Console.ForegroundColor = ConsoleColor.White;
        await foreach (var msg in chatSvc.StreamingGenerate(context)) {
            sb.Append(msg);
            Console.Write(msg);
        }
        // 將回答也納入上下文
        context.Enqueue(new ChatRecord(sb.ToString(), "A"));
        // 上下文內容有 Token 數上限,不能無限累加,取最新十次對話
        // TODO: 更精巧做法是將先前對話摘要成精簡版
        while (context.Count > 10) context.Dequeue();

        Print("");
    }

改為串流後,回答會分段顯示出來(雖然分批間隔有點久),接近 ChatGPT 網頁的操作體驗,比空等十秒再一次冒出結果順暢一點。

Fig4_638278830805919648.gif

練習完畢。


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK