📘

.NET の Semantic Kernel v1.0 の使えない子の Handlebars テンプレートを使う方法と注意点

2023/12/26に公開

はじめに

1 つ前の記事で、Semantic Kernel v1.0 の機能をいくつか試してみました。

https://zenn.dev/microsoft/articles/semantic-kernel-v1-001

ここでは、上記の記事では触れていなかった Handlebars テンプレートのサポートについて試してみた上で こんなの本番でつかえねーよ っていう話をしようと思います。

Handlebars

色々な言語で実装があるテンプレートエンジンです。詳しくはオフィシャルサイトを見てください。

https://handlebarsjs.com/

.NET 実装もあります。
多分、分岐やループといったものをテンプレート内で実装するために導入されたのではないかと思っています。

例えば以下のような感じでループが書けます。chatHistory 変数の中に rolecontent プロパティを持ったオブジェクトの配列が入っているようなイメージです。

{{#loop chatHistory}}
  <message role="{{role}}">{{content}}</message>
{{/loop}}

Semantic Kernel v1.0 で Handlebars を使う方法

Handlebars のサポートは Microsoft.SemanticKernel パッケージを追加するだけでは使えません。追加で Microsoft.SemanticKernel.PromptTemplates.Handlebars パッケージを追加する必要があります。
パッケージを追加するとテンプレートを指定するところでテンプレートの名前として handlebars という文字列を指定して HandlebarsPromptTemplateFactory を渡す事でテンプレートエンジンがデフォルトのものから Handlebars に置き換わります。

試してみましょう。コンソールアプリを追加してユーザーシークレットの構成を追加して、以下のパッケージを追加します。

  • Microsoft.SemanticKernel
  • Microsoft.SemanticKernel.PromptTemplates.Handlebars
  • Azure.Identity
  • Microsoft.Extensions.Configuration.UserSecrets

そして以下のようなコードを書きます。
CreateFunctionFromPrompt を呼び出しているところで Handlebars を使うように指定しています。

using Azure.Identity;
using Microsoft.Extensions.Configuration;
using Microsoft.SemanticKernel;
using Microsoft.SemanticKernel.ChatCompletion;
using Microsoft.SemanticKernel.PromptTemplates.Handlebars;

// ソースコピペするときにめんどくさいのでユーザーシークレットからエンドポイント等は読み込むようにした
// ユーザーシークレットにこんな 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();

// Handlebars でテンプレートを書く
var getQueryString = kernel.CreateFunctionFromPrompt("""
    {{#each chatHistory}}
        <message role="{{role}}">{{content}}</message>
    {{/each}}
    <message role="system">
        このチャットのやり取りから user が何を知りたいのかを推測して、user が知りたいことをインターネットの検索エンジンで検索する際の検索キーワードを生成してください。
        検索キーワードはスペース区切りで、余計な文章は含まないように1行で出力してください。
    </message>
    """,
    // この 2 つで指定する
    templateFormat: HandlebarsPromptTemplateFactory.HandlebarsTemplateFormat, // 実態は handlebars という文字列
    promptTemplateFactory: new HandlebarsPromptTemplateFactory());

// テンプレートに渡すチャット履歴を組み立てる
var chatHistory = new ChatHistory();
chatHistory.AddUserMessage("こんにちは");
chatHistory.AddAssistantMessage("こんにちは。何か御用でしょうか。");
chatHistory.AddUserMessage("最近、.NET 8がでたので新機能を教えてください。");

// 実行!
var response = await getQueryString.InvokeAsync<string>(kernel, new KernelArguments
{
    ["chatHistory"] = chatHistory,
});

// 結果を表示
Console.WriteLine(response);

実行すると以下のような結果になります。ちゃんと Handlebars のループが動いてチャット履歴が展開されているっぽい結果になっています。

.NET 8 new features

プラグインの処理の呼び出し

Handlebars のテンプレートエンジンでもプラグインを呼び出すことが出来ます。Handlebars のテンプレート内で {{プラグイン名-関数名}} といった書き方で呼び出すことが出来ます。引数がある場合は {{プラグイン名-関数名 引数1 引数2 (他のプラグイン-関数)}} といったように渡すことが出来ます。第三引数は別のプラグインの呼び出し結果を渡す方法になります。

では TimePlugin に現在の日付を引数で渡されたフォーマットで返すような Today 関数を追加して試しにテンプレート内で呼んでみましょう。

using Azure.Identity;
using Microsoft.Extensions.Configuration;
using Microsoft.SemanticKernel;
using Microsoft.SemanticKernel.ChatCompletion;
using Microsoft.SemanticKernel.PromptTemplates.Handlebars;

// ソースコピペするときにめんどくさいのでユーザーシークレットからエンドポイント等は読み込むようにした
// こんな 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>();

// Handlebars でテンプレートを書く
var getQueryString = kernel.CreateFunctionFromPrompt("""
    <message role="system">
        あなたはシステムアシスタントです。以下の参考情報を元にユーザーからの質問に答えてください。

        ## 参考情報
        - 今日の日付: {{TimePlugin-Today 'yyyy年MM月dd日'}}
    </message>
    <message role="user">
        {{userMessage}}
    </message>
    """,
    // この 2 つで指定する
    templateFormat: HandlebarsPromptTemplateFactory.HandlebarsTemplateFormat, // 実態は handlebars という文字列
    promptTemplateFactory: new HandlebarsPromptTemplateFactory());


//実行!
var response = await getQueryString.InvokeAsync<string>(kernel, new KernelArguments
{
    ["userMessage"] = "こんにちは、今日の日付を教えてください。",
});

// 結果を表示
Console.WriteLine(response);

// 今日の日付を返すプラグイン
class TimePlugin
{
    // Descriptionはさぼって書かない。本番では書いてね
    [KernelFunction]
    public string Today(string format) =>
        TimeProvider.System.GetLocalNow().ToString(format);
}

実行結果は以下のようになります。ちゃんとプラグインが呼び出されていることがわかります。

こんにちは!今日は2023年12月26日です。

Handlebars テンプレートの注意点

これは Semantic Kernel の問題というよりは Handlebars の実装の方の問題でもあるのですが、Handlebars のヘルパーとして Semantic Kernel のプラグインの関数を呼び出す機能の実装がちょっと微妙です。
Handlebars の機能にカスタムのヘルパーを登録しておくことで、テンプレート内でそれを呼び出すことが出来ます。これを使って Semantic Kernel のプラグインの関数を呼び出しているのですが、このヘルパーに登録できるのが同期処理のみになっています。

そのため Handlebars のヘルパーとして登録されているプラグインの関数は .GetAwaiter().GetResult(); を使って同期的に終了を待つようになっています。
コードとしては以下の部分になります。

https://github.com/microsoft/semantic-kernel/blob/dotnet-1.0.1/dotnet/src/Extensions/PromptTemplates.Handlebars/Helpers/KernelHelpers/KernelFunctionHelpers.cs#L188

そのためプラグインの関数で外部 Web API を叩いていたりデータベースを呼び出しているとスレッドがブロックされるため Web アプリにした時にスループットが大幅に低下してしまいます。また、Windows アプリで使った時に使い方によってはデッドロックを引き起こしてしまいます。

再現してみましょう。WPF アプリを新規作成して適当に画面にボタンを置いてクリックイベントのハンドラーを作って、そこに以下のように処理を追加します。TimePlugin に追加した await Task.Delay(1000); がポイントです。ここで、Semantic Kernel 内部で GetWaiter().GetResult() でロックされているスレッドに戻ろうとするためデッドロックになってアプリケーションがフリーズしてしまいます。

using Azure.Identity;
using Microsoft.Extensions.Configuration;
using Microsoft.SemanticKernel;
using Microsoft.SemanticKernel.PromptTemplates.Handlebars;
using System.Diagnostics;
using System.Windows;

namespace WpfApp2;

/// <summary>
/// Interaction logic for MainWindow.xaml
/// </summary>
public partial class MainWindow : Window
{
    public MainWindow()
    {
        InitializeComponent();
    }

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

        // プラグインを読み込む
        kernel.Plugins.AddFromType<TimePlugin>();

        // Handlebars でテンプレートを書く
        var getQueryString = kernel.CreateFunctionFromPrompt("""
            <message role="system">
                あなたはシステムアシスタントです。以下の参考情報を元にユーザーからの質問に答えてください。

                ## 参考情報
                - 今日の日付: {{TimePlugin-Today 'yyyy年MM月dd日'}}
            </message>
            <message role="user">
                {{userMessage}}
            </message>
            """,
        // この 2 つで指定する
        templateFormat: HandlebarsPromptTemplateFactory.HandlebarsTemplateFormat, // 実態は handlebars という文字列
        promptTemplateFactory: new HandlebarsPromptTemplateFactory());


        //実行!
        var response = await getQueryString.InvokeAsync<string>(kernel, new KernelArguments
        {
            ["userMessage"] = "こんにちは、今日の日付を教えてください。",
        });

        // 結果を表示
        MessageBox.Show(response);
    }
}

// 今日の日付を返すプラグイン
class TimePlugin
{
    // Descriptionはさぼって書かない。本番では書いてね
    [KernelFunction]
    public async Task<string> Today(string format)
    {
        Debug.WriteLine("Today が呼ばれました");
        // ここでロックされている UI スレッドに戻ろうと頑張るけどいつまで経っても戻れないのでデッドロック
        await Task.Delay(1000);
        Debug.WriteLine("Task.Delay 完了");
        return TimeProvider.System.GetLocalNow().ToString(format);
    }
}

まぁ、これはちゃんとお作法に従って以下のように ConfigureAwait(false) をしていれば await Task.Delay(1000).ConfigureAwait(false); の次の行は別のスレッドで実行されるのでデッドロックにはなりません。

改善されたTimePlugin
// 今日の日付を返すプラグイン
class TimePlugin
{
    // Descriptionはさぼって書かない。本番では書いてね
    [KernelFunction]
    public async Task<string> Today(string format)
    {
        Debug.WriteLine("Today が呼ばれました");
        // これなら OK
        await Task.Delay(1000).ConfigureAwait(false);
        Debug.WriteLine("Task.Delay 完了");
        return TimeProvider.System.GetLocalNow().ToString(format);
    }
}

これだとボタンを押すとデッドロックが起きずに結果が返ってきます。Windows アプリのデッドロックは、こうやって回避できますが、誰かが ConfigureAwait(false) を忘れるだけでデッドロックしちゃうので結構嫌な感じです…。しかも、テンプレートがレンダリングされる間は UI スレッドがロックされるのでアプリが固まります。それの回避方法は Task.Run などで、そもそも UI スレッド上で実行させないようにする方法がありますが、それはそれでちょっとメンドクサイです…。

Web アプリの方はもっと深刻で、HTTP リクエストを処理するスレッドをロックしてしまうのでどうしようもありません。どれくらい悲惨なことになるのかは以下の記事を見てみてください。

https://zenn.dev/microsoft/articles/do-not-lock-thread

これはスレッドプールを大量に確保することで一応回避は出来ますが、それはそれで無駄にスレッドが出来てメモリなどを無駄に消費してしまうのでちょっと嫌な感じです…。ということで根本的な対策として Handlebars の実装が非同期処理をヘルパーに登録できる機能を実装してくれないといけないのですが、特に予定があるのかはわかりません…。
そのため、基本的には Handlebars 関連の機能はバッチ処理などの非同期処理じゃなくても問題が起きない所で使う方が良いと思われます。

まとめ

個人的には Handlebars 使わないと思う。けど HandlebarsPlanner とか出てくるので、ここに色々機能が盛られると辛い…。

Microsoft (有志)

Discussion