🤖

Semantic Kernel のマルチエージェント オーケストレーションを試してみる(多分これが本命っぽい?)

に公開

はじめに

Build 2025 で「Semantic Kernel や AutoGen のエージェントのオーケストレーションの機能を統合して~」みたいな話が出ていたと思います。
それに関するものと思われる記事が公開されていたので、試してみました。

Semantic Kernel: Multi-agent Orchestration

この機能は完全に既存の AgentGroupChat と同じ機能を提供しつつ、さらに良いものになっているように見えるので、恐らくマルチエージェント機能は、今回追加がアナウンスされた Orchestrator を使うことになるのではないかと思います。

セットアップ

ということで使ってみましょう。コンソールアプリのプロジェクトを作成して、そこに以下の NuGet パッケージを追加します。

<PackageReference Include="Microsoft.SemanticKernel" Version="1.54.0" />
<PackageReference Include="Microsoft.SemanticKernel.Agents.Core" Version="1.54.0" />
<PackageReference Include="Microsoft.SemanticKernel.Agents.Orchestration" Version="1.54.0-preview" />
<PackageReference Include="Microsoft.SemanticKernel.Agents.Runtime.InProcess" Version="1.54.0-preview" />

3つ目のパッケージがオーケストレーター関連のクラスが含まれているパッケージで、4つ目のパッケージがランタイムです。今後ランタイムは分散環境でも動くものとかが出てくると思います。

まずは、オーケストレーターに呼んでもらうためのエージェントを定義します。これは Semantic Kernel のエージェントの機能をそのまま使えます。
今回は ChatCompletionAgent を使ってみます。さらに AI を直接呼ぶのがメンドクサイのでダミーの IChatClient を実装して固定文字列を返すようにしたいと思います。

using Microsoft.Extensions.AI;

namespace ConsoleApp9;

// ダミーのチャットクライアント実装
class DummyChatClient(Func<IEnumerable<ChatMessage>, string> generateAnswer) : IChatClient
{
    public DummyChatClient(string answer) : this(_ => answer) { }
    public void Dispose() { }

    public Task<ChatResponse> GetResponseAsync(IEnumerable<ChatMessage> messages, ChatOptions? options = null, CancellationToken cancellationToken = default) =>
        Task.FromResult(new ChatResponse(new ChatMessage(ChatRole.Assistant, generateAnswer(messages))));

    public object? GetService(Type serviceType, object? serviceKey = null) =>
        GetType().IsAssignableTo(serviceType) ? this : null;

    public IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(IEnumerable<ChatMessage> messages, ChatOptions? options = null, CancellationToken cancellationToken = default)
    {
        throw new NotImplementedException();
    }
}

new DummyChatClient("Hello, world!") のように使うと、常に Hello, world! を返すクライアントができます。
new DummyChatClient(messages => string.Join(" ", messages.Select(m => m.Text))) のようにすると、メッセージの内容をスペース区切りで連結したものを返すクライアントができます。

この DummyChatClient 別々のキーで 3 個くらい作っておいて、それを使うエージェントを定義していこうと思います。ChatCompletionAgent に設定する PromptExecutionSettingsServiceId プロパティに、どのクライアントを使うかを指定することができます。今回は、それを使って DummyChatClient を切り替えて観光地を提案するエージェントと、観光地でのアクティビティを提案するエージェント、そして最終的な回答をまとめる(会話履歴を連結する)エージェントを作成します。

// ダミーのチャットクライアントを登録
var builder = Kernel.CreateBuilder();
builder.Services.AddKeyedSingleton<IChatClient>(
    "area", 
    new DummyChatClient("お勧めは広島です!"));
builder.Services.AddKeyedSingleton<IChatClient>(
    "activity",
    new DummyChatClient("お勧めは宮島の弥山に登ることです!"));
builder.Services.AddKeyedSingleton<IChatClient>(
    "finalize",
    new DummyChatClient(messages => 
        string.Join('\n', ["纏めると以下の通りです。", .. messages.Select(x => x.Text)])));

