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
BackgroundServiceem .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é:
-
PeriodicTimerem vez deTask.Delaysolto. 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 pelomax_tokenspedido, não pelo que vai ser gerado de fato. Ummax_tokensexagerado 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-mse 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
MaxOutputTokensao 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
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
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
FonteDeSinaispor 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 doIChatClientpara 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)