🦁

Semantic Kernel v1.0.1 のプラグインを見てみよう

2023/12/28に公開

はじめに

Semantic Kernel の中核の Kernel クラスは Plugins プロパティ、Services プロパティ、Culture プロパティ、LoggerFactory プロパティ、ServiceSelector プロパティ、Data プロパティを持っています。以外と少ないですね。
CultureLoggerFactory は内部で使われるカルチャーやロガーを指定するもので DataIDictionary<string, object?> 型なのと、Semantic Kernel 内部でも特に積極的に利用されているように見えないので、とりあえずあまり気にしなくて良いでしょう。

ServiceSelector プロパティは IAIServiceSelector 型で実際に使われるサービスのインスタンスを選択する際に独自ロジックを差し込むための口なので、ここも一般的な用途で使うのではなく応用的な用途で使われるものになります。

となると、残りは PluginsServices プロパティですが、Services プロパティは IServicProvider 型でおなじみのものになります。Plugins プロパティが KernslPluginCollection 型で名前の通り KernelPlugin 型のコレクションになっています。そのため、Semantic Kernel では基本的に Plugins に設定されたプラグインと Services プロパティに登録されたサービスを使って様々な動作をするような仕組みになっているということが推測できます。実際には、PluginsServices に登録されたサービスの中から KernelPlugin 型として登録されているものを探して、構築されているので実態は Services 内に登録されたもので全てが構築されています。今風ですね。

このような構造になっているのですが、今回は Semantic Kernel 固有の KernelPlugin について理解を深めると、Semantic Kernel の動作を理解する上でのヒントになると思うので見て行こうと思います。

KernelPlugin を見てみる

KernelPlugin は定義を見ると IEnumerable<KernelFunction> インターフェースを実装しています。そのため本質は単なる KernelFunction を束ねるものということになります。それに加えてプラグインの名前を表す Name プロパティと概要を表す Description プロパティがあります。つまりプラグインとは名前と概要と持った KernelFunction の集合ということになります。その他に、KernelFunction を取得するためのメソッドがいくつか提供されています。

KernelPlugin は抽象クラスで、実際には KernelPlugin を継承した DefaultKernelPlugin というクラスが Semantic Kernel 内では使われています。これは internal なので外部からは使えないようになっていますが、実装は至ってシンプルで関数の名前と KernelFunction の辞書を内部に持っているだけのシンプルなクラスです。大した量じゃないのでコード全体を引用します。

DefaultKernelPlugin.cs
// Copyright (c) Microsoft. All rights reserved.

using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;

namespace Microsoft.SemanticKernel;

/// <summary>
/// Provides an <see cref="KernelPlugin"/> implementation around a collection of functions.
/// </summary>
internal sealed class DefaultKernelPlugin : KernelPlugin
{
    /// <summary>The collection of functions associated with this plugin.</summary>
    private readonly Dictionary<string, KernelFunction> _functions;

    /// <summary>Initializes the new plugin from the provided name, description, and function collection.</summary>
    /// <param name="name">The name for the plugin.</param>
    /// <param name="description">A description of the plugin.</param>
    /// <param name="functions">The initial functions to be available as part of the plugin.</param>
    /// <exception cref="ArgumentException"><paramref name="name"/> is null.</exception>
    /// <exception cref="ArgumentException"><paramref name="name"/> is an invalid plugin name.</exception>
    /// <exception cref="ArgumentNullException"><paramref name="functions"/> contains a null function.</exception>
    /// <exception cref="ArgumentException"><paramref name="functions"/> contains two functions with the same name.</exception>
    internal DefaultKernelPlugin(string name, string? description, IEnumerable<KernelFunction>? functions = null) : base(name, description)
    {
        this._functions = new Dictionary<string, KernelFunction>(StringComparer.OrdinalIgnoreCase);
        if (functions is not null)
        {
            foreach (KernelFunction f in functions)
            {
                Verify.NotNull(f, nameof(functions));
                this._functions.Add(f.Name, f);
            }
        }
    }

