Semantic Kernel v1.0.1 のプラグインを見てみよう
はじめに
Semantic Kernel の中核の Kernel
クラスは Plugins
プロパティ、Services
プロパティ、Culture
プロパティ、LoggerFactory
プロパティ、ServiceSelector
プロパティ、Data
プロパティを持っています。以外と少ないですね。
Culture
や LoggerFactory
は内部で使われるカルチャーやロガーを指定するもので Data
が IDictionary<string, object?>
型なのと、Semantic Kernel 内部でも特に積極的に利用されているように見えないので、とりあえずあまり気にしなくて良いでしょう。
ServiceSelector
プロパティは IAIServiceSelector
型で実際に使われるサービスのインスタンスを選択する際に独自ロジックを差し込むための口なので、ここも一般的な用途で使うのではなく応用的な用途で使われるものになります。
となると、残りは Plugins
と Services
プロパティですが、Services
プロパティは IServicProvider
型でおなじみのものになります。Plugins
プロパティが KernslPluginCollection
型で名前の通り KernelPlugin
型のコレクションになっています。そのため、Semantic Kernel では基本的に Plugins
に設定されたプラグインと Services
プロパティに登録されたサービスを使って様々な動作をするような仕組みになっているということが推測できます。実際には、Plugins
は Services
に登録されたサービスの中から KernelPlugin
型として登録されているものを探して、構築されているので実態は Services
内に登録されたもので全てが構築されています。今風ですね。
このような構造になっているのですが、今回は Semantic Kernel 固有の KernelPlugin
について理解を深めると、Semantic Kernel の動作を理解する上でのヒントになると思うので見て行こうと思います。
KernelPlugin を見てみる
KernelPlugin
は定義を見ると IEnumerable<KernelFunction>
インターフェースを実装しています。そのため本質は単なる KernelFunction
を束ねるものということになります。それに加えてプラグインの名前を表す Name
プロパティと概要を表す Description
プロパティがあります。つまりプラグインとは名前と概要と持った KernelFunction
の集合ということになります。その他に、KernelFunction
を取得するためのメソッドがいくつか提供されています。
KernelPlugin
は抽象クラスで、実際には KernelPlugin
を継承した DefaultKernelPlugin
というクラスが Semantic Kernel 内では使われています。これは internal
なので外部からは使えないようになっていますが、実装は至ってシンプルで関数の名前と KernelFunction
の辞書を内部に持っているだけのシンプルなクラスです。大した量じゃないのでコード全体を引用します。
// 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
メソッドを使うか、KernelFunction
の InvokeAsync
メソッドや InvokeStreamingAsync
メソッドを使います。Kernel
クラスで提供されているメソッドは、引数で渡された KernelFunction
のインスタンスやプラグイン名と関数名などから KernelFunction
を自分の中から探して、最終的に KernelFunction
の InvokeAsync
や InvokeStreamingAsync
メソッドを呼ぶ流れになっているので実態としては KernelFunction
クラスのメソッドを呼ぶのと同じです。
状況に応じて一番便利なものを使って呼ぶと良いでしょう。
KernelFunction
を呼び出すにあたって必要な引数は Kernel
クラス、KernelArguments
クラスの 2 つになります。その他に呼び出しをキャンセルするための CancellationToken
もありますが、これは一般的な非同期メソッドの呼び出しと同じです。
Kernel
クラスが引数に渡されるということは KernelFunction
の実行中に Kernel
クラスの Plugins
や Services
に登録されているプラグインやサービスを使うことが出来るということです。これを知ってると、関数内で別のプラグインを呼んだりすることが別に不思議なことではなく、ちゃんと情報として渡されてるからなんだなということがわかります。
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?>
になります。FunctionResult
は GetValue<T>()
で関数の結果の値を取れるので、結果だけが欲しい場合は型引数を指定すると楽です。FunctionResult
には呼び出された関数の情報や各種メタデータも入っているので、後続の処理でそれらの情報も必要な場合は FunctionResult
を使うと良いでしょう。
ストリーミングの方も似たような感じで型引数を指定しない場合の戻り値は IAsyncEnumerable<StreamingKernelContent>
で型引数を指定した場合は IAsyncEnumerable<TResult>
になっています。
KernelFunction
の実装クラスと作成方法
KernelFunction
自体は抽象クラスなので、それ自身はインスタンス化して呼び出すことが出来ません。実際には以下の 2 つの実装クラスが使われています。
-
KernelFunctionFromMethod
クラス- ネイティブな C# のメソッドを呼び出す
KernelFunction
の実装
- ネイティブな C# のメソッドを呼び出す
-
KernelFunctionFromPrompt
クラス- LLM モデルにプロンプトを渡して生成された結果を返す
KernelFunction
の実装
- LLM モデルにプロンプトを渡して生成された結果を返す
この 2 つの実装クラスも internal
なので外からは見えません。実際には、Kernel
クラスの拡張メソッドである CreateFunctionFromMethod
や CreateFunctionFromPrompt
メソッドを使って 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 の以下の場所にサンプルのプラグインが置かれています。
1 つ 1 つのフォルダーが 1 つのプラグインです。プラグインのフォルダー内には KernelFunction
に相当するフォルダーがあり、その中に skprompt.txt
というプロンプトが記載されたテキストファイルと、config.json
という OpenAI を呼び出すときに渡す温度などのパラメーターや、その関数の受け取るパラメーターなどの設定が書いてあるファイルがあります。
例えば FunPlugin の Joke 関数を構成するファイルは以下のようになっています。
{
"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}}
+++++
クラスから生成する方法
KernelFunctionAttribute
や DescriptionAttribute
などの属性をつけたメソッドを持ったクラスをプラグインにすることが出来ます。
例えば以下のようになります。
[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日
また、プラグインの関数のメタデータは属性や型情報から読み取って適切に設定してくれます。
実際にデバッガーで止めて Kernel
の Plugins
プロパティを見てみると以下のようになっています。
あと、ソースコードを読んでて面白かったのはプラグインのインスタンス化に Microsoft.Extensions.DependencyInjection.ActivatorUtilities
クラス (存在知らなかった) を使って生成していて、名前空間から想像が出来るように IServiceProvider
に登録されているクラスを自動的に DI してくれるようになっていました。これは便利ですね。例えば先ほどの TimePlugin
で日付を文字列化する部分を DateTimeFormatter
という別のクラスに切り出して、それを使って文字列化するように変更してみます。Kernel
の AddFromType<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日★
いちいち、AddFromType
で IServiceProvider
を引数に渡すのがメンドクサイですが、これは 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
クラスは IServiceProvider
と KernelPluginCollection
を受け取るコンストラクタがあり、これの中で KernelPlugin
を IServiceProvider
から取得して構成してくれるようになっています。
KernelPluginCollection
は AddTransient
で追加しないといけなかったりと注意点があるのですが、そこらへんを勝手にやってくれる 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 内で綺麗に扱う方法が無さそうなところでしょうか。
気づいてないだけでもしかしたらあるのかもしれませんが…。
Discussion