📑

Azure Durable Functions と Microsoft.Extensions.AI(MEAI) の統合の試行をしてみた

に公開

はじめに

先日以下のようなブログ記事が公開されました。

OpenAI Agent SDK Integration with Azure Durable Functions

この記事は、OpenAI Agent SDK で書いたコードが、デコレーターを付けるだけで Durable Functions のオーケストレーター関数上で長時間にわたり動作するようになるという内容でした。さらに Durable Task Scheduler を利用することで、実際に AI を呼び出したときのやりとりやツール呼び出しを確認できるということも紹介されていました。

私の好きな .NET では、デコレーターを付けるだけでそこまで劇的に動きを変えることは出来ないのですが、どうにか似たようなことが出来ないか試行してみました。
試行した結果のコードはリポジトリに上げています。

DurableTaskSchedulerAgentTrace

試行内容

まず、Durable Task Scheduler を使用するための下準備をします。これは以前にやった以下の記事に書いています。

Durable Task Scheduler を .NET Aspire で起動する

次に、Azure Functions プロジェクトに MEAI を使うためのパッケージを追加します。

  • Microsoft.Extensions.AI
  • Microsoft.Extensions.AI.OpenAI
  • Azure.Identity
  • Azure.AI.OpenAI

次に Durable Functions の Orchestrator 関数の制限のため Orchestrator 関数内で直接 AI を呼び出すことが出来ないため、Orchestrator 関数から呼び出される Activity 関数を用意します。この Activity 関数内で MEAI を使用して AI を呼び出します。

using Microsoft.Azure.Functions.Worker;
using Microsoft.DurableTask;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.DependencyInjection;

namespace FunctionApp12;

/// <summary>
/// IChatClient 実装を Durable Functions Activity として呼び出すためのラッパ Activity。
/// Orchestrator からの LLM 呼び出しは決定性保持のため必ず本 Activity 経由で行われます。
/// </summary>
public class ChatClientActivity([FromKeyedServices(nameof(ChatClientActivity))] IChatClient chatClient)
{
    /// <summary>
    /// LLM 応答を取得する Activity 実装。
    /// </summary>
    /// <param name="args">チャット メッセージとオプション。</param>
    [Function(nameof(ChatClientActivity))]
    public async Task<ChatResponse> GetResponseAsync(
        [ActivityTrigger] GetResponseArguments args) =>
        await chatClient.GetResponseAsync(args.Messages, args.ChatOptions);
}

/// <summary>
/// Chat Activity へ引き渡すメッセージとオプションをまとめた引数レコード。
/// </summary>
/// <param name="Messages">送信するメッセージ列。</param>
/// <param name="ChatOptions">Durable 用チャット オプション。</param>
public record GetResponseArguments(IEnumerable<ChatMessage> Messages, DurableChatOptions? ChatOptions = null);

次に、Orchestrator 関数内で IChatClient を呼び出すための DurableChatClient クラスを用意します。このクラスは IChatClient インターフェイスを実装し、Orchestrator 関数内で直接 LLM 呼び出しを行わず、Activity 経由で IChatClient を実行するためのプロキシ実装となります。

/// <summary>
/// Orchestrator 上で直接 LLM 呼び出しを行わず、Activity 経由で IChatClient を実行するためのプロキシ実装。
/// </summary>
public class DurableChatClient(TaskOrchestrationContext context) : IChatClient
{
    /// <summary>Activity 呼び出し時のデフォルト リトライ ポリシー。</summary>
    private static readonly TaskOptions DefaultTaskOptions = new()
    {
        Retry = new RetryPolicy(5, TimeSpan.FromSeconds(1), 1, TimeSpan.FromSeconds(60)),
    };

    /// <inheritdoc />
    public void Dispose() { }

    /// <inheritdoc />
    public Task<ChatResponse> GetResponseAsync(IEnumerable<ChatMessage> messages, DurableChatOptions? options = null, CancellationToken cancellationToken = default)
    {
        return ((IChatClient)this).GetResponseAsync(messages, options, cancellationToken);
    }

