One Door to the Models, Parte 4: Trabalho Assíncrono Fora do Caminho da Requisição
Inferência em batch é uma segunda porta: arquivo entra, job roda, arquivo sai, sem policy de gateway no caminho. A Parte 4 mantém a contabilidade honesta.

O caminho de inferência em batch do Azure não se parece em nada com uma chamada de API. Você faz upload de um arquivo JSONL, cria um job e coleta um arquivo de saída até 24 horas depois, o que significa que não existe uma transação por requisição para uma policy de gateway ficar na frente. Toda garantia que a Parte 3 construiu, limites de tokens, medição por tenant, roteamento, circuit breaking, se aplica a requisições que passam pelo API Management. O batch não tem nenhuma delas por construção. Esta parte move o trabalho que nunca deveria ter estado no caminho da requisição, e lida com o fato de que fazer isso abre uma segunda porta para os modelos.
O que pertence fora do caminho da requisição
As cinco aplicações da empresa produzem três tipos de trabalho que estão no caminho síncrono apenas porque esse era o lugar mais fácil de colocá-los. O teste não é "isso é lento", é quem está esperando.
- Sumarização noturna de tickets de suporte. Dezenas de milhares de itens, nenhum humano esperando, resultados necessários para o relatório da manhã. Um candidato perfeito para batch que hoje compete pela mesma quota de TPM que um cliente no meio de uma conversa.
- Ingestão de documentos para a busca de conhecimento do varejo. Em rajadas, disparada por uploads, tolerante a minutos. Uma queue, não um batch job: latência em minutos importa, latência em horas não funciona, porque alguém fez upload de um documento esperando encontrá-lo.
- Re-scoring depois de uma mudança de prompt. Roda contra um corpus, sem usuário algum, e é cancelado no meio com mais frequência do que chega ao fim. Batch, com uma história explícita de cancelamento.
Três formatos, e eles mapeiam para exatamente três mecanismos: uma queue com workers para minutos, um batch job para horas, e o gateway síncrono para tudo o que um humano está observando. Misturá-los é como um job de marketing derruba um assistente de atendimento ao cliente, que é o incidente da Parte 1.
A queue, e o que não colocar nela
O Azure Service Bus carrega o trabalho na escala de minutos. As decisões de design que importam são todas sobre o que a mensagem contém, não sobre qual broker é.
Não coloque o prompt na mensagem. O Service Bus limita cada propriedade individual de mensagem a 32 KB e o header cumulativo, propriedades de usuário mais propriedades de sistema, a 64 KB, e exceder isso levanta uma exceção de serialização em vez de truncar em silêncio. Mesmo dentro do body, um payload acima de 1 MB é contado duas vezes contra a quota de tamanho da entidade. O padrão claim-check é a resposta: o documento vai para o Blob Storage, e a mensagem carrega uma referência de blob, um tenant ID, um alias de modelo e um correlation ID. A mensagem fica pequena, a profundidade da queue continua sendo uma métrica com significado, e o payload já está onde o worker quer fazer streaming dele.
Mais duas restrições moldam o pool de workers. Uma única queue, topic ou subscription aceita 5.000 receive requests concorrentes antes de rejeitar receives adicionais com um erro de server busy, o que é um teto sobre a contagem de receivers e não sobre o throughput, e ele é atingido mais rápido do que as pessoas esperam por um worker que abre um receiver por tarefa. E um namespace permite 5.000 conexões AMQP concorrentes, então connection pooling no worker não é uma otimização, é um requisito em escala.
O dead-lettering é onde uma queue de LLM difere de uma comum. Uma mensagem que falha porque o modelo retornou um bloqueio de content filter não é o mesmo que uma que falhou porque o worker caiu, e apenas a segunda deveria ser retentada. O worker completa a mensagem e registra a recusa como resultado quando o modelo responde com uma recusa, e a abandona apenas em falhas de infraestrutura. Caso contrário, o delivery count sobe até o limite em uma mensagem que nunca vai ter sucesso, e a dead-letter queue enche de itens que ninguém consegue distinguir de falhas reais.
Escalando os workers com KEDA
Os workers vivem no cluster AKS que a Parte 2 provisionou, e eles não deveriam rodar quando a queue está vazia. O KEDA está disponível como add-on do AKS e escala workloads até zero, controlando ScaledObject para deployments e ScaledJob para trabalho em formato de job, com autenticação desacoplada do workload por meio do Microsoft Entra Workload ID.
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
name: ingest-worker
spec:
scaleTargetRef:
name: ingest-worker
minReplicaCount: 0
maxReplicaCount: 20
pollingInterval: 15
cooldownPeriod: 120
triggers:
- type: azure-servicebus
metadata:
queueName: doc-ingest
messageCount: "20" # target backlog per replica
authenticationRef:
name: keda-workload-identity
Três limitações do add-on decidem se isso funciona na primeira tentativa ou na terceira. Não emparelhe um ScaledObject com um Horizontal Pod Autoscaler no mesmo workload. O KEDA usa um HPA por baixo, então os dois competem: se o HPA existe primeiro, o ScaledObject falha ao ser criado, e se o ScaledObject existe primeiro, o HPA é criado mesmo assim e o comportamento de scaling fica estranho. Apenas um external metric server é permitido por cluster, então o add-on do KEDA precisa ser o único e múltiplas instalações de KEDA não são suportadas, o que exclui um time instalando a sua própria via Helm ao lado da instalação da plataforma. E no AKS Standard, habilite workload identity antes de habilitar o add-on do KEDA; feito na ordem errada, os pods do operador do KEDA precisam de um restart para pegar o ambiente certo.
Um default útil: escale por backlog por réplica em vez de por profundidade absoluta da queue, e defina maxReplicaCount a partir da quota do modelo, não da capacidade do cluster. Vinte workers recebendo 429 cada um são piores do que cinco que não recebem, e a queue não se importa com quanto tempo uma mensagem espera.
O caminho de batch, e por que ele é uma segunda porta
O trabalho na escala de horas vai para um model deployment Global-Batch, e o formato dessa API é o ponto desta parte. Não há requisição para interceptar. Um arquivo é enviado, um job é criado, e um arquivo de saída aparece quando o job termina. A mecânica é específica o suficiente para valer a pena declarar com precisão.
A entrada é JSONL, um objeto de requisição por linha, e cada linha carrega um custom_id:
{"custom_id": "ticket-88412", "method": "POST", "url": "/v1/chat/completions", "body": {"model": "batch-summarize", "messages": [{"role": "system", "content": "Summarize the ticket in two sentences."}, {"role": "user", "content": "..."}]}}
{"custom_id": "ticket-88413", "method": "POST", "url": "/v1/chat/completions", "body": {"model": "batch-summarize", "messages": [{"role": "system", "content": "Summarize the ticket in two sentences."}, {"role": "user", "content": "..."}]}}
As respostas não são retornadas na ordem que o arquivo definiu, e é por isso que o custom_id é obrigatório e não uma conveniência: ele é a única forma de juntar uma resposta de volta à sua entrada. O atributo model deve nomear o deployment Global Batch, e o mesmo nome de deployment deve aparecer em todas as linhas. Mirar um segundo deployment significa um segundo arquivo e um segundo job, o que torna "rotear este batch para o modelo que estiver mais barato hoje" uma decisão do momento da submissão, não de roteamento. A orientação da própria Microsoft é submeter arquivos grandes em vez de muitos pequenos.
batch = client.batches.create(
input_file_id=file_id,
endpoint="/chat/completions",
completion_window="24h",
# 1209600 to 2592000 seconds, 14 to 30 days, before the output file expires
extra_body={"output_expires_after": {"seconds": 1209600, "anchor": "created_at"}},
)
O job então passa por validating, in_progress, finalizing e completed, e carrega um expires_at de 24 horas após a criação, junto de um request_counts corrente de completados, falhados e total. A janela de conclusão é de 24 horas, e um job que não termina dentro dela expira em vez de continuar. Os arquivos de saída também expiram, em uma janela que pode ser definida entre 14 e 30 dias, então um pipeline que assume que os resultados ainda estarão lá no próximo trimestre é uma perda de dados esperando para acontecer.
A capacidade também funciona diferente aqui. Batch jobs consomem uma quota de enqueued tokens, e um job grande o suficiente para excedê-la é rejeitado em vez de enfileirado atrás do anterior. Certas regiões agora suportam um comportamento fail-fast que permite enfileirar vários batch jobs com exponential backoff, de modo que um terminando dispara o próximo automaticamente. Sem isso, o loop de retry é trabalho de quem submete, e ele pertence ao control plane, não a cada aplicação.
Mantendo a contabilidade honesta
Aqui está a parte desconfortável. O caminho de batch não atravessa o API Management, então o llm-token-limit não o limita, o llm-emit-token-metric não o mede, e a atribuição por tenant que a Parte 5 está prestes a construir tem um ponto cego do tamanho do maior workload da empresa.
Duas respostas, e a diferença entre elas merece ser decidida de forma deliberada, não por default.
- Deixar as aplicações submeterem batch jobs diretamente e aceitar uma segunda porta sem medição. O mais simples, e reintroduz em silêncio exatamente o problema sobre o qual a Parte 1 foi escrita, uma fatura que ninguém consegue atribuir.
- Fazer do control plane o único submissor de batch. Uma aplicação envia uma requisição de batch ao control plane, que valida o tenant, resolve o alias de modelo para um deployment Global Batch, escreve o JSONL, submete o job, registra a submissão contra o tenant, faz polling até a conclusão e devolve a saída. O gateway continua sendo a única porta para o tráfego síncrono, e o control plane é a única porta para o tráfego assíncrono.
A segunda dá mais trabalho e é a que mantém a premissa da série verdadeira. Ela também deixa a contabilidade em terreno mais firme do que o caso do streaming da Parte 3: um batch job concluído reporta seu próprio request_counts e o arquivo de saída carrega o usage por resposta, então o gasto de batch é exatamente atribuível, mais do que o tráfego em streaming no caminho síncrono. Essa é uma inversão agradável que vale conhecer antes que alguém assuma que assíncrono significa aproximado.
Modos de falha para observar
- O loop de retry que não pode ter sucesso. Uma recusa de content filter é um resultado, não uma falha. Retentá-la queima quota, infla o delivery count e termina em uma dead-letter queue cheia de mensagens que foram respondidas corretamente.
- Workers escalados além da quota. O KEDA escala sem hesitar até
maxReplicaCountcom base apenas no backlog. Se isso excede o que o TPM do model deployment permite, as réplicas extras geram 429s e a queue não drena mais rápido. - Um batch job que expira em silêncio. A janela de 24 horas termina em um status
expired, não em uma exceção no código de alguém. Sem um alerta sobre o status do job, o relatório da manhã simplesmente não existe, e a primeira pessoa a notar é quem o lê. - Arquivos de saída que envelhecem e somem. Um arquivo de resultados tem uma expiração entre 14 e 30 dias. Tudo o que precisa ser mantido é copiado para storage que a plataforma possui, na submissão, não depois.
- A segunda porta reaberta por conveniência. Um time com acesso direto ao deployment Global Batch desfaz o modelo de atribuição. A regra de rede e o alerta da Parte 1 também se aplicam aqui, e este é o caminho que eles têm mais chance de deixar passar.
O que a Parte 5 herda
Tráfego síncrono pelo gateway, trabalho na escala de minutos em uma queue com workers que escalam até zero, e trabalho na escala de horas submetido pelo control plane contra um deployment Global Batch. Três caminhos, três perfis de latência e três qualidades diferentes de dados de uso alimentando a mesma pergunta: qual time gastou o quê. Essa pergunta vem a seguir, junto do modelo de identidade que torna "qual time" respondível.
Leia isso a seguir
- Parte 5, Identidade, Quota e Chargeback, onde os três sinais de uso desses caminhos viram um único número por time, e um limite do Azure Monitor decide como ele é construído.
- Parte 3, A Abstração de Provider e o Streaming, a metade síncrona deste quadro, e as garantias transporte a transporte que esta parte contorna.
- Inferência em Lote no Bedrock: Metade do Preço Se Você Puder Esperar, a mesma troca de latência por custo do lado da AWS, com a aritmética de quando ela compensa.
Para o lado de infraestrutura e plataforma de rodar isso em escala, as notas de campo estão em ercan.cloud, e o hub fica em ercanermis.com.
Referências
Mais de Ercan
Mais dois sites, mesmo autor, terreno diferente.
Cloud, AWS, EKS, Terraform, engenharia de plataforma.
Notas de campo de sistemas em produção. EKS, IAM, Terraform em escala organizacional, observabilidade, otimização de custos.
Visitar ercan.cloud →O hub. Sobre, consultoria, contato.
Hub pessoal para as duas trilhas de escrita. Quem sou eu, como funciona a consultoria, como me contatar.
Visitar ercanermis.com →