【C#】OpenAI の GPT 3.5 を使って AI とチャットするプログラムを作る【.Net Standard 2.0】

興味があったので、こちらの記事を参考に ChatGPT と対話するコンソールアプリを C# で作りました。
今回作成したのは、OpenAI との通信部分だけで、アバターのモデル表示などは扱っていません。

本稿では、OpenAI の API を利用し、GPT 3.5 で通信する方法、OpenAI の API を利用する際の注意点などについて説明します。
本稿に掲載しているソースコードは、以下の環境で動作確認しています。

Visual Studio 2022
.Net Standard 2.0
C# 7
Windows 11 Home

もくじ

必要な準備

OpenAI のアカウントを取得する。
登録方法はググれば出てきます。

OpenAI の API にアクセス。
上記のブログカードから OpenAI にアクセス。


※タップまたはクリックすると大きい画像で表示します。

OpenAI のプラットフォーム選択ページで API にアクセス。

ChatGPT にウェブブラウザでアクセスすれば、AI とチャットできますが、自分が作ったプログラムで AI とチャットするには、OpenAI の API を利用する必要があります。

残りのクレジットを確認。

Manager Account > Usage にアクセスして、あとどれくらい OpenAI の API を使えるか確認します。


※タップまたはクリックすると大きい画像で表示します。

Credit used $28.00 は、アカウント登録時に無料でもらえるクレジットです。
このクレジットには有効期限があります。

私のアカウントでは Expired 2023年6月1日(UTC) なので、2023年6月1日午前9時(JST) に期限が切れていて、$18.00 分はもう使えません。
(残り $10 は来年まで待たないともらえません。)
このクレジットの期限が切れていなければ、課金しなくても $18.00 分は OpenAI の API を使えます。
期限が切れている場合は課金する必要があります。

GPT 3.5 なら、$10 課金すれば十分です。$5 でも足りるかも?

API Key を作成する。

C# のコードから OpenAI の API にアクセスするときに、API Key を使って認証します。
API Key とクレジットが紐づいているので、他人に公開しないよう注意が必要です。
自分以外の誰かが、自分の API Key を使うと、自分のクレジットが減ります。
※それを意図してやっている、かつ、第三者と API Key を共有するのが OpenAI の利用規約で許可されていれば問題ないです。

 (vi) buy, sell, or transfer API keys without our prior consent;

Restriction(禁止事項)に、「(vi) 当社の事前の同意なしに API キーを売買または譲渡すること、」

との記載がありますが、API Key を共有することについては不明です。
不明な点は OpenAI に英語で聞いて確認を取る必要があります。
API Key を共有しないなら聞く必要はありません。

API Key は作成したときしか見れないので、メモ帳などに張り付けてローカルに保存しておく必要があります。
(覚えておけるならそれが一番ですが…。)

API Key が分からなくなってしまった場合は、分からなくなった API Key を削除して、新しい API Key を作成するしかないです。

C# のコンソールアプリを作成する。

Visual Studio 2022 を起動して、プロジェクトを新規作成し、コンソール C# を選びます。
以降のデフォルトのターゲットフレームワークの選び方、ターゲットフレームワークを .Net Standard 2.0 に変更する方法は以下の記事で説明しています。

NuGet パッケージをインストール。

Json データのシリアライズ/デシリアライズに便利な NewtonSoft.Json を NuGet パッケージマネージャーでインストールします。

メニュー>ツール>NuGet パッケージマネージャー>ソリューションの NuGet パッケージの管理>参照>NewtonSoft.Json を検索>最新版をインストール

ソースファイルを作成する。

新規で cs ファイルを2つ追加します。

1. HttpRequest.cs
2. GPTChat.cs

Program.cs はプロジェクト作成時に自動的に生成されるので、合計3つの cs ファイルがプロジェクトにある状態にします。

以下をコピペします。

// HttpRequest.cs
using System;
using System.Collections.Generic;
using System.Threading;
using System.Net.Http;

using ResponseTask = System.Threading.Tasks.Task<System.Net.Http.HttpResponseMessage>;

public class SimpleRequest {
	readonly string accessURL;
	readonly Dictionary<string, string> headers = new Dictionary<string, string>();