    /// <inheritdoc/>
    public override int FunctionCount => this._functions.Count;

    /// <inheritdoc/>
    public override bool TryGetFunction(string name, [NotNullWhen(true)] out KernelFunction? function) =>
        this._functions.TryGetValue(name, out function);

    /// <inheritdoc/>
    public override IEnumerator<KernelFunction> GetEnumerator() => this._functions.Values.GetEnumerator();
}

ここまでで KernelPlugin 自体は大したことはしていなくて、単なる KernelFunction を束ねるものということがわかりました。では KernelFunction はどうなっているのか見てみましょう。

KernelFunction を見てみる

KernelFunction も実態は抽象クラスとして定義されていて、以下のような情報を保持するようになっています。

  • 名前 (Name プロパティ)
  • 概要 (Description プロパティ)
  • メタデータ (Metadata プロパティ)
    • 名前 (Name プロパティ)
    • 概要 (Description プロパティ)
    • プラグイン名 (PluginName プロパティ)
    • パラメーターのメタデータ (Parameters プロパティ)
    • 戻り値のメタデータ (ReturnParameter プロパティ)

Name プロパティと Description プロパティはメタデータの同名のプロパティに対する単なるショートカットです。

この他に、関数を実行するための InvokeAsync やストリーミングに対応した InvokeStreamingAsync メソッドがあります。

KernelFunction の呼び出し方法

KernelFunction を呼び出すには Kernel クラスの InvokeAsync メソッドや InvokeStreamingAsync メソッドを使うか、KernelFunctionInvokeAsync メソッドや InvokeStreamingAsync メソッドを使います。Kernel クラスで提供されているメソッドは、引数で渡された KernelFunction のインスタンスやプラグイン名と関数名などから KernelFunction を自分の中から探して、最終的に KernelFunctionInvokeAsyncInvokeStreamingAsync メソッドを呼ぶ流れになっているので実態としては KernelFunction クラスのメソッドを呼ぶのと同じです。

状況に応じて一番便利なものを使って呼ぶと良いでしょう。

KernelFunction を呼び出すにあたって必要な引数は Kernel クラス、KernelArguments クラスの 2 つになります。その他に呼び出しをキャンセルするための CancellationToken もありますが、これは一般的な非同期メソッドの呼び出しと同じです。

Kernel クラスが引数に渡されるということは KernelFunction の実行中に Kernel クラスの PluginsServices に登録されているプラグインやサービスを使うことが出来るということです。これを知ってると、関数内で別のプラグインを呼んだりすることが別に不思議なことではなく、ちゃんと情報として渡されてるからなんだなということがわかります。

KernelArgyments クラスは IDictionary<string, object?> を実装しているクラスで、KernelFunction に渡す引数を表すものになります。IDictionary<string, object?> を実装しているので以下のようにコレクション初期化子を使って引数を渡すことが出来ます。

var result = await someKernelFunction.InvokeAsync(kernel, new KernelArguments
{
    // arg1 と arg2 という引数を渡す
    ["arg1"] = "value1",
    ["arg2"] = 123,
});

InvokeAsync メソッドの戻り値ですが Task<FunctionResult> になります。InvokeAsync の型引数を指定するバージョンだと戻り値は Task<TResult?> になります。FunctionResultGetValue<T>() で関数の結果の値を取れるので、結果だけが欲しい場合は型引数を指定すると楽です。FunctionResult には呼び出された関数の情報や各種メタデータも入っているので、後続の処理でそれらの情報も必要な場合は FunctionResult を使うと良いでしょう。

ストリーミングの方も似たような感じで型引数を指定しない場合の戻り値は IAsyncEnumerable<StreamingKernelContent> で型引数を指定した場合は IAsyncEnumerable<TResult> になっています。

KernelFunction の実装クラスと作成方法

KernelFunction 自体は抽象クラスなので、それ自身はインスタンス化して呼び出すことが出来ません。実際には以下の 2 つの実装クラスが使われています。

  • KernelFunctionFromMethod クラス
    • ネイティブな C# のメソッドを呼び出す KernelFunction の実装
  • KernelFunctionFromPrompt クラス
    • LLM モデルにプロンプトを渡して生成された結果を返す KernelFunction の実装

