📌

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

に公開

シリーズ一覧

一覧

はじめに

Microsoft Agent FrameworkをローカルLLMで試してみる その6ではInMemoryChatHistoryProviderについて確認していました。
その7ではチャット履歴のカスタム実装がどのように動作するかを見ていきます。

ChatHistoryProviderのカスタム実装

ChatHistoryProviderは抽象クラスであり、独自の履歴管理方法を実装することができます。

Microsoft Agent FrameworkではChatHistoryProviderを差し替えることで、チャット履歴の保存先や管理方法を柔軟にカスタマイズできます。
今回は単純なメモリベースのカスタムChatHistoryProviderを実装し、呼ばれるタイミングやStateBagの内容を確認していきます。

単純なメモリベースのカスタムChatHistoryProviderの実装例

public sealed class ExternalStoreChatHistoryProvider : ChatHistoryProvider
{
    private readonly ProviderSessionState<State> _sessionState;
    private readonly ConcurrentDictionary<string, List<ChatMessage>> _externalStore = new();
    private IReadOnlyList<string>? _stateKeys;

    //プロパティで外部ストアにアクセスできるようにする
    public ConcurrentDictionary<string, List<ChatMessage>> ExternalStore => _externalStore;

    //コンストラクタでセッション状態の初期化方法と状態キーを受け取ることができるようにする
    public ExternalStoreChatHistoryProvider(Func<AgentSession?, State>? stateInitializer = null, string? stateKey = null)
    {
        _sessionState = new ProviderSessionState<State>(
            stateInitializer ?? (_ => new State(Guid.NewGuid().ToString("N"))),
            stateKey ?? nameof(ExternalStoreChatHistoryProvider));
    }
    public override IReadOnlyList<string> StateKeys => _stateKeys ??= [_sessionState.StateKey];

    //チャット履歴の提供
    protected override ValueTask<IEnumerable<ChatMessage>> ProvideChatHistoryAsync(
        InvokingContext context,
        CancellationToken cancellationToken = default)
    {
        Console.WriteLine("[ProvideChatHistoryAsync]");
        State state = _sessionState.GetOrInitializeState(context.Session);
        Console.WriteLine($"Found messages for ExternalConversationKey: {state.ExternalConversationKey}");
        if (_externalStore.TryGetValue(state.ExternalConversationKey, out List<ChatMessage>? messages))
        {

            Console.WriteLine($"Messages Count: {messages.Count}");
            Console.WriteLine($"First Message: {messages.FirstOrDefault()?.Role} , {messages.FirstOrDefault()?.Contents}");
            return new(messages);
        }

        return new(Enumerable.Empty<ChatMessage>());
    }

    //チャット履歴の保存
    protected override ValueTask StoreChatHistoryAsync(
        InvokedContext context,
        CancellationToken cancellationToken = default)
    {
        Console.WriteLine("[StoreChatHistoryAsync]");

        State state = _sessionState.GetOrInitializeState(context.Session);
        Console.WriteLine($"Storing messages for ExternalConversationKey: {state.ExternalConversationKey}");
        List<ChatMessage> bucket = _externalStore.GetOrAdd(state.ExternalConversationKey, _ => []);

        bucket.AddRange(context.RequestMessages);
        if (context.ResponseMessages is not null)
        {
            Console.WriteLine($"ResponseMessages Count: {context.ResponseMessages.Count()}");
            Console.WriteLine($"First ResponseMessage: {context.ResponseMessages.FirstOrDefault()?.Role} , {context.ResponseMessages.FirstOrDefault()?.Contents} ");

            bucket.AddRange(context.ResponseMessages);
        }

        _sessionState.SaveState(context.Session, state);
        return ValueTask.CompletedTask;
    }

    //セッションから外部会話キーを取得するためのヘルパーメソッド
    public bool TryGetExternalConversationKey(AgentSession session, out string key)
    {
        if (session.StateBag.TryGetValue(_sessionState.StateKey, out State? state) && state is not null)
        {
            key = state.ExternalConversationKey;
            return true;
        }

        key = string.Empty;
        return false;
    }

    //外部ストアに保存されているメッセージの数を取得するためのヘルパーメソッド
    public int GetStoredMessageCount(string externalConversationKey)
        => _externalStore.TryGetValue(externalConversationKey, out List<ChatMessage>? messages) ? messages.Count : 0;

    //セッションに関連付けられた外部会話キーに基づいてメッセージを取得または設定するためのヘルパーメソッド
    public List<ChatMessage> GetMessages(AgentSession session)
    {
        if (TryGetExternalConversationKey(session, out string key))
        {
            return _externalStore.TryGetValue(key, out List<ChatMessage>? messages) ? messages : new List<ChatMessage>();
        }

        return new List<ChatMessage>();
    }

    //セッションに関連付けられた外部会話キーに基づいてメッセージを取得または設定するためのヘルパーメソッド
    public void SetMessages(AgentSession session, List<ChatMessage> messages)
    {
        if (TryGetExternalConversationKey(session, out string key))
        {
            _externalStore[key] = messages;
        }
    }

