Semantic Kernel の Plan ディープダイブ
Semantic Kernel の Plan
は通常は ActionPlanner
や SequentialPlanner
で組み立てますが、手動で組み立てて実行することも出来ます。
実行順序などが決まっているスキルの組み合わせなどは AI にプランを考えてもらう必要もないので手動で Plan
を組んで実行することも出来ます。用途によってはありな気がします。ということで自分で組み立てられるように Plan
について深掘りしてみましょう。
Plan 詳細
Plan は ISKFunction
まず、最初に Plan
クラスは ISKFunction
インターフェースを実装しています。つまり ISKFunction
として扱うことができます。
Plan は ISKFunction のラッパーとして扱える
一番シンプルなプランは ISKFunction
のラッパーです。ほぼ ISKFunction
として使えます。試してみましょう。
using Microsoft.SemanticKernel;
using Microsoft.SemanticKernel.CoreSkills;
using Microsoft.SemanticKernel.Planning;
var kernel = KernelBuilder.Create();
// コアスキルの TimeSkill をインポート
var timeSkill = kernel.ImportSkill(new TimeSkill(), "time");
// TimeSkill の today 関数を元に Plan を作成
var today = new Plan(timeSkill["today"]);
// 実行して結果を表示
var result = today.InvokeAsync();
Console.WriteLine(result.Result); // -> 2023年5月20日
シンプルに今日を返す ISKFunction
を元に Plan
を作って実行しました。これは以下のコードとほぼ同じ意味になります。
using Microsoft.SemanticKernel;
using Microsoft.SemanticKernel.CoreSkills;
var kernel = KernelBuilder.Create();
// コアスキルの TimeSkill をインポート
var timeSkill = kernel.ImportSkill(new TimeSkill(), "time");
// 実行して結果を表示
var result = timeSkill["today"].InvokeAsync();
Console.WriteLine(result.Result); // -> 2023年5月20日
Plan の結果を別の Plan の入力に渡す
ISKFunction
を Plan
にラップするとどんないいことがあるのでしょうか?
Plan
にラップすると複数個の Step
を持つ Plan
を実行したときに、出力を次の Step
に渡すことが出来るようになります。
それをするためには Plan
の Outputs
プロパティに出力の名前を設定するのと、Parameters
に前の Step
から受け取る変数を定義します。
Plan
をチェーンすると、デフォルトで Step
の出力をそのまま次の入力に渡すことが出来ますが、それ以外にも Outputs
で指定したキーの値も次以降の Plan
に引きつげるようになります。例えば今日の日付をとって、それを today
という名前で後続の Step
に渡すことが出来ます。このように後続の Step
に渡すと 2 つや 3 つ先の Step
でも、結果を変数として参照することが出来ます。
文章だけで説明するのもイメージしづらいので実際にコードを書いてみます。
using Microsoft.SemanticKernel;
using Microsoft.SemanticKernel.Orchestration;
using Microsoft.SemanticKernel.Planning;
using Microsoft.SemanticKernel.SkillDefinition;
var kernel = KernelBuilder.Create();
// デバッグ用のネイティブスキルを登録
kernel.ImportSkill(new DebugSkill("test1", "Test1 result"), "test1"); ;
kernel.ImportSkill(new DebugSkill("test2", "Test2 result"), "test2"); ;
kernel.ImportSkill(new DebugSkill("test3", "Test3 result"), "test3"); ;
// import したスキルの関数をラップしたプランを作成
var test1 = new Plan(kernel.Func("test1", "invoke"));
var test2 = new Plan(kernel.Func("test2", "invoke"));
var test3 = new Plan(kernel.Func("test3", "invoke"));
// その 3 つのプランを順番に実行するプランを作成
var plan = new Plan("Planの動きを確認する。", test1, test2, test3);
// 実行して結果を表示
var result = plan.InvokeAsync();
Console.WriteLine($"Final result: {result.Result}");
// ログを出力して指定した結果を返すだけのデバッグ目的のスキル
class DebugSkill
{
private readonly string _name;
private readonly string _skillResult;
public DebugSkill(string name, string skillResult)
{
_name = name;
_skillResult = skillResult;
}
[SKFunction("テスト用関数")]
public string Invoke(string input, SKContext context)
{
Console.WriteLine($"{_name} was invoked with {input}.");
foreach (var variable in context.Variables)
{
Console.WriteLine($" {variable.Key}: {variable.Value}");
}
Console.WriteLine($" Result value: {_skillResult}");
return _skillResult;
}
}
非常にシンプルな DebugSkill
を作って、それを 3 つ連続で実行するプランを作っています。
これを実行すると以下のような結果になります。1 つ前の出力が次の入力にわたっていることが確認できます。これだと、普通に ISKFunction
を連続で実行したのと変わりません。
test1 was invoked with Planの動きを確認する。.
INPUT: Planの動きを確認する。
Result value: Test1 result
test2 was invoked with Test1 result.
INPUT: Test1 result
Result value: Test2 result
test3 was invoked with Test2 result.
INPUT: Test2 result
Result value: Test3 result
Final result: Test3 result
では、このコードを少し変えて Outputs
と Parameters
を設定してみます。
using Microsoft.SemanticKernel;
using Microsoft.SemanticKernel.Orchestration;
using Microsoft.SemanticKernel.Planning;
using Microsoft.SemanticKernel.SkillDefinition;
var kernel = KernelBuilder.Create();
// デバッグ用のネイティブスキルを登録
kernel.ImportSkill(new DebugSkill("test1", "Test1 result"), "test1");
kernel.ImportSkill(new DebugSkill("test2", "Test2 result"), "test2");
kernel.ImportSkill(new DebugSkill("test3", "Test3 result"), "test3");
// import したスキルの関数をラップしたプランを作成
var test1 = new Plan(kernel.Func("test1", "invoke"))
{
// 出力を test1Result という名前で Context に格納する
Outputs = { "test1Result" },
};
var test2 = new Plan(kernel.Func("test2", "invoke"))
{
// 出力を test2Result という名前で Context に格納する
Outputs = { "test2Result" },
// 入力として test1Result を受け取る
Parameters = new ContextVariables()
{
["test1Result"] = "",
},
};
var test3 = new Plan(kernel.Func("test3", "invoke"))
{
// 入力として test1Result と test2Result を受け取る。
// test2Result は customNameParameter という名前で受け取る
Parameters = new ContextVariables()
{
["test1Result"] = "",
["customNameParameter"] = "$test2Result",
},
};
// その 3 つのプランを順番に実行するプランを作成
var plan = new Plan("Planの動きを確認する。", test1, test2, test3);
// 実行して結果を表示
var result = plan.InvokeAsync();
Console.WriteLine($"Final result: {result.Result}");
// ログを出力して指定した結果を返すだけのデバッグ目的のスキル
class DebugSkill
{
private readonly string _name;
private readonly string _skillResult;
public DebugSkill(string name, string skillResult)
{
_name = name;
_skillResult = skillResult;
}
[SKFunction("テスト用関数")]
public string Invoke(string input, SKContext context)
{
Console.WriteLine($"{_name} was invoked with {input}.");
foreach (var variable in context.Variables)
{
Console.WriteLine($" {variable.Key}: {variable.Value}");
}
Console.WriteLine($" Result value: {_skillResult}");
return _skillResult;
}
}
注目すべきは test1
, test2
, test3
の Plan
に設定した Outputs
と Parameters
です。
Outputs
でプランの出力を格納する変数名を指定しています。Parameters
でプランの入力として受け取るパラメーターを定義しています。
実行すると以下のような結果になります。test2
と test3
では、前の Step
の実行結果が変数に格納されていることがわかると思います。
また test3
では customNameParameter
という名前で test2
の結果を受け取っています。
test1 was invoked with Planの動きを確認する。.
INPUT: Planの動きを確認する。
Result value: Test1 result
test2 was invoked with Test1 result.
test1Result: Test1 result
INPUT: Test1 result
Result value: Test2 result
test3 was invoked with Test2 result.
test1Result: Test1 result
customNameParameter: Test2 result
INPUT: Test2 result
Result value: Test3 result
Final result: Test3 result
Plan
のすべての結果は plan.State
で取得できる ContextVariables
に格納されています。
すべての Step
の Outputs
で指定した値が格納されています。
Plan 分岐したい
デフォルトでは Plan
は一直線に実行されますが、ちょっとカスタマイズをすれば分岐も出来ます。
ネイティブスキルで分岐するようなものを書くだけで大丈夫です。例えばこんな感じです。
using Microsoft.SemanticKernel;
using Microsoft.SemanticKernel.Orchestration;
using Microsoft.SemanticKernel.Planning;
using Microsoft.SemanticKernel.SkillDefinition;
var kernel = KernelBuilder.Create();
// デバッグ用のネイティブスキルを登録
kernel.ImportSkill(new DebugSkill("judge", "true"), "judge");
kernel.ImportSkill(new DebugSkill("test1", "Test1 result"), "test1");
kernel.ImportSkill(new DebugSkill("test2", "Test2 result"), "test2");
kernel.ImportSkill(new DebugSkill("test3", "Test3 result"), "test3");
kernel.ImportSkill(new FlowSkill(
kernel.Func("test2", "invoke"),
kernel.Func("test3", "invoke")),
"flow");
var test1Plan = new Plan(kernel.Func("test1", "invoke"))
{
Outputs = { "test1Result" },
};
var judgePlan = new Plan(kernel.Func("judge", "invoke"));
var ifPlan = new Plan(kernel.Func("flow", "if"))
{
Parameters = new()
{
["flowInput"] = "$test1Result",
},
};
var plan = new Plan("フロー制御の確認", test1Plan, judgePlan, ifPlan);
var result = await plan.InvokeAsync();
Console.WriteLine($"Final result: {result.Result}");
// ログを出力して指定した結果を返すだけのデバッグ目的のスキル
class DebugSkill
{
private readonly string _name;
private readonly string _skillResult;
public DebugSkill(string name, string skillResult)
{
_name = name;
_skillResult = skillResult;
}
[SKFunction("テスト用関数")]
public string Invoke(string input, SKContext context)
{
Console.WriteLine($"{_name} was invoked with {input}.");
foreach (var variable in context.Variables)
{
Console.WriteLine($" {variable.Key}: {variable.Value}");
}
Console.WriteLine($" Result value: {_skillResult}");
return _skillResult;
}
}
class FlowSkill
{
private readonly ISKFunction _trueCase;
private readonly ISKFunction _falseCase;
public FlowSkill(ISKFunction trueCase, ISKFunction falseCase)
{
_trueCase = trueCase;
_falseCase = falseCase;
}
[SKFunction("分岐をします")]
[SKFunctionInput(Description = "true of false")]
[SKFunctionContextParameter(Name = "flowInput")]
public async Task<SKContext> If(string input, SKContext context)
{
if (context.Variables.Get("flowInput", out var flowInput))
{
context.Variables.Update(flowInput);
}
if (string.Equals("true", input.ToLowerInvariant(), StringComparison.InvariantCulture))
{
return await _trueCase.InvokeAsync(context);
}
else
{
return await _falseCase.InvokeAsync(context);
}
}
}
一番下で定義している FlowSkill
の If
関数が分岐をしています。true や false を入力として受け取って flowInput
に入っている変数の値を入力として ISKFunction
を呼び分けています。
今回は true のルートにいくように kernel.ImportSkill(new DebugSkill("judge", "true"), "judge");
の箇所で値をハードコーディングしているので実行結果は test1 -> judge -> if -> test2
となります。
実行すると以下のように表示されます。ちゃんと意図した通りの順番で呼ばれているのと test2
に値もわたってますね。
test1 was invoked with フロー制御の確認.
INPUT: フロー制御の確認
Result value: Test1 result
judge was invoked with Test1 result.
INPUT: Test1 result
Result value: true
test2 was invoked with Test1 result.
INPUT: Test1 result
flowInput: Test1 result
Result value: Test2 result
Final result: Test2 result
ハードコーディングしている部分を true から false に置き換えると以下のような結果になります。ちゃんと分岐していますね。
test1 was invoked with フロー制御の確認.
INPUT: フロー制御の確認
Result value: Test1 result
judge was invoked with Test1 result.
INPUT: Test1 result
Result value: false
test3 was invoked with Test1 result.
INPUT: Test1 result
flowInput: Test1 result
Result value: Test3 result
Final result: Test3 result
ここでは純粋に true や false を渡していますが、どっちの分岐に行くべきかを決めるプロンプトを書いてやってもいいかもですね。
その他に
Plan
の Steps
自体に Plan
を設定できるので、その気になれば凄い複雑な Plan
も組み立てることも出来ますが、恐らく人類の理解の限界を超えてしまうと思うので、あまりやらないほうがいいかもしれまません…。
まとめ
ということで Plan
についてちょっと確認してみました。
Parameters
に $hogehoge
のように変数指定出来るのはドキュメントでも見かけた記憶がないのでちょっとレアかもしれないですね。
Discussion