AI Inference Engineering: explicación detallada con ejemplos
1. Descripción general
Cuando llamas a la API de un chatbot y esperas que la respuesta aparezca poco a poco en pantalla, estás presenciando el proceso de inference de un Large Language Model (LLM). Este artículo explica en qué se diferencia la inference del training, por qué consume recursos enormes, y qué técnicas usan los ingenieros para hacerla más rápida y barata: KV cache, continuous batching, quantization, speculative decoding. Al terminar, comprenderás los números TTFT/TPOT en los benchmarks, y sabrás escribir código Java para medir el rendimiento de un endpoint de inference real.
Nota: Este artículo no profundiza en el interior del transformer (attention, feed-forward…). El enfoque es desde la perspectiva de la infraestructura — cómo una request “fluye” a través del sistema de inference.
2. ¿Qué es inference?
Training es el proceso de aprender los pesos (weights) a partir de datos — consume miles de GPUs durante semanas, pero solo se hace una vez. Inference es cuando el modelo ya ha aprendido, introduces un prompt y recibes una salida — esto ocurre cada vez que hay una request, con millones de requests cada día en producción. Por eso, aunque el training sea más “caro” en costo total de una sola vez, la inference es lo que realmente determina el costo operativo a largo plazo y la experiencia del usuario (latencia).
Una diferencia importante: en training, el modelo ve la oración completa para calcular el loss de una vez. En inference de generación de texto, el modelo debe generar un token a la vez, y cada token depende del anterior — esto se llama autoregressive decoding.
2.1. Dos fases: Prefill y Decode
Una request de inference siempre pasa por 2 fases:
- Prefill: el modelo lee todo el prompt de entrada de una vez, calcula la attention para todos los tokens de entrada. Esta fase aprovecha bien la GPU porque procesa en paralelo (compute-bound).
- Decode: el modelo genera tokens de salida uno por uno, cada vez un token, basándose en todo el contexto anterior. Esta fase se ejecuta secuencialmente, cada paso calcula solo para 1 token nuevo (memory-bound — limitado por el ancho de banda de lectura de memoria, no por la computación).
Una ilustración con un bucle simple de decode en Java (simulado, no es un modelo real) para ver claramente la naturaleza secuencial:
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); // el nuevo token se convierte en entrada del siguiente paso
generated.add(nextToken);
}
return generated;
}
private String predictNextToken(List<String> context) {
// simulación: el modelo real ejecuta un forward pass a través de todo el transformer
return "token_" + context.size();
}
}
Cada vez que se llama a predictNextToken, el modelo debe leer todo el contexto que tiene para calcular la attention — si no se optimiza, el costo aumenta gradualmente con la longitud de la oración. Esta es precisamente la motivación detrás del KV cache en la sección 4.
3. Medir el rendimiento de inference: TTFT y TPOT
Los dos indicadores más importantes al hacer benchmark de un LLM serving:
- TTFT (Time To First Token): tiempo desde que se envía la request hasta que se recibe el primer token — refleja la latencia de la fase de prefill.
- TPOT (Time Per Output Token): tiempo promedio para generar cada token siguiente en la fase de decode.
public record InferenceMetrics(Duration ttft, Duration avgTpot, int totalTokens) {
public Duration totalLatency() {
return ttft.plus(avgTpot.multipliedBy(totalTokens - 1));
}
}
La función totalLatency() muestra la fórmula real: tiempo total ≈ TTFT (una vez) + TPOT × (número de tokens restantes). Con una respuesta de 200 tokens, si TPOT es solo 10ms/token, suma 2 segundos más de tiempo total — por eso TPOT es escrutado más que TTFT en los benchmarks de serving.
Ejemplo de números reales (modelo 7B, batch size 1, GPU A100):
TTFT ≈ 80ms
TPOT ≈ 15ms/token
200 tokens → 80ms + 15ms × 199 ≈ 3.07s
4. KV Cache: evitar recalcular lo que ya se sabe
Sin caché, cada paso de decode debe recalcular la attention para toda la secuencia de tokens desde el inicio — la complejidad aumenta cuadráticamente con la longitud. KV cache almacena los vectores de key/value de los tokens ya procesados, para que el siguiente paso solo calcule para el nuevo token, luego reutilice el caché 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 se llama una vez por cada token nuevo generado; allKeysUpTo permite al siguiente paso de decode recuperar todas las keys ya calculadas sin necesidad de un forward pass desde el inicio. Por eso una request larga (contexto largo) consume VRAM muy rápidamente — este caché es proporcional a batch_size × sequence_length × num_layers × hidden_size.
Nota: KV cache es la razón principal por la que ventanas de contexto largas (32K, 128K tokens) consumen RAM de GPU enormemente, no la computación. Esta es también la razón por la que técnicas como paged attention (usado en vLLM) existen — gestionar el caché como memoria virtual (paging) para evitar fragmentación.
5. Continuous batching: agrupar requests para aumentar throughput
Una GPU procesa batches más eficientemente que requests individuales. Pero cada request tiene una longitud de generación diferente — la request A termina en el token 50, la request B continúa hasta el token 300. Static batching (esperar a que todo el batch termine antes de aceptar uno nuevo) desperdiciará GPU porque tiene que esperar la request más lenta. Continuous batching lo resuelve así: tan pronto como una request en el batch se completa, su “asiento” se reemplaza con una nueva request de la cola, sin esperar al batch entero.
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()); // rellena inmediatamente con nueva request
} else {
req.decodeNextToken();
}
}
}
}
onStep() se ejecuta en cada paso de decode: si algún asiento está vacío o la request acaba de terminar, inmediatamente recibe la siguiente request esperando de waiting. De esta manera, la GPU siempre ejecuta con el batch completo, sin asientos “vacíos” esperando a la request lenta — este es el mecanismo central en vLLM, TGI (Text Generation Inference) y TensorRT-LLM.
6. Quantization: intercambiar precisión por velocidad
El modelo original típicamente almacena pesos en FP16 o BF16 (16-bit). Quantization reduce el número de bits almacenados (INT8, INT4) para disminuir el consumo de VRAM y aumentar el throughput, sacrificando un poco de precisión.
Comparación de tamaño para un modelo 7B parámetros:
- FP16: ~14 GB VRAM
- INT8: ~7 GB VRAM
- INT4: ~3.5 GB VRAM
Cuando sirves usando engines como vLLM, solo necesitas declarar la configuración:
model: meta-llama/Llama-3-8B
quantization: awq
dtype: float16
gpu_memory_utilization: 0.9
quantization: awq especifica usar el método AWQ (Activation-aware Weight Quantization) — una técnica INT4 que mantiene mejor precisión que quantizar “ingenuamente” priorizando mantener intactos los pesos más importantes.
Nota: Una cuantización muy profunda (INT4 o menos) puede hacer que el modelo “alucine” (hallucinate) más o disminuya su capacidad de razonamiento. Siempre haz benchmark nuevamente de la calidad de output (no solo velocidad) después de cuantizar, especialmente para tareas que requieren alta precisión como generación de código o cálculos.
7. Speculative decoding: predecir antes para ahorrar pasos
El decode secuencial es lento porque cada paso solo genera 1 token. Speculative decoding usa un modelo más pequeño y rápido (“draft model”) para predecir varios tokens siguientes, luego el modelo grande (“target model”) solo necesita verificar (verify) esos tokens en un solo forward pass en lugar de múltiples.
El draft model predice: "Madrid" "es" "la" "capital" "de"
El target model verifica los 5 tokens a la vez en 1 forward pass:
- Si 4/5 tokens coinciden con la distribución del modelo grande → mantén 4 tokens, descarta el quinto, decodifica desde ahí.
- Si está mal desde el inicio → solo cuesta exactamente 1 forward pass como decode normal, sin pérdida.
Esta técnica no reduce la calidad del output (porque siempre verifica usando el target model), solo reduce el número de veces que necesita ejecutar un forward pass secuencial en el modelo grande — es más efectivo cuando el draft model predice correctamente.
8. Práctica: medir TTFT/TPOT desde un endpoint de LLM serving
Supongamos que tienes un servidor vLLM ejecutando la API compatible con OpenAI en http://localhost:8000. Añade la dependencia para WebClient:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux</artifactId>
</dependency>
Escribe un cliente para medir TTFT usando respuesta en streaming:
@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 se llama cada vez que llega un nuevo chunk (token) — la primera llamada registra el momento firstTokenTime que es exactamente el TTFT. Después de que el stream termina (blockLast), calculamos el TPOT promedio como el tiempo total menos TTFT, dividido por el número de tokens restantes.
8.1. Test con JUnit 5 y AssertJ
@SpringBootTest
class LlmBenchmarkClientTest {
@Autowired
LlmBenchmarkClient client;
@Test
void shouldMeetLatencySla() {
InferenceMetrics metrics = client.measure("Explica qué es KV cache en 2 oraciones.");
assertThat(metrics.ttft()).isLessThan(Duration.ofMillis(500));
assertThat(metrics.avgTpot()).isLessThan(Duration.ofMillis(50));
assertThat(metrics.totalTokens()).isGreaterThan(0);
}
}
Este test no verifica el contenido de la respuesta, solo verifica el SLA de latencia — esto es lo correcto en “inference engineering”: asegurar que el sistema responde lo suficientemente rápido, no evaluar la calidad del modelo (eso pertenece a evaluación de calidad, fuera del alcance de este artículo).
Nota: Este test necesita un servidor LLM real ejecutándose (o mockear WebClient con
MockWebServer). En CI, deberías separar este tipo de test como integration test, no ejecutarlo junto con tests unitarios normales porque depende de infraestructura externa.
9. Errores comunes y cuándo no deberías construir tu propio motor de inference
- Reescribir batching/KV cache desde cero: a menos que hagas investigación profunda de infraestructura ML, no reconstruyas lo que vLLM, TGI, TensorRT-LLM ya han optimizado — perderás meses solo para alcanzar el rendimiento que ya tienen.
- Hacer benchmark solo con throughput, olvidando latencia de cola (tail latency): un throughput alto pero p99 latency malo sigue resultando en mala experiencia del usuario. Siempre mide p50/p95/p99, no solo el promedio.
- Cuantizar sin re-testear calidad: como se mencionó en la sección 6, reducir bits siempre tiene tradeoffs — medir velocidad pero ignorar medición de calidad es un error común.
- Usar static batching para tráfico real variable: si las requests tienen longitudes de output muy diferentes, static batching desperdiciará GPU seriamente — elige un engine con continuous batching desde el principio.
- No separar workload prefill-heavy de decode-heavy: algunos sistemas de producción separan nodos para procesar prefill y decode por separado (disaggregated serving) para optimizar recursos — si tu tráfico tiene un patrón claro (ej. RAG con prompt muy largo, output corto), vale la pena considerar esta arquitectura.
10. Conclusión
Inference engineering es el problema de optimizar entre latencia, throughput y costo de GPU — no es sobre entrenar modelos. Entender prefill/decode, KV cache, continuous batching, quantization y speculative decoding te ayuda a leer benchmarks, elegir el motor de serving correcto (vLLM, TGI…), y medir tu sistema con las métricas correctas (TTFT, TPOT) en lugar de solo decir “corre rápido o lento”. El siguiente paso natural es probar cambiar quantization o gpu_memory_utilization en la configuración real y observar cómo cambian estas métricas en tu carga de trabajo.