🦁

Agent を API として公開する - Microsoft Agent Framework (C#) その19

に公開

シリーズ記事

はじめに

今回は Agent を API として公開する方法を見ていきたいと思います。
Microsoft Agent Framework と ASP.NET Core は良い感じに統合されていて、簡単に Agent を Web API として公開することが出来ます。
早速やってみましょう。

ASP.NET Core プロジェクトの作成

ASP.NET Core の Web API プロジェクトを作成します。Minimal APIs のプロジェクトテンプレートが邪魔なコードが入っていないので、それから始めてみましょう。あとで複数プロジェクトを起動したいので .NET Aspire オーケストレーションへの参加にもチェックを入れておきます。

そして、それぞれのプロジェクトに以下のパッケージを追加します。

AppHost プロジェクト

  • Aspire.Hosting.Azure.CognitiveServices (最新安定版)

Web API プロジェクト

  • Microsoft.Agents.AI (pre release 版)
  • Microsoft.Agents.AI.Hosting.OpenAI (pre release 版)
  • Microsoft.Agents.AI.Workflows (pre release 版)
  • Microsoft.Agents.AI.OpenAI (pre release 版)
  • Azure.AI.OpenAI (pre release 版)
  • Aspire.Azure.AI.OpenAI (pre release 版)
  • Azure.Identity (最新安定版)

そして、AppHost プロジェクトに Azure の Azure Resource Provisioning の設定をします。AppHost プロジェクトの Connected Services の右クリックメニューから Add Azure Resource Provisioning を選択して、Azure OpenAI のあるサブスクリプション、リージョン、リソースグループを選択します。

これで下準備は完了です。次に AppHost プロジェクトのユーザーシークレットに Azure OpenAI のエンドポイントと、使用するモデルのデプロイ名を設定します。今回は、私の Azure サブスクリプションにデプロイしているリソースを使いたいので、その情報を設定します。

secrets.json
{
  "Parameters": {
    "existingOpenAIName": "AOAI の名前 (https://XXXX.cognitiveservices.azure.com/ の場合 XXXX)",
    "existingResourceGroup": "リソースのあるリソースグループ名"
  }
}

そして、AppHost.cs を以下のようにします。

AppHost.cs
var builder = DistributedApplication.CreateBuilder(args);

var existingOpenAIName = builder.AddParameter("existingOpenAIName");
var existingResourceGroup = builder.AddParameter("existingResourceGroup");

var aoai = builder.AddAzureOpenAI("aoai")
    .AsExisting(existingOpenAIName, existingResourceGroup);

builder.AddProject<Projects.WebApplication14>("webapplication14")
    .WithReference(aoai)
    .WithExternalHttpEndpoints();

builder.Build().Run();

これで、アプリが起動するようになりました。最後に Web API プロジェクトProgram.cs を以下のようにします。

Program.cs
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Hosting; // AddAIAgent のために必要

var builder = WebApplication.CreateBuilder(args);

builder.AddServiceDefaults();

// AOAI に繋ぐための IChatClient の登録
builder.AddAzureOpenAIClient("aoai")
    .AddChatClient("gpt-5.1");

// AIAgent を登録
builder.AddAIAgent("cat", "あなたはネコ型エージェントです。猫らしく振舞うために語尾を「にゃん」にしてください。");

// ChatCompletions に必要なサービスを登録
builder.Services.AddOpenAIChatCompletions();

var app = builder.Build();

app.MapDefaultEndpoints();

// Chat Completions API を追加する。
// 呼び出された時に cat という名前で登録した Agent が呼ばれるようにする
app.MapOpenAIChatCompletions(app.Services.GetRequiredKeyedService<AIAgent>("cat"));

app.Run();

最初のポイントは builder.AddAIAgent メソッドでエージェントを登録しているところです。
第一引数にエージェント名、第二引数に instructions を設定します。

次のポイントは以下の 3 行です。

// ChatCompletions に必要なサービスを登録
builder.Services.AddOpenAIChatCompletions();

// ...中略...

// openai の下に Chat Completions API を追加する。
// 呼び出された時に cat という名前で登録した Agent が呼ばれるようにする
var openaiGroup = app.MapGroup("openai");
openaiGroup.MapOpenAIChatCompletions(app.Services.GetRequiredKeyedService<AIAgent>("cat"));

これで Agent を Chat Completions API として公開しています。

クライアントの作成

ではクライアントのプロジェクトを作りましょう。
.NET の AI Chat Web App プロジェクトを新規作成します。

AI service provider を Azure OpenAI にして、Vector store に Local on-disk (for prototyping) を選択します。
そして Use keyless authentication for Azure services を有効にします。
後で既存の .NET Aspire に追加するので、ここでは Use Aspire orchestration にはチェックを入れないでください

AppHost プロジェクトの参照に作成した ChatApp1 のプロジェクトを追加して、AppHost.cs を以下のようにします。

AppHost.cs
var builder = DistributedApplication.CreateBuilder(args);

var existingOpenAIName = builder.AddParameter("existingOpenAIName");
var existingResourceGroup = builder.AddParameter("existingResourceGroup");

var aoai = builder.AddAzureOpenAI("aoai")
    .AsExisting(existingOpenAIName, existingResourceGroup);

var webapplication14 = builder.AddProject<Projects.WebApplication14>("webapplication14")
    .WithReference(aoai)
    .WithExternalHttpEndpoints();

// ChatApp プロジェクトを追加
builder.AddProject<Projects.ChatApp1>("chatapp1")
    // Agent をホストしている Web API を追加
    .WithReference(webapplication14)
    // 外部への公開エンドポイントを追加
    .WithExternalHttpEndpoints();

builder.Build().Run();

これで AppHost プロジェクトを起動すれば、ChatApp1 も起動して Service Discovery で Web API プロジェクトに繋がるようになります。
そして、Chat App の Program.cs を以下のようにします。

Program.cs
using System.ClientModel.Primitives;
using Microsoft.Extensions.AI;
using OpenAI;
using ChatApp1.Components;
using System.ClientModel;

var builder = WebApplication.CreateBuilder(args);
// Aspire 対応
builder.AddServiceDefaults();
builder.Services.AddRazorComponents().AddInteractiveServerComponents();

// IChatClient を登録
builder.Services.AddChatClient(sp =>
{
#pragma warning disable OPENAI001
    var aoaiClient = new OpenAIClient(
        ApiKeyAuthenticationPolicy.CreateHeaderApiKeyPolicy(new ApiKeyCredential("dummy"), "api-key"),
        new OpenAIClientOptions
        {
            Endpoint = new("https+http://webapplication14/cat/v1/"),
            Transport = new HttpClientPipelineTransport(sp.GetRequiredService<HttpClient>()), // なんかこれはダメな気がする…
        });
#pragma warning restore OPENAI001
    return aoaiClient.GetChatClient("cat").AsIChatClient();
});

var app = builder.Build();
// Aspire 対応
app.MapDefaultEndpoints();

// Configure the HTTP request pipeline.
if (!app.Environment.IsDevelopment())
{
    app.UseExceptionHandler("/Error", createScopeForErrors: true);
    // The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
    app.UseHsts();
}

app.UseHttpsRedirection();
app.UseAntiforgery();

app.UseStaticFiles();
app.MapRazorComponents<App>()
    .AddInteractiveServerRenderMode();

app.Run();

ベクトルとかをザクっと消して Aspire のお約束を追加して IChatClient を登録しています。先ほど作成したエンドポイントに繋がって欲しいので Service Discovery 経由で webapplication14 に繋がるようにしています。

そして Chat.razor からベクトル検索系のコードや不要な system プロンプトをコメントアウトします(削除でもいいです)

Chat.razor
@page "/"
@using System.ComponentModel
@inject IChatClient ChatClient
@inject NavigationManager Nav
@* @inject SemanticSearch Search *@
@implements IDisposable

<PageTitle>Chat</PageTitle>

<ChatHeader OnNewChat="@ResetConversationAsync" />

<ChatMessageList Messages="@messages" InProgressMessage="@currentResponseMessage">
    <NoMessagesContent>
        <div>To get started, try asking about these example documents. You can replace these with your own data and replace this message.</div>
        <ChatCitation File="Example_Emergency_Survival_Kit.pdf"/>
        <ChatCitation File="Example_GPS_Watch.md"/>
    </NoMessagesContent>
</ChatMessageList>

<div class="chat-container">
    <ChatSuggestions OnSelected="@AddUserMessageAsync" @ref="@chatSuggestions" />
    <ChatInput OnSend="@AddUserMessageAsync" @ref="@chatInput" />
    <SurveyPrompt /> @* Remove this line to eliminate the template survey message *@
</div>

@code {
    // private const string SystemPrompt = @"
    //     You are an assistant who answers questions about information you retrieve.
    //     Do not answer questions about anything else.
    //     Use only simple markdown to format your responses.

    //     Use the LoadDocuments tool to prepare for searches before answering any questions.

    //     Use the Search tool to find relevant information. When you do this, end your
    //     reply with citations in the special XML format:

    //     <citation filename='string'>exact quote here</citation>

    //     Always include the citation in your response if there are results.

    //     The quote must be max 5 words, taken word-for-word from the search result, and is the basis for why the citation is relevant.
    //     Don't refer to the presence of citations; just emit these tags right at the end, with no surrounding text.
    //     ";

    private int statefulMessageCount;
    private readonly ChatOptions chatOptions = new();
    private readonly List<ChatMessage> messages = new();
    private CancellationTokenSource? currentResponseCancellation;
    private ChatMessage? currentResponseMessage;
    private ChatInput? chatInput;
    private ChatSuggestions? chatSuggestions;

    protected override void OnInitialized()
    {
        statefulMessageCount = 0;
        // messages.Add(new(ChatRole.System, SystemPrompt));
        // chatOptions.Tools = [
        //     AIFunctionFactory.Create(LoadDocumentsAsync),
        //     AIFunctionFactory.Create(SearchAsync)
        // ];
    }

    private async Task AddUserMessageAsync(ChatMessage userMessage)
    {
        CancelAnyCurrentResponse();

        // Add the user message to the conversation
        messages.Add(userMessage);
        chatSuggestions?.Clear();
        await chatInput!.FocusAsync();

        // Stream and display a new response from the IChatClient
        var responseText = new TextContent("");
        currentResponseMessage = new ChatMessage(ChatRole.Assistant, [responseText]);
        currentResponseCancellation = new();
        await foreach (var update in ChatClient.GetStreamingResponseAsync(messages.Skip(statefulMessageCount), chatOptions, currentResponseCancellation.Token))
        {
            messages.AddMessages(update, filter: c => c is not TextContent);
            responseText.Text += update.Text;
            chatOptions.ConversationId = update.ConversationId;
            ChatMessageItem.NotifyChanged(currentResponseMessage);
        }

        // Store the final response in the conversation, and begin getting suggestions
        messages.Add(currentResponseMessage!);
        statefulMessageCount = chatOptions.ConversationId is not null ? messages.Count : 0;
        currentResponseMessage = null;
        chatSuggestions?.Update(messages);
    }

    private void CancelAnyCurrentResponse()
    {
        // If a response was cancelled while streaming, include it in the conversation so it's not lost
        if (currentResponseMessage is not null)
        {
            messages.Add(currentResponseMessage);
        }

        currentResponseCancellation?.Cancel();
        currentResponseMessage = null;
    }

    private async Task ResetConversationAsync()
    {
        CancelAnyCurrentResponse();
        messages.Clear();
        // messages.Add(new(ChatRole.System, SystemPrompt));
        chatOptions.ConversationId = null;
        statefulMessageCount = 0;
        chatSuggestions?.Clear();
        await chatInput!.FocusAsync();
    }

    // [Description("Loads the documents needed for performing searches. Must be completed before a search can be executed, but only needs to be completed once.")]
    // private async Task LoadDocumentsAsync()
    // {
    //     await InvokeAsync(StateHasChanged);
    //     await Search.LoadDocumentsAsync();
    // }

    // [Description("Searches for information using a phrase or keyword. Relies on documents already being loaded.")]
    // private async Task<IEnumerable<string>> SearchAsync(
    //     [Description("The phrase to search for.")] string searchPhrase,
    //     [Description("If possible, specify the filename to search that file only. If not provided or empty, the search includes all files.")] string? filenameFilter = null)
    // {
    //     await InvokeAsync(StateHasChanged);
    //     var results = await Search.SearchAsync(searchPhrase, filenameFilter, maxResults: 5);
    //     return results.Select(result =>
    //         $"<result filename=\"{result.DocumentId}\">{result.Text}</result>");
    // }

    public void Dispose()
        => currentResponseCancellation?.Cancel();
}

これで準備完了です。早速動かしてみましょう。実行して chatapp1 のページにアクセスして適当に入力すると以下のようにチャットが出来ます!

.NET では全ての Chat Completions API などのチャット系の AI の呼出しが IChatClient で抽象化されているので、IChatClient に対応している AI Chat Web App から Chat Completions API として公開したエージェントが非常に簡単に呼べました。こういう風につながると何か楽しいですね。

また、ここではやりませんが、Agent Framework のワークフローも AsAgent メソッドでエージェントとして公開することが出来ます。そのためワークフローもここで紹介した方法で Chat Completions API として公開することが出来ます。そんなに長時間実行するようなワークフローでなければ、この方法で公開するのが一番楽そうな気がします。

まとめ

ということで今回は Agent を API として公開する方法を見てきました。
ASP.NET Core と Microsoft Agent Framework は非常に相性が良く、簡単にエージェントを API として公開することが出来ます。

さらに、今回は AI Chat Web App プロジェクトを改造して IChatClient 経由でエージェントにアクセスする方法を見てきました。Chat Completions API としてエージェントを公開できるのは結構色々出来そうなので個人的にはアツい機能です。

この記事用に書いたコード

この記事用に書いたコードは以下のリポジトリに置いてあります。興味がある方はどうぞ。

https://github.com/runceel/agent-chat-template-lab

Microsoft (有志)

Discussion