🌟

Semantic Kernel + Blazor の InteractiveServer でツール呼び出し前に人の確認を挟む

に公開

表題の通りのことをやっていこうと思います。
こういうことをやるのに Blazor の Interacitive Server はとてもやりやすいです。ボタンを押した処理などがサーバーサイドで自動的に実行されて、UI の変更がいい感じに更新されるので、サーバーサイドで処理の途中に画面にフィードバックを返すのが簡単に実装できます。

AI の実行先が Web API で、フロントエンドは React や Blazor の InteractiveWebAssembly で実装していると、Web API を Human In The Loop があることを前提として形に設計しないといけないのでちょっとメンドクサイです。

ということでやっていきましょう。

プロジェクトの作成

ASP.NET Core Blazor のプロジェクトを作成します。ここでは HumanInTheLoopLabApp という名前で作成した前提で説明します。
プロジェクト作成時のオプションは以下のようにしました。(どれを選んでも大差ないものは載せていません)

  • フレームワーク: .NET 9.0
  • 認証の種類: なし
  • インタラクティビティ型: サーバー
  • インタラクティビティ場所: グローバル
  • サンプルページを含める: チェック無し
  • .NET Aspire オーケストレーションへの参加: チェック無し

そして以下の NuGet パッケージを追加します。

  • Azure.Identity v1.13.2
  • Microsoft.SemanticKernel v1.47.0
  • Microsoft.SemanticKernel.Agents.Core v1.47.0
  • CommunityToolkit.Mvvm v8.4.0

一応パッケージのバージョン番号を載せていますが、安定した API しか使わないので恐らく、これ以降のバージョンでもメジャーバージョンが変わらなければ問題ないと思います。

AI に繋ぐための準備

Azure OpenAI Service に適当なモデルをデプロイしておきます。今回は gpt-4.1 という名前のモデルをデプロイしている前提で説明します。そして、そのリソースに対してローカルの Azure CLI でログインしているユーザーに対して、Cognitive Services OpenAI User のロールを付与しておきます。これで Managed Identity で Azure OpenAI Service にアクセスできるようになります。

Managed Identity で認証するので接続のために必要な構成情報は Endpoint と DeploymentName だけです。この構成情報を確認するためのクラスを以下のように定義しておきます。

AIOptions.cs
using System.ComponentModel.DataAnnotations;

namespace HumanInTheLoopLabApp.Options;

public class AIOptions
{
    [Required]
    public string Endpoint { get; set; } = "";

    [Required]
    public string DeploymentName { get; set; } = "";
}

そして Program.cs で構成情報を AIOptions クラスにバインドして DI コンテナに登録します。

Program.cs
builder.Services.AddOptions<AIOptions>()
    .BindConfiguration(nameof(AIOptions))
    .ValidateDataAnnotations();

忘れないうちにユーザーシークレットか appsettings.Development.json に以下のように自分のリソースの情報を設定しておきましょう。

{
  "AIOptions": {
    "Endpoint": "https://<your-resource-name>.openai.azure.com/",
    "DeploymentName": "gpt-4.1"
  }
}

次に Semantic Kernel の基本的なクラスを DI コンテナに登録します。Chat Completions API に繋ぐためのサービスと Kernel を登録します。

Program.cs
// AOAI のクライアントを登録
builder.Services.AddSingleton(sp =>
{
    var aiOptions = sp.GetRequiredService<IOptions<AIOptions>>().Value;
    return new AzureOpenAIClient(
        new(aiOptions.Endpoint),
        builder.Environment.IsProduction()
            ? new DefaultAzureCredential()
            : new AzureCliCredential());
});

// Semantic Kernel の ChatCompletion サービスを登録
builder.Services.AddSingleton<IChatCompletionService>(sp =>
{
    var aiOptions = sp.GetRequiredService<IOptions<AIOptions>>().Value;
    return new AzureOpenAIChatCompletionService(
        aiOptions.DeploymentName,
        sp.GetRequiredService<AzureOpenAIClient>());
});
// Semantic Kernel の Kernel を登録
builder.Services.AddKernel();

今回は、猫っぽく振舞ってくれるアシスタントが指定した場所の天気を答えてくれるようなアシスタントを作って行きます。そのために、まずは天気を調べるためのプラグインを作成します。今回は location で場所の名前を受け取って、適当な天気を返すだけのプラグインを作成します。

WeatherForecastPlugin.cs
using Microsoft.SemanticKernel;
using System.ComponentModel;

namespace HumanInTheLoopLabApp.Plugins;

[Description("天気予報プラグイン")]
public class WeatherForecastPlubin
{
    [KernelFunction]
    [Description("天気予報を取得する")]
    public string GetWeatherForecast(
        [Description("場所")]
        string location)
    {
        var item = Random.Shared.GetItems(["晴れ", "曇り", "雨", "雪", "蛙"], 1)[0];
        return $"{location}の天気予報は{item}です。";
    }
}

では、このプラグインも DI コンテナに登録しておきます。

