🌊

普通と違う感じの Semantic Kernel 入門 010「低レベルなメッセージ ハンドリング」

に公開

これまでの記事

はじめに

前回の記事では、Semantic Kernel と Microsoft.Extensions.AI の統合について解説しました。Microsoft.Extensions.AI を利用することで、AI サービスの抽象化やエコシステムとの連携がより柔軟かつ強力になったことを紹介しました。

今回の記事では、より低レベルな視点から Semantic Kernel の「メッセージ ハンドリング」について掘り下げていきます。これまでの記事では主に高レベルな API や抽象化レイヤーを中心に解説してきましたが、実際に AI サービスとやり取りする際のメッセージの流れや、どのようにメッセージが処理されているのかを知ることで、より細かな制御やカスタマイズが可能になります。

この記事では、Semantic Kernel の低レベルなメッセージ ハンドリングの仕組みや、実際にどのようにメッセージを操作できるのかについて解説していきます。

メッセージをマニュアルで処理するケース

AI を使うときに多くのケースで使用する Chat Completions API を利用する場合にマニュアルででメッセージを処理するケースは大体ツールの呼び出しだと思います。Semantic Kernel や最近のライブラリだと、そこらへんのメッセージのハンドリングは隠ぺいされていてパラメーターなどで自動で呼び出す設定をしておくと勝手に呼び出してくれる機能があります。普通と違う感じの Semantic Kernel 入門 005「Chat Completions API を使おう」 でも、その機能を使ってツールの呼び出しを行いました。

そして、自動で呼び出されると困るような機能を使う場合は普通と違う感じの Semantic Kernel 入門 007「フィルター」で紹介したようにフィルターを使ってツールの呼び出し前に人間の確認を入れることができます。
ただ、フィルターだとちょっと不都合がある場合もあります。例えば、フィルターを使うとツールの呼び出しが行われる前に人間の確認が入るので、ツールの呼び出しが行われるまでに時間がかかります。Web API の処理の途中でこのような中断が入るようなケースでは死活問題になります。
そんな時はツールの結果を返す手前までのメッセージをシリアライズして何処かに保存しておいて、ユーザーに確認を行い、ユーザーが承認したら保存したメッセージからチャット履歴を復元してツールの呼び出しを行うという方法が一番素直でいい感じの実装になると思います。

そんな時には、今のところ手動でメッセージのハンドリングを行うのが一番素直な実装になると思います。

マニュアルでメッセージを処理する

まず、マニュアルでメッセージを処理するためには IChatClient ではなく Semantic Kernel の IChatCompletionService を使います。Microsoft.Extensions.AI 自体にも関数呼び出しをマニュアルでハンドリングする機能はありますが、Semantic Kernel で追加した IChatClient には自動で関数呼び出しを処理するミドルウェアが構成された状態になるので、マニュアルで処理する場合は IChatCompletionService を使ったほうがやりやすいです。

まず関数呼び出しのメッセージを自動でハンドリングしないようにするためには PromptExecutionSettingsFunctionChoiceBehavior に渡す値を FunctionChoiceBehavior.Auto(autoInvoke: false) のようにします。これで関数呼び出しのメッセージが来ても自動で処理されなくなります。

次に戻ってきたメッセージから実際の関数を呼び出す処理を記載する必要があります。Semantic Kernel では FunctionCallContent.GetFunctionCalls(response) メソッドを使うことで FunctionCallContent を取得できます。そして、そのクラスの InvokeAsync メソッドを使うことで関数を呼び出すことができます。関数の呼び出し結果の ToChatMessage を呼ぶことで ChatHistory に追加するためのメッセージに変換することが出来ます。

このようにして、マニュアルでメッセージを処理することができます。
実際に今日の天気を取得する関数を呼び出す例を以下に示します。まずは、今日の日付を取得するためのプラグインと時間と場所を渡すと天気の情報を返すプラグインを作成します。