	public SimpleRequest(string url) {
		if (string.IsNullOrEmpty(url)) { throw new ArgumentNullException("url"); }
		this.accessURL = url;
	}
	public void AddHeader(string name, string value) {
		this.headers.Add(name, value);
	}
	public void ClearHeaders() {
		this.headers.Clear();
	}
	public ResponseTask Post(StringContent content) {
		var client  = new HttpClient();
		var request = new HttpRequestMessage(HttpMethod.Post, this.accessURL);
		foreach (KeyValuePair<string, string> header in this.headers) {
			request.Headers.Add(header.Key, header.Value);
		}
		this.ClearHeaders();
		request.Content = content;
		return client.SendAsync(request);
	}
}
// GPTChat.cs
using Newtonsoft.Json;
using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Text;

using ResponseTask = System.Threading.Tasks.Task<System.Net.Http.HttpResponseMessage>;

public class GPTChatV35 {
	// json 形式にシリアライズする。
	[Serializable]
	public class MessageV35 {
		public string role;
		public string content;
	}

	// json 形式にシリアライズする。
	[Serializable]
	public class CompletionRequestV35 {
		public string           model;
		public List<MessageV35> messages;
	}

	// OpenAI からの返答を受け取るためのデータ。
	// 階層構造とフィールド変数名が受け取った json データと完全に一致している必要がある。
	// バージョンアップ時に変更される可能性があるので、利用する GPT のバージョンに合わせて
	// 修正が必要になる場合がある。
	// json 形式からデシリアライズする。
	[Serializable]
	public class ResponseV35 {
		public string   id;
		public string   @object;
		public int      created;
		public string   model;
		public Choice[] choices;
		public Usage    usage;

		[Serializable]
		public class Choice {
			public int        index;
			public MessageV35 message;
			public string     finish_reason;
		}

		[Serializable]
		public class Usage {
			public int prompt_tokens;
			public int completion_tokens;
			public int total_tokens;
		}
	}

	public string APIKey    { get; protected set; }
	public string AccessURL { get; protected set; }
	public string Character { get; protected set; }

	protected virtual string DefaultURL { get { return "https://api.openai.com/v1/chat/completions"; } }
	protected virtual string Version    { get { return "gpt-3.5-turbo"; } }

	//	他のバージョンでも GPT 3.5 と構造が変わらないなら継承先で使用できる。
	//	変わる場合は MessageV40 などを新しく作成し、MessageV35 を使用している
	//	全てのメソッドをオーバーライドする必要がある。
	protected List<MessageV35> MessagesV35 { get; set; } = new List<MessageV35>();

	//	チャットが終わったら true になる。
	//	AI との通信が終わったかどうかを判定する目的で使う。
	//	AI と通信を行う前に例外がスローされた場合は true にならない。
	public bool IsFinished { get; protected set; } = false;

	static readonly string Default_Character = "語尾に「ヒャッハー!」をつけて";

	public GPTChatV35(string api_key, string url, string character) {
		if (string.IsNullOrEmpty(api_key)) { throw new System.ArgumentNullException("api_key"); }
		this.APIKey    = api_key;
		this.AccessURL = url;
		this.Character = character;
	}

	public virtual async void Send(string prompt, Func<ResponseV35, bool> response_callback) {
		this.AddSystemContentToMessages();

		if (string.IsNullOrEmpty(this.AccessURL)) { this.AccessURL = this.DefaultURL; }

		var request = new SimpleRequest(this.AccessURL);

		this.AddHeadersToRequest(request);
		this.AddUserContentToMessages(prompt);

		StringContent       send_content = this.GetStringContentFromMessages();
		HttpResponseMessage response     = await request.Post(send_content);

		if (response.StatusCode == System.Net.HttpStatusCode.OK) {
			string response_content = await response.Content.ReadAsStringAsync();
			if (!string.IsNullOrEmpty(response_content)) {
				ResponseV35 deserialized_content = JsonConvert.DeserializeObject<ResponseV35>(response_content);
				if (deserialized_content != null ) {
					//	success
					this.IsFinished = true;
					_ = response_callback.Invoke(deserialized_content);
				}
			}
		} else {
			//	error
			_ = response_callback.Invoke(new ResponseV35());
		}
	}

