🌊

Microsoft Agent Framework をローカルLLMで試してみる その9(AIContextProvider)

に公開

シリーズ一覧

一覧

はじめに

Microsoft Agent FrameworkをローカルLLMで試してみる その8では、RemoteChatHistoryProvider を使ってチャット履歴の保存先を別プロセスのサーバーに切り出しました。
その9からは、LLM を呼び出す直前と直後のコンテキストに介入できるAIContextProviderを見ていきます。

AIContextProviderの目的

AIContextProviderはLLMへのリクエスト前とレスポンス後でコンテキストに介入する仕組みを提供しています。
これを利用するとステートに応じたシステムプロンプトやToolCalling定義の追加、チャット履歴に対するフィルター関数等が実現できます。
パイプラインを通じて複数のAIContextProviderを登録できるため、関心ごとに分割して実装することも可能です。

  • LLMリクエスト前
    • システムプロンプトを追加する
    • チャット履歴を追加する
    • ToolCalling定義を追加する
  • LLMレスポンス後
    • 今回の入力と出力からメモリや要約を抽出する
    • 次回以降に使うための状態を保存する

確認内容

今回は最小構成のAIContextProviderを作成して、次の内容をトレースします。

  • InvokingCoreAsync と ProvideAIContextAsync がどの順序で呼ばれるのか
  • InvokedCoreAsync と StoreAIContextAsync がどのタイミングで呼ばれるのか

AIContextProviderのメソッド

名前 役割
StateKeys 管理する状態のキー一覧
InvokingCoreAsync 呼び出し前の処理
ProvideAIContextAsync 呼び出し前に追加するコンテキスト
InvokedCoreAsync 呼び出し後の処理
StoreAIContextAsync 呼び出し後のコンテキスト

実装

MyAIContextProvider

MyAIContextProvider.cs
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
using System;
using System.Collections;
using System.Collections.Generic;
using System.Runtime.CompilerServices;
using System.Text;

namespace AIContextProviderSample
{
    public class MyAIContextProvider : AIContextProvider
    {
        //確認用にチャット履歴プロバイダーを外部からセットできるようにする
        public InMemoryChatHistoryProvider? ChatHistoryProvider { get; set; }
        // InvokingContextのチャット履歴確認
        private void DumpStateBag(InvokingContext? context)
        {

            if( ChatHistoryProvider is not null ) {
                ChatHistoryProvider.GetMessages(context.Session).ForEach(message => {
                    Console.WriteLine($"[ChatHistory] {message.Role}: {message.Text}");
                });
            }
        }
        // InvokedXontextのチャット履歴確認
        private void DumpStateBag(InvokedContext? context)
        {
            if (ChatHistoryProvider is not null)
            {
                ChatHistoryProvider.GetMessages(context.Session).ForEach(message =>
                {
                    Console.WriteLine($"[ChatHistory] {message.Role}: {message.Text}");
                });
            }
        }

        public override IReadOnlyList<string> StateKeys
        {
            get
            {
                Console.WriteLine("[StateKeys]");
                return base.StateKeys;
            }
        }

        protected override async ValueTask<AIContext> InvokingCoreAsync(InvokingContext context, CancellationToken cancellationToken = default)
        {
            Console.WriteLine("[1:InvokingCoreAsync]");
            DumpStateBag(context);
            var returnValue = await base.InvokingCoreAsync(context, cancellationToken);//内部でProvideAIContextAsyncが呼ばれる
            Console.WriteLine("[1:InvokingCoreAsync] return value");
            Console.WriteLine(returnValue.Instructions);
            Console.WriteLine("[1:InvokingCoreAsync] return value messages");
            returnValue.Messages.ToList().ForEach(message =>
            {
                Console.WriteLine($"{message.Role} :{message.Text}");
            });

            return returnValue;
        }

        protected override ValueTask<AIContext> ProvideAIContextAsync(InvokingContext context, CancellationToken cancellationToken = default)
        {
            Console.WriteLine("[2:ProvideAIContextAsync]");
            DumpStateBag(context);
            return new ValueTask<AIContext>(new AIContext());
        }

