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); } } 

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 } 

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."; } 

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(); 

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. """; 

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)); } } 

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); }); 

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) }); 

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!); } 

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); } 

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); } 

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 agr...