🕌

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

に公開

シリーズ一覧

一覧

はじめに

Microsoft Agent FrameworkをローカルLLMで試してみる その7では、ChatHistoryProviderをカスタム実装して、チャット履歴の保存処理がどのタイミングで呼ばれるのかを確認しました。
その8では、チャット履歴の保存先を別プロセスのチャット履歴管理サーバーとして切り出して、カスタム実装したRemoteChatHistoryProviderから利用してみます。

チャット履歴管理サーバー

まずは、チャット履歴を保存するためのシンプルなHTTPサーバーを用意します。
今回はASP.NET Core Minimal APIを使って、メモリ上に履歴を保持するだけの小さなサービスを作成します。

注意
今回のチャット履歴管理サーバーは動作確認用の最小実装です。認証、認可、CORS、通信の保護、入力検証、レート制限などのセキュリティ対策は入れていないため、そのまま本番用途で公開することは想定していません。

  • サーバー側の責務は、conversationKey ごとに ChatMessage の一覧を保持することだけです。
  • RemoteChatHistoryProvider が必要とする操作は、履歴の取得と追記、必要に応じた置換と削除です。

APIの構成

エンドポイント 役割
GET /chat-history 保存済みの会話一覧と件数を取得する
GET /chat-history/{conversationKey} 指定した会話の履歴を取得する
POST /chat-history/{conversationKey} 指定した会話にメッセージを追記する
PUT /chat-history/{conversationKey} 指定した会話の履歴を置き換える
DELETE /chat-history/{conversationKey} 指定した会話の履歴を削除する

ポイント

app.MapGet("/chat-history/{conversationKey}", (string conversationKey, InMemoryChatHistoryStore store) =>
    Results.Ok(store.GetMessages(conversationKey)));

app.MapPost("/chat-history/{conversationKey}", (string conversationKey, AppendChatMessagesRequest request, InMemoryChatHistoryStore store) =>
{
    store.AppendMessages(conversationKey, request.Messages);
    return Results.Accepted($"/chat-history/{conversationKey}");
});
  • GET が履歴の読み出し、POST が履歴の追記です。
  • 実装全体は後ろの「ソース」セクションにまとめています。

RemoteChatHistoryProviderの実装

次に、このチャット履歴管理サーバーを使って履歴を読み書きする ChatHistoryProvider を実装します。
前回は ConcurrentDictionary を直接触っていましたが、今回はその部分が HttpClient に置き換わるイメージです。

大事なのは、Providerが履歴本体を持たず、StateBag に保存した会話キーを使ってチャット履歴管理サーバーへアクセスする点です。

1. セッションと外部会話キーを結び付ける

private readonly ProviderSessionState<State> _sessionState;

private RemoteChatHistoryProvider(HttpClient httpClient, bool ownsHttpClient, Func<AgentSession?, State>? stateInitializer, string? stateKey)
{
    _httpClient = httpClient;
    _ownsHttpClient = ownsHttpClient;
    _sessionState = new ProviderSessionState<State>(
        stateInitializer ?? (_ => new State(Guid.NewGuid().ToString("N"))),
        stateKey ?? nameof(RemoteChatHistoryProvider));
}
  • State には externalConversationKey だけを持たせています。
  • このキーが、どの履歴をチャット履歴管理サーバーから読むかを決める識別子になります。

2. 実行前に履歴を読み出す

protected override async ValueTask<IEnumerable<ChatMessage>> ProvideChatHistoryAsync(
    InvokingContext context,
    CancellationToken cancellationToken = default)
{
    State state = _sessionState.GetOrInitializeState(context.Session);
    return await GetMessagesCoreAsync(state.ExternalConversationKey, cancellationToken).ConfigureAwait(false);
}
  • RunAsync の前に呼ばれ、チャット履歴管理サーバーから既存履歴を取得します。
  • ここで返した履歴が、そのまま会話コンテキストとしてモデルへ渡されます。

3. 実行後に履歴を追記する

protected override async ValueTask StoreChatHistoryAsync(
    InvokedContext context,
    CancellationToken cancellationToken = default)
{
    State state = _sessionState.GetOrInitializeState(context.Session);

    await AppendMessagesAsync(state.ExternalConversationKey, context.RequestMessages, cancellationToken).ConfigureAwait(false);

    if (context.ResponseMessages is not null)
    {
        await AppendMessagesAsync(state.ExternalConversationKey, context.ResponseMessages, cancellationToken).ConfigureAwait(false);
    }

    _sessionState.SaveState(context.Session, state);
}
  • ユーザー入力とエージェント応答を、同じ externalConversationKey に対して追記しています。
  • そのため、プロセスをまたいでも同じキーを使えば会話を再開できます。
  • 実装全体は後ろの「ソース」セクションにまとめています。

