🎃

Semantic Kernel のマルチエージェント AI 機能入門してみよう その 3

2024/09/16に公開

先日 Semantic Kernel の Agent のコードを見たのですがちょっと古いコードを見てました…無念…。
ということで、引き続きコードを読んで行こうと思います。

今回はチャットの履歴を要約するという部分を重点的に見ていきます。というのも複数人のエージェントが所属しているチャットに何かお願いをすると、チャットの履歴がまぁまぁ長くなっていくということが考えられます。場合によってはトークン数が溢れたり課金が膨れ上がるということが起こりえるのでチャット履歴をシュリンクさせるための要約は重要になってきます。

要約のコアクラス

チャットの要約は Agents.Core プロジェクトの History フォルダーの下にある IChatHistoryReducer インターフェースが肝になります。このインターフェースを実装する ChatHistorySummarizationReducer クラスと ChatHistoryTruncationReducer の 2 つの実装があります。

シンプルなのは ChatHistoryTruncationReducer で純粋に指定した件数でチャットを切り詰めます。動きを試すために以下のようなコードを書いてみました。30件のチャット履歴を作って targetCount に 5 をセットした ChatHistoryTruncationReducer にかけています。

Program.cs
using Microsoft.SemanticKernel;
using Microsoft.SemanticKernel.Agents.History;
using Microsoft.SemanticKernel.ChatCompletion;

var reducer = new ChatHistoryTruncationReducer(5);

// 30 件のチャットメッセージを生成
ChatHistory history = [
    ..
    Enumerable.Range(0, 30).Select(x =>
        new ChatMessageContent(x % 2 == 0 ? AuthorRole.User : AuthorRole.Assistant, $"message: {x}"))
];

// Reducer でチャットを切り詰める
ChatHistory result = [..await reducer.ReduceAsync(history)];

// 結果を表示
foreach (var item in result)
{
    Console.WriteLine($"{item.Role}: {item.Content}");
}

実行結果は以下のように最後の 5 件だけが表示されます。

assistant: message: 25
user: message: 26
assistant: message: 27
user: message: 28
assistant: message: 29

ChatHistoryTruncationReducer のコンストラクタの第二引数の thresholdCount を指定すると、チャットメッセージを切り詰める際に thresholdCount 個だけ過去のメッセージを遡って User のメッセージがあるかどうかを確認して、あれば User のメッセージが開始地点になるように調整してくれます。厳密な内部実装は tools や function calling のメッセージも考慮していたりするのですが、おおまかな動きはこのようになっています。

実際に thresholdCount に 3 を指定して実行してみました。

using Microsoft.SemanticKernel;
using Microsoft.SemanticKernel.Agents.History;
using Microsoft.SemanticKernel.ChatCompletion;

var reducer = new ChatHistoryTruncationReducer(5, 3);

// 30 件のチャットメッセージを生成
ChatHistory history = [
    ..
    Enumerable.Range(0, 30).Select(x =>
        new ChatMessageContent(x % 2 == 0 ? AuthorRole.User : AuthorRole.Assistant, $"message: {x}"))
];

// Reducer でチャットを切り詰める
ChatHistory result = [..await reducer.ReduceAsync(history)];

// 結果を表示
foreach (var item in result)
{
    Console.WriteLine($"{item.Role}: {item.Content}");
}

以下のように先ほどは assistant: message: 25 から始まっていたのが 1 つ前の user: message: 24 からになっていることが確認できます。

user: message: 24
assistant: message: 25
user: message: 26
assistant: message: 27
user: message: 28
assistant: message: 29

もう 1 つの ChatHistorySummarizationReducer は Chat Completions API を使って要約をしてくれます。こっちの方が過去のコンテキストが重要なケースでは有用そうですね。

こちらも試してみましょう。適当な Azure OpenAI Service の gpt-4o モデルを使って動かしてみます。

using Azure.Identity;
using Microsoft.SemanticKernel;
using Microsoft.SemanticKernel.Agents.History;
using Microsoft.SemanticKernel.ChatCompletion;
using Microsoft.SemanticKernel.Connectors.AzureOpenAI;

const string Endpoint = "https://<<AOAI のリソース名>>.openai.azure.com/";
const string ModelDeploymentName = "gpt-4o";

