📑

Semantic Kernel の Plan ディープダイブ

2023/05/20に公開

Semantic Kernel の Plan は通常は ActionPlannerSequentialPlanner で組み立てますが、手動で組み立てて実行することも出来ます。
実行順序などが決まっているスキルの組み合わせなどは 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 の入力に渡す

ISKFunctionPlan にラップするとどんないいことがあるのでしょうか?
Plan にラップすると複数個の Step を持つ Plan を実行したときに、出力を次の Step に渡すことが出来るようになります。
それをするためには PlanOutputs プロパティに出力の名前を設定するのと、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

では、このコードを少し変えて OutputsParameters を設定してみます。

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, test3Plan に設定した OutputsParameters です。
Outputs でプランの出力を格納する変数名を指定しています。Parameters でプランの入力として受け取るパラメーターを定義しています。
実行すると以下のような結果になります。test2test3 では、前の 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 に格納されています。
すべての StepOutputs で指定した値が格納されています。

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);
        }
    }
}

一番下で定義している FlowSkillIf 関数が分岐をしています。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 を渡していますが、どっちの分岐に行くべきかを決めるプロンプトを書いてやってもいいかもですね。

その他に

PlanSteps 自体に Plan を設定できるので、その気になれば凄い複雑な Plan も組み立てることも出来ますが、恐らく人類の理解の限界を超えてしまうと思うので、あまりやらないほうがいいかもしれまません…。

まとめ

ということで Plan についてちょっと確認してみました。
Parameters$hogehoge のように変数指定出来るのはドキュメントでも見かけた記憶がないのでちょっとレアかもしれないですね。

Microsoft (有志)

Discussion