メソッドと機能一覧

メソッド/機能 説明 必須実装
ProvideChatHistoryAsync エージェント実行前にチャット履歴管理サーバーからチャット履歴を取得する
StoreChatHistoryAsync エージェント実行後にリクエストとレスポンスをチャット履歴管理サーバーへ保存する
StateKeys StateBag に保存するプロバイダ状態のキー一覧を返す
TryGetExternalConversationKey セッションに紐づく外部会話キーを取得する
GetMessagesAsync あるセッションの保存済みメッセージを取得する
SetMessagesAsync あるセッションの保存済みメッセージを丸ごと置き換える
GetConversationSummariesAsync サーバー上の会話一覧と件数を取得する
SessionExistsAsync 指定した外部会話キーに対応する会話がサーバー上に存在するか調べる
AttachToSession 既存の外部会話キーを現在のセッションに関連付ける

実装のポイント

  • ProviderSessionState<State> を使って、セッションごとの externalConversationKey を StateBag に保存しています。
  • ProvideChatHistoryAsync は GET /chat-history/{conversationKey} を呼び出して、既存履歴を読み込みます。
  • StoreChatHistoryAsync は POST /chat-history/{conversationKey} を呼び出して、今回の入力と出力を追記します。
  • つまり AgentSession 自体には履歴本体を持たせず、外部ストアを指すためのキーだけを保持している構成です。
  • 今回はチャット履歴管理サーバーの実装がメモリベースですが、APIの後ろを差し替えれば永続化ストアに移行しやすくなります。

動作確認

実際に RemoteChatHistoryProvider を使って、セッションごとに履歴がチャット履歴管理サーバーへ保存されることを確認してみます。

なお、事前にチャット履歴管理サーバーを http://localhost:5000 で起動しておきます。

Toolの定義

[DisplayName("GetDateTime")]
[Description("現在の日時を取得します。")]
static string GetDateTime() => DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss");

確認用のヘルパー関数

static async Task PrintExternalStoreAsync(RemoteChatHistoryProvider chatHistoryProvider)
{
    Console.WriteLine("[External Store]");

    IReadOnlyList<RemoteChatHistoryProvider.ChatHistorySummary> summaries = await chatHistoryProvider.GetConversationSummariesAsync();
    foreach (var summary in summaries)
    {
        Console.WriteLine($"External Store Key: {summary.ConversationKey}, Messages Count: {summary.MessageCount}");
    }
}
  • サーバーに保存された会話一覧を表示するために PrintExternalStoreAsync を用意しています。
  • PrintSessionId は、そのセッションに紐づいた外部会話キーを、サンプル上では SessionId という表示名で出力するための補助関数です。
  • PrintStateBag では、AgentSession.StateBag に何が保存されているかを確認できます。
  • 補助関数の全文は、後ろの「ソース」セクションにまとめています。

接続先の設定とProviderの作成

string chatHistoryServiceUrl = Environment.GetEnvironmentVariable("CHAT_HISTORY_SERVICE_URL") ?? "http://localhost:5000";

//カスタムしたチャット履歴
using RemoteChatHistoryProvider chatHistoryProvider = new(chatHistoryServiceUrl);
  • 環境変数 CHAT_HISTORY_SERVICE_URL があればそれを使い、未設定なら http://localhost:5000 に接続します。

Agent作成

AIAgent agent = chatClient.AsAIAgent(new ChatClientAgentOptions
{
        ChatOptions = new ChatOptions
        {
            Instructions = "ユーザーの質問に対して適切なツールを使用して回答してください。日時は必ず{{GetDateTime}}で最新の情報を取得してください",
            Tools = [AIFunctionFactory.Create(GetDateTime) ]
        },
        ChatHistoryProvider = chatHistoryProvider,
});
  • ChatHistoryProvider = chatHistoryProvider を設定することで、このエージェントはHTTP経由で履歴を読み書きするようになります。
  • 今回の確認で重要なのは ChatHistoryProvider の差し替えなので、その他の設定は最小限だけ見れば十分です。

エージェントの実行

AgentSession session1 = await agent.CreateSessionAsync();
AgentSession session2 = await agent.CreateSessionAsync();

await PrintExternalStoreAsync(chatHistoryProvider);

AgentResponse response = await agent.RunAsync("現在時刻を教えてください", session1);
response = await agent.RunAsync("もう一度現在時刻を取得してください", session1);
response = await agent.RunAsync("最初の時刻からどれくらい経過していますか?", session1);

