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

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

關於 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 老師前幾天開心分享他排超久終於輪到了,登楞! 上週才提申請的我應該還有得等...)

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# 解:

但由以上測試會發現,使用者需要乾等 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 網頁的操作體驗,比空等十秒再一次冒出結果順暢一點。

練習完畢。

Example of using Azure OpenAI ChatGPT model to implement a simple console chat interface.


Comments

# by Benson

https://learn.microsoft.com/en-us/answers/questions/1459392/cant-azure-for-students-subscription-use-openai-se 是不是沒得玩了

# by Jeffrey

to Benson, 找到另一則討論也是相同回覆,免費訂閱及學生方案現在沒法用 Azure OpenAI 了。 https://learn.microsoft.com/en-us/answers/questions/1460307/student-subscription-no-longer-permits-the-creatio

Post a comment