Azure OpenAIでStructured Outputsを使う! C# 版

2024/09/28に公開

更新履歴

  • 2024/09/28: 初版公開
  • 2024/12/06: .NET 9 正式版対応と Description を追加

本文

暫く前に OpenAI の方でレスポンスで返す JSON の形式を JSON スキーマで指定できる Structured Outputs が追加されました。
詳細については以下の記事を参照してください。

https://zenn.dev/microsoft/articles/azure-openai-structured-outputs

この記事が出た直後に C# でも同じことをやろうと思ったのですが C# の SDK の Azure.AI.OpenAI では API バージョンの 2024-08-01-preview がサポートされていなかったため、その時は断念しました。
しかし、最新のプレビュー版の Azure.AI.OpenAI では Structured Outputs がサポートされているので、今回は C# で Structured Outputs を使ってみたいと思います。

Structured Outputs を使う

Structured Outputs を使うには Chat Completions API を呼ぶ時のオプションに ResponseFormat を指定します。
ResponseFormat には ChatResponseFormat.CreateJsonSchemaFormat で作成したオブジェクトを設定することで Structured Outputs を有効にできます。この際に JSON スキーマを指定するのですが、文字列で指定するのが苦痛だったのですが .NET 9 (まだ RC1) で JsonSchemaExporter クラスが追加されていて、クラスの定義から JSON スキーマを生成できるようになっていたので、これを使って JSON スキーマを生成しました。

ということで試してみましょう。
.NET 9 のコンソールアプリを作って以下のパッケージを追加します。

  • Azure.AI.OpenAI
  • Azure.Identity

JSON スキーマを生成するためのクラスを作成します。今回はユーザーのメッセージから名前と年齢を取得しようと思うので NameAge プロパティを持った Person クラスを定義して System.Text.Json のソースジェネレーターの設定をしておきます。この時作成される JsonTypeInfo を使って JSON スキーマを生成します。

[JsonSerializable(typeof(Person))]
partial class SourceGenerationContext : JsonSerializerContext;

class Person
{
    public string Name { get; set; } = "";
    public int Age { get; set; }
}

この定義から JSON スキーマを生成するには以下のようなコードになります。

var personJsonSchema = JsonSchemaExporter.GetJsonSchemaAsNode(
    SourceGenerationContext.Default.Person,
    exporterOptions: new()
    {
        TreatNullObliviousAsNonNullable = true,
    })
    .ToJsonString();

これで JSON スキーマが生成できたので、OpenAI の Chat Completions API を呼び出すコードを書いてみます。

// AOAI のクライアントを作成する
var openAiClient = new AzureOpenAIClient(
    // モデルのバージョンが 2024-08-06 以上の gpt-4o をデプロイしている
    // Azure OpenAI Service のエンドポイントを指定する
    new("https://<<リソース名>>.openai.azure.com/"),
    // Managed ID で認証する
    new DefaultAzureCredential(options: new()
    {
        ExcludeVisualStudioCredential = true,
    }));

// チャットクライアントを取得する
var chatClient = openAiClient.GetChatClient("gpt-4o");
// Structured Output を使って JSON Schema を指定して呼び出す
var result = await chatClient.CompleteChatAsync(
    [
        new SystemChatMessage(
            "ユーザーの発言内容から名前と年齢を抽出して JSON 形式に整形してください。"),
        new UserChatMessage("私の名前は太郎です。17歳です!"),
    ], 
    options: new()
    {
        ResponseFormat = ChatResponseFormat.CreateJsonSchemaFormat(
            "person",
            BinaryData.FromString(personJsonSchema))
    });

// 結果を表示する
Console.WriteLine(result.Value.Content.First().Text);

実行すると以下のような結果になります。

{"Name":"太郎","Age":17}

ちゃんと抽出できてますね。

もう少し試してみましょう。クラスの定義を以下のように変更して配列も取得できるかやってみます。

class Person
{
    public string Name { get; set; } = "";
    public int Age { get; set; }
    public string[] Addresses { get; set; } = [];
}

そして、プロンプトを以下のように変更してみます。東京都と大阪府に住んでるという情報を追加しています。

var result = await chatClient.CompleteChatAsync(
    [
        new SystemChatMessage(
            "ユーザーの発言内容から名前と年齢を抽出して JSON 形式に整形してください。"),
        new UserChatMessage("""
            私の名前は太郎です。17歳です!
            日本の東京都と大阪府の2拠点生活をしています!
            よろしくお願いいたします。
            """),
    ], 
    options: new()
    {
        ResponseFormat = ChatResponseFormat.CreateJsonSchemaFormat(
            "person",
            BinaryData.FromString(personJsonSchema))
    });

