Цель этой заметки - показать один из вариантов того, как прикрутить LLM к существующему ASP.NET API с минимальными трудозатратами (на базе Semantic Kernel).

Итак задача - имея в производстве классический ASP.NET API сервис на стеке Microsoft (разделённые (типа микро) сервисы, инфраструктура на Azure) добавить к нему еще один сервис для AI агента. Агент, как и прочие сервисы будет вызываться из клиента, в контексте авторизированного пользователя.

Постановка вопроса и общий принцип

Проект, с которым я работаю, можно отнести к категории «умный дом», где пользователь через сайт может создать инфраструктуру, указать, где и что стоит, связаться с реальными устройствами, настраивать их, и так далее Идея состоит в том, чтобы на сайт прикрутить «ИИ чат», дав пользователю альтернативный способ управления. «Добавь такой‑то контроллер на второй этаж», например. В общем всё то, что можно сделать через сайт, плюс новые возможности, там не предусмотренные, например: «Сравни конфигурации устройств по таким‑то параметрам». Для этого очевидно нужна какая‑то LLM с возможностью вызывать необходимый API. Получается примерно такая схема:

  • LLM вызывается через Open AI (Azure open AI)

  • Существующие API документируются и списком отправляются вместе с запросом

  • LLM исходя из контекста запроса, может вызывать функции из этого списка

Еще нужно держать где-то контекст чата. Добавляем хранилище и имеем почти стандартную диаграмму MCP сервера:

Общий принцип MCP сервера
Общий принцип 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. Выглядит ненамного проще, но на деле получилось чисто и аккуратно.

Semantic Kernel с автовызовом
Semantic 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

Комментарии (0)