var kernel = builder.Build();

// ダミーのエージェントを作成
var areaAgent = new ChatCompletionAgent
{
    Name = "AreaAgent",
    Description = "観光地を提案するエージェント",
    Kernel = kernel,
    Arguments = new(new PromptExecutionSettings
    {
        ServiceId = "area",
    }),
};

var activityAgent = new ChatCompletionAgent
{
    Name = "ActivityAgent",
    Description = "観光地でのアクティビティを提案するエージェント",
    Kernel = kernel,
    Arguments = new(new PromptExecutionSettings
    {
        ServiceId = "activity",
    }),
};

var finalizeAgent = new ChatCompletionAgent
{
    Name = "FinalizeAgent",
    Description = "最終回答をまとめるエージェント",
    Kernel = kernel,
    Arguments = new(new PromptExecutionSettings
    {
        ServiceId = "finalize",
    }),
};

ここからがオーケストレーターの出番です。とりあえず一番シンプルな SequentialOrchestration を使用してみます。

SequentialOrchestration の使い方

SequentialOrchestration は、コンストラクターで渡したエージェントを順番に呼び出していくものです。

例えば以下のように定義すると areaAgent が呼ばれ、その結果が activityAgent に渡され、最後に finalizeAgent が呼ばれるという流れになります。

SequentialOrchestration orchestration = new(areaAgent, activityAgent, finalizeAgent);

実行は IProcessRuntime を使って行います。流れとしては、まず InProcessRuntime を起動し、orchestration.InvokeAsync でオーケストレーションを呼び出し、最後に RunUntilIdleAsync を呼ぶことで、すべてのエージェントが実行されるまで待機します。

// オーケストレーションを定義
SequentialOrchestration orchestration = new(areaAgent, activityAgent, finalizeAgent);

// ランタイムを作成
InProcessRuntime runtime = new();

// 質問をして回答を取得
await runtime.StartAsync();
var result = await orchestration.InvokeAsync("観光地を教えてください", runtime);

var finalAnswer = await result.GetValueAsync();
Console.WriteLine($"最終回答: {finalAnswer}");
await runtime.RunUntilIdleAsync();

これで、観光地を提案するエージェント、観光地でのアクティビティを提案するエージェント、最終回答をまとめるエージェントが順番に呼び出され、最終的な回答が得られます。実行結果は以下のようになります。

最終回答: 纏めると以下の通りです。
お勧めは宮島の弥山に登ることです!

最初の areaAgent のセリフは finalizeAgent のセリフには含まれていません。これは SequentialOrchestration が各エージェントの結果をそのまま次のエージェントに渡すためです。少し変更することで途中の会話履歴を取得することが出来ます。SequentialOrchestrationResponseCallback プロパティを設定することで、各エージェントからの返答を受け取ることができます。
以下のように ChatHistory にメッセージを追加することで、各エージェントの返答を履歴として記録することができます。そして、以下のコードの最後で履歴をダンプすることで、各エージェントの返答を確認できます。

ChatHistory history = [];
// オーケストレーションを定義
SequentialOrchestration orchestration = new(areaAgent, activityAgent, finalizeAgent)
{
    // Agent からの返答があったときのコールバック
    ResponseCallback = message =>
    {
        // 履歴を記録しておく
        history.Add(message);
        return ValueTask.CompletedTask;
    }
};

// ランタイムを作成
InProcessRuntime runtime = new();

// 質問をして回答を取得
await runtime.StartAsync();
var result = await orchestration.InvokeAsync("観光地を教えてください", runtime);

var finalAnswer = await result.GetValueAsync();
Console.WriteLine($"最終回答: {finalAnswer}");

// ChatHistory の内容をダンプ
Console.WriteLine("==============================");
foreach (var message in history)
{
    Console.WriteLine($"{message.AuthorName}: {message.Content}");
}

await runtime.RunUntilIdleAsync();