// 普段 Semantic Kernel を使うときにはお目にかかることは少ないですけど
// IChatCompletionService の Azure OpenAI Service 用の実装を自分で
// インスタンス化して使うことも出来ます。
var service = new AzureOpenAIChatCompletionService(
    ModelDeploymentName,
    Endpoint,
    // 自分の Azure CLI の認証情報を使って認証する
    // Cognitive Services OpenAI User のロールを自分のユーザーに追加しておく必要があります。
    new AzureCliCredential());

// 5 件のメッセージに要約をする ChatHistorySummarizationReducer を作成
var reducer = new ChatHistorySummarizationReducer(service, 5);

// 30 件のチャットメッセージを生成
ChatHistory history = [
    ..
    Enumerable.Range(0, 30).Select(x =>
        new ChatMessageContent(x % 2 == 0 ? AuthorRole.User : AuthorRole.Assistant, $"message: {x}"))
];

// Reducer でチャットを切り詰める
ChatHistory result = [..await reducer.ReduceAsync(history)];

// 結果を表示
foreach (var item in result)
{
    Console.WriteLine($"{item.Role}: {item.Content}");
}

これを実行すると以下のようになります。サマリーが先頭に入ってますね。こんな意味のないメッセージでもしっかり要約してくれてることがわかります。

Assistant: The dialog consists of a sequential exchange of numbered messages between the user and the assistant, starting with "message: 0" by the user and continuing alternately between both parties. Each subsequent message increments numerically by one. The conversation follows a simple pattern without additional context or content beyond the message numbers.
assistant: message: 25
user: message: 26
assistant: message: 27
user: message: 28
assistant: message: 29

ChatHistorySummarizationReducer のコンストラクタの第三引数には ChatHistoryTruncationReducer と同じように thresholdCount があり、これを指定するとメッセージを遡って可能であれば user のメッセージから始まるようにしてくれます。

試してみましょう。コードを変更して thresholdCount に 3 を渡すようにします。

using Azure.Identity;
using Microsoft.SemanticKernel;
using Microsoft.SemanticKernel.Agents.History;
using Microsoft.SemanticKernel.ChatCompletion;
using Microsoft.SemanticKernel.Connectors.AzureOpenAI;

const string Endpoint = "https://<<AOAI のリソース名>>.openai.azure.com/";
const string ModelDeploymentName = "gpt-4o";

// 普段 Semantic Kernel を使うときにはお目にかかることは少ないですけど
// IChatCompletionService の Azure OpenAI Service 用の実装を自分で
// インスタンス化して使うことも出来ます。
var service = new AzureOpenAIChatCompletionService(
    ModelDeploymentName,
    Endpoint,
    // 自分の Azure CLI の認証情報を使って認証する
    // Cognitive Services OpenAI User のロールを自分のユーザーに追加しておく必要があります。
    new AzureCliCredential());

// 5 件のメッセージに要約をする ChatHistorySummarizationReducer を作成
var reducer = new ChatHistorySummarizationReducer(service, 5, 3); // 3 を追加

// 30 件のチャットメッセージを生成
ChatHistory history = [
    ..
    Enumerable.Range(0, 30).Select(x =>
        new ChatMessageContent(x % 2 == 0 ? AuthorRole.User : AuthorRole.Assistant, $"message: {x}"))
];

// Reducer でチャットを切り詰める
ChatHistory result = [..await reducer.ReduceAsync(history)];

// 結果を表示
foreach (var item in result)
{
    Console.WriteLine($"{item.Role}: {item.Content}");
}

実行結果は以下のようになります。ちゃんと user のメッセージから始まるようになっていますね。

Assistant: The dialog was an exchange of incrementing message numbers between the user and the assistant. The sequence began at 0 and continued in a straightforward numerical order. Each message contained a single number, which increased by one with each interaction. This pattern continued without deviation, ending with the number 23. The dialog focused solely on this numerical sequence.
user: message: 24
assistant: message: 25
user: message: 26
assistant: message: 27
user: message: 28
assistant: message: 29

その他にも ChatHistorySummarizationReducer には UseSingleSummary という bool 型のプロパティがあり、デフォルト値は true になっています。これを false にすると複数回要約を行った時に、過去の要約が残るようになります。要約が積み重なっていくのでトークンが溢れる可能性がありますが、過去の要約も加味した状態で AI が判断できるようになるというメリットがあると思います。基本はデフォルトの true のままで使っておいた方がトークンが溢れないので安心だと思います。