AgentResponse response2 = await agent.RunAsync("現在時刻を教えてください", session2);
  • session1 で2回時刻取得を行い、その後に「最初の時刻からどれくらい経過したか」を聞いています。
  • 続いて session2 でも同じように時刻を取得し、別セッションとして管理されることを確認します。
  • 最後に StateBag を表示して、セッションには外部会話キーだけが保存されていることを見ます。
  • 実際の表示処理や補助関数を含めた全文は、後ろの「ソース」セクションに置いています。

実行結果

[Session] 新しいセッションを開始します。
[External Store]
[ProvideChatHistoryAsync]
Found messages for ExternalConversationKey: 286d2f0b7b7a43688ad32556e3610f54
Messages Count: 0
First Message:  ,
[StoreChatHistoryAsync]
Storing messages for ExternalConversationKey: 286d2f0b7b7a43688ad32556e3610f54
ResponseMessages Count: 3
First ResponseMessage: assistant , System.Collections.Generic.List`1[Microsoft.Extensions.AI.AIContent]
[Response]
現在の日時は **2026年4月16日 01:30:52**(日本時間)です。
[Session1] SessionId: 286d2f0b7b7a43688ad32556e3610f54
[External Store]
External Store Key: 286d2f0b7b7a43688ad32556e3610f54, Messages Count: 4
[ProvideChatHistoryAsync]
Found messages for ExternalConversationKey: 286d2f0b7b7a43688ad32556e3610f54
Messages Count: 4
First Message: user , System.Collections.Generic.List`1[Microsoft.Extensions.AI.AIContent]
[StoreChatHistoryAsync]
Storing messages for ExternalConversationKey: 286d2f0b7b7a43688ad32556e3610f54
ResponseMessages Count: 3
First ResponseMessage: assistant , System.Collections.Generic.List`1[Microsoft.Extensions.AI.AIContent]
[Response]
現在の日時は **2026年4月16日 01:31:05**(日本時間)です。
[External Store]
External Store Key: 286d2f0b7b7a43688ad32556e3610f54, Messages Count: 8
[ProvideChatHistoryAsync]
Found messages for ExternalConversationKey: 286d2f0b7b7a43688ad32556e3610f54
Messages Count: 8
First Message: user , System.Collections.Generic.List`1[Microsoft.Extensions.AI.AIContent]
[StoreChatHistoryAsync]
Storing messages for ExternalConversationKey: 286d2f0b7b7a43688ad32556e3610f54
ResponseMessages Count: 1
First ResponseMessage: assistant , System.Collections.Generic.List`1[Microsoft.Extensions.AI.AIContent]
[Response]
最初に取得した時刻(2026?04?16?01:30:52)から、今回の取得時刻(2026?04?16?01:31:05)まで **13 秒** が経過しています。
[External Store]
External Store Key: 286d2f0b7b7a43688ad32556e3610f54, Messages Count: 10
[ProvideChatHistoryAsync]
Found messages for ExternalConversationKey: 8c0b20571e034813a6da6149dc6ddaac
Messages Count: 0
First Message:  ,
[StoreChatHistoryAsync]
Storing messages for ExternalConversationKey: 8c0b20571e034813a6da6149dc6ddaac
ResponseMessages Count: 3
First ResponseMessage: assistant , System.Collections.Generic.List`1[Microsoft.Extensions.AI.AIContent]
[Response]
現在の日時は **2026年4月16日 01:31:06** です。
[Session2] SessionId: 8c0b20571e034813a6da6149dc6ddaac
[External Store]
External Store Key: 286d2f0b7b7a43688ad32556e3610f54, Messages Count: 10
External Store Key: 8c0b20571e034813a6da6149dc6ddaac, Messages Count: 4
[Session1]
stateBagCount:1
stateBag Key: RemoteChatHistoryProvider, ValueKind: Object, Value: {"externalConversationKey":"286d2f0b7b7a43688ad32556e3610f54"}
[Session2]
stateBagCount:1
stateBag Key: RemoteChatHistoryProvider, ValueKind: Object, Value: {"externalConversationKey":"8c0b20571e034813a6da6149dc6ddaac"}

※ 実行結果内で日時の区切りが ? になっている箇所は、LLM 側の文字コード処理の影響でそのように出力されたものです。記事側で加工したものではありません。

※ 実行結果では外部会話キーが SessionIdExternal Store Key という表示名で出ていますが、ここではいずれもチャット履歴管理サーバー上の会話を識別するキーとして読めば問題ありません。

  • session1 と session2 で別々の外部会話キーが割り当てられていることがわかります。
  • session1 は1回目で4件、2回目で8件、3回目で10件と履歴が積み上がっています。
  • 一方で session2 は新しい会話として開始され、独立して4件の履歴を持っています。
  • StateBag には履歴本体ではなく、externalConversationKey だけが保存されています。