実行すると以下のような結果になります。

最終回答: 纏めると以下の通りです。
お勧めは宮島の弥山に登ることです!
==============================
AreaAgent: お勧めは広島です!
ActivityAgent: お勧めは宮島の弥山に登ることです!
FinalizeAgent: 纏めると以下の通りです。
お勧めは宮島の弥山に登ることです!

この SequentialOrchestration を使う場合は、過去の履歴に依存しないようなタスクを定義するか、各 Agent が次の Agent に渡す情報を意識して設計する必要がありそうです。

GroupChatOrchestration の使い方

SequentialOrchestration はシンプルで使いやすいですが、複数のエージェントがチャット履歴を共有しながら会話を進めるような場合は GrpupChatOrcestration を使うと良さそうです。
GroupChatOrchestration はコンストラクターで GroupChatManager と、参加する Agent を指定します。
GroupChatManager は、最終回答内容の生成や次に呼び出すエージェントの選択や、ユーザーの割り込みの制御、会話を終了するかどうかの判断などを行います。現時点では GroupChatManager クラス自体は抽象クラスで、それを実装した RoundRobinGroupChatManager が提供されています。RoundRobinGroupChatManager は、参加するエージェントを順番に呼び出していくものです。これを使って、先ほどの旅行のお勧めの例を GroupChatOrchestration で実装してみます。

オーケストレーターの定義の箇所から下の部分のコードを以下のように変更します。

GroupChatOrchestration groupChatOrchestration = new(
    // 3回で終わるラウンドロビン方式のグループチャットオーケストレーション
    new RoundRobinGroupChatManager() { MaximumInvocationCount = 3 },
    // 参加するエージェントの登録
    areaAgent,
    activityAgent,
    finalizeAgent)
{
    ResponseCallback = message =>
    {
        // 途中経過を表示
        Console.WriteLine("<ResposneCallback>");
        Console.WriteLine($"{message.AuthorName}: {message.Content}");
        Console.WriteLine("</ResponseCallback>");
        return ValueTask.CompletedTask;
    }
};

// 実行して結果を表示
InProcessRuntime runtime = new();
await runtime.StartAsync();
var result = await groupChatOrchestration.InvokeAsync("お勧めの旅行先を教えて", runtime);

Console.WriteLine("==========================");
Console.WriteLine(await result.GetValueAsync());
await runtime.RunUntilIdleAsync();

途中経過を ResponseCallback で表示するようにしています。そして最後に結果を表示するようにしています。
実行すると以下のような結果になります。

<ResposneCallback>
AreaAgent: お勧めは広島です!
</ResponseCallback>
<ResposneCallback>
ActivityAgent: お勧めは宮島の弥山に登ることです!
</ResponseCallback>
<ResposneCallback>
FinalizeAgent: 纏めると以下の通りです。
お勧めの旅行先を教えて
お勧めは広島です!
お勧めは宮島の弥山に登ることです!
</ResponseCallback>
==========================
纏めると以下の通りです。
お勧めの旅行先を教えて
お勧めは広島です!
お勧めは宮島の弥山に登ることです!

途中経過が表示されて、最後にこれまでのチャットの内容がまとめられた結果が表示されます。これはいい感じですね。
各エージェントに対してチャットの履歴が渡されていることがわかります。

さらに RoundRobinGroupChatManager を継承して独自の GroupChatManager を実装することで、より複雑な会話の制御を行うことも可能です。例えば FinalizerAgent の後に、必ず人間に確認を求めるような GroupChatManager を実装することもできます。人間の割り込みが必要かどうかは ShouldRequestUserInput メソッドで判断できます。さらに ShouldTerminate で会話を終了するかどうかを判断できます。ここでユーザーのチャットメッセージで OK の場合に会話を終了するといったロジックが組めます。最後に FilterResults メソッドで会話履歴から最終回答を生成することができます。

試しに実装すると以下のような感じになります。