この 2 つの実装クラスも internal なので外からは見えません。実際には、Kernel クラスの拡張メソッドである CreateFunctionFromMethodCreateFunctionFromPrompt メソッドを使って KernelFunction を作成することになります。裏側では上記の実装クラスのインスタンスが作成されています。他にも作り方はありますが、恐らく一番これが使いやすいため一番使う機会が多い方法になると思います。

オーバーロードはいくつかありますが、このような感じで生成します。

// メソッドから KernelFunction の作成
var methodFonction = kernel.CreateFunctionFromMethod(
    method: (string name) => $"Hello {name}.",
    functionName: "GenerateGreetingMessage",
    description: "あいさつ文を作成します。",
    parameters: 
        [
            new("name")
            {
                Description = "名前"
            }
        ],
    returnParameter: new()
    {
        Description = "あいさつ文",
    });

// プロンプトから KernelFunction の作成
var promptFunction = kernel.CreateFunctionFromPrompt(
    promptConfig: new("""
        与えられた名前の人に対して一言あいさつ文を生成してください。

        ## 相手の名前
        {{$name}}

        ## あいさつ文

        """)
    {
        Name = "GenerateGreetingMessageFromAI",
        Description = "あいさつ文を作成します。",
        InputVariables =
        [
            new InputVariable 
            {
                Name = "name",
                Description = "名前",
            },
        ],
        OutputVariable = new()
        {
            Description = "あいさつ文",
        },
        ExecutionSettings = new()
        {
            [PromptExecutionSettings.DefaultServiceId] = new OpenAIPromptExecutionSettings
            {
                Temperature = 0.0f,
                MaxTokens = 100,
            }
        }
    });

ただ、これもシンプルに OpenAI を呼び出したい時とか、ちょっとしたサンプルを書きたい時くらいにしか使用しないと思います。実際にはプラグイン単位で生成することが多いと思います。

プラグインの生成

プラグインも KernelFunction と同じように C# のクラスから生成する方法と、プロンプトから生成する方法があります。
使用することはあまりないかもしれませんが IEnumerable<KernelFunction> からプラグインを生成する方法もあります。

プロンプトから生成する方法

こちらは特定のルールに従ってプロンプトやプロンプトを実行する際に使用する OpenAI などに渡すパラメーターを構成したファイルを置いたディレクトリを指定することでプラグインを生成する方法になります。これは Kernel クラスの CreatePluginFromPromptDirectory メソッドを使います。
個人的には、こういう方法は作成のための支援ツールがよっぽどリッチにならない限りは好きじゃないのですが、アプリとは独立してファイルとしてプラグインを管理できるというメリットがあるので、そのようなユースケースの時には便利だと思います。

実際に、Semantic Kernel の以下の場所にサンプルのプラグインが置かれています。

https://github.com/microsoft/semantic-kernel/tree/dotnet-1.0.1/samples/plugins

1 つ 1 つのフォルダーが 1 つのプラグインです。プラグインのフォルダー内には KernelFunction に相当するフォルダーがあり、その中に skprompt.txt というプロンプトが記載されたテキストファイルと、config.json という OpenAI を呼び出すときに渡す温度などのパラメーターや、その関数の受け取るパラメーターなどの設定が書いてあるファイルがあります。

例えば FunPlugin の Joke 関数を構成するファイルは以下のようになっています。

config.json
{
  "schema": 1,
  "description": "Generate a funny joke",
  "execution_settings": {
    "default": {
      "max_tokens": 1000,
      "temperature": 0.9,
      "top_p": 0.0,
      "presence_penalty": 0.0,
      "frequency_penalty": 0.0
    }
  },
  "input_variables": [
    {
      "name": "input",
      "description": "Joke subject",
      "default": ""
    },
    {
      "name": "style",
      "description": "Give a hint about the desired joke style",
      "default": ""
    }
  ]
}
WRITE EXACTLY ONE JOKE or HUMOROUS STORY ABOUT THE TOPIC BELOW