チャット履歴管理サーバーを保持したまま2回目の実行

[Session] 新しいセッションを開始します。
[External Store]
External Store Key: 286d2f0b7b7a43688ad32556e3610f54, Messages Count: 10
External Store Key: 8c0b20571e034813a6da6149dc6ddaac, Messages Count: 4
[ProvideChatHistoryAsync]
Found messages for ExternalConversationKey: 7bf9d0a311de4183be5fc142cc00e647
Messages Count: 0
First Message:  ,
[StoreChatHistoryAsync]
Storing messages for ExternalConversationKey: 7bf9d0a311de4183be5fc142cc00e647
ResponseMessages Count: 3
First ResponseMessage: assistant , System.Collections.Generic.List`1[Microsoft.Extensions.AI.AIContent]
[Response]
現在の時刻は **2026?04?16?02:09:57**(日本時間)です。
[Session1] SessionId: 7bf9d0a311de4183be5fc142cc00e647
[External Store]
External Store Key: 286d2f0b7b7a43688ad32556e3610f54, Messages Count: 10
External Store Key: 7bf9d0a311de4183be5fc142cc00e647, Messages Count: 4
External Store Key: 8c0b20571e034813a6da6149dc6ddaac, Messages Count: 4
[ProvideChatHistoryAsync]
Found messages for ExternalConversationKey: 7bf9d0a311de4183be5fc142cc00e647
Messages Count: 4
First Message: user , System.Collections.Generic.List`1[Microsoft.Extensions.AI.AIContent]
[StoreChatHistoryAsync]
Storing messages for ExternalConversationKey: 7bf9d0a311de4183be5fc142cc00e647
ResponseMessages Count: 3
First ResponseMessage: assistant , System.Collections.Generic.List`1[Microsoft.Extensions.AI.AIContent]
[Response]
再確認しました。現在の時刻は **2026?04?16?02:10:10**(日本時間)です。
[External Store]
External Store Key: 286d2f0b7b7a43688ad32556e3610f54, Messages Count: 10
External Store Key: 7bf9d0a311de4183be5fc142cc00e647, Messages Count: 8
External Store Key: 8c0b20571e034813a6da6149dc6ddaac, Messages Count: 4
[ProvideChatHistoryAsync]
Found messages for ExternalConversationKey: 7bf9d0a311de4183be5fc142cc00e647
Messages Count: 8
First Message: user , System.Collections.Generic.List`1[Microsoft.Extensions.AI.AIContent]
[StoreChatHistoryAsync]
Storing messages for ExternalConversationKey: 7bf9d0a311de4183be5fc142cc00e647
ResponseMessages Count: 1
First ResponseMessage: assistant , System.Collections.Generic.List`1[Microsoft.Extensions.AI.AIContent]
[Response]
最初に取得した時刻(2026?04?16?02:09:57)から今回取得した時刻(2026?04?16?02:10:10)まで、**13 秒** が経過しています。
[External Store]
External Store Key: 286d2f0b7b7a43688ad32556e3610f54, Messages Count: 10
External Store Key: 7bf9d0a311de4183be5fc142cc00e647, Messages Count: 10
External Store Key: 8c0b20571e034813a6da6149dc6ddaac, Messages Count: 4
[ProvideChatHistoryAsync]
Found messages for ExternalConversationKey: a2fabd4d2bfe439e876a24940ea3791e
Messages Count: 0
First Message:  ,
[StoreChatHistoryAsync]
Storing messages for ExternalConversationKey: a2fabd4d2bfe439e876a24940ea3791e
ResponseMessages Count: 3
First ResponseMessage: assistant , System.Collections.Generic.List`1[Microsoft.Extensions.AI.AIContent]
[Response]
現在の日時は 2026年4月16日 02:10:11(UTC)です。
[Session2] SessionId: a2fabd4d2bfe439e876a24940ea3791e
[External Store]
External Store Key: 286d2f0b7b7a43688ad32556e3610f54, Messages Count: 10
External Store Key: 7bf9d0a311de4183be5fc142cc00e647, Messages Count: 10
External Store Key: 8c0b20571e034813a6da6149dc6ddaac, Messages Count: 4
External Store Key: a2fabd4d2bfe439e876a24940ea3791e, Messages Count: 4
[Session1]
stateBagCount:1
stateBag Key: RemoteChatHistoryProvider, ValueKind: Object, Value: {"externalConversationKey":"7bf9d0a311de4183be5fc142cc00e647"}
[Session2]
stateBagCount:1
stateBag Key: RemoteChatHistoryProvider, ValueKind: Object, Value: {"externalConversationKey":"a2fabd4d2bfe439e876a24940ea3791e"}
  • 2回目の実行開始直後の [External Store] には、1回目の実行で作成された 2 つの外部会話キーがそのまま表示されています。
  • この時点では、まだ2回目の新しい Session1 は開始したばかりで 7bf9d0a311de4183be5fc142cc00e647 は新規追加前です。そのため、表示されている2件は前回実行の残存データだと判断できます。
  • つまり、RemoteChatHistoryProvider 自体は毎回新しく作り直していても、保存先のチャット履歴管理サーバーを保持している限り、外部会話キー(externalConversationKey)とメッセージ履歴はサーバー側に残り続けることが確認できます。