Program.cs
// WeatherForecast プラグインを登録
builder.Services.AddSingleton<WeatherForecastPlubin>();
builder.Services.AddSingleton(sp => 
    KernelPluginFactory.CreateFromType<WeatherForecastPlubin>(serviceProvider: sp));

これでエージェントを作るための下準備が出来ました。エージェントも DI コンテナに登録しておきます。

Program.cs
// 天気予報エージェント
builder.Services.AddTransient(sp =>
{
    var kernel = sp.GetRequiredService<Kernel>();
    return new ChatCompletionAgent
    {
        Name = "CatAgent",
        Description = "天気予報を取得する猫型エージェント",
        Instructions = """
            あなたは猫型エージェントです。猫らしく振舞うために語尾を「にゃん」にしてください。
            """,
        Kernel = kernel,
        Arguments = new(new AzureOpenAIPromptExecutionSettings
        {
            Temperature = 0,
            // Kernel に登録されているプラグインを自動で呼び出すように設定
            FunctionChoiceBehavior = FunctionChoiceBehavior.Auto(),
        }),
    };
});

これでエージェントを呼び出す準備が出来ました。次は、Blazor の UI を作成していきます。/Components/Pages/Home.razor に以下のように UI を作成します。

Home.razor
@page "/"
@using Microsoft.SemanticKernel.Agents
@inject ChatCompletionAgent Agent

<PageTitle>Home</PageTitle>

<h1>Agent test</h1>

<div>
    <h2>入力欄</h2>
    <input type="text" @bind="_inputText" />
    <button @onclick="AskToAgentAsync">送信</button>
</div>

<div>
    <h2>出力欄</h2>
    <p>@_outputText</p>
</div>

@code {

    private string _inputText = "";
    private string _outputText = "";

    private async Task AskToAgentAsync()
    {
        _outputText = "";
        AgentThread? thread = null;
        await foreach (var message in Agent.InvokeAsync(thread))
        {
            thread = message.Thread;
            _outputText = message.Message.Content ?? "回答が得られませんでした";
        }
    }
}

単純に入力された文字列をエージェントに渡して、結果を画面に出しているだけです。実行すると以下のように天気に答えてくれます。

ツール呼び出し前に人の確認を挟む

やっと本題です。エージェントがツール(今回の場合は天気予報の取得処理)を呼び出す前に人の確認を挟むようにします。Semantic Kernel ではフィルターという機能があり、関数呼び出し時、自動で関数ば呼び出される時、プロンプトのレンダリング時の前後に任意の処理を入れることが出来るようになっています。この機能を使うと条件に応じて本来の処理を呼び出したり、かわりの処理を呼び出したり、呼び出し自体をキャンセルしたりといった様々なことが出来ます。今回は自動で関数を呼び出すときのフィルターの IAutoFunctionInvocationFilter を実装して、関数呼び出しの前に人の確認を挟むようにします。

まずは HumanInTheLoopFilter というクラスを作成します。このクラスは IAutoFunctionInvocationFilter を実装して、関数呼び出しの前に人の確認を挟むようにします。人の確認を挟むようにするために UI のレイヤーと対話をしないといけないのですが、これはメッセンジャーパターンを使って実装します。

HumanInTheLoopFilter.cs
using CommunityToolkit.Mvvm.Messaging;
using CommunityToolkit.Mvvm.Messaging.Messages;
using Microsoft.SemanticKernel;

namespace HumanInTheLoopLabApp.Filters;

public class HumanInTheLoopFilter(IMessenger messenger) : IAutoFunctionInvocationFilter
{
    public async Task OnAutoFunctionInvocationAsync(AutoFunctionInvocationContext context, Func<AutoFunctionInvocationContext, Task> next)
    {
        var arguments = string.Join(", ", context.Arguments?.Select(x => $"{x.Key}: {x.Value}") ?? []);
        var isAllow = await messenger.Send(new HumanInTheLoopMessage($"{context.Function.Name}({arguments})"));
        if (isAllow)
        {
            await next(context);
        }
        else
        {
            context.Result = new(context.Result, $"ユーザーにより {context.Function.Name} の呼び出しが拒否されました。");
            context.Terminate = true;
        }
    }
}

public class HumanInTheLoopMessage(string function) : AsyncRequestMessage<bool>
{
    public string Function => function;
}

このクラスは、関数呼び出しの前に人の確認を挟むためのフィルターです。IAutoFunctionInvocationFilter を実装して、OnAutoFunctionInvocationAsync メソッドをオーバーライドしています。このメソッドは、関数呼び出しの前に呼び出されるメソッドで、ここで人の確認を挟む処理を実装します。
IAutoFunctionInvocationFilternext メソッドを呼び出すことで、次のフィルターや関数呼び出しを実行します。人の確認を挟むために、メッセンジャーを使って UI に確認メッセージを送信します。UI からの応答が true の場合は、次の処理を実行し、false の場合は処理をキャンセルします。

この Messenger は CommunityToolkit.Mvvm のメッセンジャーを使っています。これとフィルターを DI コンテナに登録します。

