Цель этой заметки - показать один из вариантов того, как прикрутить 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
