🕌

Azure OpenAI に Function calling が来たので .NET SDK で動作確認してみた

2023/07/21に公開

追記

  • 2023/07/21 finish_reason について追記
  • 2024/07/08 tools の記事へのリンクを追加

この記事は現在は古い機能になる functions パラメーターを使用しています。
新しい tools のパラメーターを使用した記事を別途書いているため、そちらを参照してください。

https://zenn.dev/microsoft/articles/aoai-tools-jsonmode-in-dotnet

本文

寝て起きたら Azure の OpenAI にも Function calling が来ていました。

https://techcommunity.microsoft.com/t5/azure-ai-services-blog/function-calling-is-now-available-in-azure-openai-service/ba-p/3879241

.NET の SDK も見てみたら Preview6 が公開されていて試してみたら Function calling の API に対応していました。やったね!

試してみた

Azure.AI.OpenAI の 1.0.0-beta.6 と Azure.Identity のパッケージを追加して以下のコードを書いたら無事 Function calling が動きました。

using Azure.AI.OpenAI;
using Azure.Identity;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.Unicode;

// client 生成
var client = new OpenAIClient(
    new Uri("https://<<your azure openai resource name>>.openai.azure.com/"),
    // Managed ID で認証。API Key の場合は、ここを API Key にする。
    new DefaultAzureCredential(new DefaultAzureCredentialOptions
    {
        ExcludeVisualStudioCredential = true,
    }));

// Function calling が返ってくることを期待して API 呼び出し。
var response = await client.GetChatCompletionsAsync("gpt-35-turbo",
    new ChatCompletionsOptions
    {
        Messages =
        {
            new() { Role = ChatRole.User, Content = "What is the weather like in Boston?" },
        },
        Functions =
        {
            new()
            {
                Name = "get_current_weather",
                Description = "Get the current weather in a given location.",
                // Parameters はタイプセーフではないっぽい…?
                Parameters = BinaryData.FromObjectAsJson(new
                {
                    // ここを間違えると辛い…
                    Type = "object",
                    Properties = new
                    {
                        Location = new
                        {
                            Type = "string",
                            Description = "The city and state, e.g. San Francisco, CA",
                        },
                        Unit = new
                        {
                            Type = "string",
                            Enum = new[] { "celsius", "fahrenheit" },
                        }
                    },
                    Required = new[] { "location" },
                },
                new JsonSerializerOptions
                {
                    PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
                }),
            }
        }
    });

// とりあえず結果を JSON で出力
Console.WriteLine(JsonSerializer.Serialize(response, new JsonSerializerOptions
{
    WriteIndented = true,
    Encoder = JavaScriptEncoder.Create(UnicodeRanges.All)
}));

// FunctionCall の名前と引数を表示してみる
var responseMessage = response.Value.Choices[0].Message;
var functionCall = responseMessage.FunctionCall;
Console.WriteLine("## Function の名前");
Console.WriteLine(functionCall.Name);

// Arguments は JSON 文字列なのでデシリアライズしてみる。
var arg = JsonSerializer.Deserialize<Arguments>(functionCall.Arguments, new JsonSerializerOptions
{
    PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
});

Console.WriteLine("## Function calling の Arguments の値");
Console.WriteLine($"location: {arg?.Location}, unit: {arg?.Unit ?? "null"}");


// 関数の結果を返してみる。
var response2 = await client.GetChatCompletionsAsync("gpt-35-turbo",
    new ChatCompletionsOptions
    {
        Messages =
        {
            new() { Role = ChatRole.User, Content = "What is the weather like in Boston?" },
            responseMessage,
            // こんな結果が外部 API から返ってきた想定
            new() { Role = ChatRole.Function, Name = functionCall.Name, Content = """
            {
                "location": "Boston, MA",
                "unit": "celsius",
                "temperature": 21,
                "condition": "sunny"
            }
            """ },
        },
        Functions =
        {
            new()
            {
                Name = "get_current_weather",
                Description = "Get the current weather in a given location.",
                // Parameters はタイプセーフではないっぽい…?
                Parameters = BinaryData.FromObjectAsJson(new
                {
                    // ここを間違えると
                    Type = "object",
                    Properties = new
                    {
                        Location = new
                        {
                            Type = "string",
                            Description = "The city and state, e.g. San Francisco, CA",
                        },
                        Unit = new
                        {
                            Type = "string",
                            Enum = new[] { "celsius", "fahrenheit" },
                        }
                    },
                    Required = new[] { "location" },
                },
                new JsonSerializerOptions
                {
                    PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
                }),
            }
        }
    });

// とりあえず結果を JSON で出力
Console.WriteLine("## Function の呼び出し結果をつけて OpenAI を呼び出した結果のレスポンス");
Console.WriteLine(JsonSerializer.Serialize(response2, new JsonSerializerOptions
{
    WriteIndented = true,
    Encoder = JavaScriptEncoder.Create(UnicodeRanges.All)
}));