    /// <inheritdoc />
    async Task<ChatResponse> IChatClient.GetResponseAsync(IEnumerable<ChatMessage> messages, ChatOptions? options = null, CancellationToken cancellationToken = default)
    {
        if (options is not DurableChatOptions durableChatOptions)
        {
            throw new ArgumentException("options must be of type DurableChatOptions", nameof(options));
        }

        if (durableChatOptions is { Context: null })
        {
            throw new ArgumentException("DurableChatOptions.Context must be set", nameof(options));
        }

        return await context.CallActivityAsync<ChatResponse>(
            nameof(ChatClientActivity),
            new GetResponseArguments(messages, durableChatOptions),
            DefaultTaskOptions);
    }

    /// <inheritdoc />
    public object? GetService(Type serviceType, object? serviceKey = null) => null;

    /// <inheritdoc />
    IAsyncEnumerable<ChatResponseUpdate> IChatClient.GetStreamingResponseAsync(IEnumerable<ChatMessage> messages, ChatOptions? options = null, CancellationToken cancellationToken = default)
    {
        throw new NotSupportedException();
    }
}

この Activity 関数で使用するための IChatClientProgram.cs で DI コンテナに登録します。

// IChatClient をキー付きサービスとして登録。
// DurableChatClient -> Activity (ChatClientActivity) -> ここで登録した実体 (AzureOpenAIClient のラッパ) の順で呼び出される。
builder.Services.AddKeyedChatClient(nameof(ChatClientActivity), sp =>
{
    var aoaiEndpoint = sp.GetRequiredService<IConfiguration>()["AOAI_ENDPOINT"];
    // Managed Identity / 開発環境資格情報を利用して Azure OpenAI に接続。
    var aoaiClient = new AzureOpenAIClient(new(aoaiEndpoint), new DefaultAzureCredential());
    // 指定モデル (例: gpt-5) の ChatClient を IChatClient へ変換。
    return aoaiClient.GetChatClient("gpt-5").AsIChatClient();
});

これで、Orchestrator 関数内で DurableChatClient を使用して LLM 呼び出しを行うことが出来るようになります。しかし、まだツール呼び出しがうまくいきません。
ツール呼び出しは ChatOptionsTools プロパティに AITool の配列を設定することで指定しますが、AITool はシリアライズ非対応なオブジェクトやデリゲートを内部に保持する可能性があります。つまりそのままでは Orchestrator と Activity の間で安全に往復できません。また Orchestrator では決定性維持のために任意の I/O・現在時刻・乱数・ネットワーク呼び出しを直接行えず、LLM の Function Calling をその場で即実行する実装は決定性違反になります。

そのため「実行可能な処理」と「LLM に公開する宣言 (メタデータ)」を分離し、Orchestrator 上では宣言のみを扱い、実際の Activity 呼び出しは TaskOrchestrationContext が存在する場面でのみ行う構成にしています。これを実装するのが ActivityAIFunction です。

ここでのポイントは次のとおりです:

  • リフレクションで Activity メソッドを解析し、関数名 ([Function])、[ActivityTrigger] の付いたパラメータ名、[Description] の説明、引数/戻り値の JSON Schema を生成します。
  • 生成したメタデータは JsonElement 等のシリアライズ可能な値だけを保持します。
  • Orchestrator 実行中 (TaskOrchestrationContext がある場合) は context.CallActivityAsync を内部で実行する AIActivityFunction を生成します。
  • Orchestrator 外 (context == null) では AIFunctionDeclaration 派生のダミーを返し、LLM にはシグネチャだけを提示して実行は行いません。

これで Activity 関数へツールの情報を渡しつつ、手元ではツール呼び出しを Activity 関数として実行できるようになります。