Program.cs
// Messenger はユーザーごとにインスタンス化するため Scoped で登録
builder.Services.AddScoped<IMessenger, StrongReferenceMessenger>();
// HumanInTheLoopFilter を登録
builder.Services.AddScoped<IAutoFunctionInvocationFilter, HumanInTheLoopFilter>();

DI コンテナに IAutoFunctionInvocationFilter を登録することで、Semantic Kernel が関数呼び出しの前にこのフィルターを呼び出すようになります。最後に、画面でこのメッセージを受け取ってユーザーに確認をする処理を実装します。/Components/Pages/Home.razor に以下のようにメッセージを受け取る処理を追加します。

Home.razor
@page "/"
@implements IDisposable
@using CommunityToolkit.Mvvm.Messaging
@using HumanInTheLoopLabApp.Filters
@using Microsoft.SemanticKernel.Agents
@inject ChatCompletionAgent Agent
@inject IMessenger Messenger

<PageTitle>Home</PageTitle>

<h1>Agent test</h1>

<div>
    <h2>入力欄</h2>
    <input type="text" @bind="_inputText" />
    <button @onclick="AskToAgentAsync">送信</button>
</div>

<div>
    <h2>出力欄</h2>
    <p>@_outputText</p>
</div>

@* 関数呼び出しをしてもいいか確認をするための UI *@
@if (!string.IsNullOrEmpty(_functionName))
{
    <div>
        <h2>関数の呼び出し確認</h2>
        <p>AI が @_functionName を呼び出そうとしています。</p>
        <button @onclick="() => _allowHandler?.Invoke()">許可</button>
        <button @onclick="() => _denyHandler?.Invoke()">拒否</button>
    </div>
}

@code {

    private string _inputText = "";
    private string _outputText = "";

    // 関数呼び出しの確認をするための UI に使用する変数
    private string _functionName = "";
    private Action? _allowHandler;
    private Action? _denyHandler;

    protected override void OnInitialized()
    {
        // HumanInTheLoopMessage を受け取ったときの処理を登録
        Messenger.Register<HumanInTheLoopMessage>(this, (_, message) => message.Reply(ConfirmAsync(message.Function)));
    }

    private async Task<bool> ConfirmAsync(string function)
    {
        Action createHandler(bool isAllow, TaskCompletionSource<bool> tcs)
        {
            return () =>
            {
                tcs.SetResult(isAllow);
                _functionName = "";
                _allowHandler = null;
                _denyHandler = null;
            };
        }

        // 確認のための UI に必要な情報をセットして画面を更新
        TaskCompletionSource<bool> tcs = new();
        _functionName = function;
        _allowHandler = createHandler(true, tcs);
        _denyHandler = createHandler(false, tcs);
        await InvokeAsync(StateHasChanged);
        return await tcs.Task;
    }

    public void Dispose()
    {
        // 後始末
        Messenger.Unregister<HumanInTheLoopMessage>(this);
        _denyHandler?.Invoke();
    }


    private async Task AskToAgentAsync()
    {
        _outputText = "";
        AgentThread? thread = null;
        await foreach (var message in Agent.InvokeAsync(_inputText, thread))
        {
            thread = message.Thread;
            _outputText = message.Message.Content ?? "回答が得られませんでした";
        }
    }
}

この処理は、HumanInTheLoopMessage を受け取ったときに呼び出される処理です。メッセージを受け取ったときに、確認のための UI を表示するための変数をセットして画面を更新します。ユーザーが許可ボタンを押した場合は true を返し、拒否ボタンを押した場合は false を返します。メッセンジャーからのメッセージの受信処理は UI スレッド以外から来るため、StateHasChanged を呼び出して画面を更新する必要があります。

IDisposable を実装して、後始末をするようにしています。これで、関数呼び出しの前に人の確認を挟むことが出来るようになりました。
実行すると以下のように関数呼び出しの前に人の確認を挟むことが出来ます。

許可を押すと回答が表示されます。

拒否を押すと以下のように関数呼び出しがキャンセルされます。

まとめ

ということで、Semantic Kernel のフィルター機能を使って、関数呼び出しの前に人の確認を挟むようにしてみました。Blazor の InteractiveServer を使うと UI の更新が簡単に出来るので、こういった処理を実装するのが楽ですね。フロントエンドとバックエンドを分離して作る場合は、Web API としてツール呼び出し確認も考慮したつくりにしないといけないので少しめんどくさそうです。

参考情報

この記事で使ったプログラムのソースコードは以下の GitHub リポジトリに置いています。

https://github.com/runceel/HumanInTheLoopLabApp

Semantic Kernel のフィルター機能については以下のドキュメントを参考にしました。

https://learn.microsoft.com/ja-jp/semantic-kernel/concepts/enterprise-readiness/filters

CommunityToolkit.Mvvm のメッセンジャーのドキュメントは以下になります。

https://learn.microsoft.com/ja-jp/dotnet/communitytoolkit/mvvm/messenger

Microsoft (有志)

Discussion