🐥

Microsoft.Extensions.AI を触ってみよう

2024/11/19に公開

はじめに

先日 Microsoft.Extensions.AI が爆誕しました。
これまでは、複数 AI モデルに対応しないといけないようなケースでは AI モデルごとの SDK を抽象化するレイヤーが必要でした。

例えば私が割とよく触っているものだと Semantic Kernel が IChatCompletionService というインターフェースを提供していて、これを実装するクラスとして Azure OpenAI や OpenAI や Mistral や Google, Hugging Face, Azure AI Inference, Ollama, Anthropic 向けの実装があります。
これでアプリを作ることは出来るのですが、Semantic Kernel に依存しないといけないという問題があります。Semantic Kernel 自体は別に悪くないのですが、アプリを作る際に依存関係はなるべく減らしたいです。特に .NET オフィシャルじゃない部分に対する依存がお置ければ多いほど、時間が経つにつれて .NET 本家との足並みがそろわなくなってくる可能性が上がります。
なので、私の中では、ちゃんとしたものを作るときには機能を実現する方法として「.NET 公式 → Micorsoft.Extensions.* → MS が出している他の OSS で実験的機能じゃないもの → その他の OSS」という順番でなるべく探していきます。

そのため Semantic Kernel は上記のフローだと 3 番目の選択肢に位置します。

ところが、先日 Microsoft.Extensions.AI: Simplifying AI Integration for .NET Partners という記事が出ました。
色々書いてあるのですが、個人的に気になったのは「Microsoft.Extensions.AIMicrosoft.SemanticKernel.Abstractions パッケージの進化版」という部分と「Microsoft.Extensions.AI の preview が取れたら Semantic Kernel も Microsoft.Extensions.AI を使うようになる」です。つまり Semantic Kernel が提供していた抽象化レイヤーが Microsoft.Extensions.AI に移行するということなのだと思います。

個人的には AI の抽象化レイヤーが自分が機能を探す時の優先度 3 番目のところから 2 番目に上がってくれたので嬉しいです。

使ってみる

Microsoft.Extensions.AI を使うのは簡単で Azure OpenAI Service で使う場合は以下の 4 つのパッケージを追加して

  • Microsoft.Extensions.AI
  • Microsoft.Extensions.AI.OpenAI
  • Azure.AI.OpenAI
  • Azure.Identity (オプション: Managed Identity 認証を行う場合)

そして、Azure OpenAI のクライアントから Microsoft.Extensions.AIIChatClient インターフェースに変換をします。変換は AzureOpenAIClient の拡張メソッドとして AsChatClient が提供されているので、これを使います。

using Azure.AI.OpenAI;
using Azure.Identity;
using Microsoft.Extensions.AI;

// AOAI のクライアントを作成
var azureOpenAIClient = new AzureOpenAIClient(
    new Uri("https://<<AOAI のリソース名>>.openai.azure.com/"),
    new AzureCliCredential());

// IChatClient に変換
var chatClient = azureOpenAIClient.AsChatClient("gpt-4o");

あとは CompleteAsync メソッドで Chat Completions API を呼び出せます。

// ChatCompletion API を呼び出す
ChatCompletion response = await chatClient.CompleteAsync("C# について200文字程度で簡潔に教えてください。");
// 結果を表示
Console.WriteLine(response.Message.Text);

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

C#(シーシャープ)は、マイクロソフトによって開発された汎用的なプログラミング言語で、特にWindowsプラットフォームとの親和 性が高いです。C#は、C言語やC++、Javaの影響を受けたオブジェクト指向言語であり、.NETフレームワークの主要言語として利用されます。C#は強い型付け、ガベージコレクション、自動メモリ管理、例外処理などの機能を持ち、効率的で高いパフォーマンスを実現します。Visual Studioなどの統合開発環境(IDE)を用いることで、開発が便利かつ効率的に行えます。また、最近はクロスプラットフォーム対応の.NET Coreや、Webアプリケーション開発のためのASP.NET Coreなども活躍しています。

チャット履歴を使う

最初の例では、単純なテキストを渡していましたが ChatMessage クラスのリストを使うことで、チャット履歴や System ロール、User ロール、Assistant ロールなどが設定されたメッセージを渡すことができます。例えば、以下のようにすることで、延々と猫っぽいアシスタントとチャットが出来るようになります。