// Arguments に入ってるデータをデシリアライズするための型
record Arguments(string Location, string? Unit);

実行結果配下のようになります。

{
  "HasValue": true,
  "Value": {
    "Id": "chatcmpl-7ea17VZtmo64BykeMu2csWdTQ7MBO",
    "Created": "2023-07-21T02:18:45+00:00",
    "Choices": [
      {
        "Message": {
          "Role": {},
          "Content": null,
          "Name": null,
          "FunctionCall": {
            "Name": "get_current_weather",
            "Arguments": "{\n  \u0022location\u0022: \u0022Boston, MA\u0022\n}"
          }
        },
        "Index": 0,
        "FinishReason": {},
        "ContentFilterResults": {
          "Sexual": null,
          "Violence": null,
          "Hate": null,
          "SelfHarm": null
        }
      }
    ],
    "PromptFilterResults": [
      {
        "PromptIndex": 0,
        "ContentFilterResults": {
          "Sexual": {
            "Severity": {},
            "Filtered": false
          },
          "Violence": {
            "Severity": {},
            "Filtered": false
          },
          "Hate": {
            "Severity": {},
            "Filtered": false
          },
          "SelfHarm": {
            "Severity": {},
            "Filtered": false
          }
        }
      }
    ],
    "Usage": {
      "CompletionTokens": 18,
      "PromptTokens": 82,
      "TotalTokens": 100
    }
  }
}
## Function の名前
get_current_weather
## Function calling の Arguments の値
location: Boston, MA, unit: null
## Function の呼び出し結果をつけて OpenAI を呼び出した結果のレスポンス
{
  "HasValue": true,
  "Value": {
    "Id": "chatcmpl-7ea1HaFodBHxcI9AZq2cFgPyOndrI",
    "Created": "2023-07-21T02:18:55+00:00",
    "Choices": [
      {
        "Message": {
          "Role": {},
          "Content": "The current weather in Boston, MA is sunny with a temperature of 21 degrees Celsius.",
          "Name": null,
          "FunctionCall": null
        },
        "Index": 0,
        "FinishReason": {},
        "ContentFilterResults": {
          "Sexual": {
            "Severity": {},
            "Filtered": false
          },
          "Violence": {
            "Severity": {},
            "Filtered": false
          },
          "Hate": {
            "Severity": {},
            "Filtered": false
          },
          "SelfHarm": {
            "Severity": {},
            "Filtered": false
          }
        }
      }
    ],
    "PromptFilterResults": [
      {
        "PromptIndex": 0,
        "ContentFilterResults": {
          "Sexual": {
            "Severity": {},
            "Filtered": false
          },
          "Violence": {
            "Severity": {},
            "Filtered": false
          },
          "Hate": {
            "Severity": {},
            "Filtered": false
          },
          "SelfHarm": {
            "Severity": {},
            "Filtered": false
          }
        }
      }
    ],
    "Usage": {
      "CompletionTokens": 19,
      "PromptTokens": 143,
      "TotalTokens": 162
    }
  }
}

JSON の中身を全部出したので長いですがちゃんと動いてそうですね。ポイントとなるのは以下の箇所です。

ChatCompletionsOptions の Functions プロパティ

GetChatCompletionsAsync 呼び出し時の ChatCompletionsOptionsFunctions プロパティが追加されていて、そこに関数の一覧を設定します。
C# なので基本タイプセーフなのですが Parameters の指定の部分はタイプセーフじゃないです…。任意のオブジェクトのスキーマを渡す形なので、致し方ないといえば仕方ないのですが、沢山 Function calling をするならクラス定義からリフレクションやソースジェネレーターあたりで自動生成してくれるようなものを用意してもいいかも…?

コードから関係する部分だけ抜粋してみました。BinaryData クラスの FromObjectAsJson メソッドで Parameters を設定しています。
この例では JsonSerializerOptions で camelCase になるように指定していますが、どうせタイプセーフじゃないので最初から camelCase で書いてもいいと思いますし、そっちのほうが楽だと思います。

// Parameters はタイプセーフではないっぽい…?
Parameters = BinaryData.FromObjectAsJson(new
{
    // ここを間違えると辛い…
    Type = "object",
    Properties = new
    {
        Location = new
        {
            Type = "string",
            Description = "The city and state, e.g. San Francisco, CA",
        },
        Unit = new
        {
            Type = "string",
            Enum = new[] { "celsius", "fahrenheit" },
        }
    },
    Required = new[] { "location" },
},
 new JsonSerializerOptions
{
    PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
}),

Function calling のレスポンス

Function calling の結果を受け取るには戻り値のメッセージの FunctionCall プロパティの値の有無で見れます。ただ、FunctionCall プロパティは null 許容型になっていないので注意が必要です。ちょっとイマイチ。