要約のプロンプトのカスタマイズは SummarizationInstructions プロパティを変更することで可能になります。デフォルトの要約プロンプトは DefaultSummarizationPrompt という静的定数に以下のように定義されています。

Provide a concise and complete summarization of the entire dialog that does not exceed 5 sentences

This summary must always:
- Consider both user and assistant interactions
- Maintain continuity for the purpose of further dialog
- Include details from any existing summary
- Focus on the most significant aspects of the dialog

This summary must never:
- Critique, correct, interpret, presume, or assume
- Identify faults, mistakes, misunderstanding, or correctness
- Analyze what has not occurred
- Exclude details from any existing summary

なので例えばデフォルトの要約プロンプトの味は残しつつ日本語にしたい場合は以下のようにすることで対応できます。

using Azure.Identity;
using Microsoft.SemanticKernel;
using Microsoft.SemanticKernel.Agents.History;
using Microsoft.SemanticKernel.ChatCompletion;
using Microsoft.SemanticKernel.Connectors.AzureOpenAI;

const string Endpoint = "https://<<AOAI のリソース名>>.openai.azure.com/";
const string ModelDeploymentName = "gpt-4o";

// 普段 Semantic Kernel を使うときにはお目にかかることは少ないですけど
// IChatCompletionService の Azure OpenAI Service 用の実装を自分で
// インスタンス化して使うことも出来ます。
var service = new AzureOpenAIChatCompletionService(
    ModelDeploymentName,
    Endpoint,
    // 自分の Azure CLI の認証情報を使って認証する
    // Cognitive Services OpenAI User のロールを自分のユーザーに追加しておく必要があります。
    new AzureCliCredential());

// 5 件のメッセージに要約をする ChatHistorySummarizationReducer を作成
var reducer = new ChatHistorySummarizationReducer(service, 5, 3)
{
    // デフォルトの要約プロンプトに日本語で要約するように指示
    SummarizationInstructions = $"""
    {ChatHistorySummarizationReducer.DefaultSummarizationPrompt}

    Output language:
    - Japanese
    """
};

// 30 件のチャットメッセージを生成
ChatHistory history = [
    ..
    Enumerable.Range(0, 30).Select(x =>
        new ChatMessageContent(x % 2 == 0 ? AuthorRole.User : AuthorRole.Assistant, $"message: {x}"))
];

// Reducer でチャットを切り詰める
ChatHistory result = [..await reducer.ReduceAsync(history)];

// 結果を表示
foreach (var item in result)
{
    Console.WriteLine($"{item.Role}: {item.Content}");
}

実行すると以下のような結果になります。ちゃんと日本語になっていますね。

Assistant: ユーザーとアシスタントは、番号が奇数のメッセージを交互にやり取りする形で対話を続けています。このパターンは、 数字が増えていく中で一貫して維持されています。各メッセージは数字のみで構成されており、会話は具体的な内容やテーマを持っていませんが、番号が順に進んでいく形式です。
user: message: 24
assistant: message: 25
user: message: 26
assistant: message: 27
user: message: 28
assistant: message: 29

Reducer の設定箇所

Reducer の動きと使い方はわかりましたが Reducer をこのように直接使うことは基本的にはありません。Reducer は以下のように ChatHistoryKernelAgent クラスのプロパティとして定義されています。

実際に使うクラスは、その派生先の ChatCompletionAgent クラスなので、ここに設定することになります。ただ、ChatCompletionAgent 単体ではチャットの履歴の要約は自動的にはやってくれません。ChatCompletionAgent クラス (厳密には ChatHisotryKernelAgent クラス) の ReduceAsync メソッドでチャット履歴を渡すと設定した Reducer を使ってチャットを要約してくれます。

ということでチャットの要約を組み込んだ Agent とのチャットを行うコンソールアプリは以下のようになります。

using Azure.Identity;
using Microsoft.SemanticKernel;
using Microsoft.SemanticKernel.Agents;
using Microsoft.SemanticKernel.Agents.History;
using Microsoft.SemanticKernel.ChatCompletion;

const string Endpoint = "https://<<AOAI のリソース名>>.openai.azure.com/";
const string ModelDeploymentName = "gpt-4o";

// Kernel の作成
var builder = Kernel.CreateBuilder();
builder.AddAzureOpenAIChatCompletion(
    ModelDeploymentName,
    Endpoint,
    new AzureCliCredential());

var kernel = builder.Build();