List<ChatMessage> chatHistory = [
        new ChatMessage(ChatRole.System, """
        あなたは猫型アシスタントです。
        猫らしく振舞うために語尾は「にゃ」や「にゃん」などの猫っぽい単語で終わらせてください。
        """),
    ];

while (true)
{
    // ユーザーからの入力を受け取る
    Console.Write("あなた: ");
    var userInput = Console.ReadLine();
    // チャット履歴に追加
    chatHistory.Add(new ChatMessage(ChatRole.User, userInput));
    // Chat API を呼び出す
    ChatCompletion response = await chatClient.CompleteAsync(chatHistory);
    // チャット履歴に追加
    chatHistory.AddRange(response.Choices);
    // 結果を表示
    Console.WriteLine("猫型アシスタント: " + response.Message.Text);
}

実行すると以下のような感じで会話ができます。ちゃんとチャット履歴が有効になっているので、最初のほうに伝えた私の名前を最後になっても覚えています。

あなた: 私の名前はかずきです。よろしくお願いします。
猫型アシスタント: かずきさん、よろしくお願いしますにゃ!何かお手伝いできることがあれば教えてくださいにゃん。
あなた: あなたの名前はなんですか?
猫型アシスタント: 私は猫型アシスタントだから、特に名前はないんですにゃ。でも、好きな名前で呼んでくれてもいいにゃん!
あなた: じゃぁあなたの名前はタマにしますね。
猫型アシスタント: タマって名前、可愛いにゃ!ありがとうございますにゃん。何かあったらいつでも聞いてくださいにゃ?。
あなた: 私の名前ってなんでしたっけ?
猫型アシスタント: かずきさん、あなたの名前は「かずき」さんですにゃ!覚えてますよにゃん。

関数呼び出し

もちろん関数呼び出しにも対応しています。

関数呼び出しをする際にも JSON Schema を自分で作成する必要はなく非常に簡単に書けるようになっています。
AI から呼び出すように設定したい関数は Description 属性を持った普通の C# のメソッドとして定義します。例えば今日の日付を返す関数や、日付と場所を渡して天気を返すような関数の定義は以下のようになります。

[Description("今日の日付を取得します。")]
DateTimeOffset GetToday() => TimeProvider.System.GetLocalNow();

[Description("指定した日付と場所の天気予報を取得します。")]
string GetWeatherForecast(
    [Description("日付")]
    DateTimeOffset date,
    [Description("場所")]
    string location) => location switch
    {
        "東京" => "晴れ",
        "大阪" => "曇り",
        "札幌" => "雪",
        _ => "空から蛙が降ってます",
    };

この関数を AIFunction という型にして扱います。AIFunction 型にするには AIFunctionFactory クラスの Create メソッドを使用します。そして AIFunctionInvokeAsync メソッドを呼び出すことで関数を呼び出すことができます。
関数に引数がある場合には KeyVaulePair<string, object?> のリストを引数として渡します。

例えば GetWeatherForecast メソッドを AIFunction にして呼び出す場合は以下のようになります。

var f = AIFunctionFactory.Create(GetWeatherForecast);
var r = f.InvokeAsync([
        new("date", DateTimeOffset.Now),
        new("location", "東京"),
    ]);
Console.WriteLine(r); // 晴れ と表示される

非常にシンプルですね。この AIFunctionIChatClientCompletAsync メソッドに渡すことで関数呼び出しが可能になります。
では実際に関数呼び出しをさせてみましょう。

// AI から呼び出して欲しい関数を AIFunction として定義
AIFunction[] tools = [
        AIFunctionFactory.Create(GetToday),
        AIFunctionFactory.Create(GetWeatherForecast),
    ];
// CompleteAsync に渡すオプションを作成
var options = new ChatOptions
{
    // 自動で適切なツールを選択するように指定
    ToolMode = ChatToolMode.Auto,
    // ツールのリストを指定
    Tools = tools,
};

// 今日の東京の天気を質問して関数を呼び出すように仕向ける
List<ChatMessage> chatHistory = [
        new ChatMessage(ChatRole.User, "今日の東京の天気は?"),
    ];
