この記事は
.NET(C#)からOpenAI APIにアクセスしてGPTモデルとメッセージをやり取りします。GPTモデルはAzureからデプロイしたGPT-5.4-nanoを、ライブラリはNuGetのAzure.AI.OpenAIを利用します。
前提条件
.NETでGPTモデルを利用する場合、モデルとAPIとライブラリのバージョンの組合せがちょっと複雑です。おまけに.NETの場合、サンプルのコードも少なく、ネット上の情報も新旧が入り乱れています。この記事では以下の条件で書いています。
GPT-5以降
モデルはGPT-5以降を利用します。GPT-5以降のモデルは推論モデルと呼ばれ、モデル内部で思考の連鎖のような処理が行われます。この思考の連鎖によってより深い推論を可能にし、より詳細で正確な解答を可能にします。この点が、従来のGPTシリーズとの大きな違いです。
その代わり、ちょっとした質問でも回答にものすごく時間がかかるようになりました。あまり複雑な思考を必要としないFAQのようなチャットアプリであれば、従来のモデルでも十分かもしれません。
Azureでは以下のGPTモデルが利用可能です。
このうちGPT-5以降は gpt-5, gpt-5.1, gpt-5.2, gpt-5.4, gpt-5.5です。さらに、-nano もしくは -mini のサフィックスが付いているモデルが低価格です。
Responses API
GPT-5以降のモデルを利用する場合、OpenAIのResponses APIを利用します。GPT-4まではCompletions API が利用されていました。GPT-4までとGPT-5以降ではモデルの設計思想が大きく変わっているため、この2つのAPIに互換性はありません。また、GPT-4までにあった temperature や max_token などのパラメータはGPT-5以降では全て廃止され、新しいパラメータが採用されています。
ちなみに、GPT-4モデルからメッセージを取得することをChatCompletionもしくはCompletionと表記したりします。Microsoftのドキュメントを読んでいると、これを(機械翻訳したからなのか)「チャット完了」、もしくは単に「完了」と表記して日本語ユーザを惑わせます。GPT-5以降のモデルの場合、モデルからの応答をResponseと表記し、「応答」と訳されています。
Azure.AI.OpenAI 2.8.0-beta.1
ライブラリはNuGetのAzure.AI.OpenAIプレリリース版の2.8.0-beta.1を利用します。安定版の最新バージョンは2.1.0ですが、これにはResponses APIが含まれていません。また、Responses APIの安定版はまだリリースされていないため今後仕様は変わることがあります(実際よく変わります)。
ステートフルとステートレス
Responses APIはデフォルトでステートフルです。ステートフルとは、メッセージやツールコールなどの履歴がAPI側サーバに保持される状態のことです。つまり、アプリ側でメッセージやツールコールなどの履歴を管理する必要はありません。ただし、「API側は履歴を持って」いますが、その履歴と次の新しいメッセージを紐付けることができません。モデルに次のメッセージを送るには、APIから返された直前のメッセージのIDを指定する形をとります。メッセージの保持期間は30日間です。
APIサーバ側にメッセージ履歴を保存したくない場合は、ステートレスにもできます。この場合はアプリ側でメッセージの履歴をリストなどにして管理しておく手間がかかります。
メッセージのやりとり
一番基本となる機能であるGPTモデルとメッセージをやり取りするコードを、ステートフルとステートレスの両方で示します。コードの単純化のため、ユーザからのメッセージ入力や、APIキーなど認証情報の環境変数からの取得を省略しています。
単一のメッセージの送信
「人口ベースで日本の3大都市を教えて」というプロンプトをGPTモデルに送信し、その解答を取得するだけのコードです。
まず、必要なライブラリをインポートしResponsesClientを取得します。また、APIがプレリリース版なので、OPENAI001というプラグマを立てておく必要があります。
using Azure.AI.OpenAI;
using OpenAI.Responses;
using System.ClientModel;
#pragma warning disable OPENAI001
// Azure認証情報
var endpoint = new Uri("https://<AZURE ENDPOINT URL>/");
var apiKey = "<API KEY>";
var deploymentName = "<DEPLOYMENT NAME>";
// AIクライアントの取得
AzureOpenAIClient azureClient = new(
endpoint,
new ApiKeyCredential(apiKey));
var responsesClient = azureClient.GetResponsesClient(deploymentName);
ResponsesClientの取得には、AzureポータルでのサービスのエンドポイントとAPIキー、モデルのデプロイ名が必要です。これらはAzureポータルで作成済みのものを入力します。
次に、モデルにメッセージを送信し、その応答をリクエストします。
/* 以降のコードではモジュールのインポート・Azure認証・クライアントの取得が省略されています */
// プロンプトの送信と応答の取得
ClientResult<ResponseResult> clientResult = await responsesClient.CreateResponseAsync(
"人口ベースで日本の3大都市を教えて。"
);
ResponseResult response = clientResult.Value;
// 応答の表示
Console.WriteLine(response.GetOutputText());
#pragma warning restore OPENAI001
CreateResponseAsync()で送ったプロンプトに対するモデルからの応答は、GetOutputText()で取得できます。以下のような解答が取得できました。
目的によって数え方が少し変わりますが、代表的な見方を2つ挙げます。
- 市区町村の人口(市レベルの人口が大きい順)での三大都市
1) 横浜市
2) 大阪市
3) 名古屋市
おおよそそれぞれ数百万人規模です...(以下略)
このコードはResponses APIの"Hello, World"的な立ち位置で、あまり実用的ではないです。実際のプロンプトはユーザが入力するのでそのあたりも工夫が必要ですが、コードが複雑になるのでそれは置いといて、以降でもう少し実用的なモデルとのやり取りを説明します。
複数のメッセージのやり取り
ステートフル
Responses APIはデフォルトでステートフルなAPIです。しかし、API側がきちんとメッセージを追跡させるために、直前のResponseItemのレスポンスIDを渡す必要があります。
// 1回目のメッセージ
ClientResult<ResponseResult> firstClientResult = await responsesClient.CreateResponseAsync(
"こんにちは。私の名前はアラン・チューリングです。"
);
ResponseResult firstResponse = firstClientResult.Value;
Console.WriteLine(firstResponse.GetOutputText());
// 2回目のメッセージ
ClientResult<ResponseResult> secondClientResult = await responsesClient.CreateResponseAsync(
"私と同姓同名の歴史上の人物を教えてください。",
// レスポンスIDを渡す
firstResponse.Id
);
ResponseResult secondResponse = secondClientResult.Value;
Console.WriteLine(secondResponse.GetOutputText());
このコードでは、1回目のメッセージで名前を教え、2回目のメッセージでそれに関連する質問をしています。1回目と2回目のメッセージが紐付けられていれば答えられるという仕組みです。
1回目と2回目のメッセージの送付時の違いは、2回目のCreateResponseAsyncには1回目のメッセージのレスポンスIDであるfirstResponse.Idを渡している点です。API側はこのIDを元にメッセージ履歴を判断しています。なので、2回目のメッセージでIDを渡さない場合(もしくは間違っていた場合)、モデルは1回目に入力された名前を参照することができません。
ステートレス
セキュリティ的な考え方で、外部のサーバにメッセージの履歴を残したくないことも考えられます。その場合、CreateResponseOptions.StoredOutputEnabled = falseとすることで、ステートレスな状態にできます。
ステートレスの場合、アプリ側でプロンプトの履歴を管理しないといけないのと、それをモデルに渡さないといけないのとで、コードが先ほどよりやや複雑になります。
// メッセージの履歴を保持するリスト
List<ResponseItem> inputItems = [];
// 1回目のメッセージ
var firstMessageItem = ResponseItem.CreateUserMessageItem(
"こんにちは。私の名前はアラン・チューリングです。"
);
inputItems.Add(firstMessageItem);
var firstOptions = new CreateResponseOptions(inputItems) {
StoredOutputEnabled = false
};
ClientResult<ResponseResult> firstClientResult = await responsesClient.CreateResponseAsync(firstOptions);
ResponseResult firstResponse = firstClientResult.Value;
Console.WriteLine(firstResponse.GetOutputText());
// 2回目のメッセージ
var secondMessageItem = ResponseItem.CreateUserMessageItem(
"私と同姓同名の歴史上の人物を教えてください。"
);
inputItems.Add(secondMessageItem);
var secondOptions = new CreateResponseOptions(inputItems) {
// ステートレスにする
StoredOutputEnabled = false
};
ClientResult<ResponseResult> secondClientResult = await responsesClient.CreateResponseAsync(secondOptions);
ResponseResult secondResponse = secondClientResult.Value;
Console.WriteLine(secondResponse.GetOutputText());
1回目と2回目のメッセージでやっていることは同じです。
ステートレスでは、まずMessageResponseItemインスタンスの作成し、メッセージ履歴のリストであるinputItemsに追加します。次にCreateResponseOptions()でinputItemsをResponseOptionsインスタンスに渡します。このとき渡されたinputItemsはインスタンス内で読み取り専用となるため、ResponseOptionsはメッセージ送信のたび作成する必要があります。また、このときにStoredOutputEnabled = falseでステートレスとしています。
Response Itemを利用する
ここまで、モデルからの応答は、responsesClient.CreateResponseAsync()から受け取るResponseResultのGetOutputText()で取得していました。しかし、ResponseResultにはこのテキスト以外に様々な情報が含まれています。それらはResponseItemクラスを継承したクラスのインスタンスで渡されます。
ResponseItem継承クラスの代表的なものに以下のクラスがあります。
MessageResponseItemFunctionCallResponseItemFunctionCallOutputResponseItemReasoningResponseItem
さらに、モデル側にこちらからの情報を渡すのにもこれらのクラスを使用します。そのうちMessageResponseItemは、メッセージをモデルへ送付するときに使っています。これまでモデルにメッセージ渡す際に使っていたResponseItem.CreateUserMessageItem()の戻り値がMessageResponseItemです。
MessageResponseItem firstMessageItem = ResponseItem.CreateUserMessageItem(
"こんにちは。私の名前はアラン・チューリングです。"
);
ResponseResultを受け取ったあとのResponseItemは、以下のようにインスタンスの種類によって処理分けできます。
ClientResult<ResponseResult> clientResult = await responsesClient.CreateResponseAsync(
"人口ベースで日本の3大都市を教えて。"
);
ResponseResult response = clientResult.Value;
foreach (var item in response.OutputItems)
{
switch (item)
{
// モデルからのメッセージ
case MessageResponseItem messageItem:
if (messageItem.Role == MessageRole.Assistant)
{
Console.WriteLine($"Assistant: {messageItem.Content[0].Text}");
}
break;
// ファンクションコール
case FunctionCallResponseItem functionCallItem:
// ファンクションコールで説明
break;
// 推論アイテム
case ReasoningResponseItem reasoningItem:
Console.WriteLine($"Reasoning: {reasoningItem.GetSummaryText()}");
break;
default:
throw new NotImplementedException();
}
}
MessageResponseItem
MessageResponseItemは、開発者・ユーザ・モデルそれぞれのメッセージを格納するResponseItemです。MessageResponseItem.Roleによって、開発者・ユーザ・モデルどれからのメッセージなのかが分かります。上のコードではモデルからの応答しか考えられないので、Assistant決め打ちで処理しています。
また、このRoleはモデルに対してユーザからの質問のほか、開発者側からの回答のルールなどを指示できます。各Roleの用途は以下の通りです。
-
System
モデルからの回答を拘束する最上位のルールです。「日本語で回答する」「データの構造はJSONとする」などのモデルの回答の基本方針を書きます。ResponseItem.CreateSystemMessageItem()の戻り値がこれに対応します -
Developer
アプリ開発者側の指示であり、SystemとUserの中間の優先度を持ちます。「簡潔に回答し、すでに回答済みの内容は繰り返さない」など、アプリとしての回答の振る舞いを記入します。ResponseItem.CreateDeveloperMessageItem()の戻り値がこれに対応します -
User
アプリを使用するエンドユーザからの質問や依頼です。通常のチャットアプリでユーザが入力するメッセージです。ここまでのコードで使用したResponseItem.CreateUserMessageItem()の戻り値がこれに対応します -
Assistant
Userのメッセージに対してモデルが回答したメッセージです。アプリに表示するほか、ステートレスでResponses APIを使用する場合、メッセージのリストであるInputItemsにも履歴として追加しておく必要があります。ResponseItem.CreateAssistantMessageItem()の戻り値がこれに対応します
各RoleでのMessageResponseItemは、以下のようにモデルに渡すメッセージのリストに追加します。
List<ResponseItem> inputItems = [
// システムメッセージ
ResponseItem.CreateSystemMessageItem(
"ユーザからの入力言語に関わらず、日本語で回答してください。"
),
// 開発者メッセージ
ResponseItem.CreateDeveloperMessageItem(
"簡潔に回答し、すでに回答済みの内容は繰り返さないでください。"
),
// ユーザメッセージ
ResponseItem.CreateUserMessageItem(
"人口ベースで日本の3大都市を教えて。"
)
];
/* 下のコードに続く */
ステートレスでResponses APIを使用している場合、以下のようにモデルからの応答もメッセージのリストに追加しておく必要があります。
/* 上のコードからの続き */
var option = new CreateResponseOptions(inputItems) {
// ステートレスにする
StoredOutputEnabled = false
};
// メッセージの送信と応答をリクエスト
ClientResult<ResponseResult> clientResult = await responsesClient.CreateResponseAsync(option);
ResponseResult response = clientResult.Value;
// 応答を表示
Console.WriteLine(response.GetOutputText());
// モデルからの応答をメッセージ履歴に追加
inputItems.AddRange(response.OutputItems);
// 会話を進める
inputItems.Add(
ResponseItem.CreateUserMessageItem(
// 次のユーザメッセージ
)
);
ポイントは以下のコードです。モデルからの応答であるResponseItemはrespose.OutputItemに格納されています。そこに含まれるResponseItemを全てinputItemsに追加しています。
// モデルからの応答をメッセージ履歴に追加
inputItems.AddRange(response.OutputItems);
1つ1つのResponseItemを選択してInputItemsに追加するには、respose.OutputItemの各要素をswitchで分岐させます。
foreach (var item in response.OutputItems)
{
switch (item)
{
case MessageResponseItem messageItem:
inputItems.Add(messageItem);
break;
case FunctionCallResponseItem functionCallItem:
inputItems.Add(functionCallItem);
break;
case ReasoningResponseItem reasoningItem:
inputItems.Add(reasoningItem);
break;
default:
throw new NotImplementedException();
}
}
ReasoningResponseItem
ReasoningResponseItemは、モデル内での推論中にどのような思考過程を経たかを確認できます。私はあまり使い道が分かっていませんが、推論の内容はGetSummaryText()からテキストで受け取ることができます。
FunctionCallResponseItem
FunctionCallResponseItemは、モデルの代表的な機能の1つであるファンクションコールで利用します。
ファンクションコール
モデルは、学習が実行された時点までの知識しか知り得ません。また、学習内容は基本的にインターネットから収集されたテキストなので、公開情報しか答えることができません。そのため、天気予報や最新ニュースなどの即時性が求められる情報や、就業規則などの社内情報といった特定の組織のクローズな情報にはアクセスできません。
それでは何かと不便なので、モデルはファンクションコール機能を使って社内規則や最新のニュースなどの必要な情報を取得します。ファンクションコールのファンクションは、C#であればメソッドのことです。つまり、モデルは適切なメソッドを呼び出すことで、必要な情報にアクセスします。
ファンクションコールとは
例えば、アプリ側に今日の天気予報を取得する次のようなメソッドを用意しておきます。
private static string GetTodaysWeather(string location)
{
// 実際はOpenWeatherなどで天気予報を取得するコードを書く
return $"今日の{location}の天気は晴れで、最高気温は25度です。";
}
モデルは、ユーザから以下のようなメッセージを受け取ったとします。
今日の横浜の天気を教えて。
するとモデルは、このメッセージの内容から、GetTodaysWeatherメソッドを呼ぶべきであることと、パラメータとしてlocation="横浜"を渡す必要があることを判断します。次にモデルは、FunctionCallResponseItemにメソッド名とそのパラメータを格納してアプリに返信します。アプリ側では、以下のコード部分でFunctionCallResponseItemを受け取ります。
foreach (var item in response.OutputItems)
{
switch (item)
{
...
case FunctionCallResponseItem functionCallItem:
// (1) GetTodaysWeather()の実行と戻り値の取得
// (2) 戻り値をFunctionCallOutputResponseItemに格納
// (3) FunctionCallOutputResponseItemをinputItemsに追加
break;
...
}
}
// (4) responsesClient.CreateResponseAsync()を呼ぶ
処理の流れをざっくり説明します。アプリは(1)でGetTodaysWeatherメソッドを実行して天気予報を取得します。どのメソッドを実行するか判断するのはモデルですが、そのメソッドを実行するのはアプリの仕事です。(2)でアプリはFunctionCallOutputResponseItemにその結果を格納し、(3)でInputItemsに追加し、(4)でモデルに送り返します。
モデルは、FunctionCallOutputResponseItemで受け取った天気予報を参照して、改めてユーザメッセージに回答します。このとき受け取るMessageResponseItemは以下のようになるはずです。
本日の横浜の天気は晴れ、最高気温は25度です。熱中症に気をつけましょう。 ...
モデルにメソッドとパラメータを渡す
モデルはユーザのメッセージからどのメソッドを呼ぶか判断しないといけないので、メソッドの存在を通知しておく必要があります。ここでは次の2つのメソッドを例にします。
ファンクション本体
private static string GetTodaysWeather(string location)
{
return $"今日の{location}の天気は晴れで、最高気温は25度です。";
}
private static string GetTodaysSchedule()
{
return "今日の予定は午前中に会議、午後にプロジェクトの作業があります。";
}
メソッドの戻り値はstringです。その内容は文章・CSV・JSON・Markdown・HTMLもしくはそれらの混合など、テキスト形式でモデルが読めればOKです。
ファンクションツール
モデルにはメソッドをそのまま渡すことはできず、それぞれのメソッドのFunctionToolインスタンスを作成します。ここでは、それらのツールを返すGetTodaysScheduleFunctionTool()とGetTodaysWeatherFunctionTool()を定義します。
GetTodaysScheduleFunctionTool()
GetTodaysWeather()のFunctionToolを返します。GetTodaysSchedule()は引数を持たないので、モデルに必要な情報としてはfunctionNameとfunctionDescriptionだけです。functionDescriptionは省略可能となっており、その場合モデルはfunctionNameだけでメソッドの役割を判断しているようです。ユーザのメッセージに対して正しくメソッドが呼ばれない場合は、メソッド名を見直すか、functionDescriptionで説明を補足します。
private static FunctionTool GetTodaysScheduleFunctionTool() =>
ResponseTool.CreateFunctionTool(
functionName: "GetTodaysSchedule",
functionDescription: "今日の予定を取得するツール",
strictModeEnabled: true,
functionParameters: BinaryData.FromString("""
{
"type": "object",
"properties": {},
"additionalProperties": false
}
""")
);
GetTodaysWeatherFunctionTool()
GetTodaysSchedule()のFunctionToolを返します。functionNameとfunctionDescriptionを指定します。
private static FunctionTool GetTodaysWeatherFunctionTool() =>
ResponseTool.CreateFunctionTool(
functionName: "GetTodaysWeather",
functionDescription: "今日の天気を取得するツール",
strictModeEnabled: true,
functionParameters: BinaryData.FromString("""
{
"type": "object",
"properties": {
"location": {
"type": "string",
"description": "天気予報を取得したい地名"
}
},
"required": ["location"],
"additionalProperties": false
}
""")
);
GetTodaysWeather()はstring型の引数locationを1つ持ちます。上のコードでは、"properties":
"location": {
"type": "string",
"description": "天気予報を取得したい地名"
}
としてメソッドの引数の情報を記述しています。また、この引数はメソッド実行で必須となるので、"required": ["location"]も指定しています。
ファンクションの実行
ファンクションコールが必要なユーザのプロンプトをリクエストし、モデルにファンクションコールさせ、ファンクションコールの結果を再度モデルに渡して回答を得るまでのコードです。
// ユーザプロンプトをリクエストの入力に追加
var userMessageItem = ResponseItem.CreateUserMessageItem("今日の予定と横浜市の天気を教えて。");
List<ResponseItem> inputItems = [
userMessageItem
];
Console.WriteLine($"Message, Role {userMessageItem.Role}: {userMessageItem.Content[0].Text}");
var requestFunctionCall = false;
do
{
requestFunctionCall = false;
// (1)(5) inputItemsの登録
var options = new CreateResponseOptions(inputItems) {
// (2)(6) ファンクションの登録
Tools = {
GetTodaysScheduleFunctionTool(),
GetTodaysWeatherFunctionTool()
},
// ステートレスに設定
StoredOutputEnabled = false,
};
// (3)(7) リクエストと応答の取得
ResponseResult response = await responsesClient.CreateResponseAsync(options);
foreach (var item in response.OutputItems)
{
switch (item)
{
// (8) メッセージの取得
case MessageResponseItem messageItem:
inputItems.Add(messageItem);
Console.WriteLine($"Message, Role {messageItem.Role}: {messageItem.Content[0].Text}");
break;
// (4) ファンクションコール
case FunctionCallResponseItem functionCallItem:
// (4-1) ファンクションコールの呼び出しアイテムをリクエストの入力に追加
inputItems.Add(functionCallItem);
// (4-2) 再度リクエスト
requestFunctionCall = true;
// ファンクションを実行
switch (functionCallItem.FunctionName)
{
case nameof(GetTodaysSchedule):
// メソッドの実行
var schedule = GetTodaysSchedule();
// (4-3) 戻り値をリクエストの入力に追加
var scheduleOutputItem = ResponseItem.CreateFunctionCallOutputItem(
functionCallItem.CallId,
schedule
);
inputItems.Add(scheduleOutputItem);
break;
case nameof(GetTodaysWeather):
// メソッドの引数を取得
var args = functionCallItem.FunctionArguments;
var obj = args.ToObjectFromJson<Dictionary<string, string>>();
// メソッドの実行
var weather = GetTodaysWeather(obj["location"]);
// (4-3) 戻り値をリクエストの入力に追加
var weatherOutputItem = ResponseItem.CreateFunctionCallOutputItem(
functionCallItem.CallId,
weather
);
inputItems.Add(weatherOutputItem);
break;
default:
throw new NotImplementedException();
}
break;
case ReasoningResponseItem reasoningItem:
// このコードでは何もしない
break;
}
}
// ファンクションコールがあった場合、再度リクエストを送る
} while (requestFunctionCall);
まずポイントは、do ... while()のループです。このループの中で、1回目のリクエスト→ファンクションコールと結果を取得→2回目のリクエスト→ユーザへの回答を取得 とリクエストを繰り返し、最終的なユーザへの応答を取得します。
まず、(1)でinputItemsをoptionに登録しています。この段階では、inputItemsの中身はユーザプロンプトのみです。(2)では、さきほど作成したGetTodaysSchedule()とGetTodaysWeather()のFunctionToolをoptionにセットしています。
次に、(3)で1回目のリクエスト、すなわち今日の予定と横浜の天気を質問します。
この例の場合、1回目のモデルからの応答にはファンクションコールが含まれるため、(4)でそれらを処理します。ここで重要な点は、(4-1)でファンクションコールの呼び出しアイテムであるfunctionCallItem自身をinputItemsに追加している点です。各ファンクションコールは固有のIDを持っていて、API側ではファンクションコールの呼び出しアイテムと結果アイテムを紐付けて処理しています。(4-1)をしないと、モデルにファンクションコールの結果を渡しても呼び出し元のアイテムが見つからないため、例外が返されます。
また、requestFunctionCallをtrueとしています。ファンクションコールされれば必ずその結果をモデルに返す必要があるので、そのためのフラグです。
(4-3)でファンクションコールの結果をFunctionCallOutputItemに格納し、inputItemsに追加しています。
1回目のwhile (requestFunctionCall)へ到達したこの段階でinputItemsの中身は以下の4つです。
-
MessageResponseItem: 「今日の予定と横浜市の天気を教えて。」 -
FunctionCallResponseItem: (ファンクションコールID) -
FunctionCallOutputResponseItem: 「今日の予定は午前中に会議、午後にプロジェクトの作業があります。」 -
FunctionCallOutputResponseItem: 「今日の横浜の天気は晴れで、最高気温は25度です。」
このままループの先頭に戻り、このinputItemsを使って(5)〜(7)で再びモデルへリクエストすると、(8)でinputItemsを考慮したモデルからの回答を得ます。
メッセージの出力は概ね以下のようになります。
Message, Role User: 今日の予定と横浜市の天気を教えて。
Message, Role Assistant: 今日の予定は、午前中に会議、午後にプロジェクトの作業があります。
横浜市の天気は晴れ、最高気温は25°Cです。外出時は日差し対策をどうぞ。