// クラスでプラグインを定義
[Description("A plugin that provides time-related functions.")]
class TimePlugin
{
    [KernelFunction, Description("Get the current local time.")]
    [return: Description("The current local time as a DateTimeOffset object.")]
    public DateTimeOffset GetLocalNow() =>
        TimeProvider.System.GetLocalNow();
}

class WeatherPlugin
{
    [KernelFunction, Description("Get the current weather for a given location.")]
    [return: Description("The current weather as a string.")]
    public string GetWeatherByDate(
        [Description("The date and time for which to get the weather.")]
        DateTimeOffset dateTime,
        [Description("The location for which to get the weather.")]
        string location)
    {
        return $"The weather in {location} on {dateTime:yyyy-MM-dd} is sunny with a high of 25°C.";
    }
}

これを使ってマニュアルで関数の呼び出しをハンドリングする処理を書きます。以下のようになります。

using System.ComponentModel;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.Unicode;
using Azure.Identity;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.SemanticKernel;
using Microsoft.SemanticKernel.ChatCompletion;
using FunctionCallContent = Microsoft.SemanticKernel.FunctionCallContent;

// User Secrets から設定を読み込む
var configuration = new ConfigurationBuilder()
    .AddUserSecrets<Program>()
    .Build();
// AOAI にデプロイしているモデル名
var modelDeploymentName = configuration["AOAI:ModelDeploymentName"]
    ?? throw new ArgumentNullException("AOAI:ModelDeploymentName is not set in the configuration.");
// AOAI のエンドポイント
var endpoint = configuration["AOAI:Endpoint"]
    ?? throw new ArgumentNullException("AOAI:Endpoint is not set in the configuration.");

// Builder を作成
var builder = Kernel.CreateBuilder();

// Kernel にプラグインを登録
builder.Plugins.AddFromType<TimePlugin>();
builder.Plugins.AddFromType<WeatherPlugin>();

// AOAI 用の Chat Completion を登録
builder.AddAzureOpenAIChatClient(
    modelDeploymentName,
    endpoint,
    new AzureCliCredential());

// Kernel を作成
var kernel = builder.Build();

#pragma warning disable SKEXP0001
// IChatClient を IChatCompletionService に変換(プレビュー機能なので警告の抑止が必要)
var chatCompletion = kernel.GetRequiredService<IChatClient>().AsChatCompletionService();
#pragma warning restore SKEXP0001


// GetLocalNow と GetWeather を呼び出してもらうためのメッセージを作成
ChatHistory messages = [
        new (AuthorRole.System, "あなたは猫型アシスタントです。猫らしく振舞うために語尾は「にゃん」にしてください。"),
        new (AuthorRole.User, "今日の東京の天気を教えて"),
    ];

IEnumerable<FunctionCallContent> functionCalls = [];
do
{
    // ツール呼び出しが無くなるまでループ
    var response = await chatCompletion.GetChatMessageContentAsync(
        messages,
        new PromptExecutionSettings
        {
            // 関数の選択は行うが自動呼出しは行わない
            FunctionChoiceBehavior = FunctionChoiceBehavior.Auto(autoInvoke: false),
        },
        kernel);
    messages.Add(response);
    functionCalls = FunctionCallContent.GetFunctionCalls(response);
    if (functionCalls.Any())
    {
        // ツール呼び出しのメッセージを表示
        foreach (var functionCall in functionCalls)
        {
            // ツール呼び出しの内容を表示
            var functionArgs = string.Join(", ", functionCall.Arguments?.Select(x => $"{x.Key}: {x.Value}") ?? []);
            Console.WriteLine($"Function call: {functionCall.PluginName}-{functionCall.FunctionName}({functionArgs})");
            var functionResult = await functionCall.InvokeAsync(kernel);
            messages.Add(functionResult.ToChatMessage());
        }
    }
} while (functionCalls.Any());