    public sealed class State
    {
        [JsonConstructor]
        public State(string externalConversationKey)
        {
            ExternalConversationKey = externalConversationKey;
        }

        [JsonPropertyName("externalConversationKey")]
        public string ExternalConversationKey { get; }
    }
}

メソッドと機能一覧

メソッド/機能 説明 必須実装
ProvideChatHistoryAsync エージェント実行前に呼ばれ、セッションのチャット履歴を提供する
StoreChatHistoryAsync エージェント実行後に呼ばれ、リクエスト・レスポンスメッセージを履歴に保存する
StateKeys プロパティ プロバイダが管理する状態キーの一覧を返す。StateBagに保存される状態の識別に使用
コンストラクタ ProviderSessionStateを初期化し、セッション状態の初期化方法をカスタマイズ可能にする
ExternalStore プロパティ 外部ストレージ(メモリ内辞書)への直接アクセスを提供(確認・テスト用)
TryGetExternalConversationKey StateBagからセッションに紐付けられた外部キーを取得するヘルパーメソッド
GetStoredMessageCount 外部ストレージに保存されているメッセージ数をカウントするヘルパーメソッド
GetMessages セッションに紐付けられたメッセージ一覧を取得するヘルパーメソッド
SetMessages セッションに紐付けられたメッセージ一覧を上書きするヘルパーメソッド
State クラス ExternalConversationKeyを保持してStateBagに格納される状態オブジェクト

実装のポイント

  • 必須実装(3つ): ProvideChatHistoryAsyncStoreChatHistoryAsyncStateKeys - ChatHistoryProviderの抽象メンバー
  • ProviderSessionState: セッションごとの状態(外部キー)をStateBagに安全に保存・管理
  • 残りの要素はチャット履歴の管理や動作確認を補助するためのヘルパーメソッドやプロパティ

動作確認

Toolの定義

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

StateBag確認用のヘルパー関数

static void PrintStateBag(AgentSession session)
{
    Console.WriteLine("stateBagCount:" + session.StateBag.Count);

    JsonElement stateBagJson = session.StateBag.Serialize();

    foreach (JsonProperty entry in stateBagJson.EnumerateObject())
    {
        Console.WriteLine($"stateBag Key: {entry.Name}, ValueKind: {entry.Value.ValueKind}, Value: {entry.Value}");
    }
}

Agent作成

//カスタムしたチャット履歴
ExternalStoreChatHistoryProvider chatHistoryProvider = new ExternalStoreChatHistoryProvider();

//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,
});

  • ChatHistoryProvider = chatHistoryProvider を設定することで、エージェントは履歴保存先としてこちらが作成したExternalStoreChatHistoryProviderを使用します。

エージェントの実行

セッションの初期化

//-----セッションの初期化  -----
//sessionの作成
AgentSession session1 = await agent.CreateSessionAsync();
AgentSession session2 = await agent.CreateSessionAsync();

//セッション開始前の状態を確認
var store = chatHistoryProvider.ExternalStore;
Console.WriteLine("[External Store]" );
foreach (var kvp in store)
{
    Console.WriteLine($"External Store Key: {kvp.Key}, Messages Count: {kvp.Value.Count}");
}
  • 2つセッションを作成して、外部ストアの初期状態を確認しています。

出力

[External Store]
  • セッション作成直後は、外部ストアに履歴がない状態で開始されていることがわかります。

プロンプトの送信と応答の確認

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

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

//セッション開始後の状態を確認
Console.WriteLine("[External Store]");
foreach (var kvp in store)
{
    Console.WriteLine($"External Store Key: {kvp.Key}, Messages Count: {kvp.Value.Count}");
}

//10秒待機してから再度プロンプトを送信
await Task.Delay(10000);

//再度プロンプトの送信
response = await agent.RunAsync("もう一度現在時刻を取得してください", session1);

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

//セッション開始後の状態を確認
Console.WriteLine("[External Store]");
foreach (var kvp in store)
{
    Console.WriteLine($"External Store Key: {kvp.Key}, Messages Count: {kvp.Value.Count}");
}

//最初のプロンプトからどれくらい経過しているかをプロンプトしてみる
response = await agent.RunAsync("最初の時刻からどれくらい経過していますか?", session1);

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

//セッション開始後の状態を確認
Console.WriteLine("[External Store]");
foreach (var kvp in store)
{
    Console.WriteLine($"External Store Key: {kvp.Key}, Messages Count: {kvp.Value.Count}");
}
  • 下記のシナリオでプロンプトを送信しています。
    1. 「現在時刻を教えてください」と送信して、1回目の履歴保存を確認する。
    2. 10秒待機してから「もう一度現在時刻を取得してください」と送信し、同じ外部キーに履歴が追記されることを確認する。
    3. 「最初の時刻からどれくらい経過していますか?」と送信し、過去履歴を参照した応答になることを確認する。

1回目のレスポンス

