DEV Community

Cover image for Criando um agente de IA 24/7 para aplicações .NET
Henrique Mauri
Henrique Mauri

Posted on Originally published at henriquemauri.net

Criando um agente de IA 24/7 para aplicações .NET

São 3h12 da manhã. A API de pedidos começa a devolver erro 500, o health check fica vermelho e ninguém está olhando. Quando o time chega às 8h, o problema já virou reclamação de cliente, e a primeira hora do dia é gasta só para entender o que aconteceu.

Esse é o cenário em que um agente de IA de plantão faz sentido. Ele não substitui o time nem sai reiniciando servidor por conta própria. Ele faz a triagem: percebe o problema, investiga com as ferramentas que você liberou, junta as evidências, abre o incidente com um diagnóstico e, se for o caso, pede autorização para agir.

Neste artigo eu mostro como construir esse agente em .NET 10, rodando 24 horas por dia, usando o gpt-5.4-mini no Microsoft Foundry. E falo de um assunto que quase ninguém aborda até tomar o primeiro erro 429 em produção: as cotas do Foundry.

Agente 24/7 não é chat

Num chat, tem uma pessoa do outro lado. Se a resposta demorar, falhar ou sair estranha, ela percebe. Num agente que roda sozinho de madrugada, ninguém percebe nada. Isso muda as regras do jogo:

  • Custo: um loop que chama o modelo a cada minuto, 24 horas por dia, gera uma conta que ninguém aprovou.
  • Cota: o modelo tem limite de tokens e requisições por minuto. Quando estoura, o agente fica cego justamente quando mais precisa enxergar.
  • Segurança: um agente com permissão para agir, sem ninguém supervisionando, é um risco operacional.
  • Travamento: se o próprio agente travar, quem avisa?

O código que eu mostro aqui foi desenhado em cima desses quatro problemas.

A arquitetura

O código completo está no meu GitHub: hgmauri/sample-agente-24x7. São duas aplicações:

  • Loja.Api: a aplicação vigiada. Tem health check, um endpoint de pedidos e um jeito de simular a queda do banco.
  • Vigia.Agente: o agente. Um BackgroundService em .NET 10 que acorda a cada ciclo, coleta os sinais e só chama a IA quando existe algo para investigar.

O ciclo do agente é sempre o mesmo:

  • Coleta os sinais: health check e erros 5xx dos últimos 15 minutos.
  • Aplica as regras: está tudo bem? Esse problema já foi reportado? Ainda tem orçamento de tokens?
  • Se precisar, chama o modelo com as ferramentas liberadas.
  • O modelo investiga, abre o incidente e, se fizer sentido, pede um reinício, que fica aguardando aprovação humana.

O worker: um ciclo por vez, com tempo máximo

protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
    logger.LogInformation("Agente iniciado. Ciclo a cada {Intervalo}", vigia.Value.IntervaloDoCiclo);

    // PeriodicTimer não acumula ticks: se um ciclo atrasar, o próximo não roda em paralelo
    using var timer = new PeriodicTimer(vigia.Value.IntervaloDoCiclo);

    do
    {
        await ExecutarCicloAsync(stoppingToken);
    }
    while (await timer.WaitForNextTickAsync(stoppingToken));
}

private async Task ExecutarCicloAsync(CancellationToken stoppingToken)
{
    using var limite = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken);
    limite.CancelAfter(vigia.Value.TempoMaximoPorCiclo);

    try
    {
        var resultado = await AnalisarAsync(limite.Token);
        estado.Registrar(resultado.Descricao, usouIa: resultado.UsouIa);
    }
    catch (OperationCanceledException) when (!stoppingToken.IsCancellationRequested)
    {
        logger.LogWarning("Ciclo passou de {Limite} e foi cancelado", vigia.Value.TempoMaximoPorCiclo);
        estado.Registrar("Ciclo cancelado por tempo", falhou: true);
    }
    catch (Exception ex) when (ex is not OperationCanceledException)
    {
        // Um ciclo com erro nunca derruba o agente. Registra e segue para o próximo.
        logger.LogError(ex, "Falha no ciclo do agente");
        estado.Registrar($"Falha: {ex.GetType().Name}", falhou: true);
    }
}
Enter fullscreen mode Exit fullscreen mode