解説

  1. 履歴の読み出し
protected override async ValueTask<IEnumerable<ChatMessage>> ProvideChatHistoryAsync(
    InvokingContext context,
    CancellationToken cancellationToken = default)
{
    State state = _sessionState.GetOrInitializeState(context.Session);
    List<ChatMessage> messages = await GetMessagesCoreAsync(state.ExternalConversationKey, cancellationToken).ConfigureAwait(false);
    return messages;
}
  • RunAsync の実行前に呼ばれます。
  • ここで StateBag に保存された externalConversationKey を取り出し、チャット履歴管理サーバーから履歴を取得しています。
  • そのため、プロバイダ本体は履歴を保持しておらず、必要なタイミングで外部ストアから読み込む形になります。
  1. 履歴の保存
protected override async ValueTask StoreChatHistoryAsync(
    InvokedContext context,
    CancellationToken cancellationToken = default)
{
    State state = _sessionState.GetOrInitializeState(context.Session);

    await AppendMessagesAsync(state.ExternalConversationKey, context.RequestMessages, cancellationToken).ConfigureAwait(false);

    if (context.ResponseMessages is not null)
    {
        await AppendMessagesAsync(state.ExternalConversationKey, context.ResponseMessages, cancellationToken).ConfigureAwait(false);
    }

    _sessionState.SaveState(context.Session, state);
}
  • RunAsync の完了後に呼ばれます。
  • ユーザー入力の RequestMessages と、モデルおよびツール実行を含む ResponseMessages をチャット履歴管理サーバーへ追記しています。
  • その結果、前回と同じように user -> assistant(tool call) -> tool -> assistant の流れが外部ストアに保存されます。
  1. セッションには履歴本体を持たせない
public sealed class State
{
    [JsonConstructor]
    public State(string externalConversationKey)
    {
        ExternalConversationKey = externalConversationKey;
    }

    [JsonPropertyName("externalConversationKey")]
    public string ExternalConversationKey { get; }
}
  • AgentSession.StateBag に保存しているのは、外部保存先を指す externalConversationKey だけです。
  • 履歴全体をセッションに持たせないため、セッションオブジェクトは軽量なまま保てます。
  • 将来的に別のアプリケーションから同じ会話キーを指定して再開することもやりやすくなります。
  1. 別セッションは完全に分離される
  • 実行結果を見ると、session1 と session2 には異なる外部会話キーが割り当てられています。
  • そのため、同じ RemoteChatHistoryProvider を使っていても、履歴は会話キー単位で完全に分離されます。
  • 複数ユーザーや複数会話を扱う場合でも、履歴の混線を防ぎやすい構成です。
  1. 保存先を差し替えやすい
  • 今回のチャット履歴管理サーバーは内部的には InMemoryChatHistoryStore を使っています。
  • ただし RemoteChatHistoryProvider から見ると、必要なのはHTTP APIだけです。
  • つまりサーバー側の実装をSQL Database、Cosmos DB、Redisなどに差し替えても、クライアント側の ChatHistoryProvider はほぼそのまま利用できます。

ソース

チャット履歴管理サーバー

Services/InMemoryChatHistoryStore.cs
using System.Collections.Concurrent;
using ChatHistoryService.Contracts;
using Microsoft.Extensions.AI;

namespace ChatHistoryService.Services;

public sealed class InMemoryChatHistoryStore
{
    private readonly ConcurrentDictionary<string, ConversationBucket> _store = new();

    public IReadOnlyList<ChatMessage> GetMessages(string conversationKey)
    {
        if (!_store.TryGetValue(conversationKey, out ConversationBucket? bucket))
        {
            return [];
        }

        lock (bucket.SyncRoot)
        {
            return bucket.Messages.ToList();
        }
    }

    public void AppendMessages(string conversationKey, IEnumerable<ChatMessage> messages)
    {
        List<ChatMessage> batch = messages.ToList();
        if (batch.Count == 0)
        {
            return;
        }

        ConversationBucket bucket = _store.GetOrAdd(conversationKey, static _ => new ConversationBucket());
        lock (bucket.SyncRoot)
        {
            bucket.Messages.AddRange(batch);
        }
    }