// チャット履歴を全て表示
Console.WriteLine(JsonSerializer.Serialize(messages, new JsonSerializerOptions
{
    WriteIndented = true,
    Encoder = JavaScriptEncoder.Create(UnicodeRanges.All),
}));

このコードでは、最初に TimePluginWeatherPlugin を定義し、GetLocalNowGetWeatherByDate の関数を提供しています。次に、チャット履歴を作成し、ユーザーからのメッセージを受け取ります。その後、IChatCompletionService を使ってメッセージを処理し、関数呼び出しがある場合はその内容を表示し、関数を呼び出して結果をチャット履歴に追加します。
最後に、チャット履歴を JSON 形式で表示します。このようにして、マニュアルでメッセージを処理し、関数呼び出しを行うことができます。

実行すると以下のようになります。チャット履歴の JSON はトークンの使用量なども含まれていますが、ここでは省略しています。

Function call: -TimePlugin_GetLocalNow()
Function call: -WeatherPlugin_GetWeatherByDate(dateTime: 2025-06-15T10:16:23.2713992+09:00, location: 東京)
[
  {
    "Role": {
      "Label": "system"
    },
    "Items": [
      {
        "$type": "TextContent",
        "Text": "あなたは猫型アシスタントです。猫らしく振舞うために語尾は「にゃん」にしてください。"
      }
    ]
  },
  {
    "Role": {
      "Label": "user"
    },
    "Items": [
      {
        "$type": "TextContent",
        "Text": "今日の東京の天気を教えて"
      }
    ]
  },
  {
    "Role": {
      "Label": "assistant"
    },
    "Items": [
      {
        "$type": "FunctionCallContent",
        "Id": "call_0uwddT5qclhGHiaVNgxuVDvv",
        "FunctionName": "TimePlugin_GetLocalNow",
        "Arguments": {},
        "ModelId": "gpt-4.1-2025-04-14"
      }
    ],
    // 省略
  },
  {
    "Role": {
      "Label": "tool"
    },
    "Items": [
      {
        "$type": "FunctionResultContent",
        "CallId": "call_0uwddT5qclhGHiaVNgxuVDvv",
        "FunctionName": "TimePlugin_GetLocalNow",
        "Result": "2025-06-15T10:16:23.2713992+09:00"
      }
    ]
  },
  {
    "Role": {
      "Label": "assistant"
    },
    "Items": [
      {
        "$type": "FunctionCallContent",
        "Id": "call_P7mRhitUUOX9Lw1qfZmD1w5Z",
        "FunctionName": "WeatherPlugin_GetWeatherByDate",
        "Arguments": {
          "dateTime": "2025-06-15T10:16:23.2713992\u002B09:00",
          "location": "東京"
        },
        "ModelId": "gpt-4.1-2025-04-14"
      }
    ],
    // 省略
  },
  {
    "Role": {
      "Label": "tool"
    },
    "Items": [
      {
        "$type": "FunctionResultContent",
        "CallId": "call_P7mRhitUUOX9Lw1qfZmD1w5Z",
        "FunctionName": "WeatherPlugin_GetWeatherByDate",
        "Result": "The weather in 東京 on 2025-06-15 is sunny with a high of 25°C."
      }
    ]
  },
  {
    "Role": {
      "Label": "assistant"
    },
    "Items": [
      {
        "$type": "TextContent",
        "Text": "今日の東京の天気は晴れで、最高気温は25度にゃん。お出かけ日和だにゃん!",
        "ModelId": "gpt-4.1-2025-04-14"
      }
    ],
    // 省略
  }
]

Function call: -WeatherPlugin_GetWeatherByDate(dateTime: 2025-06-15T10:16:23.2713992+09:00, location: 東京) のように関数呼び出しをハンドリングしている箇所で出力しているメッセージが表示されています。チャット履歴の JSON では Roletool になっているメッセージが含まれているので、ツールの呼び出しもきちんと行えていることがわかります。

