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é maxReplicaCount com 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

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