AI Inference Engineering: explicação detalhada com exemplos
1. Visão Geral
Quando você chama a API de um chatbot e aguarda a resposta aparecer gradualmente na tela, você está testemunhando o processo de inference de um Large Language Model (LLM). Este artigo explica como inference difere de training, por que consome recursos enormes, e quais técnicas engenheiros usam para deixá-lo mais rápido e barato: KV cache, continuous batching, quantization, speculative decoding. Depois de ler, você entenderá os números TTFT/TPOT em benchmarks, e saberá escrever código Java para medir a performance de um endpoint de inference real.
Nota: Este artigo não aprofunda nos detalhes internos do transformer (attention, feed-forward…). O foco é na perspectiva técnica da infraestrutura — como um request “flui” através do sistema de inference.
2. O que é Inference?
Training é o processo de aprender os pesos (weights) a partir dos dados — consome milhares de GPUs por várias semanas, mas é feito apenas uma vez. Inference é quando o modelo já terminou de aprender, você fornece um prompt e recebe uma saída — isso acontece cada vez que há um request, com milhões de requests por dia em produção. Portanto, embora training seja “mais caro” em termos de custo total de uma vez, inference é o que determina os custos operacionais a longo prazo e a experiência do usuário (latência).
Diferença importante: no training, o modelo vê a sentença completa para calcular o loss de uma só vez. Na inference para geração de texto (text generation), o modelo deve gerar um token por vez, e o próximo token depende do anterior — isso é chamado autoregressive decoding.
2.1. Dois estágios: Prefill e Decode
Um request de inference sempre passa por 2 estágios:
- Prefill: o modelo lê o prompt de entrada completo de uma vez, calcula attention para todos os tokens de entrada. Este estágio aproveita bem a GPU porque processa em paralelo (compute-bound).
- Decode: o modelo gera tokens de saída sequencialmente, um por vez, baseado no contexto inteiro anterior. Este estágio roda sequencialmente, cada passo calcula apenas para 1 novo token (memory-bound — gargalo na largura de banda de leitura de memória, não no cálculo).
Ilustração com um loop de decode simples em Java (simulação, não um modelo real) para ver claramente a natureza sequencial:
public class AutoregressiveDecoder {
public List<String> generate(List<String> prompt, int maxNewTokens) {
List<String> context = new ArrayList<>(prompt);
List<String> generated = new ArrayList<>();
for (int step = 0; step < maxNewTokens; step++) {
String nextToken = predictNextToken(context); // 1 forward pass
if (nextToken.equals("<EOS>")) break;
context.add(nextToken); // novo token se torna entrada da próxima etapa
generated.add(nextToken);
}
return generated;
}
private String predictNextToken(List<String> context) {
// simulação: modelo real executa forward pass através de todo transformer
return "token_" + context.size();
}
}
A cada chamada de predictNextToken, o modelo precisa ler todo o contexto anterior para calcular attention — sem otimização, o custo cresce progressivamente com a duração da sentença. Esta é exatamente a motivação para o surgimento do KV cache na seção 4.
3. Medindo performance de inference: TTFT e TPOT
Os dois métricas mais importantes ao fazer benchmark de um LLM serving:
- TTFT (Time To First Token): tempo desde enviar o request até receber o primeiro token — reflete a latência do estágio prefill.
- TPOT (Time Per Output Token): tempo médio para gerar cada token subsequente durante o estágio de decode.
public record InferenceMetrics(Duration ttft, Duration avgTpot, int totalTokens) {
public Duration totalLatency() {
return ttft.plus(avgTpot.multipliedBy(totalTokens - 1));
}
}
A função totalLatency() mostra a fórmula prática: tempo total ≈ TTFT (1 vez) + TPOT × (número de tokens restantes). Com uma resposta de 200 tokens, TPOT apenas 10ms/token já adiciona 2 segundos ao tempo total — é por isso que TPOT é escrutinizado mais que TTFT nos benchmarks de serving.
Dados de exemplo do mundo real (modelo 7B, tamanho de batch 1, GPU A100):
TTFT ≈ 80ms
TPOT ≈ 15ms/token
200 tokens → 80ms + 15ms × 199 ≈ 3.07s
4. KV Cache: evitar recalcular o que já foi processado
Sem cache, cada passo de decode precisa recalcular attention para toda a sequência de tokens do início — a complexidade cresce quadraticamente com o comprimento. KV cache armazena os vetores key/value dos tokens já processados, então o próximo passo apenas precisa calcular para o novo token, reutilizando o cache anterior.
public class KvCache {
private final Map<Integer, float[]> keyCache = new HashMap<>();
private final Map<Integer, float[]> valueCache = new HashMap<>();
public void store(int position, float[] key, float[] value) {
keyCache.put(position, key);
valueCache.put(position, value);
}
public List<float[]> allKeysUpTo(int position) {
return IntStream.rangeClosed(0, position)
.mapToObj(keyCache::get)
.toList();
}
}
store é chamado uma vez para cada novo token gerado; allKeysUpTo permite que o próximo passo de decode recupere todas as chaves já calculadas sem precisar fazer forward pass novamente. Esta é a razão pela qual um request longo (contexto longo) consome VRAM muito rapidamente — este cache é proporcional a batch_size × sequence_length × num_layers × hidden_size.
Nota: KV cache é a principal razão pela qual janelas de contexto longas (32K, 128K tokens) consomem RAM de GPU enormemente, não a computação. É também por isso que técnicas como paged attention (usada em vLLM) surgiram — gerenciar cache como memória virtual (páginas) para evitar fragmentação.
5. Continuous batching: agrupar requests para aumentar throughput
GPU processando em batch é mais eficiente do que processar cada request individualmente. Mas cada request tem um comprimento de saída diferente — request A termina no token 50, request B continua até token 300. Static batching (esperar que todo o batch termine antes de aceitar um novo batch) desperdiçará GPU porque precisa esperar pelo request mais lento. Continuous batching resolve isso: assim que um request no batch termina, seu “slot” é substituído por um novo request da fila, sem precisar esperar por todo o batch.
public class ContinuousBatchScheduler {
private final int batchSlots;
private final Queue<InferenceRequest> waiting = new LinkedList<>();
private final List<InferenceRequest> active;
public ContinuousBatchScheduler(int batchSlots) {
this.batchSlots = batchSlots;
this.active = new ArrayList<>(Collections.nCopies(batchSlots, null));
}
public void onStep() {
for (int slot = 0; slot < batchSlots; slot++) {
InferenceRequest req = active.get(slot);
if (req == null || req.isFinished()) {
active.set(slot, waiting.poll()); // preencha imediatamente com novo request
} else {
req.decodeNextToken();
}
}
}
}
onStep() roda a cada passo de decode: se algum slot estiver vazio ou um request terminou, ele imediatamente recebe o próximo request aguardando de waiting. Dessa forma, a GPU sempre roda com o batch completo, sem slots “ociosos” esperando requests lentos — este é o mecanismo principal em vLLM, TGI (Text Generation Inference) e TensorRT-LLM.
6. Quantization: trocar precisão por velocidade
O modelo original geralmente armazena pesos em FP16 ou BF16 (16-bit). Quantization reduz o número de bits armazenados (INT8, INT4) para reduzir o uso de VRAM e aumentar throughput, em troca de uma leve redução na precisão.
Modelo com 7B parâmetros, comparação de tamanho:
- FP16: ~14 GB VRAM
- INT8: ~7 GB VRAM
- INT4: ~3.5 GB VRAM
Ao servir com engines como vLLM, você apenas precisa declarar a configuração:
model: meta-llama/Llama-3-8B
quantization: awq
dtype: float16
gpu_memory_utilization: 0.9
quantization: awq especifica o uso do método AWQ (Activation-aware Weight Quantization) — uma técnica INT4 que mantém melhor precisão do que quantization “ingênuo” ao priorizar preservar os pesos mais importantes.
Nota: Quantization muito profundo (INT4 ou menos) pode fazer o modelo “alucinar” (hallucination) mais ou reduzir a capacidade de reasoning. Sempre faça benchmark novamente da qualidade do output (não apenas velocidade) após quantizar, especialmente para tarefas que exigem alta precisão como code generation ou cálculos.
7. Speculative decoding: prever antecipadamente para economizar passos
Decode sequencial é lento porque cada passo gera apenas 1 token. Speculative decoding usa um modelo pequeno e rápido (“draft model”) para prever vários tokens à frente, então o modelo grande (“target model”) apenas precisa verificar (verify) esses tokens em uma única forward pass em vez de múltiplas.
Draft model prevê: "Ha" "Noi" "eh" "a" "capital"
Target model verifica todos os 5 tokens de uma vez em 1 forward pass:
- Se 4/5 tokens correspondem à distribuição do modelo grande → mantenha 4 tokens, descarte o 5º, decodifique daquele ponto.
- Se errado desde o início → custa apenas 1 forward pass como decode normal, sem perda.
Esta técnica não reduz a qualidade do output (porque sempre verifica com target model), apenas reduz o número de vezes que precisa executar forward pass sequencialmente no modelo grande — é mais eficaz quando o draft model acerta muitas previsões.
8. Prática: medindo TTFT/TPOT de um endpoint de LLM serving
Suponha que você tenha um servidor vLLM rodando OpenAI-compatible API em http://localhost:8000. Adicione a dependência para WebClient:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux</artifactId>
</dependency>
Escreva um client para medir TTFT usando streaming response:
@Service
public class LlmBenchmarkClient {
private final WebClient webClient;
public LlmBenchmarkClient(WebClient.Builder builder) {
this.webClient = builder.baseUrl("http://localhost:8000").build();
}
public InferenceMetrics measure(String prompt) {
Instant start = Instant.now();
AtomicReference<Instant> firstTokenTime = new AtomicReference<>();
AtomicInteger tokenCount = new AtomicInteger();
webClient.post()
.uri("/v1/completions")
.bodyValue(Map.of("prompt", prompt, "stream", true, "max_tokens", 100))
.retrieve()
.bodyToFlux(String.class)
.doOnNext(chunk -> {
if (firstTokenTime.get() == null) {
firstTokenTime.set(Instant.now());
}
tokenCount.incrementAndGet();
})
.blockLast();
Duration ttft = Duration.between(start, firstTokenTime.get());
Duration total = Duration.between(start, Instant.now());
Duration avgTpot = tokenCount.get() > 1
? total.minus(ttft).dividedBy(tokenCount.get() - 1)
: Duration.ZERO;
return new InferenceMetrics(ttft, avgTpot, tokenCount.get());
}
}
doOnNext é chamado cada vez que um novo chunk (token) chega — a primeira chamada registra o momento em firstTokenTime que é exatamente o TTFT. Após o stream terminar (blockLast), calculamos TPOT médio subtraindo TTFT do tempo total, dividido pelo número de tokens restantes.
8.1. Teste com JUnit 5 e AssertJ
@SpringBootTest
class LlmBenchmarkClientTest {
@Autowired
LlmBenchmarkClient client;
@Test
void shouldMeetLatencySla() {
InferenceMetrics metrics = client.measure("Explique KV cache em 2 frases.");
assertThat(metrics.ttft()).isLessThan(Duration.ofMillis(500));
assertThat(metrics.avgTpot()).isLessThan(Duration.ofMillis(50));
assertThat(metrics.totalTokens()).isGreaterThan(0);
}
}
Este teste não verifica o conteúdo da resposta, apenas o SLA de latência — o verdadeiro foco da “inference engineering”: garantir que o sistema responde rápido o suficiente, não avaliar a qualidade do modelo (isso é responsabilidade de avaliação de qualidade, fora do escopo deste artigo).
Nota: Este teste requer um servidor LLM real rodando (ou mock WebClient usando
MockWebServer). Em CI, você deve separar esse tipo de teste em um teste de integração, não executando junto com testes unitários normais porque depende de infraestrutura externa.
9. Erros comuns e quando não construir seu próprio inference engine
- Reescrever batching/KV cache do zero: a menos que você esteja fazendo pesquisa aprofundada de infraestrutura ML, não reconstrua o que vLLM, TGI, TensorRT-LLM já otimizaram — você perderá meses apenas para igualar o desempenho que eles já conseguiram.
- Fazer benchmark apenas por throughput, esquecer latência de cauda (tail latency): throughput alto mas p99 latency ruim ainda arruína a experiência do usuário. Sempre meça p50/p95/p99, não apenas a média.
- Quantizar sem testar novamente a qualidade: como mencionado na seção 6, reduzir bits sempre tem tradeoffs — medir velocidade mas ignorar medição de qualidade é um erro comum.
- Usar static batching para tráfego variável em produção: se requests têm comprimentos de saída muito diferentes, static batching desperdiçará GPU seriamente — escolha um engine que suporte continuous batching desde o início.
- Não separar workloads prefill-heavy e decode-heavy: alguns sistemas de produção separam nós que processam prefill e nós que processam decode (disaggregated serving) para otimizar recursos — se seu tráfego tem um padrão claro (por exemplo, RAG com prompts muito longos, saída curta), vale considerar essa arquitetura.
10. Conclusão
Inference engineering é um problema de otimização entre latência, throughput e custo de GPU — não é sobre treinamento de modelo. Dominar prefill/decode, KV cache, continuous batching, quantization e speculative decoding ajuda você a entender benchmarks, escolher o serving engine correto (vLLM, TGI…), e medir seu sistema com as métricas certas (TTFT, TPOT) em vez de apenas “rápido ou lento”. O próximo passo natural é experimentar alterar quantization ou gpu_memory_utilization na configuração real e observar como essas métricas mudam na sua carga de trabalho específica.