ActivityAIFunction.cs
using Microsoft.Azure.Functions.Worker;
using Microsoft.DurableTask;
using Microsoft.Extensions.AI;
using System.ComponentModel;
using System.Reflection;
using System.Text.Json;
using System.Text.Json.Serialization;

namespace FunctionApp12;

/// <summary>
/// Durable Functions の Activity メソッドを Microsoft.Extensions.AI の "ツール" (Function Calling) として
/// LLM に公開するためのメタデータ (JSON Schema 等) を生成・保持し、Orchestrator 上で実行可能な <see cref="AITool"/> に変換します。
/// </summary>
/// <remarks>
/// <list type="number">
/// <item><description>リフレクションによりメソッド シグネチャからパラメータ / 戻り値の JSON Schema を構築</description></item>
/// <item><description>Orchestrator 実行中は実際に Activity を呼び出す <see cref="AITool"/> を生成</description></item>
/// <item><description>Orchestrator 外 (シリアライズ等) では宣言のみの <see cref="AIFunctionDeclaration"/> を生成</description></item>
/// </list>
/// </remarks>
public class ActivityAIFunction
{
    /// <summary>Activity 引数の JSON Schema。</summary>
    public JsonElement JsonSchema { get; private set; }
    /// <summary><c>FunctionAttribute.Name</c> から取得した Functions 上の関数名。</summary>
    public string Name { get; private set; }
    /// <summary><c>[ActivityTrigger]</c> が付与されたパラメータ名。引数抽出に利用。</summary>
    public string ParameterName { get; private set; }
    /// <summary><c>DescriptionAttribute</c> 由来の説明文。LLM への tool 説明。</summary>
    public string Description { get; private set; }
    /// <summary>戻り値の JSON Schema (void の場合は null)。</summary>
    public JsonElement? ReturnJsonSchema { get; private set; }

    /// <summary>
    /// Activity メソッド (<see cref="MethodInfo"/>) を解析し、tool 呼び出しに必要なメタデータを生成します。
    /// </summary>
    /// <param name="activityFunction">Durable Activity 関数のメソッド情報。</param>
    /// <exception cref="InvalidOperationException">必要な属性またはパラメータが見つからない場合。</exception>
    public ActivityAIFunction(MethodInfo activityFunction)
    {
        Name = activityFunction.GetCustomAttribute<FunctionAttribute>()?.Name ?? throw new InvalidOperationException();
        var activityTriggerParameter = activityFunction.GetParameters().First(x => x.GetCustomAttribute<ActivityTriggerAttribute>() is not null);
        ParameterName = activityTriggerParameter.Name ?? throw new InvalidOperationException();
        // Microsoft.Extensions.AI ユーティリティを利用して JSON Schema を生成。
        JsonSchema = AIJsonUtilities.CreateFunctionJsonSchema(activityFunction);
        Description = activityFunction.GetCustomAttribute<DescriptionAttribute>()?.Description ?? string.Empty;
        ReturnJsonSchema = AIJsonUtilities.CreateJsonSchema(activityFunction.ReturnType);
    }

    /// <summary>
    /// JSON シリアライズ / デシリアライズ用コンストラクタ。
    /// </summary>
    [JsonConstructor]
    public ActivityAIFunction(
        JsonElement jsonSchema,
        string name,
        string parameterName,
        string description,
        JsonElement? returnJsonSchema)
    {
        JsonSchema = jsonSchema;
        Name = name;
        ParameterName = parameterName;
        Description = description;
        ReturnJsonSchema = returnJsonSchema;
    }