        protected override async ValueTask InvokedCoreAsync(InvokedContext context, CancellationToken cancellationToken = default)
        {
            Console.WriteLine("[3:InvokedCoreAsync]");
            DumpStateBag(context);
            var returnValue = base.InvokedCoreAsync(context, cancellationToken);//内部でStoreAIContextAsyncが呼ばれる
            Console.WriteLine("[3:InvokedCoreAsync] base invoked");
            await returnValue;
            Console.WriteLine("[3:InvokedCoreAsync] base completed");
        }

        protected override ValueTask StoreAIContextAsync(InvokedContext context, CancellationToken cancellationToken = default)
        {
            Console.WriteLine("[4:StoreAIContextAsync]");
            DumpStateBag(context);
            return ValueTask.CompletedTask;
        }
    }
}
  • Console.WriteLine でログを出力している部分を除き、既定の動作をそのまま使用しています。

実行シナリオ

下記のシナリオで実行してみます。

なお、実行ログには AIContextProvider のメソッド呼び出し以外に、次のログも含まれます。

ログ 出力箇所
[Session Created] セッション作成後に Program.cs で出力
[HTTP Request] LLM への HTTP リクエスト時に LoggingHttpMessageHandler で出力
[GetDateTime] called Tool 実行時に GetDateTime 関数内で出力
[Response] Agent の応答受信後に Program.cs で出力

実行結果

[StateKeys]
[Session Created]
[1:InvokingCoreAsync]
[2:ProvideAIContextAsync]
[1:InvokingCoreAsync] return value
ユーザーの質問に対して適切なツールを使用して回答してください。日時は必ず{{GetDateTime}}で最新の情報を取得してください
[1:InvokingCoreAsync] return value messages
user :現在時刻を教えてください
[HTTP Request] POST http://localhost:1234/v1/chat/completions
[GetDateTime] called
[HTTP Request] POST http://localhost:1234/v1/chat/completions
[3:InvokedCoreAsync]
[ChatHistory] user: 現在時刻を教えてください
[ChatHistory] assistant:
[ChatHistory] tool:
[ChatHistory] assistant: 現在の時刻は **2026年4月18日 20:18:54**(日本標準時間)です。
[4:StoreAIContextAsync]
[ChatHistory] user: 現在時刻を教えてください
[ChatHistory] assistant:
[ChatHistory] tool:
[ChatHistory] assistant: 現在の時刻は **2026年4月18日 20:18:54**(日本標準時間)です。
[3:InvokedCoreAsync] base invoked
[3:InvokedCoreAsync] base completed
[Response]
現在の時刻は **2026年4月18日 20:18:54**(日本標準時間)です。

解説

ログから、次の順序でメソッドが呼ばれることが確認できます。

  1. セッション作成時に StateKeys が呼ばれる
    [StateKeys] がセッション作成直後に出力されています。

  2. LLMへのリクエストが送信される前に InvokingCoreAsyncProvideAIContextAsync の順で呼ばれる
    [1:InvokingCoreAsync] の後に [2:ProvideAIContextAsync] が出力されています。
    コードでbase.InvokingCoreAsync を呼び出さないように変更すると [2:ProvideAIContextAsync] がログに出なくなったため、base の内部で ProvideAIContextAsync が呼ばれていることが確認できました。

  3. InvokingCoreAsync の戻り値には元のコンテキストの内容が含まれる
    ProvideAIContextAsync は空の AIContext を返したため、戻り値に含まれている ChatOptions.Instructions の文字列とユーザーメッセージは元の inputContext の内容です。

  4. InvokedCoreAsync は LLM の応答がすべて完了した後に呼ばれる
    2 回目の HTTP リクエストの後に [3:InvokedCoreAsync] が出力されています。ToolCalling を含む一連の推論がすべて終わってから呼ばれます。

  5. StoreAIContextAsyncInvokedCoreAsyncbase 呼び出しの前に呼ばれる
    [4:StoreAIContextAsync][3:InvokedCoreAsync] base invoked より前に出力されています。
    コードでbase.InvokedCoreAsync を呼び出さないように変更すると [4:StoreAIContextAsync] がログに出なくなったため、base の内部で StoreAIContextAsync が呼ばれていることが確認できました。

