Цель этой заметки - показать один из вариантов того, как прикрутить LLM к существующему ASP.NET API с минимальными трудозатратами (на базе Semantic Kernel).
Итак задача - имея в производстве классический ASP.NET API сервис на стеке Microsoft (разделённые (типа микро) сервисы, инфраструктура на Azure) добавить к нему еще один сервис для AI агента. Агент, как и прочие сервисы будет вызываться из клиента, в контексте авторизированного пользователя.
Постановка вопроса и общий принцип
Проект, с которым я работаю, можно отнести к категории «умный дом», где пользователь через сайт может создать инфраструктуру, указать, где и что стоит, связаться с реальными устройствами, настраивать их, и так далее Идея состоит в том, чтобы на сайт прикрутить «ИИ чат», дав пользователю альтернативный способ управления. «Добавь такой‑то контроллер на второй этаж», например. В общем всё то, что можно сделать через сайт, плюс новые возможности, там не предусмотренные, например: «Сравни конфигурации устройств по таким‑то параметрам». Для этого очевидно нужна какая‑то LLM с возможностью вызывать необходимый API. Получается примерно такая схема:
LLM вызывается через Open AI (Azure open AI)
Существующие API документируются и списком отправляются вместе с запросом
LLM исходя из контекста запроса, может вызывать функции из этого списка
Еще нужно держать где-то контекст чата. Добавляем хранилище и имеем почти стандартную диаграмму MCP сервера:

тут эта диаграмма в разметке mermaid
mermaid
sequenceDiagram
participant FE as Frontend
participant API as AiAgent API<br/>(MCP Client)
participant MCP as MCP Servers
participant AOAI as Azure OpenAI
participant APIs as Microservices
participant DB as Database
FE->>API: POST /chats {message}
API->>DB: Load history
API->>MCP: Discover all tools
MCP-->>API: All available tools
API->>AOAI: Chat + all tools
AOAI-->>API: tool_calls
API->>MCP: Execute tool
MCP->>APIs: API call
APIs-->>MCP: Result
MCP-->>API: Tool result
API->>AOAI: Continue with result
AOAI-->>API: Final response
API->>DB: Save conversation
API-->>FE: ResponseПрототип заработал, но получился громоздким и негибким, особенно в плане оркестрации. Лучшей альтернативой оказался Semantic Kernel, "официальный SDK" для ИИ интеграции. Он и оркестрацию на себя берёт и описание API может брать просто из ссылки на swagger.json ! То есть мой ИИ агент будет всегда иметь актуальную информацию и всё, что мне нужно реализовать — это тонкую прослойку для формирования этого самого kernel. Выглядит ненамного проще, но на деле получилось чисто и аккуратно.