JOKE MUST BE:
- G RATED
- WORKPLACE/FAMILY SAFE
NO SEXISM, RACISM OR OTHER BIAS/BIGOTRY

BE CREATIVE AND FUNNY. I WANT TO LAUGH.
Incorporate the style suggestion, if provided: {{$style}}
+++++

{{$input}}
+++++

クラスから生成する方法

KernelFunctionAttributeDescriptionAttribute などの属性をつけたメソッドを持ったクラスをプラグインにすることが出来ます。

例えば以下のようになります。

TimePlugin.cs
[Description("日付や時間に関するプラグイン")]
class TimePlugin
{
    [KernelFunction]
    [Description("今日の日付を返します。")]
    [return:Description("format 引数で指定した書式の今日の日付")]
    public string Today(
        [Description("日付の書式")]
        string format = "yyyy/MM/dd") =>
        TimeProvider.System.GetLocalNow().ToString(format);
}

この例では1クラスに1関数しか定義していませんが、関数は複数個定義することが出来ます。
この TimePlugin クラスを Kernel に登録するには以下のようにします。

using Azure.Identity;
using Microsoft.Extensions.Configuration;
using Microsoft.SemanticKernel;
using System.ComponentModel;

// ソースコピペするときにめんどくさいのでユーザーシークレットからエンドポイント等は読み込むようにした
// こんな JSON で設定がされている想定
//{
//    "AzureOpenAI": {
//      "Endpoint": "https://リソース名.openai.azure.com/",
//      "ModelDeploymentName": "モデルデプロイ名"
//    }
//}
var configuration = new ConfigurationBuilder()
    .AddUserSecrets<Program>()
    .Build();
var kernel = Kernel.CreateBuilder()
    .AddAzureOpenAIChatCompletion(
        configuration["AzureOpenAI:ModelDeploymentName"]!,
        configuration["AzureOpenAI:Endpoint"]!,
        new AzureCliCredential())
    .Build();

// ここで登録
kernel.Plugins.AddFromType<TimePlugin>();

// 登録したプラグインの関数を呼び出す
// デフォルトでプラグイン名はクラスの型名になる。
var today = await kernel.InvokeAsync<string>(
    nameof(TimePlugin),
    nameof(TimePlugin.Today),
    new()
    {
        ["format"] = "yyyy年MM月dd日",
    });
Console.WriteLine(today);

[Description("日付や時間に関するプラグイン")]
class TimePlugin
{
    [KernelFunction]
    [Description("今日の日付を返します。")]
    [return:Description("format 引数で指定した書式の今日の日付")]
    public string Today(
        [Description("日付の書式")]
        string format = "yyyy/MM/dd") =>
        TimeProvider.System.GetLocalNow().ToString(format);
}

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

2023年12月28日

また、プラグインの関数のメタデータは属性や型情報から読み取って適切に設定してくれます。
実際にデバッガーで止めて KernelPlugins プロパティを見てみると以下のようになっています。

あと、ソースコードを読んでて面白かったのはプラグインのインスタンス化に Microsoft.Extensions.DependencyInjection.ActivatorUtilities クラス (存在知らなかった) を使って生成していて、名前空間から想像が出来るように IServiceProvider に登録されているクラスを自動的に DI してくれるようになっていました。これは便利ですね。例えば先ほどの TimePlugin で日付を文字列化する部分を DateTimeFormatter という別のクラスに切り出して、それを使って文字列化するように変更してみます。KernelAddFromType<T> 拡張メソッドは実は IServiceProvider を受け取るオーバーロードがあります。これを使えば DateTimeFormatter を DI コンテナから取ってくるように出来ます。

コードは以下のようになります。

using Azure.Identity;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.SemanticKernel;
using System.ComponentModel;

// ソースコピペするときにめんどくさいのでユーザーシークレットからエンドポイント等は読み込むようにした
// こんな JSON で設定がされている想定
//{
//    "AzureOpenAI": {
//      "Endpoint": "https://リソース名.openai.azure.com/",
//      "ModelDeploymentName": "モデルデプロイ名"
//    }
//}
var configuration = new ConfigurationBuilder()
    .AddUserSecrets<Program>()
    .Build();
