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

に公開

シリーズ一覧

一覧

はじめに

Microsoft Agent FrameworkをローカルLLMで試してみる その9では、最小構成の AIContextProvider を使って、メソッドの呼び出し順序を確認しました。

その10では一歩進めて、AIContextProviderでコンテキストをどのように変更できるのか、そして変更したコンテキストが最終的なリクエストにどう反映されるのかを確認します。

今回は次の点を確認します。

  • ProvideAIContextAsyncで追加したコンテキストが最終的なリクエストにどう反映されるか
  • InvokingCoreAsyncでコンテキストを直接編集した場合、最終的なリクエストにどう反映されるか

Agent の実装

Agent の実装コード
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.ComponentModel;
using System.Net.Http;

//ツールとして使用する関数の定義
[DisplayName("GetDateTime")]
[Description("現在の日時を取得します。")]
static string GetDateTime()
{
    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 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();

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

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

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

//ChatClientAgentOptionsでエージェントの設定を行う
AIAgent agent = chatClient.AsAIAgent(chatClientAgentOptions);

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

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

//応答の表示
Console.WriteLine("[Response]");
Console.WriteLine(response);
コンテキスト 内容
システムプロンプト ユーザーの質問に対して適切なツールを使用して回答してください。日時は必ず{{GetDateTime}}で最新の情報を取得してください
ユーザーメッセージ 現在時刻を教えてください
ツール GetDateTime 関数(日時を返す)

ProvideAIContextAsyncでコンテキスト追加

コード

//ツールとして使用する関数の定義
[DisplayName("GetDiceRoll")]
[Description("サイコロを振って1から6の数字をランダムに返します。")]
static string GetDiceRoll()
{
    int diceRoll = Random.Shared.Next(1, 7);
    return diceRoll.ToString();
}
protected override ValueTask<AIContext> ProvideAIContextAsync(InvokingContext context, CancellationToken cancellationToken = default)
{
    var newContext = new AIContext
    {
        Instructions = "{{GetDiceRoll}}で6面のサイコロを振ります。",
        Messages = [new ChatMessage { Role = ChatRole.User, Contents = [new TextContent("サイコロを振ってください。")] }],
        Tools = [AIFunctionFactory.Create(GetDiceRoll)]
    };
    return new ValueTask<AIContext>(newContext);
}

コンテキストとしては、Instructions、Messages、Tools のそれぞれに要素を追加しています。

LM Studioで確認したリクエスト

{
  "messages": [
    {
      "role": "system",
      "content": "ユーザーの質問に対して適切なツールを使用して回答してください。日時は必ず{{GetDateTime}}で最新の情報を取得してください\n{{GetDiceRoll}}で6面のサイコロを振ります。"
    },
    {
      "role": "user",
      "content": "現在時刻を教えてください"
    },
    {
      "role": "user",
      "content": "サイコロを振ってください。"
    }
  ],
  "model": "openai/gpt-oss-20b",
  "max_completion_tokens": 5000,
  "tools": [
    {
      "type": "function",
      "function": {
        "description": "現在の日時を取得します。",
        "name": "GetDateTime",
        "parameters": {
          "type": "object",
          "required": [],
          "properties": {},
          "additionalProperties": false
        }
      }
    },
    {
      "type": "function",
      "function": {
        "description": "サイコロを振って1から6の数字をランダムに返します。",
        "name": "GetDiceRoll",
        "parameters": {
          "type": "object",
          "required": [],
          "properties": {},
          "additionalProperties": false
        }
      }
    }
  ],
  "tool_choice": "auto"
}

コンテキストがどう変わったか

LM Studioで確認したリクエストを見ると、AIContextProviderが返した内容は元のコンテキストを置き換えるのではなく、追加されていることが分かります。

項目 追加前 追加後
Instructions ユーザーの質問に対して適切なツールを使用して回答してください。日時は必ず{{GetDateTime}}で最新の情報を取得してください 元のInstructionsの後ろに改行付きで {{GetDiceRoll}}で6面のサイコロを振ります。 が連結される
Messages 現在時刻を教えてください の1件だけ 元のユーザーメッセージの後ろに サイコロを振ってください。 が追加され、userメッセージが2件並ぶ
Tools GetDateTime GetDateTime に加えて GetDiceRoll も使える状態になる

特に重要なのは、Instructionsが別のsystemメッセージとして増えるのではなく、1件のsystemメッセージに連結されている点です。実際のリクエストでは次のように2つの命令が1つの文字列にまとまっています。

ユーザーの質問に対して適切なツールを使用して回答してください。日時は必ず{{GetDateTime}}で最新の情報を取得してください
{{GetDiceRoll}}で6面のサイコロを振ります。

Messagesの変化も同じで、元の 現在時刻を教えてください が消えることはなく、その後ろに AIContextProviderが返した サイコロを振ってください。 が追加されています。そのため、モデルには「現在時刻を知りたい」という元の要求と、「サイコロを振ってほしい」という追加要求の両方が渡されます。

Toolsも置き換えではなく追加です。最終リクエストには GetDateTime と GetDiceRoll の両方が含まれているため、モデルはどちらのツールも選べる状態で推論します。

この結果から、既定のInvokingCoreAsyncは AIContextProviderが返したコンテキストを元のAIContextに追加しており、元のInstructions / Messages / Toolsを保持したまま必要な要素だけを増やしていることが確認できます。

InvokingCoreAsyncでコンテキスト編集

コード

protected override async ValueTask<AIContext> InvokingCoreAsync(InvokingContext context, CancellationToken cancellationToken = default)
{
    var returnValue = await base.InvokingCoreAsync(context, cancellationToken);
    returnValue.Instructions = "";
    returnValue.Tools = [];
    returnValue.Messages = [new ChatMessage { Role = ChatRole.User, Contents = [new TextContent("こんにちは")] }];

    return returnValue;
}

このコードでは、base.InvokingCoreAsyncが組み立てた内容を無視し、InstructionsとToolsを空に、Messagesを「こんにちは」というユーザーメッセージだけに置き換えています。

LM Studioで確認したリクエスト

 {
  "messages": [
    {
      "role": "user",
      "content": "こんにちは"
    }
  ],
  "model": "openai/gpt-oss-20b",
  "max_completion_tokens": 5000
}

コンテキストがどう変わったか

リクエストを見ると、ChatClientAgentOptionsで設定したInstructions、Messages、Toolsはすべて消えており、InvokingCoreAsyncで指定した内容だけがリクエストに入っていることが分かります。

項目 編集前 編集後
Instructions ChatClientAgentOptionsとProvideAIContextAsyncの両方で組み立てられたsystemメッセージがある 空文字にしたためsystemメッセージ自体が送られない
Messages 現在時刻を教えてくださいサイコロを振ってください。 の 2 件が入る こんにちは の 1 件だけに置き換わる
Tools GetDateTime と GetDiceRoll の両方が含まれる 空配列にしたためツール定義は送られない

つまり、ProvideAIContextAsyncは「コンテキストを追加する」ための拡張ポイントですが、InvokingCoreAsyncは base.InvokingCoreAsync の戻り値を受け取ったあとで最終的なAIContextを直接書き換えられます。
そのため、不要なInstructionsやMessagesやToolsを落としたい場合や、特定条件でリクエスト全体を別の内容に差し替えたい場合は、InvokingCoreAsyncの方が強い制御点になります。

ProvideAIContextAsyncとInvokingCoreAsyncの違い

今回の確認結果を並べると、それぞれの役割の違いが見えやすくなります。

メソッド 今回確認できたこと 向いている用途
ProvideAIContextAsync 元のAIContextに対して追加のInstructions / Messages / Toolsを返す 補助命令の追加、追加メッセージの注入、追加ツールの登録
InvokingCoreAsync baseの戻り値を受けたあとに、最終的なAIContextをそのまま編集できる 不要要素の除去、フィルタリング、リクエスト全体の差し替え

単純にコンテキストを足したいだけならProvideAIContextAsyncで十分です。
一方で、「この条件ではツールを全部外したい」「この入力ではシステムプロンプトを入れ替えたい」のように最終形を直接制御したいときはInvokingCoreAsyncを使う方が適しています。

まとめ

  • ProvideAIContextAsyncで返したAIContextは、既定のInvokingCoreAsyncによって元のInstructions / Messages / Toolsに追加されます。
  • LM Studioで確認したリクエストから、Instructionsは1件のsystemメッセージ内で連結され、MessagesとToolsは後ろに追加されることが確認できました。
  • InvokingCoreAsyncでは、base.InvokingCoreAsyncが組み立てた最終AIContextをそのまま編集できるため、追加だけでなく削除や全面差し替えも可能です。

Discussion