11 мин чтения

.NET и Azure для AI-систем: референсная архитектура

Как собрать production-ready AI-систему на .NET и Azure: async-пайплайн, RAG, Polly, observability и контроль стоимости — с рабочим кодом.

Виктор Бузин · Founder & Principal Engineer, AnetLab
16+ лет в enterprise-разработке: .NET, AI/LLM-системы, RAG, MCP. Автор материала и практик в студии.

Зачем нужна эта архитектура

AI-enabled системы в production — это не просто вызов LLM из контроллера. В реальной эксплуатации вам нужны identity, авторизация, асинхронное выполнение, retrieval, retry budgets, observability, управление стоимостью и защита данных. .NET и Azure хорошо подходят для такого сценария, потому что дают зрелую платформу, понятную облачную интеграцию и удобный путь к операционной готовности.

Эта reference architecture описывает практичный enterprise-подход: приложение на .NET принимает запрос, проверяет identity и policy, отправляет работу в асинхронный поток там, где это нужно, подмешивает контекст через retrieval, вызывает model endpoint, пишет telemetry и возвращает результат через наблюдаемый и защищённый pipeline.

Ключевой выбор в этой архитектуре — не модель, а граница между синхронным и асинхронным. Всё, что укладывается в 800–1500 мс, должно жить в HTTP-запросе; всё, что дольше, — в фоновой очереди с явным статусом и возможностью отмены. Ошибка в этом выборе стоит дороже всего остального: она либо убивает SLA, либо прячет в шуме HTTP-ошибок все реальные сбои.

Один из наших контуров — ассистент поверх корпоративной документации заказчика: документы разнородные, права различаются на уровне файла, а повторяющиеся вопросы одни и те же. Этот контур показал две вещи: где именно в .NET-стеке выгодно резать стоимость запроса и что нельзя класть в кэш без оглядки на права доступа. Ниже разбираем его на уровне кода.

Базовый .NET AI stack

Практический .NET AI stack обычно включает такие слои:

  • ASP.NET Core для API, auth, streaming и orchestration.
  • Microsoft.Extensions.AI и связанные абстракции, если нужен model-agnostic подход.
  • Semantic Kernel для planners, tool calling, prompt composition и более высокого уровня orchestration.
  • Azure OpenAI для chat, embeddings и function calling.
  • Azure AI Search или другой vector-capable retrieval layer для RAG.
  • Entity Framework Core или другие data access библиотеки для transactional state.
  • Azure Service Bus / Storage Queues для async workflows.
  • Azure Cache for Redis для горячего контекста и short-lived state.
  • OpenTelemetry с Application Insights / Azure Monitor для traces, metrics и logs.
  • Polly для retries, circuit breakers и timeout policies.
  • IHostedService / BackgroundService для фоновой обработки, indexing и long-running tasks.

Главная идея — не в количестве пакетов, а в разделении ответственности: request handling, orchestration, retrieval, generation и persistence должны быть разнесены по понятным границам.

Общий вид reference architecture

В production-ready .NET + Azure AI архитектуре обычно есть такие логические компоненты:

  1. Client layer: web app, mobile app или internal tool.
  2. API gateway / edge: Azure API Management или reverse proxy для auth, quotas и validation.
  3. .NET application layer: ASP.NET Core API, minimal APIs или worker services.
  4. Orchestration layer: prompt builder, policy engine, tool invocation и response shaping.
  5. Retrieval layer: Azure AI Search, vector indexes, SQL/NoSQL metadata, document chunk store.
  6. Model layer: Azure OpenAI или другой LLM endpoint, embeddings endpoint, moderation endpoint.
  7. Async processing layer: Service Bus, queues, Durable Functions или worker services.
  8. Observability layer: OpenTelemetry, logs, traces, metrics, dashboards, alerts.
  9. Governance and security layer: Managed Identity, Key Vault, content filters, DLP, RBAC, network isolation.

Ключевое правило: синхронные запросы должны быть короткими и предсказуемыми, а всё дорогое, долгое или нестабильное нужно выносить в asynchronous workflows.

Краткое описание диаграммы