    public void SetMessages(string conversationKey, IEnumerable<ChatMessage> messages)
    {
        ConversationBucket bucket = _store.GetOrAdd(conversationKey, static _ => new ConversationBucket());
        List<ChatMessage> replacement = messages.ToList();

        lock (bucket.SyncRoot)
        {
            bucket.Messages.Clear();
            bucket.Messages.AddRange(replacement);
        }
    }

    public void ClearMessages(string conversationKey)
    {
        _store.TryRemove(conversationKey, out _);
    }

    public IReadOnlyList<ChatHistorySummary> GetSummaries()
        => _store
            .OrderBy(entry => entry.Key)
            .Select(entry => new ChatHistorySummary(entry.Key, entry.Value.GetCount()))
            .ToList();

    private sealed class ConversationBucket
    {
        public object SyncRoot { get; } = new();

        public List<ChatMessage> Messages { get; } = [];

        public int GetCount()
        {
            lock (SyncRoot)
            {
                return Messages.Count;
            }
        }
    }
}

Contracts/ChatHistoryContracts.cs
using Microsoft.Extensions.AI;

namespace ChatHistoryService.Contracts;

public sealed record AppendChatMessagesRequest(IReadOnlyList<ChatMessage> Messages);

public sealed record ReplaceChatMessagesRequest(IReadOnlyList<ChatMessage> Messages);

public sealed record ChatHistorySummary(string ConversationKey, int MessageCount);

Program.cs
using ChatHistoryService.Contracts;
using ChatHistoryService.Services;

var builder = WebApplication.CreateBuilder(args);

builder.Services.ConfigureHttpJsonOptions(static options =>
{
    options.SerializerOptions.WriteIndented = true;
});
builder.Services.AddSingleton<InMemoryChatHistoryStore>();

var app = builder.Build();

app.MapGet("/", () => Results.Ok(new
{
    service = "chat-history-service",
    description = "Chat history is stored in memory and exposed over HTTP."
}));

app.MapGet("/chat-history", (InMemoryChatHistoryStore store) => Results.Ok(store.GetSummaries()));

app.MapGet("/chat-history/{conversationKey}", (string conversationKey, InMemoryChatHistoryStore store) =>
    Results.Ok(store.GetMessages(conversationKey)));

app.MapPost("/chat-history/{conversationKey}", (string conversationKey, AppendChatMessagesRequest request, InMemoryChatHistoryStore store) =>
{
    store.AppendMessages(conversationKey, request.Messages);
    return Results.Accepted($"/chat-history/{conversationKey}");
});

app.MapPut("/chat-history/{conversationKey}", (string conversationKey, ReplaceChatMessagesRequest request, InMemoryChatHistoryStore store) =>
{
    store.SetMessages(conversationKey, request.Messages);
    return Results.NoContent();
});

app.MapDelete("/chat-history/{conversationKey}", (string conversationKey, InMemoryChatHistoryStore store) =>
{
    store.ClearMessages(conversationKey);
    return Results.NoContent();
});

app.Run();

エージェントの動作確認プロジェクト

Program.cs
using MAF_LMStudio_Samples;
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() => DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss");

//ChatMessageのContentsはobject型のリストなので、内容をわかりやすく表示するためのヘルパー関数
static string FormatContent(object content) => content switch
{
    TextContent textContent => $"{nameof(TextContent)}: {textContent.Text}",
    _ => content.GetType().Name
};

static void PrintMessages(IEnumerable<ChatMessage> messages)
{
    foreach (var message in messages)
    {
        Console.WriteLine($"Role: {message.Role}, Contents: {string.Join(", ", message.Contents.Select(FormatContent))}");
    }
}

static async Task<AgentSession> CreateOrResumeSessionAsync(AIAgent agent, RemoteChatHistoryProvider chatHistoryProvider, string? sessionId)
{
    AgentSession session = await agent.CreateSessionAsync();

    if (string.IsNullOrWhiteSpace(sessionId))
    {
        Console.WriteLine("[Session] 新しいセッションを開始します。");
        return session;
    }

    if (!await chatHistoryProvider.SessionExistsAsync(sessionId))
    {
        Console.WriteLine($"[Session] セッションID '{sessionId}' は見つからなかったため、新しいセッションを開始します。");
        return session;
    }

    chatHistoryProvider.AttachToSession(session, sessionId);
    Console.WriteLine($"[Session] セッションID '{sessionId}' を再開しました。");
    return session;
}