実行すると以下のような結果になりました。いい感じですね。

{"Name":"太郎","Age":17,"Addresses":["東京都","大阪府"]}

最後に、JSON の出力結果が PascalCase なのが気持ち悪いので camelCase に変更してみます。
これをするには SourceGenerationContextJsonSourceGenerationOptions 属性を追加して PropertyNamingPolicyJsonKnownNamingPolicy.CamelCase を指定します。

[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)]
[JsonSerializable(typeof(Person))]
partial class SourceGenerationContext : JsonSerializerContext;

これを実行すると以下のような結果になりました。ちゃんと camelCase になっていますね。

{"name":"太郎","age":17,"addresses":["東京都","大阪府"]}

余談: System.Text.Json のソースジェネレーターを使わない場合に JSON スキーマを生成する方法

記事の本題ではありませんが System.Text.Json のソースジェネレーターを使わない場合に JSON スキーマを生成する場合は以下のようになります。

var personJsonSchema = JsonSchemaExporter.GetJsonSchemaAsNode(
    new JsonSerializerOptions
    {
        PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
        TypeInfoResolver = new DefaultJsonTypeInfoResolver(),
    },
    typeof(Person),
    exporterOptions: new()
    {
        TreatNullObliviousAsNonNullable = true,
    })
    .ToJsonString();

JsonSerializerOptionsTypeInfoResolver の設定は必須なので気を付けてください。

より精度を上げるために Description を追加する

今回の例くらいのシンプルなケースでは、上手く動きましたがもう少し複雑なデータを Structured outputs で取得する場合は Description を追加しておくとより精度を上げることができます。現状の System.Text.Json では、Description はサポートされていないので、少しカスタマイズが必要になります。

JsonSchemaExporterGetJsonScehmaAsNode メソッドの exporterOptions にある TransformSchemaNode を使って Description を追加することができます。コードは以下のようになります。

var personJsonSchema = JsonSchemaExporter.GetJsonSchemaAsNode(
    SourceGenerationContext.Default.Person,
    exporterOptions: new()
    {
        TreatNullObliviousAsNonNullable = true,
        // Description を追加する
        TransformSchemaNode = (context, schema) =>
        {
            var attributeProvider = context.PropertyInfo is not null ?
                context.PropertyInfo.AttributeProvider :
                context.TypeInfo.Type;

            var description = (DescriptionAttribute?) attributeProvider?.GetCustomAttributes(false)
                .FirstOrDefault(x => x is DescriptionAttribute);

            if (description == null) return schema;

            if (schema is JsonObject jsonObject)
            {
                jsonObject.Insert(0, "description", description.Description);
            }

            return schema;
        },
    })
    .ToJsonString(new JsonSerializerOptions
    {
        // 見やすいようにインデントと日本語が含まれる場合のエンコードを指定
        WriteIndented = true,
        Encoder = JavaScriptEncoder.Create(UnicodeRanges.All),
    });

そして、以下のようにクラスに Description 属性を追加しておきます。

[Description("ユーザー情報")]
class Person
{
    [Description("ユーザーの名前")]
    public string Name { get; set; } = "";
    [Description("ユーザーの年齢")]
    public int Age { get; set; }
    [Description("ユーザーの住所")]
    public string[] Addresses { get; set; } = [];
}

こうすると生成される JsonSchema が以下のようになります。

{
  "description": "ユーザー情報",
  "type": "object",
  "properties": {
    "name": {
      "description": "ユーザーの名前",
      "type": "string"
    },
    "age": {
      "description": "ユーザーの年齢",
      "type": "integer"
    },
    "addresses": {
      "description": "ユーザーの住所",
      "type": "array",
      "items": {
        "type": "string"
      }
    }
  }
}

こうすることで、Structured Outputs で、どのプロパティにどのような値を格納するべきかという情報をより正確に AI に伝えることができるようになります。実際のプロダクトで使う場合は Description を追加しておくとより精度を上げることができるのでおすすめです。

まとめ

ということで、Azure OpenAI の Structured Outputs を使って JSON スキーマで指定した形式でレスポンスを取得する方法を試してみました。
JSON の形式を強制できるようになるのでプログラムで処理する前提のケースで使用するのが凄く便利になりそうな機能なので、結構使うことになると思います。

大したコードではないですが以下のリポジトリでコードは全体を公開しています。

https://github.com/runceel/StructuredOutputTest

Microsoft (有志)

Discussion