    /// <summary>
    /// Orchestrator の <see cref="TaskOrchestrationContext"/> の有無に応じ、実行可能な <see cref="AITool"/> 又は宣言のみの tool を返します。
    /// </summary>
    /// <param name="context">Durable Orchestrator コンテキスト。null の場合は実行不能 (宣言のみ)。</param>
    /// <returns><see cref="AITool"/> 実装。</returns>
    public AITool ToAITool(TaskOrchestrationContext? context)
    {
        if (context is null)
        {
            // 実行コンテキストが無い場合は宣言のみ (呼び出しは行われない) のダミーを返却。
            return new DummyAIFunction(Name, JsonSchema, Description, ReturnJsonSchema);
        }

        // Orchestrator からの tool 呼び出し時には Durable の Activity 実行へ委譲するラッパを返す。
        return new AIActivityFunction(context, Name, ParameterName, JsonSchema, Description, ReturnJsonSchema);
    }

    /// <summary>
    /// Orchestrator 上で Activity を呼び出すための実行可能 AI ツール実装。
    /// </summary>
    class AIActivityFunction(
        TaskOrchestrationContext context,
        string name,
        string parameterName,
        JsonElement jsonSchema,
        string description,
        JsonElement? returnJsonSchema) : AIFunction
    {
        /// <inheritdoc />
        public override string Name => name;
        /// <inheritdoc />
        public override JsonElement JsonSchema => jsonSchema;
        /// <inheritdoc />
        public override string Description => description;
        /// <inheritdoc />
        public override JsonElement? ReturnJsonSchema => returnJsonSchema;
        /// <inheritdoc />
        protected async override ValueTask<object?> InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken)
        {
            // パラメーターを渡して Activity を呼び出す。
            return await context.CallActivityAsync<object?>(name, arguments[parameterName]);
        }
    }

    /// <summary>
    /// Orchestrator コンテキストが無い状況用の宣言専用 AI ツール実装。
    /// </summary>
    class DummyAIFunction(
        string name,
        JsonElement jsonSchema,
        string description,
        JsonElement? returnJsonSchema) : AIFunctionDeclaration
    {
        /// <inheritdoc />
        public override string Name => name;
        /// <inheritdoc />
        public override JsonElement JsonSchema => jsonSchema;
        /// <inheritdoc />
        public override string Description => description;
        /// <inheritdoc />
        public override JsonElement? ReturnJsonSchema => returnJsonSchema;
    }
}

ActivityAIFunction で宣言と実行を分離できました。次は ChatOptions を拡張した DurableChatOptions を用意し、ActivityAIFunction 配列を受け取って ChatOptions.Tools に反映するようにします。

以下のような動作になります。

  • DurableChatOptions は生成時に TaskOrchestrationContext (null になる場合あり) を受け取って保持します。
  • ツールはすぐに Tools に入れず、まず ActivityAIFunction[] ActivityAIFunctions としてシリアライズ可能な形で保持します。
  • ActivityAIFunctions の setter で f.ToAITool(Context) を実行し、Context の有無に応じた AITool 配列を Tools に再構築します。
    • Context != null (Orchestrator 上): 実行可能な AIActivityFunction になります。
    • Context == null (逆シリアライズ直後や Activity 内): 宣言のみの DummyAIFunction になります。
  • 逆シリアライズ用にパラメータレス private コンストラクタを用意し、復元後に再度 setter を通して Tools を安全に再生成できます。

以下のようなコードになります。

DurableChatOptions.cs
using Microsoft.DurableTask;
using Microsoft.Extensions.AI;
using System.Text.Json.Serialization;

namespace FunctionApp12;

/// <summary>
/// Durable Orchestrator 内で IChatClient を利用するための拡張 <see cref="ChatOptions"/>
/// Activity ベースの AI ツール (<see cref="ActivityAIFunction"/>) を <see cref="AITool"/> に変換して <see cref="ChatOptions.Tools"/> に反映します。
/// </summary>
public class DurableChatOptions(TaskOrchestrationContext? context) : ChatOptions
{
    /// <summary>
    /// Orchestrator の <see cref="TaskOrchestrationContext"/>。Activity 呼び出し可能かを判定するために利用。
    /// null の場合はツールは Dummy 宣言として扱われます。
    /// </summary>
    public TaskOrchestrationContext? Context => context;