var builder = Kernel.CreateBuilder();
builder.AddAzureOpenAIChatCompletion(
    configuration["AzureOpenAI:ModelDeploymentName"]!,
    configuration["AzureOpenAI:Endpoint"]!,
    new AzureCliCredential());

// ここでサービスを登録する
builder.Services.AddSingleton<DateTimeFormatter>();

var kernel = builder.Build();

// KernelBuilder で Add したサービスは Kernel の Services で取得できるので、それを渡してあげれば OK (自動でやってくれとは思いますが…)
kernel.Plugins.AddFromType<TimePlugin>(serviceProvider: kernel.Services);

var today = await kernel.InvokeAsync<string>(
    nameof(TimePlugin),
    nameof(TimePlugin.Today),
    new()
    {
        ["format"] = "yyyy年MM月dd日",
    });
Console.WriteLine(today);

[Description("日付や時間に関するプラグイン")]
class TimePlugin(DateTimeFormatter dateTimeFormatter)
{
    private readonly DateTimeFormatter _dateTimeFormatter = dateTimeFormatter;

    [KernelFunction]
    [Description("今日の日付を返します。")]
    [return:Description("今日の日付")]
    public string Today() =>
        _dateTimeFormatter.Format(TimeProvider.System.GetLocalNow());
}

class DateTimeFormatter
{
    public string Format(DateTimeOffset dateTimeOffset) =>
        // わかりやすいように★をつけてみる
        dateTimeOffset.ToString("★yyyy年MM月dd日★");
}

実行すると以下のようになります。ちゃんと DateTimeFormatter が使われていることがわかります。

★2023年12月28日★

いちいち、AddFromTypeIServiceProvider を引数に渡すのがメンドクサイですが、これは KernelBuilder のタイミングでプラグインを登録してあげると勝手にやってくれたりします。

using Azure.Identity;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.SemanticKernel;
using System.ComponentModel;

// ソースコピペするときにめんどくさいのでユーザーシークレットからエンドポイント等は読み込むようにした
// こんな JSON で設定がされている想定
//{
//    "AzureOpenAI": {
//      "Endpoint": "https://リソース名.openai.azure.com/",
//      "ModelDeploymentName": "モデルデプロイ名"
//    }
//}
var configuration = new ConfigurationBuilder()
    .AddUserSecrets<Program>()
    .Build();
var builder = Kernel.CreateBuilder();
builder.AddAzureOpenAIChatCompletion(
    configuration["AzureOpenAI:ModelDeploymentName"]!,
    configuration["AzureOpenAI:Endpoint"]!,
    new AzureCliCredential());
// ここでサービスを登録する
builder.Services.AddSingleton<DateTimeFormatter>();
// ここでプラグインも登録する。この場合は勝手に KernelBuilder に登録したサービスは解決してくれる
builder.Plugins.AddFromType<TimePlugin>();

var kernel = builder.Build();

var today = await kernel.InvokeAsync<string>(
    nameof(TimePlugin),
    nameof(TimePlugin.Today),
    new()
    {
        ["format"] = "yyyy年MM月dd日",
    });
Console.WriteLine(today);

[Description("日付や時間に関するプラグイン")]
class TimePlugin(DateTimeFormatter dateTimeFormatter)
{
    private readonly DateTimeFormatter _dateTimeFormatter = dateTimeFormatter;

    [KernelFunction]
    [Description("今日の日付を返します。")]
    [return:Description("今日の日付")]
    public string Today() =>
        _dateTimeFormatter.Format(TimeProvider.System.GetLocalNow());
}

class DateTimeFormatter
{
    public string Format(DateTimeOffset dateTimeOffset) =>
        dateTimeOffset.ToString("★yyyy年MM月dd日★");
}

ASP.NET Core とかで Kernel を使う方法