Brief для reference architecture diagram:

  • Слева: пользователи и enterprise apps, которые обращаются к ASP.NET Core API через API Management.
  • В центре сверху: .NET orchestration service с prompt builder, policy checks, tool invocation и response formatter.
  • В центре: retrieval services, подключённые к Azure AI Search, document store и embeddings index.
  • В центре снизу: async worker services, которые читают сообщения из Service Bus для indexing, summarization и long-running tasks.
  • Справа: Azure OpenAI и вспомогательные model endpoints.
  • Нижний слой: telemetry pipeline в OpenTelemetry, Application Insights, Log Analytics, dashboards и alerts.
  • Боковые рельсы: Managed Identity, Key Vault, private endpoints, rate limits и cost budgets.

Диаграмма должна показывать сплошные стрелки для synchronous request/response и пунктирные стрелки для queue-based async processing.

Async architecture и поток выполнения

Mature AI system не должен блокировать HTTP request во время chunking, embedding, indexing или summarization больших документов. Вместо этого:

  • API принимает пользовательский запрос.
  • Выполняет identity, authorization, request size и tenant policy checks.
  • Пишет job record или command message.
  • Worker service обрабатывает retrieval и model interaction, если задача долгая.
  • API возвращает correlation ID или lightweight initial response.
  • Клиент polling, webhook или websocket получает финальный результат.

Такой подход снижает latency, делает поведение предсказуемым и ограничивает радиус отказа при проблемах модели.

Типовые сценарии для async:

  • document ingestion и chunking
  • generation of embeddings и reindexing
  • summarizing long conversations
  • batch classification и extraction
  • tool-call fan-out
  • human-in-the-loop review queues

Для production важно добавить idempotency keys и deduplication, иначе retries могут создавать дубликаты.

Resilience patterns

Поведение AI-сервисов отличается от обычных REST-вызовов: бывают timeouts, throttling, moderation denials, transient network errors и service overload. Поэтому resilience должна быть явной.

Рекомендуемые подходы:

  • Timeouts для каждого outbound model и retrieval call.
  • Retries with jitter только для transient failures.
  • Circuit breakers для деградирующих зависимостей.
  • Bulkheads для изоляции prompt generation, embeddings и search traffic.
  • Fallbacks к cache, smaller models или retrieval-only режиму.
  • Queue-based buffering для пиков.
  • Idempotency для защиты от повторных side effects.

Polly удобно использовать вместе с HttpClient pipeline, чтобы policies были единообразными.

Cost controls

Контроль стоимости — это архитектурная задача, а не постфактум-оптимизация. Расходы на AI растут из-за token volume, длинного context window, повторных retrieval и бесконечных loops.

Практические меры:

  • Token budgets per request.
  • Prompt trimming и сокращение истории.
  • Retrieval top-k limits.
  • Model tiering для простых задач.
  • Caching embeddings, retrieval results и deterministic outputs.
  • Batching offline summarization и embedding jobs.
  • Rate limiting по пользователю, tenant или feature.
  • Budget alerts на аномальный spend.

Сначала улучшайте качество context, а уже потом увеличивайте model size.

Кейс: семантический кэш по вектору запроса

В корпоративном контуре заказчика пользователи задают одни и те же вопросы десятки раз в день: по регламентам, по составу команды, по срокам. Кэш по точному совпадению строки на них не срабатывает — вопрос отличается формулировкой. Кэш по вектору запроса срабатывает: в контуре, где мы это внедряли, в кэш попадала 30–50% запросов — треть–половина обращений не доходила до генерации.

public sealed class SemanticCache(
    IEmbeddingService embedder,
    ISemanticCacheStore store,
    ILogger<SemanticCache> logger)
{
    // Порог подбирается на golden set, а не «на глаз»: при слишком низком
    // кэш начинает отдавать ответ на другой вопрос.
    private const double MatchThreshold = 0.94;
    private const int ProbeLimit = 5;

    public async Task<CachedAnswer?> TryGetAsync(
        string query, ClaimsPrincipal user, CancellationToken ct)
    {
        var scope = AccessScope.For(user);
        var embedding = await embedder.EmbedAsync(query, ct);

        var candidates = await store.SearchAsync(
            embedding,
            scope,                    // область доступа — обязательный фильтр
            limit: ProbeLimit,
            ct);

        var best = candidates.FirstOrDefault();
        if (best is null || best.Similarity < MatchThreshold) return null;

        logger.LogInformation(
            "semantic cache hit {Similarity:F3} for {User}",
            best.Similarity, user.Identity?.Name);

        return best.Answer;
    }
}