[ProvideChatHistoryAsync]
Found messages for ExternalConversationKey: fcd7c4f34f4d45b195c9a94b285de15e
[StoreChatHistoryAsync]
Storing messages for ExternalConversationKey: fcd7c4f34f4d45b195c9a94b285de15e
ResponseMessages Count: 3
First ResponseMessage: assistant , System.Collections.Generic.List`1[Microsoft.Extensions.AI.AIContent]
[Response]
現在の日時は **2026?04?10?01:08:47** です。
[External Store]
External Store Key: fcd7c4f34f4d45b195c9a94b285de15e, Messages Count: 4
  • 1回目のプロンプト送信時に、外部ストアに新しいキーが作成され、リクエスト・レスポンスメッセージが保存されていることがわかります。

2回目のレスポンス

[ProvideChatHistoryAsync]
Found messages for ExternalConversationKey: fcd7c4f34f4d45b195c9a94b285de15e
Messages Count: 4
First Message: user , System.Collections.Generic.List`1[Microsoft.Extensions.AI.AIContent]
[StoreChatHistoryAsync]
Storing messages for ExternalConversationKey: fcd7c4f34f4d45b195c9a94b285de15e
ResponseMessages Count: 3
First ResponseMessage: assistant , System.Collections.Generic.List`1[Microsoft.Extensions.AI.AIContent]
[Response]
現在の日時は **2026?04?10?01:09:00** です。
[External Store]
External Store Key: fcd7c4f34f4d45b195c9a94b285de15e, Messages Count: 8
  • 2回目のプロンプト送信前に、同じ外部キーに対して履歴が4件保存されていることがわかります。
  • 2回目のレスポンス後に、同じ外部キーに対して履歴が追記されていることがわかります。

3回目のレスポンス

[ProvideChatHistoryAsync]
Found messages for ExternalConversationKey: fcd7c4f34f4d45b195c9a94b285de15e
Messages Count: 8
First Message: user , System.Collections.Generic.List`1[Microsoft.Extensions.AI.AIContent]
[StoreChatHistoryAsync]
Storing messages for ExternalConversationKey: fcd7c4f34f4d45b195c9a94b285de15e
ResponseMessages Count: 1
First ResponseMessage: assistant , System.Collections.Generic.List`1[Microsoft.Extensions.AI.AIContent]
[Response]
最初の時刻(2026?04?10?01:08:47)から2回目の取得までに **13 秒** 経過しています。
[External Store]
External Store Key: fcd7c4f34f4d45b195c9a94b285de15e, Messages Count: 10
  • 3回目も2回目と同じ外部キーに対して履歴が保存されていることがわかります。
  • この時点ではExternalStoreに同じキーで10件のメッセージが保存されている状態になっています。

別のセッションの確認

//session2でプロンプトの送信
AgentResponse response2 = await agent.RunAsync("現在時刻を教えてください", session2);

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

//別のセッションの状態を確認
Console.WriteLine("[External Store]");
foreach (var kvp in store)
{
    Console.WriteLine($"External Store Key: {kvp.Key}, Messages Count: {kvp.Value.Count}");
}
  • 別のセッションで同じプロンプトを送信して、履歴が分離されていることを確認しています。

出力

[ProvideChatHistoryAsync]
Found messages for ExternalConversationKey: 74bd74df50d44b038eb3a60558abc861
[StoreChatHistoryAsync]
Storing messages for ExternalConversationKey: 74bd74df50d44b038eb3a60558abc861
ResponseMessages Count: 3
First ResponseMessage: assistant , System.Collections.Generic.List`1[Microsoft.Extensions.AI.AIContent]
[Response]
現在の時刻は **2026年4月10日 01:09:01** です。
[External Store]
External Store Key: 74bd74df50d44b038eb3a60558abc861, Messages Count: 4
External Store Key: fcd7c4f34f4d45b195c9a94b285de15e, Messages Count: 10
  • session1とsession2で異なるExternalConversationKeyが割り当てられ、履歴が独立して管理されていることがわかります。

StateBagの内容確認

//----- StateBagの確認-----
Console.WriteLine("[Session1]");
PrintStateBag(session1);
Console.WriteLine("[Session2]");
PrintStateBag(session2);
  • sessionのStateBagに、ExternalConversationKeyのみが保存されていることを確認しています。

出力

[Session1]
stateBagCount:1
stateBag Key: ExternalStoreChatHistoryProvider, ValueKind: Object, Value: {"externalConversationKey":"fcd7c4f34f4d45b195c9a94b285de15e"}
[Session2]
stateBagCount:1
stateBag Key: ExternalStoreChatHistoryProvider, ValueKind: Object, Value: {"externalConversationKey":"74bd74df50d44b038eb3a60558abc861"}
  • StateBagには履歴本体ではなく外部キーが保持され、実際のメッセージはExternalStore側で管理される構成になっていることがわかります。

まとめ

  • ChatHistoryProviderを継承することで、履歴管理をカスタマイズできます
  • StateBagに外部キーを保存し、実際の履歴は外部ストレージに保存します
  • ProviderSessionState<T>を使用することで、セッションごとの状態をStateBagに安全に保存できます
  • 実際にプロンプトを送信してみると、同じセッションでは同じ外部キーに履歴が積み上がり、別セッションでは外部キーが分離されることが確認できます
  • 次回は保存先を簡単なステートサーバーにして、プロセスを跨いだ履歴管理を試してみたいと思います

Discussion