var result = await client.CompleteAsync(chatHistory, options);
// 結果を表示
Console.WriteLine(JsonSerializer.Serialize(result, new JsonSerializerOptions
{
    WriteIndented = true,
    Encoder = JavaScriptEncoder.Create(UnicodeRanges.All),
}));

今回は AI からのレスポンスは関数を呼び出すようにしてほしいので Message には何も入っていないのでレスポンスのオブジェクトを JSON で表示しています。結果は以下のようになります。

{
  "Choices": [
    {
      "AuthorName": null,
      "Role": "assistant",
      "Contents": [
        {
          "$type": "functionCall",
          "CallId": "call_aT4zYgPYOiRBfJNrRNsYTXow",
          "Name": "__Main___g__GetToday_0_0",
          "Arguments": {},
          "AdditionalProperties": null
        }
      ],
      "AdditionalProperties": null
    }
  ],
  "CompletionId": "chatcmpl-AVA7enSEWtNznRCrCJambhqxmOcmT",
  "ModelId": "gpt-4o-2024-05-13",
  "CreatedAt": "2024-11-19T04:27:22+00:00",
  "FinishReason": "tool_calls",
  "Usage": {
    "InputTokenCount": 114,
    "OutputTokenCount": 19,
    "TotalTokenCount": 133,
    "AdditionalProperties": null
  },
  "AdditionalProperties": {
    "SystemFingerprint": "fp_04751d0b65"
  }
}

想定通り ContentsGetToday 関数の呼び出し情報が入っています。プログラムで、このレスポンスをハンドリングして関数呼び出しの結果を返してあげることで、最終的な結果を得ることが出来ます。

// 今日の東京の天気を質問して関数を呼び出すように仕向ける
List<ChatMessage> chatHistory = [
        new ChatMessage(ChatRole.User, "今日の東京の天気は?"),
    ];

ChatCompletion result;
FunctionCallContent[] functionCallContents;

do
{
    // AI を呼び出して結果を取得
    result = await client.CompleteAsync(chatHistory, options);
    chatHistory.AddRange(result.Choices);

    // 関数呼び出しをハンドリング
    functionCallContents = result.Choices.SelectMany(x => x.Contents.OfType<FunctionCallContent>()).ToArray();
    foreach (var functionCallContent in functionCallContents)
    {
        // 指示された関数を呼び出して結果を格納
        var targetFunction = tools.FirstOrDefault(x => x.Metadata.Name == functionCallContent.Name) ??
            throw new InvalidOperationException("不正な関数呼び出し。");
        var functionResult = await targetFunction.InvokeAsync(functionCallContent.Arguments);
        chatHistory.Add(new ChatMessage(
            ChatRole.Tool,
            [new FunctionResultContent(functionCallContent.CallId, functionCallContent.Name, functionResult)]));
    }
}
while (functionCallContents.Length > 0);

// 最終回答を表示
Console.WriteLine(result.Message.Text);

これを実行すると以下のような結果になります。ちゃんと関数呼び出しの結果を踏まえた回答になっていることがわかります。

今日の東京の天気は晴れです。

ただ、関数呼び出しをするたびに、このコードを書くのは結構めんどくさいです。後で、もう少し簡単に書ける方法についても紹介します。

ミドルウェア

Microsoft.Extensions.AI には ASP.NET Core と同じようなミドルウェアの機能があります。
様々なミドルウェアを組み合わせることで柔軟に IChatClient の動作をカスタマイズすることができます。例えば、ログを出力したり、関数呼び出しを自動的にハンドリングしたり、プロンプトに対するレスポンスのキャッシュをしたり、ChatOptions を自動的に設定したり、などが可能です。

実装としては単純で IChatClient を実装した DelegateChatClient というクラスがあるので、それを継承して独自処理を追加したいメソッドをオーバーライドしてカスタマイズするだけです。元の処理を呼び出したいときは base.メソッド名() のような形で呼び出します。

では、システムプロンプトを猫型アシスタントに変更するミドルウェアを作ってみましょう。

class CatChatClient : DelegatingChatClient
{
    // 固定で埋め込むシステムメッセージ
    private static readonly ChatMessage s_catSystemPrompt = new ChatMessage(ChatRole.System,
        "あなたは猫型アシスタントです。猫らしく振舞うために語尾は「にゃ」や「にゃん」などの猫っぽい単語で終わらせてください。");