Três decisões que mantêm o agente de pé:

  • PeriodicTimer em vez de Task.Delay solto. Ele não acumula ticks. Se um ciclo atrasar, o próximo não roda em paralelo.
  • Tempo máximo por ciclo. Uma chamada pendurada no modelo ou na aplicação vigiada não pode travar o agente para sempre.
  • Um ciclo com erro nunca derruba o agente. Registra, conta a falha e segue para o próximo.

Regra primeiro, IA depois

Esse é o trecho que mais economiza dinheiro e cota. A IA só entra depois que as regras dizem que vale a pena:

private async Task<(string Descricao, bool UsouIa)> AnalisarAsync(CancellationToken ct)
{
    var opcoes = vigia.Value;
    var sinais = await fonte.ColetarAsync(opcoes.JanelaDeErrosEmMinutos, ct);

    // 1. Regra primeiro: com tudo saudável, nenhum token é gasto
    if (!sinais.PrecisaDeAtencao(opcoes.LimiteDeErros))
        return ("Aplicação saudável", false);

    // 2. Mesmo problema de 10 minutos atrás? Não abre outro incidente nem chama a IA de novo
    var assinatura = sinais.Assinatura();
    if (incidentes.HouveIncidenteRecente(assinatura, opcoes.IntervaloEntreIncidentes))
        return ("Problema já reportado, aguardando o time", false);

    // 3. Orçamento do dia esgotado: incidente só com os dados das regras
    if (!orcamento.TemSaldo)
    {
        await AbrirSemIaAsync(sinais, assinatura, "Orçamento diário de tokens esgotado", ct);
        return ("Incidente aberto sem IA (orçamento)", false);
    }

    // 4. Agora sim, a IA investiga usando as ferramentas
    var ferramentas = new FerramentasDoAgente(fonte, incidentes, aprovacoes, assinatura);
    List<ChatMessage> mensagens =
    [
        new(ChatRole.System, PromptDoAgente.Sistema),
        new(ChatRole.User, $"Sinais coletados agora ({DateTimeOffset.UtcNow:O}):\n{JsonSerializer.Serialize(sinais)}")
    ];

    try
    {
        var resposta = await chat.GetResponseAsync(mensagens, new ChatOptions
        {
            Tools = ferramentas.Todas(),
            MaxOutputTokens = foundry.Value.MaxTokensResposta
        }, ct);

        orcamento.Consumir(resposta.Usage);
        logger.LogInformation("Análise da IA: {Resumo} ({Tokens} tokens)", resposta.Text, resposta.Usage?.TotalTokenCount);

        // Garantia: se o modelo não abriu incidente, o agente abre
        if (!incidentes.HouveIncidenteRecente(assinatura, opcoes.IntervaloEntreIncidentes))
            await AbrirSemIaAsync(sinais, assinatura, "O modelo não abriu incidente", ct);

        return ("Incidente analisado pela IA", true);
    }
    // catch (CotaEsgotadaException): veja a seção de cotas
}
Enter fullscreen mode Exit fullscreen mode

Na prática, com a aplicação saudável, o agente roda o dia inteiro gastando zero tokens. Quando um problema aparece, a IA é chamada uma vez. Nos ciclos seguintes, a deduplicação segura novas chamadas até o intervalo configurado passar. E se o orçamento do dia acabar, o agente continua de plantão, abrindo incidente só com os dados das regras.

As ferramentas: o que o agente pode fazer

Aqui mora a segurança do agente. Eu separo as ferramentas em três níveis: leitura livre, escrita que só registra e ação com efeito colateral, que nunca é executada pela IA.

public IList<AITool> Todas() =>
[
    AIFunctionFactory.Create(VerificarSaudeAsync, "verificar_saude"),
    AIFunctionFactory.Create(ConsultarErrosRecentesAsync, "consultar_erros_recentes"),
    AIFunctionFactory.Create(AbrirIncidenteAsync, "abrir_incidente"),
    AIFunctionFactory.Create(SolicitarReinicio, "solicitar_reinicio"),
];