観測したフロー

エージェントのソース

Program.cs
using AIContextProviderSample;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using OpenAI;
using System.ClientModel;
using System.ClientModel.Primitives;
using System.ComponentModel;
using System.Linq;
using System.Net.Http;
using System.Reflection;
using System.Text.Json;


//Toolとして使用する関数の定義
[DisplayName("GetDateTime")]
[Description("現在の日時を取得します。")]
static string GetDateTime()
{
    Console.WriteLine("[GetDateTime] called");
    return DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss");
}

//LM Studioの接続設定
const string lmStudioUrl = "http://localhost:1234/v1";
const string modelName = "openai/gpt-oss-20b";
const string dummyApiKey = "sk-dummy";

var httpClient = new HttpClient(new LoggingHttpMessageHandler(new HttpClientHandler()));

var clientOptions = new OpenAIClientOptions
{
    Endpoint = new Uri(lmStudioUrl),
    Transport = new HttpClientPipelineTransport(httpClient)
};

//OpenAI互換クライアントの作成
var openAIClient = new OpenAIClient(
    new ApiKeyCredential(dummyApiKey),
    clientOptions
);
var openAIChatClient = openAIClient.GetChatClient(modelName);
IChatClient chatClient = openAIChatClient.AsIChatClient();

//チャット履歴プロバイダーの作成
var chatHistoryProvider = new InMemoryChatHistoryProvider();

//カスタムしたAIコンテキストプロバイダーの作成
var aIContextProvider = new MyAIContextProvider();
aIContextProvider.ChatHistoryProvider = chatHistoryProvider;


//ChatClientAgentOptionsでAgentの設定を行う
AIAgent agent = chatClient.AsAIAgent(new ChatClientAgentOptions
{
    Name = "SampleAgent",
    ChatOptions = new ChatOptions
    {
        Instructions = "ユーザーの質問に対して適切なツールを使用して回答してください。日時は必ず{{GetDateTime}}で最新の情報を取得してください",
        Tools = [AIFunctionFactory.Create(GetDateTime)],
        MaxOutputTokens = 5000
    },
    Description = "このAI Agentは日時に関する情報を提供します。",
    ChatHistoryProvider = chatHistoryProvider,
    AIContextProviders = [aIContextProvider],
});

//-----セッションの初期化  -----
//sessionの作成
AgentSession session = await agent.CreateSessionAsync();
Console.WriteLine("[Session Created]");

//ユーザーからの質問
// -----プロンプトの送信と応答の確認  -----
//プロンプトの送信
AgentResponse response = await agent.RunAsync("現在時刻を教えてください", session);

//応答の表示
Console.WriteLine("[Response]");
Console.WriteLine(response);

sealed class LoggingHttpMessageHandler(HttpMessageHandler innerHandler) : DelegatingHandler(innerHandler)
{
    protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
    {
        Console.WriteLine($"[HTTP Request] {request.Method} {request.RequestUri}");
        return base.SendAsync(request, cancellationToken);
    }
}

まとめ

  • 最小構成の AIContextProvider を実装して、各メソッドが呼ばれる順序を確認しました。
    • セッション作成時に StateKeys が呼ばれる
    • LLMへのリクエスト送信前に InvokingCoreAsyncProvideAIContextAsync の順で呼ばれる
    • ToolCalling を含む推論がすべて完了した後に InvokedCoreAsyncStoreAIContextAsync の順で呼ばれる
  • 次回は実際にコンテキストを追加・変更して、Instructions や Messages の差分マージがどのように動作するかを確認します。

Discussion