// Reducer を設定した Agent を作成
var agent = new ChatCompletionAgent
{
    Name = "Chat",
    Instructions = "あなたは雑談相手をおこなうエージェントとして振舞ってください。",
    Kernel = kernel,
    // 日本語でチャットの履歴の要約を行う Reducer を設定
    HistoryReducer = new ChatHistorySummarizationReducer(
        kernel.GetRequiredService<IChatCompletionService>(),
        4)
    {
        SummarizationInstructions = $"""
        {ChatHistorySummarizationReducer.DefaultSummarizationPrompt}

        Output language:
        - Japanese
        """
    },
};

ChatHistory history = [];
while (true)
{
    // 要約を行い、実際に要約が行われた場合は要約後のチャット履歴を表示
    if (await agent.ReduceAsync(history))
    {
        Console.WriteLine("## 要約が行われました。要約後のチャット履歴は以下のようになっています。");
        foreach (var message in history)
        {
            Console.WriteLine($"## {message.Role}: {message.Content}");
        }
    }

    // ユーザーからのメッセージを受け取り、エージェントにメッセージを送信
    Console.Write("You > ");
    var userMessage = Console.ReadLine();
    if (string.IsNullOrEmpty(userMessage))
    {
        break;
    }

    history.AddUserMessage(userMessage);
    await foreach (var message in agent.InvokeAsync(history))
    {
        Console.WriteLine($"{message.Role} > {message.Content}");
        history.Add(message);
    }
}

実行すると以下のような結果になります。ちゃんとチャット履歴が要約されている様子が確認できますん。

You > こんにちは
Assistant > こんにちは!今日はどう過ごしていますか?
You > きのこの山のことを考えています。
Assistant > きのこの山、美味しいですよね。チョコとビスケットのバランスが絶妙!他のお菓子と比べて好きなポイントはありますか?
You > チョコが多いところですね。
Assistant > なるほど、たっぷりチョコが魅力ですよね!チョコが多いうえに、サクサク感も楽しめるのがいいですよね。他に好きなお菓子はありますか?
## 要約が行われました。要約後のチャット履歴は以下のようになっています。
## Assistant: ユーザーが「こんにちは」と挨拶しました。アシスタントが今日の過ごし方について尋ねました。
## user: きのこの山のことを考えています。
## Assistant: きのこの山、美味しいですよね。チョコとビスケットのバランスが絶妙!他のお菓子と比べて好きなポイントはありますか?
## user: チョコが多いところですね。
## Assistant: なるほど、たっぷりチョコが魅力ですよね!チョコが多いうえに、サクサク感も楽しめるのがいいですよね。他に好きなお菓子はありますか?
You > いえ、きのこの山こそ至高
Assistant > きのこの山をそんなに愛しているなんて素敵です!飽きずに楽しめるのは、本当に特別なお菓子ですよね。特にどんなシーンで食べたくなりますか?
## 要約が行われました。要約後のチャット履歴は以下のようになっています。
## Assistant: ユーザーは「こんにちは」と挨拶をし、アシスタントが今日の過ごし方について尋ねました。ユーザーは「きのこの山」のことを考えていると答えました。アシスタントは、きのこの山の美味しさやチョコとビスケットのバランスについて話しました。
## user: チョコが多いところですね。
## Assistant: なるほど、たっぷりチョコが魅力ですよね!チョコが多いうえに、サクサク感も楽しめるのがいいですよね。他に好きなお菓子はありますか?
## user: いえ、きのこの山こそ至高
## Assistant: きのこの山をそんなに愛しているなんて素敵です!飽きずに楽しめるのは、本当に特別なお菓子ですよね。特にどんなシーンで食べたくなりますか?
You > いつでも…!
Assistant > 本当に好きなんですね!自由に楽しめるのがいいところですよね。ちなみに、きのこの山と一緒に飲むならどんなドリンクが合うと思いますか?
## 要約が行われました。要約後のチャット履歴は以下のようになっています。
## Assistant: ユーザーが「こんにちは」と挨拶し、アシスタントが今日の過ごし方を尋ねました。ユーザーは「きのこの山」を考えていると答え、アシスタントは美味しさやチョコとビスケットのバランスについて話しました。ユーザーはチョコが多いところが好きだと述べ、アシスタントもそれに共感し、サクサク感とのバランスが良いと答えました。アシスタントはさらに他に好きなお菓子があるか尋ねました。
## user: いえ、きのこの山こそ至高
## Assistant: きのこの山をそんなに愛しているなんて素敵です!飽きずに楽しめるのは、本当に特別なお菓子ですよね。特にどんなシーンで食べたくなりますか?
## user: いつでも…!
## Assistant: 本当に好きなんですね!自由に楽しめるのがいいところですよね。ちなみに、きのこの山と一緒に飲むならどんなドリンクが合うと思いますか?