	protected virtual void AddSystemContentToMessages() {
		string character = this.Character;
		if (string.IsNullOrEmpty(this.Character)) {
			character = GPTChatV35.Default_Character;
		}
		var message = new MessageV35() {
			role    = "system",
			content = character,
		};
		this.MessagesV35.Add(message);
	}

	protected virtual void AddHeadersToRequest(SimpleRequest request) {
		request.AddHeader("Authorization"   , "Bearer " + this.APIKey);
		request.AddHeader("X-Slack-No-Retry", "1");
	}

	protected virtual void AddUserContentToMessages(string prompt) {
		var message = new MessageV35() {
			role    = "user",
			content = prompt,
		};
		this.MessagesV35.Add(message);
	}

	protected virtual StringContent GetStringContentFromMessages() {
 		var options = new CompletionRequestV35() {
			model    = this.Version,
			messages = this.MessagesV35,
		};
		string json    = JsonConvert.SerializeObject(options);
		var    content = new StringContent(json, Encoding.UTF8, "application/json");
		return content;
	}
}
// Program.cs
using System;

class Program {
	static readonly string Default_Api_Key = "ここに自分の API Key をコピペ";

	static bool IsReceived { get; set; } = true;

	static void Main(string[] args) {
		Console.WriteLine("Start OpenAI GPT chat" + System.Environment.NewLine);

		bool must_input_api_key      = false;
		bool must_input_ai_character = false;

		foreach (string arg in args) {
			if (arg == "-k") { must_input_api_key      = true; }
			if (arg == "-c") { must_input_ai_character = true; }
		}
		do {
			bool must_finish = false;
			string api_key   = string.Empty;
			string character = string.Empty;

			if (must_input_api_key) {
				api_key = Program.InputAPIKey(ref must_finish);
				if (must_finish) { break; }
			} else {
				if (string.IsNullOrEmpty(Default_Api_Key)) {
					Console.WriteLine("Default_Api_Key が未設定のため、コマンドライン引数に -k を指定する必要があります。");
					break;
				}
			}
			if (must_input_ai_character) {
				character = Program.InputAICharacter(ref must_finish);
				if (must_finish) { break; }
			}
			if (string.IsNullOrEmpty(api_key)) { api_key = Default_Api_Key; }

			Program.InputPrompt(api_key, character);
		}
		while (false);

		Console.WriteLine(System.Environment.NewLine + "Finished");
	}

	static bool MustFinishOrNot(string prompt) {
		if (string.IsNullOrEmpty(prompt)) { return false; }
		if (prompt.ToLower() == "q" || prompt.ToLower() == "quit" || prompt.ToLower() == "exit") {
			return true;
		}
		return false;
	}

	static string GetInputString(ref bool must_finish, string prompt_message) {
		while (!must_finish) {
			Console.Write("{0} ? ", prompt_message);

			string prompt = Console.ReadLine();
			if (string.IsNullOrEmpty(prompt)) { continue; }

			prompt = prompt.Trim();
			must_finish = MustFinishOrNot(prompt);

			return prompt;
		}
		return string.Empty;
	}

	static string InputAPIKey(ref bool must_finish) {
		return Program.GetInputString(ref must_finish, "OpenAI API Key");
	}

	static string InputAICharacter(ref bool must_finish) {
		return Program.GetInputString(ref must_finish, "AI Character");
	}

	static void InputPrompt(string api_key, string character) {
		var  chat        = new GPTChatV35(api_key, string.Empty, character);
		bool must_finish = false;

		while (!must_finish) {
			if (!Program.IsReceived) { continue; }

			string prompt = Program.GetInputString(ref must_finish, "Prompt");

			if (must_finish) { continue; }
			if (string.IsNullOrEmpty(prompt)) { continue; }

			Program.IsReceived = false;
			Console.WriteLine("waiting for reply...");

			chat.Send(prompt, Program.OnReceived);
		}

		Program.WaitForFinish(chat);
	}

	static bool OnReceived(GPTChatV35.ResponseV35 response) {
		if (response == null) { return false; }
		Console.WriteLine(response.choices[0].message.content);
		Program.IsReceived = true;
		return true;
	}

	static void WaitForFinish(GPTChatV35 chat) {
		while (!chat.IsFinished) { System.Threading.Thread.Sleep(1); }
	}
}