[Description("Abre um incidente para o time de plantão. Use uma única vez por análise.")]
public async Task<string> AbrirIncidenteAsync(
    [Description("Título curto e objetivo.")] string titulo,
    [Description("Severidade: SEV1 (fora do ar), SEV2 (degradado) ou SEV3 (atenção).")] string severidade,
    [Description("Diagnóstico baseado apenas nos dados consultados.")] string diagnostico,
    [Description("Próximo passo recomendado para o time.")] string acaoRecomendada,
    CancellationToken ct)
{
    var incidente = await incidentes.AbrirAsync(titulo, severidade, diagnostico, acaoRecomendada, assinaturaDoCiclo, geradoPorIa: true, ct);
    return $"Incidente {incidente.Id} aberto.";
}

[Description("Solicita o reinício da aplicação. Não reinicia: cria um pedido que precisa de aprovação humana.")]
public string SolicitarReinicio([Description("Motivo do reinício, com a evidência.")] string motivo)
{
    var solicitacao = aprovacoes.Solicitar("reiniciar-aplicacao", motivo);
    return $"Solicitação {solicitacao.Id} registrada. Aguardando aprovação humana.";
}
Enter fullscreen mode Exit fullscreen mode

Repare no solicitar_reinicio. A descrição diz claramente para o modelo que a ferramenta não reinicia nada. Ela cria um pedido que aparece em /aprovacoes, e só uma pessoa aprova. Em produção, eu levo essa aprovação para um card no Teams.

O registro do IChatClient monta o pipeline do Microsoft.Extensions.AI. O UseFunctionInvocation executa as ferramentas que o modelo pede, e o limite de voltas evita que o agente entre em loop gastando tokens:

services.AddChatClient(sp =>
    {
        var opcoes = sp.GetRequiredService<IOptions<FoundryOptions>>().Value;

        if (string.IsNullOrWhiteSpace(opcoes.Endpoint))
            return new ChatClientSimulado();

        var reserva = string.IsNullOrWhiteSpace(opcoes.EndpointReserva)
            ? null
            : FoundryChatClientFactory.Criar(opcoes.EndpointReserva, opcoes.DeploymentReserva);

        return new ChatClientResiliente(
            FoundryChatClientFactory.Criar(opcoes.Endpoint, opcoes.Deployment),
            reserva,
            FoundryChatClientFactory.ClassificarCota,
            sp.GetRequiredService<TimeProvider>(),
            sp.GetRequiredService<ILogger<ChatClientResiliente>>());
    })
    // Executa as ferramentas pedidas pelo modelo, com limite de voltas para não entrar em loop
    .UseFunctionInvocation(configure: f => f.MaximumIterationsPerRequest = 6)
    .UseLogging();
Enter fullscreen mode Exit fullscreen mode

O prompt do agente

public const string Sistema = """
    Você é o agente de plantão da aplicação Loja.Api, uma API .NET em produção.
    Você é acionado quando os sinais de saúde ou de erros passam do limite.

    Como trabalhar:
    1. Investigue com as ferramentas antes de concluir. Não suponha.
    2. Abra exatamente um incidente com diagnóstico objetivo e uma ação recomendada.
    3. Só peça reinício (solicitar_reinicio) se houver evidência de que ele resolve. Quem aprova é uma pessoa.
    4. Nunca invente dados, causas ou números. Se a evidência for insuficiente, diga isso no diagnóstico.
    5. Responda ao final com um resumo de no máximo três linhas.
    """;
Enter fullscreen mode Exit fullscreen mode

Prompt curto, com regras objetivas. Para agente autônomo, eu prefiro instruções que caibam em uma tela. Quanto maior o prompt, mais tokens por chamada e mais cota consumida.

Quem vigia o vigia?

Um agente 24/7 precisa de batimento cardíaco. O health check abaixo fica vermelho se o agente ficar três ciclos sem rodar, e a plataforma reinicia o container:

public sealed class AgenteVivoHealthCheck(EstadoDoAgente estado, IOptions<VigiaOptions> opcoes, TimeProvider relogio) : IHealthCheck
{
    public Task<HealthCheckResult> CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default)
    {
        // Três ciclos sem batimento: o agente travou e a plataforma deve reiniciar o container
        var limite = opcoes.Value.IntervaloDoCiclo * 3 + opcoes.Value.TempoMaximoPorCiclo;

        if (estado.UltimoCiclo is { } ultimo && relogio.GetUtcNow() - ultimo > limite)
            return Task.FromResult(HealthCheckResult.Unhealthy($"Sem ciclo desde {ultimo:O}"));

        return Task.FromResult(HealthCheckResult.Healthy(estado.UltimoResultado));
    }
}
Enter fullscreen mode Exit fullscreen mode

E, por padrão, uma exceção não tratada em um BackgroundService derruba o processo inteiro. Para o agente, eu prefiro que o processo continue e o batimento decida:

// Um erro em um serviço em segundo plano não deve derrubar o processo inteiro
builder.Services.Configure<HostOptions>(o =>
{
    o.BackgroundServiceExceptionBehavior = BackgroundServiceExceptionBehavior.Ignore;
    o.ShutdownTimeout = TimeSpan.FromSeconds(30);
});
Enter fullscreen mode Exit fullscreen mode

Cotas do Microsoft Foundry: o que ninguém conta

Agora a parte que mais gera dor de cabeça. Todo deployment no Foundry tem cota, e quando ela estoura o modelo responde HTTP 429 (Too Many Requests). Entender como essa cota funciona evita metade dos problemas.

Como a cota funciona

  • A cota é medida em TPM (tokens por minuto) e definida por assinatura, por região, por modelo e por tipo de deployment. Você distribui esse total entre os seus deployments.
  • O RPM vem junto. Cada unidade de cota carrega uma proporção fixa de requisições por minuto, e o limite de requisições é avaliado em janelas curtas, de 1 ou 10 segundos. Dez chamadas no mesmo segundo podem tomar 429 mesmo com o minuto longe do limite.
  • A estimativa usa o max_tokens. Quando a requisição chega, o Foundry estima o consumo pelo tamanho do prompt e pelo max_tokens pedido, não pelo que vai ser gerado de fato. Um max_tokens exagerado consome cota que você não usa.
  • Requisição que falhou também conta. Retry sem espera piora o throttling.
  • Os valores padrão variam por modelo e por nível da assinatura. Na data deste artigo, a documentação lista para o gpt-5.4-mini em Global Standard, no Tier 1, 1.000.000 TPM e 1.000 RPM. Confira sempre o valor atual da sua assinatura no portal.

Os dois tipos de 429

  • "Rate limit is exceeded": você passou da sua cota. A solução é reduzir consumo, redistribuir ou aumentar cota.
  • "System is experiencing high demand": a capacidade da região está sob pressão, mesmo com a sua cota sobrando. Esperar o retry-after-ms e ter um plano B em outra região resolve.

A resposta traz cabeçalhos que ajudam: retry-after-ms (quanto esperar), x-ratelimit-remaining-tokens e x-ratelimit-remaining-requests (quanto ainda sobra). Dá para usar esses dois últimos para desacelerar antes de tomar o erro.

O SDK já tenta de novo, mas não resolve tudo

O SDK da OpenAI para .NET já faz retry com backoff exponencial e respeita o retry-after. O padrão são 3 tentativas, e dá para ajustar:

    options: new OpenAIClientOptions
    {
        Endpoint = new Uri(new Uri(endpoint), "openai/v1/"),
        // O SDK já tenta de novo com backoff e respeita o retry-after. 3 é o padrão.
        RetryPolicy = new ClientRetryPolicy(maxRetries: 3)
    });
Enter fullscreen mode Exit fullscreen mode

Só que retry não cria cota. Se o deployment está esgotado por alguns minutos, o agente precisa de um plano B. No exemplo, o ChatClientResiliente faz isso:

public async Task<ChatResponse> GetResponseAsync(
    IEnumerable<ChatMessage> messages, ChatOptions? options = null, CancellationToken cancellationToken = default)
{
    var agora = relogio.GetUtcNow();
    Exception? ultimaFalha = null;
    TimeSpan espera = TimeSpan.FromMinutes(1);

    if (agora >= _principalBloqueadoAte)
    {
        try
        {
            return await principal.GetResponseAsync(messages, options, cancellationToken);
        }
        catch (Exception ex) when (classificarCota(ex) is { } tempo)
        {
            _principalBloqueadoAte = agora + tempo;
            (ultimaFalha, espera) = (ex, tempo);
            logger.LogWarning("Deployment principal com cota esgotada. Bloqueado por {Tempo}", tempo);
        }
    }

    if (reserva is not null && agora >= _reservaBloqueadaAte)
    {
        try
        {
            logger.LogInformation("Usando deployment reserva");
            return await reserva.GetResponseAsync(messages, options, cancellationToken);
        }
        catch (Exception ex) when (classificarCota(ex) is { } tempo)
        {
            _reservaBloqueadaAte = agora + tempo;
            (ultimaFalha, espera) = (ex, TimeSpan.FromTicks(Math.Min(tempo.Ticks, espera.Ticks)));
        }
    }

    throw new CotaEsgotadaException(espera, ultimaFalha!);
}
Enter fullscreen mode Exit fullscreen mode

E é ele que lê o tempo de espera que o Foundry mandou:

public static TimeSpan? ClassificarCota(Exception ex)
{
    if (ex is not ClientResultException { Status: 429 } erro)
        return null;

    var cabecalhos = erro.GetRawResponse()?.Headers;

    if (cabecalhos?.TryGetValue("retry-after-ms", out var ms) == true && double.TryParse(ms, out var milissegundos))
        return TimeSpan.FromMilliseconds(milissegundos);

    if (cabecalhos?.TryGetValue("retry-after", out var s) == true && double.TryParse(s, out var segundos))
        return TimeSpan.FromSeconds(segundos);

    return TimeSpan.FromMinutes(1);
}
Enter fullscreen mode Exit fullscreen mode

Se principal e reserva estiverem esgotados, o agente não fica cego. Ele cai em modo degradado e continua de plantão sem IA:

catch (CotaEsgotadaException ex)
{
    // Cota do Foundry estourou em todos os deployments: o plantão continua sem IA
    logger.LogWarning("Cota do Foundry esgotada. Nova tentativa após {Espera}", ex.TentarApos);
    await AbrirSemIaAsync(sinais, assinatura, $"Cota do Foundry esgotada (429). IA indisponível por {ex.TentarApos}", ct);
    return ("Incidente aberto sem IA (cota)", false);
}
Enter fullscreen mode Exit fullscreen mode

Dicas para não sofrer com cota

  • Chame a IA menos vezes. No agente 24/7, a regra antes da IA é a principal economia. O melhor token é o que você não gasta.
  • Ajuste o MaxOutputTokens ao que você precisa. Se a resposta tem 300 tokens, não reserve 4.000. Em modelos de raciocínio, deixe margem para o raciocínio, mas sem exagero.
  • Enxugue o prompt. Mande para o modelo só os sinais agregados, não o log inteiro. Prompt menor é menos TPM.
  • Evite picos. Vários agentes acordando no mesmo segundo estouram o RPM. Espalhe os horários dos ciclos.
  • Prefira Global Standard. Ele roteia a requisição para onde há capacidade e costuma ter cota padrão maior. Se houver exigência de residência de dados, use Data Zone.
  • Tenha um deployment reserva em outra região. Como a cota é por região, um segundo deployment em outra região dobra o fôlego e cobre o 429 de alta demanda.
  • Redistribua antes de pedir mais. No portal do Foundry, em Gerenciar > Cota, dá para tirar TPM de um deployment ocioso e passar para o que precisa. Para ver a cota da assinatura, o usuário precisa da role Cognitive Services Usages Reader no escopo da assinatura.
  • Peça aumento com histórico. O pedido é feito no mesmo lugar, em Solicitar cota. A Microsoft prioriza quem já usa bem a cota atual, então peça com o consumo na mão.
  • Para produção crítica, avalie PTU. Provisioned Throughput reserva capacidade dedicada, com latência previsível. E o recurso de spillover manda o excedente do PTU para um deployment Standard.
  • O que não é tempo real vai para batch. Relatórios e análises noturnas não precisam disputar cota com o agente de plantão.
  • Monitore. Acompanhe consumo e 429 no Azure Monitor e coloque alerta. Cota é capacidade de produção, trate como trata CPU e memória.
  • Cuidado ao apagar recurso pela API. Recurso excluído por API ou CLI segura a cota por 48 horas até ser expurgado. Se for recriar, faça o purge.