static void PrintSessionId(string sessionName, RemoteChatHistoryProvider chatHistoryProvider, AgentSession session)
{
    if (chatHistoryProvider.TryGetExternalConversationKey(session, out string sessionId))
    {
        Console.WriteLine($"[{sessionName}] SessionId: {sessionId}");
    }
}

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}");
    }
}

static async Task PrintExternalStoreAsync(RemoteChatHistoryProvider chatHistoryProvider)
{
    Console.WriteLine("[External Store]");

    IReadOnlyList<RemoteChatHistoryProvider.ChatHistorySummary> summaries = await chatHistoryProvider.GetConversationSummariesAsync();
    foreach (var summary in summaries)
    {
        Console.WriteLine($"External Store Key: {summary.ConversationKey}, Messages Count: {summary.MessageCount}");
    }
}

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


var clientOptions = new OpenAIClientOptions
{
    Endpoint = new Uri(lmStudioUrl)
};

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


//カスタムしたチャット履歴
string chatHistoryServiceUrl = Environment.GetEnvironmentVariable("CHAT_HISTORY_SERVICE_URL") ?? "http://localhost:5000";
using RemoteChatHistoryProvider chatHistoryProvider = new(chatHistoryServiceUrl);

//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,
});
//-----セッションの初期化  -----
//sessionの作成
AgentSession session1 = await agent.CreateSessionAsync();
AgentSession session2 = await agent.CreateSessionAsync();

//セッション開始前の状態を確認
await PrintExternalStoreAsync(chatHistoryProvider);

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


//応答の表示
Console.WriteLine("[Response]");
Console.WriteLine(response);
PrintSessionId("Session1", chatHistoryProvider, session1);

//セッション開始後の状態を確認
await PrintExternalStoreAsync(chatHistoryProvider);

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

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

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

//セッション開始後の状態を確認
await PrintExternalStoreAsync(chatHistoryProvider);

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

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

//セッション開始後の状態を確認
await PrintExternalStoreAsync(chatHistoryProvider);


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

//応答の表示
Console.WriteLine("[Response]");
Console.WriteLine(response2);
PrintSessionId("Session2", chatHistoryProvider, session2);

//別のセッションの状態を確認
await PrintExternalStoreAsync(chatHistoryProvider);

//----- StateBagの確認-----
Console.WriteLine("[Session1]");
PrintStateBag(session1);
Console.WriteLine("[Session2]");
PrintStateBag(session2);
RemoteChatHistoryProvider.cs
using System.Net.Http.Json;
using System.Text.Json.Serialization;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;

namespace MAF_LMStudio_Samples;

public sealed class RemoteChatHistoryProvider : ChatHistoryProvider, IDisposable
{
    private readonly HttpClient _httpClient;
    private readonly bool _ownsHttpClient;
    private readonly ProviderSessionState<State> _sessionState;
    private IReadOnlyList<string>? _stateKeys;

    public RemoteChatHistoryProvider(string chatHistoryServiceUrl, Func<AgentSession?, State>? stateInitializer = null, string? stateKey = null)
        : this(CreateHttpClient(chatHistoryServiceUrl), ownsHttpClient: true, stateInitializer, stateKey)
    {
    }

    public RemoteChatHistoryProvider(HttpClient httpClient, Func<AgentSession?, State>? stateInitializer = null, string? stateKey = null)
        : this(httpClient, ownsHttpClient: false, stateInitializer, stateKey)
    {
    }

    private RemoteChatHistoryProvider(HttpClient httpClient, bool ownsHttpClient, Func<AgentSession?, State>? stateInitializer, string? stateKey)
    {
        _httpClient = httpClient;
        _ownsHttpClient = ownsHttpClient;
        _sessionState = new ProviderSessionState<State>(
            stateInitializer ?? (_ => new State(Guid.NewGuid().ToString("N"))),
            stateKey ?? nameof(RemoteChatHistoryProvider));
    }

    public override IReadOnlyList<string> StateKeys => _stateKeys ??= [_sessionState.StateKey];