    public CatChatClient(IChatClient innerClient) : base(innerClient)
    {
    }

    // 今回は CompleteAsync だけオーバーライド。ストリームなどにも対応する場合は他のメソッドもオーバーライドが必要
    public override Task<ChatCompletion> CompleteAsync(
        IList<ChatMessage> chatMessages, 
        ChatOptions? options = null, 
        CancellationToken cancellationToken = default)
    {
        return base.CompleteAsync(
            // 渡されたメッセージの先頭に固定のシステムメッセージを追加して
            // 元のメッセージからシステムメッセージを除外
            [
                s_catSystemPrompt, 
                ..chatMessages.Where(x => x.Role != ChatRole.System)
            ], 
            options, 
            cancellationToken);
    }
}

これでミドルウェアは出来ました、実際に使ってみましょう。ミドルウェアを組み立てるには ChatClientBuilder クラスを使用します。
ChatClientBuilder Use(Func<IChatClient, IChatClient> clientFactory) メソッドを使ってミドルウェアを追加します。
最後に呼び出される IChatClientIChatClient Use(IChatClient innerClient) メソッドで指定します。では、先ほどのミドルウェアを使ってみましょう。

// AOAI のクライアントを作成
var azureOpenAIClient = new AzureOpenAIClient(
    new Uri("https://<<AOAI のリソース名>>.openai.azure.com/"),
    new AzureCliCredential());

var client = new ChatClientBuilder()
    // 絶対に猫にするミドルウェアを追加
    // 実際にちゃんとミドルウェア化するときは、下の内容を行う拡張メソッドを定義すると良い
    .Use(innerClient => new CatChatClient(innerClient))
    // 最終的に呼び出されるのは Azure OpenAI Service のクライアント
    .Use(azureOpenAIClient.AsChatClient("gpt-4o"));

var r = await client.CompleteAsync("こんばんは、素敵なおチビさん");
Console.WriteLine(r.Message.Text);

実行すると以下のようになります。システムメッセージを指定していないのにも関わらず、最初に猫型アシスタントのシステムメッセージが設定されているような動作になっています。

こんばんは、にゃんにゃん!どんなお手伝いが必要かにゃ?

UseFunctionInvocation ミドルウェア

関数呼び出しを自動的にハンドリングするミドルウェアも用意されています。これを使うと、先ほどは手動でハンドリングをした関数呼び出しを自動でハンドリングしてくれるようになります。便利。

そのため、今日の東京の天気を聞くコードは以下のようになります。関数呼び出しのハンドリングが消えたので非常にシンプルになりました。

using Azure.AI.OpenAI;
using Azure.Identity;
using Microsoft.Extensions.AI;
using System.ComponentModel;

// AOAI のクライアントを作成
var azureOpenAIClient = new AzureOpenAIClient(
    new Uri("https://<<AOAI のリソース名>>.openai.azure.com/"),
    new AzureCliCredential());

// AI から呼び出して欲しい関数を AIFunction として定義
AIFunction[] tools = [
        AIFunctionFactory.Create(GetToday),
        AIFunctionFactory.Create(GetWeatherForecast),
    ];
// CompleteAsync に渡すオプションを作成
var options = new ChatOptions
{
    // 自動で適切なツールを選択するように指定
    ToolMode = ChatToolMode.Auto,
    // ツールのリストを指定
    Tools = tools,
};

var client = new ChatClientBuilder()
    // 関数呼び出しの自動ハンドリングを有効化
    .UseFunctionInvocation()
    // ChatOptions を設定
    .UseChatOptions(_ => options)
    // Azure OpenAI のクライアントを設定
    .Use(azureOpenAIClient.AsChatClient("gpt-4o"));

// 今日の東京の天気を質問して関数を呼び出すように仕向ける
var result = await client.CompleteAsync("今日の東京の天気は?", options);
// 最終回答を表示
Console.WriteLine(result.Message.Text);

// AI から呼び出して欲しい関数を定義
[Description("今日の日付を取得します。")]
DateTimeOffset GetToday() => TimeProvider.System.GetLocalNow();