    // Orchestrator / Activity 変換用に保持する Activity ベースのツール一覧。
    // Setter で Microsoft.Extensions.AI の Tools プロパティへ AIFunction / AITool へ変換した配列をセットする。
    private ActivityAIFunction[] _activityAIFunctions = Array.Empty<ActivityAIFunction>();

    /// <summary>
    /// Orchestrator / Activity 化される AI ツール定義一覧。設定時に <see cref="ChatOptions.Tools"/> へ反映されます。
    /// </summary>
    public ActivityAIFunction[] ActivityAIFunctions 
    { 
        get => _activityAIFunctions; 
        set
        {
            _activityAIFunctions = value ?? Array.Empty<ActivityAIFunction>();
            // Context が null の場合はダミー (宣言のみ) のツール、ある場合は Orchestrator 経由で Activity 呼び出し可能なツールとして構築。
            Tools = _activityAIFunctions.Select(f => f.ToAITool(Context)).ToArray();
        }
    }

    /// <summary>
    /// JSON 逆シリアライズ用のパラメータレス コンストラクタ。
    /// </summary>
    [JsonConstructor]
    private DurableChatOptions() : this(null)
    {
    }
}

実際に動かしてみよう

下準備が出来たので実際に動かしてみます。よくある天気を聞くツールを用意し、AI に天気を回答してもらいます。ツールも Activity 関数として実装します。

GetWeatherTool.cs
using Microsoft.Azure.Functions.Worker;
using System.ComponentModel;

namespace FunctionApp12;

/// <summary>
/// 現在の天気を取得する (想定) Activity。LLM の tool 呼び出し用に公開されるサンプル実装。
/// </summary>
public class GetWeatherTool
{
    /// <summary>
    /// 指定された場所の天気を返します (サンプルのため固定メッセージ)。
    /// </summary>
    /// <param name="location">天気を取得したい場所。</param>
    /// <returns>場所に関する天気説明テキスト。</returns>
    [Function(nameof(GetWeatherTool))]
    [Description("Get the current weather for a given location.")]
    public string Run(
        [ActivityTrigger]
        [Description("Location to get the weather for.")]
        string location)
    {
        return $"{location} は晴れでお出かけ日和です。気温は 27 度です。";
    }
}

そして Orchestrator 関数と Starter 関数を以下のように実装します。
AgentOrchestrator はユーザー入力 (HTTP の message クエリ) を受け取り、
DurableChatClientActivityAIFunction を使い LLM 応答 (必要に応じて Activity 経由でツール呼び出し) を生成して最終テキストを返します。

AgentOrchestrator.cs
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.DurableTask;
using Microsoft.DurableTask.Client;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.Logging;

namespace FunctionApp12;

/// <summary>
/// Durable Functions のオーケストレーターで Microsoft.Extensions.AI を用いた LLM + Tool Calling (Activity 呼び出し) を実装するサンプル。
/// </summary>
public class AgentOrchestrator
{
    /// <summary>
    /// Orchestrator 関数本体。ユーザー入力を受け取り、LLM 応答 (必要に応じて Activity でツール呼び出し) を生成して最終テキストを返します。
    /// </summary>
    /// <param name="context">Durable Orchestration の実行コンテキスト。</param>
    /// <returns>LLM が最終的に生成した応答テキスト。</returns>
    [Function(nameof(AgentOrchestrator))]
    public async Task<string> RunOrchestrator(
        [OrchestrationTrigger] TaskOrchestrationContext context)
    {
        ILogger logger = context.CreateReplaySafeLogger(nameof(AgentOrchestrator));

        // Activity 関数 (GetWeatherTool.Run) から AI ツール呼び出しに利用する JSON Schema / メタデータを生成するヘルパー。
        var aiFunction = new ActivityAIFunction(typeof(GetWeatherTool).GetMethod("Run")!);

        // DurableChatClient は Orchestrator から直接 AI / ネットワークを呼ばず、
        // ChatClientActivity (Activity) を経由して IChatClient を実行するためのプロキシです。
        // UseFunctionInvocation で自動的にツール呼び出しを行うようにします。
        var chatClient = new DurableChatClient(context)
            .AsBuilder()
            .UseFunctionInvocation()
            .Build();

        // システムプロンプトとユーザーの入力とツールをセットして LLM を呼び出す。
        var response = await chatClient.GetResponseAsync([
            new(ChatRole.System, "You are a helpful assistant."),
            new(ChatRole.User, context.GetInput<string>())],
            new DurableChatOptions(context)
            {
                // LLM 側が必要と判断した場合に tool 呼び出しを行う。
                ToolMode = ChatToolMode.Auto,
                // Orchestrator から利用可能な Activity ベースのツール一覧
                ActivityAIFunctions = [ aiFunction ],
            });

        // ChatResponse から最終テキストを返す。 (tool 呼び出し結果が反映された後の最終応答)
        return response.Text;
    }