    protected override async 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}");

        List<ChatMessage> messages = await GetMessagesCoreAsync(state.ExternalConversationKey, cancellationToken).ConfigureAwait(false);

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

        return messages;
    }

    protected override async ValueTask StoreChatHistoryAsync(
        InvokedContext context,
        CancellationToken cancellationToken = default)
    {
        Console.WriteLine("[StoreChatHistoryAsync]");

        State state = _sessionState.GetOrInitializeState(context.Session);
        Console.WriteLine($"Storing messages for ExternalConversationKey: {state.ExternalConversationKey}");

        await AppendMessagesAsync(state.ExternalConversationKey, context.RequestMessages, cancellationToken).ConfigureAwait(false);

        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} ");

            await AppendMessagesAsync(state.ExternalConversationKey, context.ResponseMessages, cancellationToken).ConfigureAwait(false);
        }

        _sessionState.SaveState(context.Session, state);
    }

    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 async Task<int> GetStoredMessageCountAsync(string externalConversationKey, CancellationToken cancellationToken = default)
        => (await GetMessagesCoreAsync(externalConversationKey, cancellationToken).ConfigureAwait(false)).Count;

    public async Task<IReadOnlyList<ChatMessage>> GetMessagesAsync(AgentSession session, CancellationToken cancellationToken = default)
    {
        if (!TryGetExternalConversationKey(session, out string key))
        {
            return [];
        }

        return await GetMessagesCoreAsync(key, cancellationToken).ConfigureAwait(false);
    }

    public async Task SetMessagesAsync(AgentSession session, IReadOnlyList<ChatMessage> messages, CancellationToken cancellationToken = default)
    {
        if (!TryGetExternalConversationKey(session, out string key))
        {
            return;
        }

        using HttpResponseMessage response = await _httpClient.PutAsJsonAsync(
            $"chat-history/{key}",
            new ReplaceChatMessagesRequest(messages),
            cancellationToken).ConfigureAwait(false);

        response.EnsureSuccessStatusCode();
    }

    public async Task<IReadOnlyList<ChatHistorySummary>> GetConversationSummariesAsync(CancellationToken cancellationToken = default)
    {
        IReadOnlyList<ChatHistorySummary>? summaries = await _httpClient.GetFromJsonAsync<IReadOnlyList<ChatHistorySummary>>(
            "chat-history",
            cancellationToken).ConfigureAwait(false);

        return summaries ?? [];
    }

    public async Task<bool> SessionExistsAsync(string sessionId, CancellationToken cancellationToken = default)
    {
        ArgumentException.ThrowIfNullOrWhiteSpace(sessionId);

        IReadOnlyList<ChatHistorySummary> summaries = await GetConversationSummariesAsync(cancellationToken).ConfigureAwait(false);
        return summaries.Any(summary => string.Equals(summary.ConversationKey, sessionId, StringComparison.Ordinal));
    }

    public void AttachToSession(AgentSession session, string sessionId)
    {
        ArgumentNullException.ThrowIfNull(session);
        ArgumentException.ThrowIfNullOrWhiteSpace(sessionId);

        _sessionState.SaveState(session, new State(sessionId));
    }

    private async Task<List<ChatMessage>> GetMessagesCoreAsync(string externalConversationKey, CancellationToken cancellationToken)
    {
        IReadOnlyList<ChatMessage>? messages = await _httpClient.GetFromJsonAsync<IReadOnlyList<ChatMessage>>(
            $"chat-history/{externalConversationKey}",
            cancellationToken).ConfigureAwait(false);

        return messages?.ToList() ?? [];
    }

    private async Task AppendMessagesAsync(string externalConversationKey, IEnumerable<ChatMessage> messages, CancellationToken cancellationToken)
    {
        List<ChatMessage> batch = messages.ToList();
        if (batch.Count == 0)
        {
            return;
        }

        using HttpResponseMessage response = await _httpClient.PostAsJsonAsync(
            $"chat-history/{externalConversationKey}",
            new AppendChatMessagesRequest(batch),
            cancellationToken).ConfigureAwait(false);

        response.EnsureSuccessStatusCode();
    }

    public void Dispose()
    {
        if (_ownsHttpClient)
        {
            _httpClient.Dispose();
        }
    }

    private static HttpClient CreateHttpClient(string chatHistoryServiceUrl)
    {
        ArgumentException.ThrowIfNullOrWhiteSpace(chatHistoryServiceUrl);

        return new HttpClient
        {
            BaseAddress = new Uri(chatHistoryServiceUrl, UriKind.Absolute),
            Timeout = TimeSpan.FromMinutes(1)
        };
    }

    internal sealed record AppendChatMessagesRequest(IReadOnlyList<ChatMessage> Messages);

    internal sealed record ReplaceChatMessagesRequest(IReadOnlyList<ChatMessage> Messages);

    public sealed record ChatHistorySummary(string ConversationKey, int MessageCount);

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

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

まとめ

  • ChatHistoryProvider の保存先をチャット履歴管理サーバーに切り出して、チャット履歴を外部化できました。
  • RemoteChatHistoryProvider は StateBag に externalConversationKey だけを保持し、履歴本体はHTTP経由で取得・保存しています。
  • セッションごとに別の会話キーが割り当てられるため、複数会話を分離して扱えることも確認できました。
  • 今回はメモリ上のチャット履歴管理サーバーでしたが、この構成にしておくと後から永続化ストアへ置き換えやすくなります。

Discussion