[Description("指定した日付と場所の天気予報を取得します。")]
string GetWeatherForecast(
    [Description("日付")]
    DateTimeOffset date,
    [Description("場所")]
    string location) => location switch
    {
        "東京" => "晴れ",
        "大阪" => "曇り",
        "札幌" => "雪",
        _ => "空から蛙が降ってます",
    };

実行結果は以下のようになります。ちゃんと関数を呼び出した結果を見て回答していることがわかります。

今日の東京の天気は晴れです。

Prompty の統合

Microsoft.Extensions.AI は、これまで見てきたようにミドルウェアによる拡張性 (関数呼び出し、ChatOptions の設定、ロギング、キャッシュなど) を備えつつ、様々な Chat Completions API 系の API への抽象化レイヤーを設けています。実際の抽象化レイヤーは Microsoft.Extensions.AI.Abstractions にあり、ここの抽象化レイヤーに対して各 AI モデルのベンダーが自分達の API に対するアダプターを作成することで Microsoft.Extensions.AI に対応することが出来ます。

こうすることで、アプリケーションや周辺ライブラリは Microsoft.Extensions.AI.Abstractions を使うようにすることで、様々な AI モデルに共通のコードで対応できるようになります。
何か月か前に突如出てきた Prompty というプロンプトや使う生成 AI やプロンプトを組み立てるためのテンプレートなどを記述するためのファイルフォーマットと、その周辺ツールとライブラリがありますが、先日 .NET 用の Prompty を扱うための Prompty.Core が突然リリースされました (現在 alpha 版)。今までも Semantic Kernel で使用することが出来たのですが、この Prompty.CoreMicrosoft.Extensions.AI.Abstractions を使っています。

そのため Prompty.Core を使うことで自動的に様々な LLM に対応することが出来るようになります。
例えば Prompty.Core というライブラリを追加して以下のような AnimalAssistant.prompty ファイルを追加したコンソールアプリのプロジェクトを用意します。

---
name: Animal assistant prompt
authors:
  - Kazuki Ota
model:
  api: chat
sample:
  animalName: 猫
  cry: にゃん
  question: 今日の品川の天気は?
---
system:
あなたは{{animalName}}型のアシスタントです。
{{animalName}}らしさを出すために語尾は必ず「{{cry}}」をつけてください。
user:
{{question}}

Prompty.Core.Prompty.LoadAsync で読み込んで Prepare メソッドで ChatMessage のリストを取得することが出来ます。

using Prompty.Core;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.Unicode;

// Prompty.Core が内部で使用するレンダリングやパーサーの登録を行う
InvokerFactory.AutoDiscovery();

// Prompty ファイルを読み込んで ChatMessage を作成
var prompty = await Prompty.Core.Prompty.LoadAsync("./AnimalAssistant.prompty");
var messages = (ChatMessage[])prompty.Prepare(new
{
    animalName = "犬",
    cry = "わんっ",
    question = "今日の東京の天気は何ですか?",
});

// JSON にして表示
Console.WriteLine(JsonSerializer.Serialize(messages, new JsonSerializerOptions
{
    WriteIndented = true,
    Encoder = JavaScriptEncoder.Create(UnicodeRanges.All),
}));

Prompty.Core の現状の作りは *.prompty ファイルの modelapichat が設定されている場合は Prepare メソッドで Microsoft.Extensions.AI.AbstractionsChatMessage の配列を組み立てるようになっています。そのため、Prepare の戻り値を Microsoft.Extensions.AI.AbstractionsChatMessage[] にキャストすることが出来ます。個人的には型がゆるいので嫌いなタイプの API ですが…。

実行すると以下のような結果になります。ちゃんと animalNamecryquestion に値が埋め込まれた状態になっています。

[
  {
    "AuthorName": null,
    "Role": "system",
    "Contents": [
      {
        "$type": "text",
        "Text": "あなたは犬型のアシスタントです。\r\n犬らしさを出すために語尾は必ず「わんっ」をつけてください。",
        "AdditionalProperties": null
      }
    ],
    "AdditionalProperties": null
  },
  {
    "AuthorName": null,
    "Role": "user",
    "Contents": [
      {
        "$type": "text",
        "Text": "今日の東京の天気は何ですか?",
        "AdditionalProperties": null
      }
    ],
    "AdditionalProperties": null
  }
]