class HumanInTheLoopRoundRobinGroupChatManager : RoundRobinGroupChatManager
{
    // 最後の FinalizeAgent のメッセージを最終回答として扱う
    public override ValueTask<GroupChatManagerResult<string>> FilterResults(ChatHistory history, CancellationToken cancellationToken = default)
    {
        // 最後の FinalizeAgent のメッセージを取得
        var lastMessage = history.LastOrDefault(m => m.AuthorName == "FinalizeAgent");
        if (lastMessage is null)
        {
            return ValueTask.FromResult(new GroupChatManagerResult<string>("No final answer available."));
        }
        // 最終回答を返す
        return ValueTask.FromResult(new GroupChatManagerResult<string>(lastMessage.Content ?? ""));
    }

    // ユーザーからの入力が必要かどうかを判断する
    public override ValueTask<GroupChatManagerResult<bool>> ShouldRequestUserInput(ChatHistory history, CancellationToken cancellationToken = default)
    {
        var lastMessage = history.LastOrDefault();
        // 最終回答の後に確認してもらうために入力を要求する
        return new(new GroupChatManagerResult<bool>(
            lastMessage is not null && lastMessage.AuthorName == "FinalizeAgent"));
    }

    // チャット履歴の最後のメッセージがユーザーからのもので、内容が "OK" ならば終了する
    public override ValueTask<GroupChatManagerResult<bool>> ShouldTerminate(ChatHistory history, CancellationToken cancellationToken = default)
    {
        var lastMessage = history.LastOrDefault();
        if (lastMessage is null) 
            return ValueTask.FromResult(new GroupChatManagerResult<bool>(false));

        return ValueTask.FromResult(new GroupChatManagerResult<bool>(
            lastMessage.Role == AuthorRole.User && lastMessage.Content == "OK"));
    }
}

実際に人が割り込んだときの入力の受付については GroupChatManagerInteractiveCallback でユーザーの入力内容を ValueTask<ChatMessageContent> として返す処理を実装すれば OK です。これを使って GroupChatOrchestration の例を少し書き換えてみます。

GroupChatOrchestration groupChatOrchestration = new(
    // 3回で終わるラウンドロビン方式のグループチャットオーケストレーション
    new HumanInTheLoopRoundRobinGroupChatManager() 
    { 
        InteractiveCallback = () =>
        {
            // ユーザーからの入力を待つためのコールバック
            Console.WriteLine("ユーザーからの入力を待っています...");
            var input = Console.ReadLine();
            return ValueTask.FromResult(new ChatMessageContent(AuthorRole.User, input));
        }
    },
    // 参加するエージェントの登録
    areaAgent,
    activityAgent,
    finalizeAgent)
{
    ResponseCallback = message =>
    {
        // 途中経過を表示
        Console.WriteLine("<ResposneCallback>");
        Console.WriteLine($"{message.AuthorName}: {message.Content}");
        Console.WriteLine("</ResponseCallback>");
        return ValueTask.CompletedTask;
    }
};

// 実行して結果を表示
InProcessRuntime runtime = new();
await runtime.StartAsync();
var result = await groupChatOrchestration.InvokeAsync("お勧めの旅行先を教えて", runtime);

Console.WriteLine("==========================");
Console.WriteLine(await result.GetValueAsync());
await runtime.RunUntilIdleAsync();

実行してみると以下のような結果になります。最初のユーザー割り込みでは NG と入力して、2 回目のユーザー割り込みで OK と入力しています。