これだと、あんまり Agent の旨味が無いように感じますが実際に Semantic Kernel の Agent を使うときには AgentGroupChatAgent を追加して行うと思います。このときに使われる ChatHistoryChannel クラス内で上記のようにチャットの要約を行って質問を行うという処理が組み込まれています。

実際に動きを確認するために以下のようなコードを書いてみました。

using Azure.Identity;
using Microsoft.SemanticKernel;
using Microsoft.SemanticKernel.Agents;
using Microsoft.SemanticKernel.Agents.Chat;
using Microsoft.SemanticKernel.Agents.History;
using Microsoft.SemanticKernel.ChatCompletion;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;

const string Endpoint = "https://<<AOAI のリソース名>>.openai.azure.com/";
const string ModelDeploymentName = "gpt-4o";

// Kernel の作成
var builder = Kernel.CreateBuilder();
builder.AddAzureOpenAIChatCompletion(
    ModelDeploymentName,
    Endpoint,
    new AzureCliCredential());

var kernel = builder.Build();

// Reducer を設定した Agent を作成
var agent = new ChatCompletionAgent
{
    Name = "Chat",
    Instructions = "あなたは雑談相手をおこなうエージェントとして振舞ってください。",
    Kernel = kernel,
    // 日本語でチャットの履歴の要約を行う Reducer を設定
    HistoryReducer = new ChatHistorySummarizationReducer(
        kernel.GetRequiredService<IChatCompletionService>(),
        4)
    {
        SummarizationInstructions = $"""
        {ChatHistorySummarizationReducer.DefaultSummarizationPrompt}

        Output language:
        - Japanese
        """
    },
};

var chat = new AgentGroupChat(agent)
{
    ExecutionSettings = new()
    {
        SelectionStrategy = new SequentialSelectionStrategy(),
        TerminationStrategy = new RegexTerminationStrategy("さようなら|終了|exit|quit")
        {
            MaximumIterations = 1,
        },
    },
};

while (true)
{
    // ユーザーからのメッセージを受け取り、エージェントにメッセージを送信
    Console.Write("You > ");
    var userMessage = Console.ReadLine();
    if (string.IsNullOrEmpty(userMessage))
    {
        break;
    }

    if (userMessage == "history")
    {
        await foreach (var message in chat.GetChatMessagesAsync())
        {
            Console.WriteLine($"## History > {message.Role}: {message.Content}");
        }
        continue;
    }

    chat.AddChatMessage(new ChatMessageContent(AuthorRole.User, userMessage));
    await foreach (var message in chat.InvokeAsync())
    {
        Console.WriteLine($"{message.Role}: {message.Content}");
    }
}

残念ながら AgentGroupChat のパブリックな API を使って要約されたチャット履歴は確認できないのですが、デバッガーでプライベートなメンバーを展開していくと確認できます。

実際に上記プログラムを実行して暫く会話したあとにデバッガーで確認した画面が以下になります。選択している行が2つありますが、上側が AgentChatGroup 自信が内部で管理しているチャットの履歴の件数になります。こちらは 24 件の履歴があります。下の方が Agent と紐づく AgentChannel が内部で管理しているチャット履歴の件数になります。こちらは 6 件と元の履歴と比べて圧倒的に少なくなっていることが確認できます。

因みにスタバについてだらだらと話していたのですが、要約は以下のようになっていました。ちゃんと要約できていそうです。

ユーザーは特別な時にスターバックスでメロンフラペチーノとマンゴーパッションフラペチーノを楽しみ、普段はダイエット中でアイスコーヒーやアイスティーを選んでいます。スターバックスの雰囲気や心地よい香り、音楽を気に入っており、特に少し良い椅子がある席に座りたいと思っています。

まとめ

ということで今回は要約にフォーカスして確認してみました。エージェント単位で要約の方法を指定できるので長いコンテキストが必要なものには、長い履歴を持たせるようにしたり、回答に長いコンテキストがいらないものには短い履歴だけ保持するようにするといった様々な設定ができそうですね。

次は何をみようかなぁ。

Microsoft (有志)

Discussion