ここまで KernelBuilder でサービス登録をする方法などを見てきましたが ASP.NET Core などの最近のフレームワークでは、そもそもフレームワーク側で DI コンテナである Microsoft.Extensions.DependencyInjection のパッケージが組み込まれていて、IServiceCollection の構成は組み込みで提供されています。Semantic Kernel と ASP.NET Core などのフレームワークで DI コンテナを 2 つ管理するのは現実的ではありません。

実は Semantic Kernel の Kernel クラスは IServiceProviderKernelPluginCollection を受け取るコンストラクタがあり、これの中で KernelPluginIServiceProvider から取得して構成してくれるようになっています。
KernelPluginCollectionAddTransient で追加しないといけなかったりと注意点があるのですが、そこらへんを勝手にやってくれる AddKernel という拡張メソッドもあります。基本的に、そのメソッドを使っておくのがいいでしょう。

そのため ASP.NET Core などのように、フレームワークに DI コンテナが統合されている場合は以下のような使い方になります。

using Azure.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.SemanticKernel;
using System.ComponentModel;

var builder = WebApplication.CreateBuilder(args);

// 各種サービスの登録
builder.Services.AddHttpClient();
builder.Services.AddSingleton<DateTimeFormatter>();
builder.Services.AddSingleton(sp => KernelPluginFactory.CreateFromType<TimePlugin>(serviceProvider: sp));
builder.Services.AddAzureOpenAIChatCompletion(
    builder.Configuration["AzureOpenAI:ModelDeploymentName"]!,
    builder.Configuration["AzureOpenAI:Endpoint"]!,
    new DefaultAzureCredential());
// Kernel も登録
builder.Services.AddKernel();

var app = builder.Build();

// Kernel を DI コンテナから取得
app.MapGet("/", async ([FromQuery]string name, Kernel kernel) =>
{
    // 適当に挨拶を返す感じで
    var func = kernel.CreateFunctionFromPrompt("""
        あなたは日本語の専門家です。
        与えられた名前の人に対する気の利いた短いあいさつ文を作成してください。
        あいさつ文には必ず今日の日付に因んだネタを含めてください。

        ## 相手の名前
        {{$name}}

        ## 今日の日付
        {{TimePlugin.Today}}

        ## あいさつ文

        """);
    return await kernel.InvokeAsync<string>(func, new()
    {
        ["name"] = name,
    });
});

app.Run();

[Description("日付や時間に関するプラグイン")]
class TimePlugin(DateTimeFormatter dateTimeFormatter)
{
    private readonly DateTimeFormatter _dateTimeFormatter = dateTimeFormatter;

    [KernelFunction]
    [Description("今日の日付を返します。")]
    [return: Description("今日の日付")]
    public string Today() =>
        _dateTimeFormatter.Format(TimeProvider.System.GetLocalNow());
}

class DateTimeFormatter
{
    public string Format(DateTimeOffset dateTimeOffset) =>
        dateTimeOffset.ToString("yyyy年MM月dd日");
}

実行して ?name=大田 を URL の末尾につけて叩くと以下のようなレスポンスが返ってきました。
ちゃんと動いてそうですね!

大田様、

こんにちは!年末の忙しさの中、メッセージをお送りします。2023年12月28日ということで、今年も残りわずかとなりましたね。大掃除はもう始められましたか?部屋も心も新たに、新しい年を迎える準備を進めていることと思います。この一年の締めくくりと新たな年への期待を込めて、良いお年をお迎えください!

敬具

[お名前]

まとめ

とりとめもなくコードを見ながら Kernel のプラグインまわりを見てきました。最後には ASP.NET Core などの DI コンテナ (Microsoft.Extensions.DependencyInjection) が組み込まれているフレームワークでの使い方も見てみました。
個人的に気になっているのは、現状だと Azure SDK の DI のサポート機能で登録された Azure.AI.OpenAI の OpenAIClient のインスタンスを Semantic Kernel 内で綺麗に扱う方法が無さそうなところでしょうか。

気づいてないだけでもしかしたらあるのかもしれませんが…。

Microsoft (有志)

Discussion