Name プロパティに呼び出す関数名が入っていて、Arguments プロパティに呼び出す関数に渡すオブジェクトを表す JSON 文字列が入っています。
今回は locationunit というプロパティを持っているオブジェクトを表す JSON が入っているのでデシリアライズして中身を確認しています。Parameters で指定したスキーマの JSON が入っていることが期待できるので、いい感じ。万が一に備えて例外処理は必要でしょうけど。

// FunctionCall の名前と引数を表示してみる
var responseMessage = response.Value.Choices[0].Message;
var functionCall = responseMessage.FunctionCall; // FunctionCall は null の可能性があるので実際には注意!
Console.WriteLine(functionCall.Name);

// Arguments は JSON 文字列なのでデシリアライズしてみる。
var arg = JsonSerializer.Deserialize<Arguments>(functionCall.Arguments, new JsonSerializerOptions
{
    PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
});

Console.WriteLine("## Function calling の Arguments の値");
Console.WriteLine($"location: {arg?.Location}, unit: {arg?.Unit ?? "null"}");

// Arguments に入ってるデータをデシリアライズするための型
record Arguments(string Location, string? Unit);

Function の結果を返す

OpenAI から FunctionCall が帰ってきたら、そのままプログラムで処理して終わってもいいし、OpenAI に関数の結果を渡すことで会話の続きを生成してもらうことも出来ます。会話の続きを生成してもらうには ChatCompletionsOptionsMessages に FunctionCall が設定されたメッセージに加えて、 RoleFunctionNameFunctionCallName と同じ値になるメッセージを追加して Content に関数の結果を JSON 文字列を設定したメッセージを追加してあげることで続きの会話を生成してもらうことが出来ます。

コードの以下の部分です。

// 関数の結果を返してみる。
var response2 = await client.GetChatCompletionsAsync("gpt-35-turbo",
    new ChatCompletionsOptions
    {
        Messages =
        {
            new() { Role = ChatRole.User, Content = "What is the weather like in Boston?" },
            responseMessage,
            // こんな結果が外部 API から返ってきた想定
            new() { Role = ChatRole.Function, Name = functionCall.Name, Content = """
            {
                "location": "Boston, MA",
                "unit": "celsius",
                "temperature": 21,
                "condition": "sunny"
            }
            """ },
        },
        Functions =
        {
            new()
            {
                Name = "get_current_weather",
                Description = "Get the current weather in a given location.",
                // Parameters はタイプセーフではないっぽい…?
                Parameters = BinaryData.FromObjectAsJson(new
                {
                    // ここを間違えると
                    Type = "object",
                    Properties = new
                    {
                        Location = new
                        {
                            Type = "string",
                            Description = "The city and state, e.g. San Francisco, CA",
                        },
                        Unit = new
                        {
                            Type = "string",
                            Enum = new[] { "celsius", "fahrenheit" },
                        }
                    },
                    Required = new[] { "location" },
                },
                new JsonSerializerOptions
                {
                    PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
                }),
            }
        }
    });

// とりあえず結果を JSON で出力
Console.WriteLine("## Function の呼び出し結果をつけて OpenAI を呼び出した結果のレスポンス");
Console.WriteLine(JsonSerializer.Serialize(response2, new JsonSerializerOptions
{
    WriteIndented = true,
    Encoder = JavaScriptEncoder.Create(UnicodeRanges.All)
}));

この結果のメッセージ部分だけを抜粋すると The current weather in Boston, MA is sunny with a temperature of 21 degrees Celsius. と返ってきてるので、ちゃんと関数呼び出しの結果として渡したデータを加味してメッセージが生成されているのがわかりますね。

Finish reason の確認

サンプルコード内には書いていませんが、Function calling について本番のコードで正しくハンドリングするには MessageFinishReason を確認する必要があります。
この記事内に出している JSON の結果は、OpenAI からのレスポンスを C# のオブジェクトにして、それを再度 JSON にシリアライズしてる結果なので、オリジナルの JSON とは異なっている部分があります。
特に FinishReason{} になっていますが、これは C# のオブジェクトから JSON にシリアライズする際に欠落してしまっただけでちゃんと値が入っています。

C# の SDK での FinishReasonCompletionsFinishReason 構造体で表されていて、以下のような感じで判定することが出来ます。

if (response.Value.Choices[0].FinishReason == CompletionsFinishReason.FunctionCall)
{
    // Function calling の結果が返ってきた
}

まとめ

Function calling が Azure にも来たのと同時に .NET の SDK も更新されました。
試してみた感想としては、Parameters の設定がタイプセーフじゃないのがちょっと悲しい感じですね…。いい解決方法があればいいけど…。

それ以外はおおむね公式の Function calling のドキュメントを見ることで各引数の意味がわかるので、それに従って設定していけば問題ないと思います。

https://platform.openai.com/docs/guides/gpt/function-calling

Microsoft (有志)

Discussion