人による関数呼び出し確認

このままでは、自動での関数呼び出しと同じことを行っているだけなので、関数呼び出しを確認するための処理を追加します。
ポイントは、Web API のような人による確認が必要になったタイミングで処理を止めて待つのではなく、進捗をいったん保存して置いて後から、それを復元して続きの処理を行うことです。
先ほどのコードに以下のようなメソッドを追加します。

static async Task<(string State, IEnumerable<FunctionCallContent> FunctionCalls)> AdvanceTurnAsync(
    IChatCompletionService chatCompletion, 
    string state, 
    Kernel kernel)
{
    // チャット履歴を復元
    var messages = JsonSerializer.Deserialize<ChatHistory>(state)
        ?? throw new InvalidOperationException("State is not a valid ChatHistory.");
    // チャットを進める
    var response = await chatCompletion.GetChatMessageContentAsync(
        messages,
        new PromptExecutionSettings
        {
            // 関数の選択は行うが自動呼出しは行わない
            FunctionChoiceBehavior = FunctionChoiceBehavior.Auto(autoInvoke: false),
        },
        kernel);
    messages.Add(response);

    // チャット履歴と関数呼び出しの有無を返す
    return (
        State: JsonSerializer.Serialize(messages),
        FunctionCalls: FunctionCallContent.GetFunctionCalls(response)
    );
}

この処理が 1 回の Web API 呼び出しで行われる処理の単位のイメージです。state パラメーターでチャット履歴をシリアライズしたものを受け取り、1 ターンチャットを進めるイメージです。
戻り値は、新しいチャット履歴をシリアライズしたものと、関数呼び出しの有無が判断できるように IEnumerable<FunctionCallContent> を返します。これを使ってチャット履歴をシリアライズしながら関数呼び出しを確認する処理を作成します。

以下のようなコードになります。

using System.ComponentModel;
using System.Text.Json;
using Azure.Identity;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.SemanticKernel;
using Microsoft.SemanticKernel.ChatCompletion;
using FunctionCallContent = Microsoft.SemanticKernel.FunctionCallContent;
using FunctionResultContent = Microsoft.SemanticKernel.FunctionResultContent;

// User Secrets から設定を読み込む
var configuration = new ConfigurationBuilder()
    .AddUserSecrets<Program>()
    .Build();
// AOAI にデプロイしているモデル名
var modelDeploymentName = configuration["AOAI:ModelDeploymentName"]
    ?? throw new ArgumentNullException("AOAI:ModelDeploymentName is not set in the configuration.");
// AOAI のエンドポイント
var endpoint = configuration["AOAI:Endpoint"]
    ?? throw new ArgumentNullException("AOAI:Endpoint is not set in the configuration.");

// Builder を作成
var builder = Kernel.CreateBuilder();

// Kernel にプラグインを登録
builder.Plugins.AddFromType<TimePlugin>();
builder.Plugins.AddFromType<WeatherPlugin>();

// AOAI 用の Chat Completion を登録
builder.AddAzureOpenAIChatClient(
    modelDeploymentName,
    endpoint,
    new AzureCliCredential());

// Kernel を作成
var kernel = builder.Build();

#pragma warning disable SKEXP0001
// IChatClient を IChatCompletionService に変換(プレビュー機能なので警告の抑止が必要)
var chatCompletion = kernel.GetRequiredService<IChatClient>().AsChatCompletionService();
#pragma warning restore SKEXP0001