Rodando localmente

Sem o Foundry configurado, o agente usa um cliente simulado que chama as ferramentas de verdade. Dá para ver o ciclo completo sem Azure e sem custo:

# terminal 1: a aplicação vigiada
dotnet run --project src/Loja.Api

# terminal 2: o agente, com ciclo de 10 segundos para a demo
dotnet run --project src/Vigia.Agente -- --Vigia:IntervaloDoCiclo=00:00:10

# terminal 3: derruba o banco e gera erros
curl -X POST http://localhost:5100/simular/falha
for i in $(seq 1 8); do curl -s http://localhost:5100/pedidos/$i > /dev/null; done

# o que o agente fez
curl http://localhost:5200/incidentes
curl http://localhost:5200/status
Enter fullscreen mode Exit fullscreen mode

Para usar o modelo de verdade, configure Foundry:Endpoint (e, se quiser, Foundry:EndpointReserva) com user secrets e faça az login. O agente autentica com Entra ID, sem chave.

Publicando para rodar 24/7

Para esse tipo de carga, eu uso o Azure Container Apps. O repositório traz um Dockerfile e o script infra/deploy-containerapps.sh. Os pontos que importam:

  • --min-replicas 1 --max-replicas 1. Mínimo 1 para nunca escalar para zero. Máximo 1 para nunca rodar duplicado: dois agentes abririam incidentes em dobro. Se precisar de alta disponibilidade, use eleição de líder (por exemplo, lease em um blob).
  • Managed Identity. A identidade do container recebe a role Cognitive Services OpenAI User no recurso do Foundry. Nenhum segredo no container.
  • Liveness probe no /health. É o batimento do agente. Se ele travar, o Container Apps reinicia.
probes:
  - type: Liveness
    httpGet:
      path: /health
      port: 8080
    periodSeconds: 30
    failureThreshold: 3
Enter fullscreen mode Exit fullscreen mode

Evite hospedar o agente em plano que hiberna (Functions no plano de consumo ou App Service sem Always On). Agente de plantão que dorme não está de plantão.

Antes de colocar em produção

  • Sinais reais: troque a FonteDeSinais por consultas ao Application Insights ou Log Analytics.
  • Incidentes onde o time já trabalha: Azure DevOps, Jira ou ServiceNow, com notificação no Teams.
  • Aprovação com identidade: o endpoint de aprovação precisa de autenticação, ou a aprovação vai para um card no Teams.
  • Telemetria: adicione UseOpenTelemetry() no pipeline do IChatClient para ver tokens, latência e chamadas de ferramenta por ciclo.
  • Persistência: incidentes, aprovações e consumo de tokens em banco, para sobreviver a reinício.

Conclusão

O que faz um agente 24/7 dar certo não é o modelo. É a engenharia em volta dele: regra antes da IA, ferramentas com permissão bem definida, humano aprovando o que é irreversível, teto de gasto e um plano para quando a cota acabar.

E tem um pré-requisito que eu repito sempre: o agente só é tão bom quanto os sinais que ele lê. Sem health check decente e sem log estruturado, não existe IA que faça diagnóstico. Primeiro os dados e as APIs, depois a IA. Se as suas regras de negócio já estão expostas como servidor MCP, elas viram ferramentas desse agente sem esforço, e o mesmo Foundry que atende o seu chat com React e .NET atende o plantão.

O código completo está em github.com/hgmauri/sample-agente-24x7. Roda no modo simulado, derruba o banco e vê o agente trabalhar.

Top comments (0)