диаграмма в разметке mermaid
mermaid
sequenceDiagram
participant FE as Frontend
participant API as AiAgent API
participant KF as Semantic Kernel
participant AOAI2 as Azure OpenAI<br/>(Auto Function Calls)
participant APIs as Microservices
participant DB as Database
FE->>API: POST /chats {message}
API->>DB: Load history
API->>KF: Chat + Swagger.json files
KF->>AOAI2: Chat + function list
AOAI2-->>KF: Required functions
KF->>APIs: Function calls
APIs-->>KF: Results
KF->>AOAI2: Function Results
AOAI2-->>API: Final response
API->>DB: Save conversation
API-->>FE: ResponseТеперь немного кода
Сперва выбираем LLM модель. Что-то небольшое, но не слишком старое, чтоб была поддержка структурированного вывода. Я взял gpt-4.1-mini. В портале Azure создаем Microsoft Foundry | Azure OpenAI ресурс и публикуем выбранную модель. Из деплоймента нужен ключ, адрес и имя.
Добавляем новый ASP.NET API проект, и в числе прочего добавляем необходимые материалы в pipeline в Program.cs
// там лежат OpenAi Endpoint, ApiKey, DeploymentId и SystemPrompt
builder.Services.AddOptions<AiAgentOptions>().Bind(builder.Configuration.GetSection("AiAgent"));
// обычный EF core db context, для истории чата
builder.Services.AddScoped<IChatSessionRepository, ChatSessionRepository>();
// в IKernelFactory один метод CreateForApisAsync(IEnumerable<string> apiNames, Guid sessionId),
// выдающий легковесный kernel
builder.Services.AddScoped<IKernelFactory, KernelFactory>();
// вспомогательный агент, IApiClassifier содержит один метод ClassifyAsync(string userMessage),
// для фильтрации функций API
builder.Services.AddScoped<IApiClassifier, ApiClassifier>();
// дирижер этого балагана, содержит один метод ExecuteTurnAsync(Guid sessionId, string userMessage)
builder.Services.AddScoped<IChatOrchestrator, ChatOrchestrator>();
Чтобы агент мог поддерживать беседу, нужно передавать весь чат с каждым запросом. Для этого на обоих концах держим SessionId, создаваемый в начале беседы. Когда приходит новое сообщение, по SessionId загружаем историю чата и закидываем в соответствующие коллекции в хронологическом порядке.
internal class ChatOrchestrator : IChatOrchestrator
{
...
public async Task<string> ExecuteTurnAsync(Guid sessionId, string userMessage, CancellationToken cancellationToken)
{
...
//самый обычный EF db context
var conversationMessages = await m_Repository.GetLastMessagesAsync(sessionId, cancellationToken);
//API так много, что всё не влазит и нужно ограничевать количество функций
var apiNames = await m_ApiClassifier.ClassifyAsync(userMessage, cancellationToken);
//создаём легковесный kernel
var kernel = await m_KernelFactory.CreateForApisAsync(apiNames, sessionId, cancellationToken);
var chatHistory = new ChatHistory();
// В системном сообщении объясняются задачи и ограничения ассистента,
// достаточно подробно и с примерами.
chatHistory.AddSystemMessage(m_Options.SystemPrompt);
foreach (var msg in conversationMessages.OrderBy(m => m.CreatedUtc))
{
if (msg.Role.Equals("user"))
{
chatHistory.AddUserMessage(msg.Content);
}
else if (msg.Role.Equals("assistant"))
{
chatHistory.AddAssistantMessage(msg.Content);
}
}
// Добовляем само сообщение
chatHistory.AddUserMessage(userMessage);
// Получаем ответ
var responseRaw = await InvokeWithAutomaticToolLoopAsync(kernel, chatService, chatHistory, sessionId, cancellationToken);
В строке 12 выше вызывается m_ApiClassifier.ClassifyAsync . Это не не имеет прямого отношения в Semantic Kernel, просто если в API больше чем 128 функций, то их приходится как-то ограничить и фильтровать. Дело в том, что у Open AI стоит жёсткий лимит на количество "tools". Как вариант обойти ограничение, можно попросить LLM выбрать только необходимые инструменты, исходя из их описания. В этом случает на каждый пользовательский вопрос приходится два вызова ИИ ассистента. (первый - исходя из семантики вопроса выбрать необходимые функции).
В строке 15 m_KernelFactory.CreateForApisAsync создаёт kernel на основе ключа и адреса LLM ассистента.
public Task<Kernel> CreateForApisAsync(...)
{
var builder = Kernel.CreateBuilder();
builder.AddAzureOpenAIChatCompletion(
deploymentName: m_Options.AzureOpenAiDeploymentId,
endpoint: m_Options.AzureOpenAiEndpoint,
apiKey: m_Options.AzureOpenAiApiKey);
var kernel = builder.Build();
...
Там же загружаются необходимые инструменты с указанием callback для авторизации
...
var executionParameters = new OpenApiFunctionExecutionParameters(httpClient)
{
// в authCallback прокидивыются нужные authentication headers,
// authCallback вызывается на каждую функцию
AuthCallback = authCallback
};
// для каждого сервиса, который, согласно ApiClassifier, может потребоваться
// для ответа на вопрос, указываем swagger документ с описанием функций и DTO
await kernel.ImportPluginFromOpenApiAsync(
pluginName: api.Name,
uri: new Uri(api.SwaggerUrl),
executionParameters: executionParameters,
cancellationToken: cancellationToken);
...
Первый листинг завершается вызовом InvokeWithAutomaticToolLoopAsync, на строке 38 вот важные моменты оттуда:
private async Task<string> InvokeWithAutomaticToolLoopAsync(Kernel kernel ...
{
//основные параметры для ИИ ассистента.
//Вызов API можно контролировать вручную с ToolCallBehavior = ToolCallBehavior.EnableKernelFunctions,
//с ToolCallBehavior.AutoInvokeKernelFunctions Semantic Kernel вызывает API сам.
//Temperature чем ниже, тем меньше LLM фантазирует.
//ResponseFormat можно задать в JSON схеме (если выбранная LLM поддерживает)
var executionSettings = new OpenAIPromptExecutionSettings
{
ToolCallBehavior = ToolCallBehavior.AutoInvokeKernelFunctions,
Temperature = 0.3,
ResponseFormat = null,
MaxTokens = 5000
};
//передаём полную историю чата, настройки и получаем ответ
ChatMessageContent result = await chatService.GetChatMessageContentAsync(
chatHistory,
executionSettings: executionSettings,
kernel: kernel,
cancellationToken: cancellationToken);
Как вывод, можно сказать:
Если Open API документация прописана хорошо, то агент определяет, что и как вызвать, прямо таки с пугающей точностью. Правда, стоит отметить, что системное сообщение нужно прописывать весьма детально, начиная с описания системы, что она такое и зачем. Что пользователь может делать, что нет, как понимать DTO и другие нюансы.
Были, конечно, сугубо специфичные для нашей системы проблемы. И разумеется, чтоб внедрить этот чат в продукт, придется решить вопросы по безопасности и с большой вероятностью адаптировать API. Но в общем и целом, создать свой "co-pilot" оказалось намного проще, чем я думал. Удивило и то, как тонко и гибко можно настроить, контролировать и логировать вызываемые функции. Да, они вызываются автоматически, но при необходимости можно настроить http request перед вызовом API и контролировать контекст вызова через IFunctionInvocationFilter