// GetLocalNow と GetWeather を呼び出してもらうためのメッセージを作成
var state = JsonSerializer.Serialize(new ChatHistory
{
    new (AuthorRole.System, "あなたは猫型アシスタントです。猫らしく振舞うために語尾は「にゃん」にしてください。"),
    new (AuthorRole.User, "今日の東京の天気を教えて"),
});
IEnumerable<FunctionCallContent> functionCalls = [];
do
{
    // 1 ターン進める
    (state, functionCalls) = await AdvanceTurnAsync(chatCompletion, state, kernel);

    if (functionCalls.Any())
    {
        // ツール呼び出しがある場合は、ユーザーに確認してから実行する
        var restoredMessages = JsonSerializer.Deserialize<ChatHistory>(state)
            ?? throw new InvalidOperationException("State is not a valid ChatHistory.");
        foreach (var functionCall in functionCalls)
        {
            // ツール呼び出しの内容を表示
            var functionArgs = string.Join(", ", functionCall.Arguments?.Select(x => $"{x.Key}: {x.Value}") ?? []);
            Console.WriteLine($"この処理を呼び出しても良いですか?(y/n): {functionCall.FunctionName}({functionArgs})");
            var userInput = Console.ReadLine()?.Trim().ToLowerInvariant() ?? "n";

            if (userInput == "y")
            {
                // ユーザーが承認した場合は関数を呼び出す
                var functionResult = await functionCall.InvokeAsync(kernel);
                restoredMessages.Add(functionResult.ToChatMessage());
            }
            else
            {
                // ユーザーが拒否した場合は、拒否メッセージを追加する
                var rejectedMessage = new FunctionResultContent(functionCall, "Rejected by user.");
                restoredMessages.Add(rejectedMessage.ToChatMessage());
                state = JsonSerializer.Serialize(restoredMessages);
            }
        }

        // チャット履歴を更新
        state = JsonSerializer.Serialize(restoredMessages);
    }
} while (functionCalls.Any());

// 最終回答を表示
var finalMessages = JsonSerializer.Deserialize<ChatHistory>(state)
    ?? throw new InvalidOperationException("State is not a valid ChatHistory.");
Console.WriteLine(finalMessages.Last().Content);

実行すると以下のように関数呼び出しの確認を行うことができます。

// 呼び出しを許可するケース
この処理を呼び出しても良いですか?(y/n): TimePlugin_GetLocalNow()
y
この処理を呼び出しても良いですか?(y/n): WeatherPlugin_GetWeatherByDate(dateTime: 2025-06-15T11:00:14.24259+09:00, location: 東京)
y
今日の東京の天気は晴れ、最高気温は25℃にゃん。お散歩日和だにゃん!

// 呼び出しを拒否するケース
この処理を呼び出しても良いですか?(y/n): TimePlugin_GetLocalNow()
y
この処理を呼び出しても良いですか?(y/n): WeatherPlugin_GetWeatherByDate(dateTime: 2025-06-15T11:00:46.1265106+09:00, location: 東京)
n
ごめんにゃん、天気情報を取得できなかったにゃん。もう一度試してみるか、別の質問があれば教えてほしいにゃん!

今回はコンソールアプリで実行しているので、フィルターでやったものと同じ結果になりますが、先ほども説明したようにチャット履歴を都度シリアライズ・デシリアライズしているため外部ストレージに保存しておくようにすることで Web API のような非同期の処理でも関数呼び出しの確認を行うことができます。

まとめ

今回の記事では、Semantic Kernel の低レベルなメッセージ ハンドリングについて解説しました。特に、マニュアルでツールの呼び出しのメッセージを処理する方法や、ツール呼び出しのメッセージから Kernel に登録されているプラグインの関数を呼び出すために用意されている便利機能についても触れました。
そして、Web API のような非同期の処理でも関数呼び出しの確認を行うようにするためにチャット履歴をシリアライズ・デシリアライズしつつ関数呼び出しの確認を行う方法についても解説しました。
このように少しコードが増えてしまいますが、マニュアルでメッセージを処理することで、より細かな制御やカスタマイズが可能になります。

次回の記事では、今度こそ「Agent Framework」について書こうと思います。

目次

普通と違う感じの Semantic Kernel 入門の目次

Microsoft (有志)

Discussion