ChatGPT 聊天程式練習 - 使用 .NET + Azure OpenAI API
2 |
半年過去,大家已學會平常心看待 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