Три вещи, которые в такой реализации решают всё:

  • Область доступа входит в поиск, а не в проверку после. Ответ, собранный для другого набора прав, не должен находиться кандидатом даже при высокой схожести вопроса.
  • Порог подбирается на golden set. При слишком низком пороге кэш отдаёт ответ на похожий, но другой вопрос — и это выглядит как «модель галлюцинирует», хотя виноват кэш.
  • Совпадение фиксируется в логах. Без Similarity в телеметрии вы не увидите, какой реальной долей запросов кэш закрывает, и не заметите, что он перестал работать.

Экономия складывается не из одной настройки, а из того, что кэш снимает самый дорогой участок — генерацию с длинным контекстом. При этом embeddings кэшируются отдельно и всегда: они зависят только от текста запроса, а не от прав доступа.

Observability и token-aware logging

В AI-системах observability — это не только HTTP status. Нужно понимать, какая prompt version использовалась, какие retrieval sources попали в ответ, сколько token было потрачено, какие tools были вызваны и где возникла задержка.

Отслеживайте:

  • request ID / correlation ID
  • tenant и user context с учётом privacy
  • prompt template version
  • retrieval query и source IDs
  • model name и deployment
  • input/output token estimates
  • latency по этапам: auth, retrieval, generation, post-processing
  • retries, fallback mode, moderation outcome

Не логируйте secrets, полный prompt content и документы без необходимости. Используйте structured logging с redaction и sampling.

Кодовый пример: ILogger token logging

using Microsoft.Extensions.Logging;

public sealed class AiRequestLogger
{
    private readonly ILogger<AiRequestLogger> _logger;

    public AiRequestLogger(ILogger<AiRequestLogger> logger)
    {
        _logger = logger;
    }

    public void LogPromptSummary(string correlationId, string model, int inputTokens, int outputTokens, TimeSpan latency)
    {
        _logger.LogInformation(
            "AI request completed. CorrelationId={CorrelationId} Model={Model} InputTokens={InputTokens} OutputTokens={OutputTokens} LatencyMs={LatencyMs}",
            correlationId,
            model,
            inputTokens,
            outputTokens,
            latency.TotalMilliseconds);
    }
}

Такой logging помогает анализировать cost и performance без раскрытия prompt bodies.

RAG integration

Retrieval-Augmented Generation часто отделяет demo от реально полезной системы. RAG повышает factuality, потому что модель получает grounding на внутренних источниках: policies, manuals, tickets, knowledge base articles.

Типовой RAG flow в .NET:

  1. Ingest documents.
  2. Chunk по смысловым границам.
  3. Generate embeddings.
  4. Сохранить vectors и metadata в retrieval engine.
  5. На запросе нормализовать user query.
  6. Получить top-k кандидатов с фильтрами по tenant, language, freshness и permissions.
  7. Собрать grounded prompt с citations.
  8. Отправить в model.
  9. Вернуть answer со source references.

Важно:

  • хранить document-level и chunk-level metadata
  • использовать стабильный citation format
  • фильтровать по permissions до retrieval или сразу после него
  • при необходимости re-rank chunks
  • отделять offline indexing от online query handling

В RAG лучше работает хорошо отобранный context, чем длинный, но шумный prompt.

Decision matrix

Scenario Best pattern Why
Нужен быстрый ответ из одной базы знаний Synchronous RAG в ASP.NET Core Низкая latency и простой UX
Document ingestion или reindexing Async worker через queue Нет timeout в request path
Multi-step tool use между системами Orchestrated workflow Чёткие границы и observability
Частые дорогие prompts Cache + prompt versioning Снижает token spend
Tenant-specific policies и quotas API gateway + app policy checks Defense in depth
Массовая background summarization IHostedService или worker pool Эффективно и масштабируемо
Высокие требования к доступности Fallbacks + circuit breakers + async buffering Снижает blast radius

Кодовые примеры

1) Polly + HttpClient pipeline

using System.Net.Http.Headers;
using Microsoft.Extensions.DependencyInjection;
using Polly;
using Polly.Extensions.Http;

var services = new ServiceCollection();

services.AddHttpClient("AzureOpenAI", client =>
{
    client.BaseAddress = new Uri("https://your-resource.openai.azure.com/");
    client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
    client.Timeout = TimeSpan.FromSeconds(30);
})
.AddPolicyHandler(GetRetryPolicy())
.AddPolicyHandler(GetCircuitBreakerPolicy());