あとは、JSON を出力しているところを IChatClient を使用したコードに置き換えることで、AI に問い合わせることが出来ます。

using Azure.AI.OpenAI;
using Azure.Identity;
using Microsoft.Extensions.AI;
using Prompty.Core;
using System.ComponentModel;

// Prompty.Core が内部で使用するレンダリングやパーサーの登録を行う
InvokerFactory.AutoDiscovery();

// Prompty ファイルを読み込んで ChatMessage を作成
var prompty = await Prompty.Core.Prompty.LoadAsync("./AnimalAssistant.prompty");
// 関数呼び出しをするには List にする必要があるのでリスト化
List<ChatMessage> messages = [..(ChatMessage[])prompty.Prepare(new
{
    animalName = "犬",
    cry = "わんっ",

    question = "今日の東京の天気は何ですか?",
})];

// AOAI のクライアントを作成
var azureOpenAIClient = new AzureOpenAIClient(
    new Uri("https://<<AOAI のリソース名>>.openai.azure.com/"),
    new AzureCliCredential());
// AI から呼び出して欲しい関数を AIFunction として定義
AIFunction[] tools = [
        AIFunctionFactory.Create(GetToday),
        AIFunctionFactory.Create(GetWeatherForecast),
    ];
// CompleteAsync に渡すオプションを作成
var options = new ChatOptions
{
    // 自動で適切なツールを選択するように指定
    ToolMode = ChatToolMode.Auto,
    // ツールのリストを指定
    Tools = tools,
};

var client = new ChatClientBuilder()
    // 関数呼び出しの自動ハンドリングを有効化
    .UseFunctionInvocation()
    // ChatOptions を設定
    .UseChatOptions(_ => options)
    // Azure OpenAI のクライアントを設定
    .Use(azureOpenAIClient.AsChatClient("gpt-4o"));

// Prompty から読み込んだメッセージを渡す
var result = await client.CompleteAsync(messages, options);
// 最終回答を表示
Console.WriteLine(result.Message.Text);

// AI から呼び出して欲しい関数を定義
[Description("今日の日付を取得します。")]
DateTimeOffset GetToday() => TimeProvider.System.GetLocalNow();

[Description("指定した日付と場所の天気予報を取得します。")]
string GetWeatherForecast(
    [Description("日付")]
    DateTimeOffset date,
    [Description("場所")]
    string location) => location switch
    {
        "東京" => "晴れ",
        "大阪" => "曇り",
        "札幌" => "雪",
        _ => "空から蛙が降ってます",
    };

実行すると、ちゃんと犬っぽく天気を答えてくれました。

今日の東京の天気は晴れですわんっ!

まとめ

Microsoft.Extensions.AI による LLM の呼び出しの抽象化についての解説を行いました。
これを使うことで、様々な LLM に対して共通のコードで対応することが出来るようになります。

また、多くの LLM が Microsoft.Extensions.AI.Abstractions を実装することで .NET から LLM を呼び出すコードが共通化されるだけではなく LLM に対応する周辺ライブラリの実装も共通化されることが期待されます。実際に、この記事では Prompty.CoreMicrosoft.Extensions.AI.AbstractionsChatMessage を組み立てるという形でになっているため、そのままシームレスに IChatClient へ渡せることを紹介しました。

まだまだ、Microsoft.Extensions.AIPrompty.Core もプレビュー段階ですが個人的には Microsoft.Extensions.AI は .NET 10 より前に GA するのではないか…と思っています。Prompty.Core の方はもう少しこなれた API になってほしいなと思っていますが、これからの展開が楽しみです。Semantic Kernel も Microsoft.Extensions.AI を使うように将来的にはなるので本当に今後の展開が楽しみですね!楽しみなことが多すぎてウォッチしているだけでワクワクしてしまいます。

Microsoft.Extensions.AI には Chat だけではなくベクトルを生成する Embeddings の抽象化レイヤーもあります。これについてはまた別の機会に紹介したいと思います。気になる方は以下の Blog 記事で紹介されているので、そちらを参考にしてみてください。

https://devblogs.microsoft.com/dotnet/introducing-microsoft-extensions-vector-data/

参考サイト

この記事を書くにあたって参考にした記事やサイトです。

Microsoft (有志)

Discussion