掲載したコードについて。

掲載したコードは .Net Standard 2.0 に合わせて作成したものです。
対応する C# のバージョンは 7 です。
そのため、文法が古いです。
これは、Unity のマルチプラットフォーム対応 DLL に移植することを想定しているためです。

Program.Default_API_Key に、自分の API Key をコピペしてください。
デフォルトの API Key を指定したくない場合は、string.Empty を指定してください。

AI とチャットする仕組みは単純で、HTTP プロトコルを使って、シリアライズした json 形式のデータを POST しているだけです。
レスポンスも json で返って来るので、デシリアライズしてから必要なデータを抽出するだけです。
プロトコルの詳細は公式のドキュメントを参照。

プログラムの使い方。

プログラムを終了するには、q か quit か exit を入力します。
Ctrl + C で強制終了しても構いませんし、コンソールウィンドウの閉じるボタンで終了しても構いません。


コマンドライン引数に -k を指定することで、プログラム起動時に API Key の入力ができるようになりますが、プログラムを起動する度に入力する必要があり、少々面倒なのでお勧めはしません。
-k を指定しない場合は、Program.Default_Api_Key が使われます。


コマンドライン引数に -c を指定することで、AI の性格を入力できるようになります。
-c を指定しない場合はデフォルトの性格(語尾に「ヒャッハー!」をつける)になります。

例えば、以下のような性格を指示することもできます。

あなたは私の妹で、お兄ちゃんが大好きですが、恥ずかしがり屋で素直に気持ちを伝えることができず、ついつい厳しい言葉を使ってしまいます。たまに素直な気持ちを伝えることがあります。

AI の性格をどのようなものにするかは、この指示が AI にとって適切かどうかにかかっています。
自分の出した指示が AI にとって適切かどうかは、しばらく会話しないと分かりません。

こだわりだすと時間とクレジットが溶けます。

動作確認。

実行中に例外が出たとき。

プログラムを実行して、AI に送信する会話を入力してエンターしたとき、下記のような例外が出てプログラムが中断するかも知れません。

ハンドルされていない例外: System.IO.FileNotFoundException: ファイルまたはアセンブリ 'Newtonsoft.Json, Version=13.0.0.0, Culture=neutral, PublicKeyToken=30ad4fe6b2a6aeed'、またはその依存関係の 1 つが読み込めませんでした。指定されたファイルが見つかりません。
   場所 GPTChatV35.d__29.MoveNext()
   場所 System.Runtime.CompilerServices.AsyncVoidMethodBuilder.Start[TStateMachine](TStateMachine& stateMachine)
   場所 GPTChatV35.Send(String prompt, Func`2 response_callback)
   場所 Program.InputPrompt(String api_key, String character) 場所 C:\Users\ユーザー名\Desktop\OpenAI Test\Program.cs:行 92
   場所 Program.Main(String[] args) 場所 C:\Users\ユーザー名\Desktop\OpenAI Test\Program.cs:行 39

この例外が出たら、以下の対応が必要です。

C:\Users\ユーザー名\.nuget\packages\newtonsoft.json\バージョン\lib\netstandard2.0\Newtonsoft.Json.dll

上記のファイルを、プログラムの実行ファイルがあるフォルダにコピーします。
Debug ビルドした場合は、プロジェクトのフォルダ/bin/Debug/netstandard2.0/ になります。

環境変数で上記の dll にパスを通しても良いと思います。

実行結果。

※タップまたはクリックすると大きい画像で表示します。

うーん…ツンデレ妹にしたかったんですが、ツンが足りないですね…。
距離感はあるけど、仲が悪いわけじゃない妹って感じです。
あと、徐々にキャラが崩れてる。

OpenAI からレスポンスが返ってこないとき。

ここまでの手順に間違いがないなら、クレジットが残っているか確認します。

クレジットが切れた場合、課金してから新しく API Key を作る必要があります。
新しく作った API Key を使います。

クレジットが切れていなくても、たまにレスポンスが返ってこないことがあります。
長文での返答が必要になるような質問を投げると、1分くらいレスポンスが返ってこないこともあります。
5分くらい様子を見てもダメなら、プログラムを再起動します。

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です