<ResposneCallback>
AreaAgent: お勧めは広島です!
</ResponseCallback>
<ResposneCallback>
ActivityAgent: お勧めは宮島の弥山に登ることです!
</ResponseCallback>
<ResposneCallback>
FinalizeAgent: 纏めると以下の通りです。
お勧めの旅行先を教えて
お勧めは広島です!
お勧めは宮島の弥山に登ることです!
</ResponseCallback>
ユーザーからの入力を待っています...
NG
<ResposneCallback>
AreaAgent: お勧めは広島です!
</ResponseCallback>
<ResposneCallback>
ActivityAgent: お勧めは宮島の弥山に登ることです!
</ResponseCallback>
<ResposneCallback>
FinalizeAgent: 纏めると以下の通りです。
お勧めの旅行先を教えて
お勧めは広島です!
お勧めは宮島の弥山に登ることです!
纏めると以下の通りです。
お勧めの旅行先を教えて
お勧めは広島です!
お勧めは宮島の弥山に登ることです!
NG
お勧めは広島です!
お勧めは宮島の弥山に登ることです!
</ResponseCallback>
ユーザーからの入力を待っています...
OK
==========================
纏めると以下の通りです。
お勧めの旅行先を教えて
お勧めは広島です!
お勧めは宮島の弥山に登ることです!
纏めると以下の通りです。
お勧めの旅行先を教えて
お勧めは広島です!
お勧めは宮島の弥山に登ることです!
NG
お勧めは広島です!
お勧めは宮島の弥山に登ることです!

ちゃんとエージェントが 2 周して、最終的な回答が得られています。いい感じですね。今回は使用していませんが SelectNextAgent メソッドをオーバーライドすることで、次に呼び出すエージェントを選択するロジックを実装することもできます。そこまでカスタムするなら RoundRobinGroupChatManager を継承せずに GroupChatManager を直接実装してしまった方が良いです。

従来からある AgentGroupChat よりも拡張ポイントが多くて、より柔軟な制御ができるようになっているので個人的にはこちらの方が使いやすいと思います。

ConcurrentOrchestration の使い方

これは、渡された入力を複数のエージェントに同時に投げて、結果を集約するものです。例えば、観光地を提案するエージェントと、アクティビティを提案するエージェントを同時に呼び出して、それぞれの結果をまとめるような使い方ができます。これまでの Orchstration は戻り値は string でしたが、ConcurrentOrchestration は戻り値が string[] になります。以下のように使います。

//areaAgent と activityAgent に平行に問い合わせる Orchestration を作成
ConcurrentOrchestration concurrentOrchestration = new(areaAgent, activityAgent)
{
    ResponseCallback = message =>
    {
        // 途中経過を表示
        Console.WriteLine("<ResposneCallback>");
        Console.WriteLine($"{message.AuthorName}: {message.Content}");
        Console.WriteLine("</ResponseCallback>");
        return ValueTask.CompletedTask;
    }
};

// 実行して結果を表示
InProcessRuntime runtime = new();
await runtime.StartAsync();
var result = await concurrentOrchestration.InvokeAsync("お勧めの旅行先を教えて", runtime);

// 戻り値が string[] なので ", " で結合して表示
Console.WriteLine("==========================");
Console.WriteLine(string.Join(", ", await result.GetValueAsync()));
await runtime.RunUntilIdleAsync();

実行すると以下のような結果になります。

<ResposneCallback>
AreaAgent: お勧めは広島です!
</ResponseCallback>
<ResposneCallback>
ActivityAgent: お勧めは宮島の弥山に登ることです!
</ResponseCallback>
==========================
お勧めは広島です!, お勧めは宮島の弥山に登ることです!

ちゃんと、2 つのエージェントが同時に呼び出されて、それぞれの結果が得られています。

HandoffOrchestration の使い方

これはちょっと難しいですが複数のエージェントが次に呼び出すエージェントを選択しつつ、選択されたエージェントが仕事をして最終目的を果たすような動きをします。

冒頭で紹介した記事の動作イメージ図がわかりやすいです。

これは、動きが安定しなかったので実際に試せていません…。

まとめ

今回取り上げたもの以外にも Magentic Orchestration というものがあります。ちょっと複雑そうだったので今回は触れませんでしたが、興味のある方はブログを見てみてください。

なんとなく、これが Semantic Kernel を使うときのマルチエージェントの本命っぽい感じがするので、今後もウォッチしていこうと思います。

Microsoft (有志)

Discussion