static IAsyncPolicy<HttpResponseMessage> GetRetryPolicy()
{
    return HttpPolicyExtensions
        .HandleTransientHttpError()
        .OrResult(msg => (int)msg.StatusCode == 429)
        .WaitAndRetryAsync(
            retryCount: 3,
            sleepDurationProvider: attempt => TimeSpan.FromMilliseconds(200 * Math.Pow(2, attempt))
                                       + TimeSpan.FromMilliseconds(Random.Shared.Next(0, 100)));
}

static IAsyncPolicy<HttpResponseMessage> GetCircuitBreakerPolicy()
{
    return HttpPolicyExtensions
        .HandleTransientHttpError()
        .OrResult(msg => (int)msg.StatusCode == 429)
        .CircuitBreakerAsync(5, TimeSpan.FromSeconds(30));
}

2) ILogger token logging

using Microsoft.Extensions.Logging;

public sealed class GenerationTelemetry
{
    private readonly ILogger<GenerationTelemetry> _logger;

    public GenerationTelemetry(ILogger<GenerationTelemetry> logger)
    {
        _logger = logger;
    }

    public void LogGeneration(string requestId, string deployment, int promptTokens, int completionTokens, bool usedRag)
    {
        _logger.LogInformation(
            "Generation telemetry RequestId={RequestId} Deployment={Deployment} PromptTokens={PromptTokens} CompletionTokens={CompletionTokens} UsedRag={UsedRag}",
            requestId,
            deployment,
            promptTokens,
            completionTokens,
            usedRag);
    }
}

3) IHostedService background processing

using System.Threading.Channels;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;

public record AiJob(string JobId, string Payload);

public sealed class AiJobQueue
{
    private readonly Channel<AiJob> _channel = Channel.CreateUnbounded<AiJob>();

    public ValueTask EnqueueAsync(AiJob job) => _channel.Writer.WriteAsync(job);
    public IAsyncEnumerable<AiJob> DequeueAllAsync(CancellationToken cancellationToken) => _channel.Reader.ReadAllAsync(cancellationToken);
}

public sealed class AiBackgroundWorker : BackgroundService
{
    private readonly AiJobQueue _queue;
    private readonly ILogger<AiBackgroundWorker> _logger;

    public AiBackgroundWorker(AiJobQueue queue, ILogger<AiBackgroundWorker> logger)
    {
        _queue = queue;
        _logger = logger;
    }

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        await foreach (var job in _queue.DequeueAllAsync(stoppingToken))
        {
            try
            {
                _logger.LogInformation("Processing AI job {JobId}", job.JobId);
                await ProcessJobAsync(job, stoppingToken);
            }
            catch (Exception ex)
            {
                _logger.LogError(ex, "AI job failed {JobId}", job.JobId);
            }
        }
    }

    private static Task ProcessJobAsync(AiJob job, CancellationToken cancellationToken)
    {
        // Chunk, retrieve, call model, persist results.
        return Task.CompletedTask;
    }
}

FAQ

.NET подходит для AI-enabled systems?

Да. .NET особенно силён там, где важны enterprise integration, security, observability и maintainable service architecture вокруг model calls.

Все ли LLM calls должны быть synchronous?

Нет. Быстрые user-facing calls можно оставить synchronous. Всё долгое, batch-oriented или failure-prone лучше перенести в async processing.

Нужен ли Semantic Kernel в каждом проекте?

Нет. Используйте его там, где его abstractions реально помогают orchestration, а не по умолчанию.

Как избежать роста стоимости prompts?

Ограничивайте token budgets, сокращайте историю, кэшируйте deterministic outputs и используйте cheaper models для простых задач.

Какая самая частая ошибка в RAG?

Индексировать слишком много low-quality content и возвращать слишком много chunks. RAG требует relevance и governance.

Как безопасно логировать AI requests?

Логируйте structured metadata, token counts, latency, model version и correlation IDs. Prompt bodies и secrets редактируйте.

CTA

Если вы проектируете AI-enabled platform на .NET и Azure, начните с границ: synchronous vs asynchronous, retrieval vs generation, online vs offline processing. Затем добавьте resilience, logging и cost controls до масштабирования функциональности. Такой порядок снижает риски и упрощает развитие архитектуры.

Если система уже работает и нужно понять, где именно течёт бюджет и где ломается качество, — напишите нам. Разберём архитектуру и отдадим план с приоритетами.

Читайте также

Заявка

Обсудить задачу

Оставьте имя и email — отвечу в течение рабочего дня. Если задача срочная, проще написать напрямую наproject@anetlab.ru.