はじめに
2026年4月初頭に、Microsoft FoundryとAgent Frameworkが統合(?)され、
私がお勉強していたAgent Frameworkの旧ライブラリはレガシー✨️となったわけですが、
名前からして Foundry Localと連携できそ...
...うん。
てか、Foundry Local は OpenAI APIに準拠しているので、そっち経由で利用します。
※Foundry Localにも ChatClientがあるので、そちらを利用するほうが良い場合もあるかもしれないです。
ってことで、本日もやっていきましょう
なお、Foundry Local CLIについては、下記の記事をご参照ください
実践
パッケージの追加
<!-- Foundry Local -->
<PackageReference Include="Microsoft.AI.Foundry.Local" Version="1.0.0" />
<!-- Agent Framework -->
<PackageReference Include="Microsoft.Agents.AI.OpenAI" Version="1.1.0" />
<PackageReference Include="OpenAI" Version="2.10.0" />
<!-- Vector Store -->
<PackageReference Include="Microsoft.SemanticKernel.Connectors.Qdrant" Version="1.74.0-preview" />
<PackageReference Include="Microsoft.ML.Tokenizers" Version="2.0.0" />
<PackageReference Include="Tokenizers.DotNet" Version="1.4.1" />
<PackageReference Include="Tokenizers.DotNet.runtime.win-x64" Version="1.4.1" />
Foudry Localのセットアップ
Foundry Localを利用するには事前準備が必要です。
- execution providersのダウンロード
- モデルカタログの取得
- モデルのダウンロード
- モデルのロード
- REST API公開(OpenAPI経由の場合)
execution providersや、モデルのダウンロードは、1回すれば省略されて起動時間が短縮します。
CLIで別プロセスとして起動してもいいけど、
プログラム起動中だけインスタンス化したい場合は事前準備も実装するのがいいでしょう。
ダウンロードには時間がかかるので、バックグラウンドサービスでやるとよろしいかと
private static readonly string APP_NAME = "foundry";
private static readonly string ENDPOINT_URL = "https://localhost:52495";
private static readonly string MODEL_NAME = "qwen2.5-7b";
protected async override Task ExecuteAsync(CancellationToken stoppingToken)
{
using var scope = App.AppHost.Services.CreateScope();
Configuration = new Microsoft.AI.Foundry.Local.Configuration
{
// ダウンロードしたLLMはAppNameにつけた名前で、%USERPROFILE%\.%AppName% フォルダ配下に保存される
AppName = APP_NAME,
LogLevel = Microsoft.AI.Foundry.Local.LogLevel.Debug,
Web = new Configuration.WebService
{
Urls = ENDPOINT_URL
}
};
StateChanged?.Invoke("Foundry Localを初期化しています。", null, false);
await FoundryLocalManager.CreateAsync(Configuration, logger);
FoundryLocalManager = FoundryLocalManager.Instance;
// モデルを作成できるまで頑張る
while (!stoppingToken.IsCancellationRequested)
{
try
{
var eps = FoundryLocalManager.DiscoverEps();
if (eps.Length > 0)
{
await FoundryLocalManager.DownloadAndRegisterEpsAsync((epName, percent) =>
{
StateChanged?.Invoke($"{epName}をダウンロードしています。({percent:0.00}%)", percent, false);
});
}
StateChanged?.Invoke("カタログを取得しています。", null, false);
var catalog = await FoundryLocalManager.GetCatalogAsync();
StateChanged?.Invoke("モデルをダウンロードしています。", 0, false);
Model = await catalog.GetModelAsync(MODEL_NAME) ?? throw new Exception("Model not found");
await Model.DownloadAsync(progress =>
{
StateChanged?.Invoke($"{Model.Id}をダウンロードしています。({progress:0.00}%)", progress, false);
});
StateChanged?.Invoke("モデルをロードしています。", null, false);
await Model.LoadAsync();
StateChanged?.Invoke($"Webサービス({Configuration.Web.Urls})を起動しています。", null, false);
await FoundryLocalManager.StartWebServiceAsync();
StateChanged?.Invoke("チャットを取得しています。", null, false);
// OpenAIクライアントを作成
var endpoint = new Uri("http://127.0.0.1:52495/v1");
var openAiClient = new OpenAIClient(
new ApiKeyCredential("dummy"), // APIキーはダミーでOK
new OpenAIClientOptions
{
Endpoint = endpoint,
NetworkTimeout = TimeSpan.FromMinutes(60), // for Debug
RetryPolicy = ClientRetryPolicy.Default,
}
);
var chatClient = openAiClient.GetChatClient(MODEL_NAME);
// エントリーエージェントを作成
EntryAgent = chatClient.AsAIAgent(
name: "EntryAgent",
instructions: ReadResource("Kahin777.FoundryLocalApp.Instructions.EntryAgent.md"),
services: scope.ServiceProvider,
loggerFactory: loggerFactory
);
// チャットエージェントを作成
ChatAgent = chatClient.AsAIAgent(
name: "ChatAgent",
instructions: ReadResource("Kahin777.FoundryLocalApp.Instructions.ChatAgent.md"),
services: scope.ServiceProvider,
loggerFactory: loggerFactory);
// 永続化したセッションファイルがある場合は読み込む
if (File.Exists(SessionJson))
{
try
{
using var inStream = new FileStream(SessionJson, FileMode.Open);
var loadSession = await JsonDocument.ParseAsync(inStream);
AgentSession = await EntryAgent.DeserializeSessionAsync(loadSession!.RootElement);
}
catch (Exception ex)
{
logger.LogError(ex, ex.Message);
}
}
// 無ければセッションを作成
AgentSession ??= await EntryAgent.CreateSessionAsync();
// ベクターを生成するのに利用
StateChanged?.Invoke("ONNXをロードしています。", null, false);
OnnxSession = new InferenceSession(Path.Combine(AppContext.BaseDirectory, "e5_onnx/model.onnx"));
OnnxTokenizer = new Tokenizer(Path.Combine(AppContext.BaseDirectory, "e5_onnx/tokenizer.json"));
// ベクターストア
StateChanged?.Invoke("Qdrantをロードしています。", null, false);
var client = new QdrantClient("localhost", 6334);
var vectorStore = new QdrantVectorStore(client, ownsClient: true);
VectorCollection = vectorStore.GetCollection<ulong, VectorRecord>("rag_data");
StateChanged?.Invoke("初期化が完了しました。", null, true);
}
catch (Exception ex)
{
logger.LogError(ex, ex.Message);
}
if (Model != null
&& await Model.IsLoadedAsync()
&& EntryAgent != null
&& ChatAgent != null
&& AgentSession != null
&& OnnxTokenizer != null
&& VectorCollection != null) break;
}
try
{
// アプリが閉じるのを無限ループ
while (stoppingToken.IsCancellationRequested == false)
{
await Task.Delay(1000, stoppingToken);
}
}
catch (OperationCanceledException)
{
// NOOP
}
}
public async override Task StopAsync(CancellationToken cancellationToken)
{
try
{
// セッション情報を永続化
if (EntryAgent != null && AgentSession != null)
{
var serializeSession = await EntryAgent.SerializeSessionAsync(AgentSession);
using var outStream = new FileStream(SessionJson, FileMode.Create);
using Utf8JsonWriter writer = new(outStream);
serializeSession.WriteTo(writer);
writer.Flush();
}
// Webサービスを停止
if (FoundryLocalManager != null)
{
await FoundryLocalManager.StopWebServiceAsync();
}
// モデルをアンロード
if (Model != null)
{
await Model.UnloadAsync();
}
}
catch (Exception ex)
{
logger.LogError(ex, ex.Message);
}
await base.StopAsync(cancellationToken);
}
※TODO ダウンロード失敗時のリトライ処理(失敗しても途中から再開されるの助かる)、例外処理
チャットを投げる
AIAgentにツールを使わせたいのですが、
私の環境で動くモデルのエージェントに何もかもやらせるのは限界があるので、機能を分離しました
※本当はワークフローを使うべきなのでしょうが未勉...フローが単純なので良しとします
- エントリーエージェント
ユーザーからのメッセージを分析して何がしたいのかを解析する - ツール実行するエージェント(独自実装)
解析結果に応じてツールを呼び出して必要な情報を入手する
このサンプルでは、天気予報(場所は固定)を調べるツールと
ベクターストアの検索結果をRAGとして提供するツールを実装しています。 - チャットエージェント
情報をもとにユーザーへ回答する
[RelayCommand]
public async Task SendMessageAsync()
{
// ユーザーのメッセージが空の場合は何もしない
if (string.IsNullOrEmpty(UserMessage)) return;
// ユーザーのメッセージを表示
dispatcherQueueService.TryEnqueue(async () =>
{
ChatMessages.Add(new()
{
IsAgent = false,
Message = UserMessage
});
UserMessage = string.Empty;
});
// 試験的なので待機時間多め
using var stoppingToken = new CancellationTokenSource(TimeSpan.FromMinutes(60));
// エントリーエージェントにメッセージを送信
List<ChatMessage> forEntryAgentMessages = new()
{
// 現在時刻をお知らせ
new ChatMessage(ChatRole.System,
$"""
# Current datetime
{DateTime.Now.ToString("yyyy-MM-dd'T'HH:mm:sszzz")}
"""),
// ユーザーのメッセージ
new ChatMessage(ChatRole.User, UserMessage)
};
// エントリーエージェントのレスポンスを受け取る
var entryAgentResponse = await agentService.EntryAgent!.RunAsync(forEntryAgentMessages,
options: new AgentRunOptions()
{
ResponseFormat = ChatResponseFormat.ForJsonSchema<EntryAgentResponse>()
},
session: agentService.AgentSession,
cancellationToken: stoppingToken.Token);
// エントリーエージェントのレスポンスを解析
var entryResponseJson = EntryAgentResponse.Parse(entryAgentResponse.Text);
// デバッグ用にエントリーエージェントのレスポンスを表示
dispatcherQueueService.TryEnqueue(async () =>
{
ChatMessages.Add(new()
{
IsAgent = true,
Message = entryAgentResponse.Text
});
});
// エントリーエージェントのレスポンスに応じてチャットエージェントへのメッセージを作成
List<ChatMessage> forChatAgentMessages = [];
if (entryResponseJson.IntentCandidates.Count > 0)
{
foreach (var intent in entryResponseJson.IntentCandidates)
{
switch (intent.Name)
{
// 天気予報を検索
case "search_weather":
forChatAgentMessages.Add(
new ChatMessage(ChatRole.System,
$$"""
# Current time
{{DateTime.Now.ToString("yyyy-MM-dd'T'HH:mm:sszzz")}}
# weather Infomation
## Json Format
- date : yyyy/MM/dd
- weather_type : whter type (e.g., 晴れ, 曇り)
- max_tempature : max tempature (e.g., 23℃)
```json
[
{ "date": ["weather_type", "max_tempature"] },
{ "date": ["weather_type", "max_tempature"] }
]
```
## Context
```json
{{await AiAgentTools.GetWeather()}}
```
"""));
break;
// その他は、チャットエージェントに任せる
default:
// RAG (Retrieval-Augmented Generation) を提供する
var queryVector = VectorRecord.Embed(intent.Original, agentService.OnnxSession!, agentService.OnnxTokenizer!)!;
var results = agentService.VectorCollection!.SearchAsync<float[]>(queryVector, top: 3);
// TODO スコア次第で付加するかしないかを判断
var builder = new StringBuilder();
await foreach (var r in results)
{
builder.AppendLine($"## {r.Record.Title}");
builder.AppendLine($"Score: {r.Score}");
builder.AppendLine($"```");
builder.AppendLine(r.Record.Content);
builder.AppendLine($"```");
}
forChatAgentMessages.Add(
new ChatMessage(ChatRole.System,
$$"""
# Current datetime
{{DateTime.Now.ToString("yyyy-MM-dd'T'HH:mm:sszzz")}}
# RAG (Retrieval-Augmented Generation)
{{builder.ToString()}}
"""));
break;
}
}
}
forChatAgentMessages.Add(
new ChatMessage(ChatRole.User, entryResponseJson.Original));
// チャットエージェントにメッセージを送信
var chatAgentResponse = await agentService.ChatAgent!.RunAsync(forChatAgentMessages, session: agentService.AgentSession, cancellationToken: stoppingToken.Token);
// チャットエージェントのレスポンスを表示
dispatcherQueueService.TryEnqueue(async () =>
{
ChatMessages.Add(new()
{
IsAgent = true,
Message = chatAgentResponse.Text
});
});
}
※TODO 例外処理
LLM側で発生したエラー
C:\Users\kashin777\.{AppName}\logs\ 配下に、エジェーントのやりとりのログが出ています。
Vector Store
ローカルLLMを利用する場合、ベクターストアも時前で用意します。
Qdrantをベクターストアとして利用。
ベクターはONNX Runtimeで作成しました。
await agentService.VectorCollection.UpsertAsync(new VectorRecord()
{
Id = BitConverter.ToUInt64(Guid.NewGuid().ToByteArray(), 0),
PageId = page.id,
Title = page.title,
Content = text,
Vector = VectorRecord.UpsertEmbed(text, session, tokenizer) // text を 書き込み用のvectorに変換
});