    /// <summary>
    /// Orchestrator を起動する HTTP トリガー。インスタンス ID を含むステータス確認用レスポンスを返します。
    /// </summary>
    /// <param name="req">HTTP リクエスト (message クエリで入力を受け取る)。</param>
    /// <param name="client">Durable クライアント。</param>
    /// <param name="executionContext">関数実行コンテキスト。</param>
    /// <returns>ステータス エンドポイント URL 群を含む HTTP 応答。</returns>
    [Function("AgentOrchestrator_HttpStart")]
    public async Task<HttpResponseData> HttpStart(
        [HttpTrigger(AuthorizationLevel.Anonymous, "get", "post")] HttpRequestData req,
        [DurableClient] DurableTaskClient client,
        FunctionContext executionContext)
    {
        ILogger logger = executionContext.GetLogger("AgentOrchestrator_HttpStart");

        // Orchestrator へ渡す初期入力はクエリパラメータ "message"。
        string instanceId = await client.ScheduleNewOrchestrationInstanceAsync(
            nameof(AgentOrchestrator),
            req.Query.Get("message"));

        logger.LogInformation("Started orchestration with ID = '{instanceId}'.", instanceId);

        // Durable Functions 既定のステータス確認エンドポイント群 (statusQueryGetUri など) を含む応答を生成。
        return await client.CreateCheckStatusResponseAsync(req, instanceId);
    }
}

実行してみましょう。実行して以下のようなリクエストを送ってみます。

test.http
GET http://localhost:7200/api/AgentOrchestrator_HttpStart?message=品川の天気を教えて。

Durable Functions のオーケストレーターが起動し、AI がツール呼び出しを行い、最終的にオーケストレーターのステータス確認のエンドポイントを叩くと以下のような応答が返ってきます。

{
    "name": "AgentOrchestrator",
    "instanceId": "8ecd2a8c462849fdad9c668c3a8a86f5",
    "runtimeStatus": "Completed",
    "input": "品川の天気を教えて。",
    "customStatus": null,
    "output": "品川は晴れていてお出かけ日和です。気温は約27℃です。",
    "createdTime": "2025-09-26T14:50:09Z",
    "lastUpdatedTime": "2025-09-26T14:50:24Z"
}

ちゃんとツールを呼び出せていそうですね。Durable Task Scheduler でオーケストレーターの実行履歴を確認すると以下のように表示されました。いい感じですね。

個別の AI 呼び出しもちゃんと入力と出力が記録されています。

ツール呼び出しもばっちりでした。

まとめ

MEAI と Durable Functions を組み合わせて、Orchestrator 上で LLM 呼び出しとツール呼び出しを行う仕組みを試行してみました。

ActivityAIFunctionDurableChatClient などのような、普通とは少し違うクラスを用意する必要がありましたが、MEAI の仕組みをうまく利用することで、比較的シンプルに実装できたと思います。もう少し洗練出来るといいですが、ひとまず動いたので良しとします。

Microsoft (有志)

Discussion