Ronda de Diseño ML · Serving de LLMs · ejemplos del C10

Modelo y API

Serving de LLMs con vLLM

Para levantar un LLM con vLLM con la documentación abierta, saber cuántas conversaciones caben en tu GPU y diagnosticar por qué el primer token tarda, las respuestas se cortan o el servidor no arranca por memoria. En el C10, un modelo de ≈ 70B con pesos FP8 corre en 2 H100 por réplica: vLLM lee el prompt completo de una vez (prefill), guarda lo leído en el KV cache y escribe la respuesta token por token (decode), mezclando en cada paso las conversaciones de muchos usuarios. Cada sección trae la sintaxis como plantilla con variables, las piezas, un ejemplo real del curso, una figura, un truco para recordarla, la trampa típica y un laboratorio opcional. En la versión web, al final hay un diagnosticador de síntomas, una calculadora de VRAM y KV cache y un quiz de errores.

Cómo recordarlaTrampa típicavariablela cambias túEjemplos en verde

En las figuras:pesosKV cacheprefill o espera en la coladecode o tokens generadosok, libre o dentro del SLOOOM, preemption, fuera del SLO o rechazadooverhead, activaciones, reservado u ocioso

0

Números de bolsillo: tokens, bytes por parámetro, KV cache, TTFT y TPOT, puertos y endpoints

¿Qué números de un LLM servido con vLLM conviene saberse de memoria para dimensionar una réplica y leer sus métricas en segundos?

Sintaxis con variables

curl -s localhost:puerto/v1/models

El primer chequeo: el servidor contesta y sirve el nombre que esperas, el de --served-model-name. Puerto de bolsillo: 8000, el default de vllm serve y el del C10.

curl -s localhost:puerto/tokenize -H 'content-type: application/json' -d '{"model": "modelo", "messages": [{"role": "user", "content": "texto"}]}'

Cuenta tokens con el tokenizer (el que parte el texto en tokens) y el chat template (la plantilla que envuelve tus mensajes con las marcas del modelo) del modelo servido. Devuelve count, max_model_len y la lista de tokens.

GPU KV cache size: tokens tokens, Maximum concurrency for max_model_len tokens per request: xx

La línea del log de arranque con tu capacidad real: cuántos tokens de KV cache (lo que el modelo ya leyó de cada conversación, guardado en la GPU) caben, y cuántas secuencias del largo máximo a la vez. Con los números del C10, la cuenta da 208,736 tokens y 25.48x; vLLM no corrió en la referencia, así que el número real sale del log.

curl -s localhost:puerto/metrics | grep -E '^vllm:(num_requests_running|num_requests_waiting|kv_cache_usage_perc)'

Los tres gauges (números que suben y bajan) de capacidad: requests en el batch, requests en la cola y fracción del KV cache ocupada (1 = 100 %). La utilización de la GPU no sirve para esto: el decode (generar la respuesta token por token) marca ≈ 100 % con 5 secuencias o con 90.

Variables

VariableQué esEjemplo
puertodónde escucha vllm serve8000: el default y el del C10
modeloel nombre que pide el cliente: el de --served-model-namechat-70b-fp8-2026-09-28
textolo que quieres contar en tokensHola, ¿dónde está mi pedido?
tokens, max_model_len, xlo que imprime vLLM al arrancar: tokens de KV, el contexto máximo y cuántas secuencias de ese largo caben208,736 · 8,192 · 25.48 con la cuenta del C10
LLcapas del modelo (num_hidden_layers en el config.json)80
HkvH_{\text{kv}}KV heads: cuántos pares de K y V tiene cada capa (num_key_value_heads)8, de 64 heads de atención (GQA)
dheadd_{\text{head}}tamaño de cada head (head_dim; si el config no lo trae, hidden_size ÷ num_attention_heads)128 = 8,192 ÷ 64
bkv, bwb_{\text{kv}},\ b_{w}bytes por número: en el KV cache y en los pesos2 (BF16, --kv-cache-dtype auto) y 1 (pesos FP8)
PPparámetros del modelo70.6 B (miles de millones)
Ngpu, MgpuN_{\text{gpu}},\ M_{\text{gpu}}GPUs por réplica y memoria de cada una2 × 80 GB (TP=2)
uu--gpu-memory-utilization: la fracción de la memoria de cada GPU que vLLM se permite usar0.90 en el C10; 0.92 si no la escribes (desde v0.20)
overhead\text{overhead}lo que no es pesos ni KV: activaciones, CUDA graphs y buffers de comunicación≈ 5 GB en la réplica (estimación del C10)
Tseq, NoutT_{\text{seq}},\ N_{\text{out}}tokens de una secuencia (prompt + salida) y tokens de salida2,250 = 2,000 + 250; N_out = 250
TTFT, TPOT\text{TTFT},\ \text{TPOT}tiempo hasta el primer token; tiempo promedio por token después del primero≈ 0.5 s y ≈ 40 ms (los SLOs, las metas en p95: 1 s y 50 ms)
C, λ, WC,\ \lambda,\ Wsecuencias vivas a la vez, llegadas por segundo y cuánto dura cada request (ley de Little)630 = 60 × 10.5

Fórmulas

KVtoken=2×L×Hkv×dhead×bkv=2×80×8×128×2 B=327,680 B=320 KiB\text{KV}_{\text{token}} = 2 \times L \times H_{kv} \times d_{head} \times b_{kv} = 2 \times 80 \times 8 \times 128 \times 2\ \text{B} = 327{,}680\ \text{B} = 320\ \text{KiB}
pesos=P×bw=70.6 B×1 B≈70.6 GB(BF16: 141.2 GB; INT4: ≈35.3 GB)\text{pesos} = P \times b_w = 70.6\ \text{B} \times 1\ \text{B} \approx 70.6\ \text{GB} \qquad (\text{BF16: } 141.2\ \text{GB; INT4: } \approx 35.3\ \text{GB})
KVlibre=Ngpu×Mgpu×u−pesos−overhead=2×80×0.90−70.6−5≈68.4 GB\text{KV}_{\text{libre}} = N_{gpu} \times M_{gpu} \times u - \text{pesos} - \text{overhead} = 2 \times 80 \times 0.90 - 70.6 - 5 \approx 68.4\ \text{GB}
Nseq=⌊KVlibreTseq×KVtoken⌋=⌊68.4 GB2,250×327,680 B⌋=⌊92.8⌋=92N_{seq} = \left\lfloor \frac{\text{KV}_{\text{libre}}}{T_{seq} \times \text{KV}_{\text{token}}} \right\rfloor = \left\lfloor \frac{68.4\ \text{GB}}{2{,}250 \times 327{,}680\ \text{B}} \right\rfloor = \lfloor 92.8 \rfloor = 92
E2E=TTFT+(Nout−1)×TPOT≈0.5 s+249×40 ms≈10.5 s\text{E2E} = \text{TTFT} + (N_{out} - 1) \times \text{TPOT} \approx 0.5\ \text{s} + 249 \times 40\ \text{ms} \approx 10.5\ \text{s}
C=λ×W=60 rps×10.5 s=630  ⇒  630÷16≈39.4 secuencias por reˊplica  <  92C = \lambda \times W = 60\ \text{rps} \times 10.5\ \text{s} = 630 \;\Rightarrow\; 630 \div 16 \approx 39.4 \text{ secuencias por réplica} \;<\; 92

Las piezas

PiezaQué haceEjemplo
1 token ≈ ¾ de palabra en inglésLa unidad en que el modelo lee y escribe: un pedazo de palabra. La regla de ¾ es la de OpenAI; con el tokenizer de Llama 3.1 se midieron ≈ 1.23 tokens por palabra en inglés y ≈ 1.61 en español, en noticias.2,000 tokens de prompt ≈ 1,240 palabras en español
2 · 1 · 0.5 bytes por parámetroBF16 (16 bits, como se entrenó), FP8 o INT8 (8 bits) e INT4 (4 bits). Los pesos pesan parámetros × bytes.70.6 B: 141.2 · 70.6 · ≈ 35.3 GB; el C10 sirve FP8
KV por token = 2 × capas × KV heads × head_dim × bytesLo que ocupa en la GPU guardar lo que el modelo ya leyó de un token: una K y una V por capa y por KV head. No depende de los parámetros.320 KiB en BF16 y 160 KiB en FP8; Llama 3.1 8B: 128 KiB
GQA (grouped-query attention)Varios heads de atención comparten un mismo par de K y V: menos KV heads y un KV cache más chico. Se lee del config.json.64 heads y 8 KV heads: 8× menos KV que con un par por head
--gpu-memory-utilization 0.92La fracción de la memoria TOTAL de cada GPU que vLLM se permite usar entre pesos, activaciones y KV. Es el default desde v0.20; hasta v0.19 era 0.9.el C10 fija 0.90: 2 × 80 GB × 0.90 = 144 GB
Bloque de 16 tokensPagedAttention reparte el KV cache en bloques de 16 tokens (--block-size): cada secuencia desperdicia, como mucho, lo que le falta a su último bloque.2,250 tokens = 141 bloques
GPU KV cache sizeLa línea del arranque con la capacidad que midió vLLM con tu modelo, tu versión y tus GPUs. Manda sobre cualquier cuenta.la cuenta del C10 da 208,736 tokens y 25.48x para 8,192
TTFT · TPOT · ITLTTFT (time to first token): lo que se espera hasta el primer token. TPOT (time per output token): el promedio entre tokens después del primero. ITL (inter-token latency): el hueco entre dos tokens seguidos.SLOs del C10: TTFT p95 ≤ 1 s en el gateway y TPOT p95 ≤ 50 ms
E2E = TTFT + (N − 1) × TPOTEl request completo: el primer token y después un paso de decode por cada uno de los demás.0.5 + 249 × 0.040 ≈ 10.5 s
Throughput · goodputThroughput: tokens de salida, o requests, por segundo de la réplica. Goodput: requests por segundo que cumplen todos los SLOs, uno por uno. Se planea con el goodput.≈ 6 rps por réplica (72 en vuelo, en el barrido sintético); planeado al 70 %: 4.2
8000 · /v1/chat/completions · /v1/completions · /v1/modelsEl puerto por defecto y la API compatible con OpenAI que expone vllm serve.el gateway llama a http://vllm-70b.c10.svc.cluster.local:8000/v1
/health · /metrics · /tokenize/health: responde 200 si el motor está vivo. /metrics: las métricas vllm: para Prometheus. /tokenize: cuenta tokens como los cuenta el modelo.las tres probes del C10 llaman a /health
vllm:num_requests_running · vllm:num_requests_waiting · vllm:kv_cache_usage_percRequests en el batch, requests en la cola y fracción del KV cache ocupada (1 = 100 %).KVCacheSaturation: KV > 95 % y requests esperando, 15 min
vllm:time_to_first_token_seconds · vllm:inter_token_latency_secondsLos histogramas de TTFT y de ITL que mide el propio vLLM, del lado del servidor.el TTFT del SLO se mide en el gateway, que además ve la red y los proxies
H100 SXM: 80 GB · 3.35 TB/s · ≈ 1,979 TFLOPS en FP8La GPU del C10. Su memoria decide cuántas secuencias caben; su ancho de banda, cuánto tarda cada token; sus TFLOPS (billones de operaciones por segundo), el prefill. La hoja de NVIDIA dice 3,958 TFLOPS «with sparsity» (con la mitad de los pesos en cero, algo que casi ningún modelo usa): el denso es la mitad.una réplica = 2 H100 unidas por NVLink (el enlace rápido entre GPUs); 8 por p5.48xlarge, ≈ US$55/h
US$2.50 y US$10 por millón de tokensPrecios de lista ilustrativos del C10 para el proveedor, de entrada y de salida: un token de salida cuesta 4×.un request de soporte (2,000 + 250): US$0.0075
FlagDefault (v0.30.0 y v0.32.0)En el C10Por qué importa
--port80008000el Service y el gateway apuntan ahí
--gpu-memory-utilization0.92 (0.9 hasta v0.19)0.90explícito: la capacidad no cambia con un upgrade
--max-model-lenel del config.json del modelo (131,072 en Llama 3.3)8192prompt + salida; acepta auto (el máximo que cabe) y sufijos: 8k = 8,000 y 8K = 8,192
--max-num-seqscon vllm serve: 1,024 en GPUs de ≥ 70 GiB que no son A100 (H100, H200, B200); 256 en las demás128techo de secuencias por paso: el KV del C10 da para 92 de 2,250 tokens
--max-num-batched-tokenscon vllm serve: 8,192 en la H100; 16,384 desde 160 GiB por GPU; 2,048 en la A100 y en las chicas8192tokens por paso: más mejora el TTFT; menos, el ITL
--block-size16sin cambiaruna secuencia de 2,250 tokens ocupa 141 bloques
--kv-cache-dtypeauto: el dtype del modelo (BF16)autofp8 duplica las secuencias; entra solo si pasa el golden set
--enable-prefix-caching · --enable-chunked-prefillactivados si el modelo los soportaexplícitosel plan del C10 cuenta con los dos
--tensor-parallel-size1270.6 GB de pesos FP8 más el KV no caben cómodos en 80 GB
--scheduling-policyfcfspriorityla documentación dice que con fcfs un priority ≠ 0 da error, pero el código de v0.30.0 y v0.32.0 no lo valida (la cola fcfs lo ignora): no cuentes con el error, pon la flag. Con priority, las preemptions caen primero sobre el batch
--watermark0.0 (apagado)sin cambiardeja bloques libres al admitir, para no tener que desalojar después
--max-num-active-seqsno existe en v0.30; desde v0.31, None—tope de secuencias corriendo sin achicar max_num_seqs
max_tokens del requestsin él, en chat: todo lo que queda de la ventana (en /v1/completions, 16)el gateway pone el tope del tenant (1,024 en soporte)una respuesta sin tope ocupa su KV hasta llenar la ventana
prompt + max_tokens > max_model_lenv0.30: 400; desde v0.32: entra y la salida se recorta a lo que quedael gateway lo frena antes, con un 400 propio: prompt estimado ≤ 6,000 y max_tokens ≤ 1,024 en soporteel upgrade cambia el comportamiento sin avisar (sección 2)

Para ver lo que usó tu servidor: el log de arranque imprime la configuración y la línea GPU KV cache size, y /metrics expone vllm:cache_config_info, con la configuración del cache en sus labels.

Defaults de vLLM v0.30.0 (el pin del C10, 22 de septiembre de 2026) y v0.32.0 (la versión actual, 18 de octubre) contra lo que fija el C10. Leídos en el código de las dos versiones (vllm/config, vllm/engine/arg_utils.py y vllm/renderers/params.py) y en la referencia de vllm serve. Son iguales en las dos salvo donde se dice.

Ejemplo real · La cuenta de VRAM del C10, a mano

textoreference/c10/capacity/vram_calc.py (líneas 3–12, el docstring)
La cuenta del caso, a mano, con la arquitectura de Llama 3.x 70B (la de #m04) y los números del caso:

  KV por token  = 2 (K y V) × 80 capas × 8 KV heads × 128 dims × 2 B (BF16)  = 327,680 B = 320 KiB
                  (en FP8: 1 B por elemento → 160 KiB)
  pesos en FP8  = 70.6 B params × 1 B                                        ≈ 70.6 GB
  una réplica   = 2 × H100 (80 GB cada una) con tensor parallelism 2 (TP=2)
  lugar para KV = 0.90 × 160 GB − 70.6 GB − ≈ 5 GB (runtime, activaciones, CUDA graphs; est.) ≈ 68.4 GB
  una secuencia = 2,000 tokens de prompt + 250 de salida = 2,250 tokens × 320 KiB ≈ 0.737 GB
  secuencias    = 68.4 ÷ 0.737 ≈ 92.8 → 92 enteras (el caso dice "≈ 93": es el cociente redondeado)
                  con KV en FP8, el doble: ≈ 185
  • La primera línea es la fórmula del KV cache con el config de Llama 3.x 70B: 80 capas, 8 KV heads y 128 dims. Los 2 B por número son BF16, lo que da --kv-cache-dtype auto con este modelo; en FP8, la mitad.
  • «70.6 B params × 1 B»: un checkpoint FP8 guarda un byte por peso. Uno real suele dejar en BF16 las dos tablas del vocabulario, embeddings (la de entrada) y lm_head (la de salida): ≈ 2.1 B parámetros, y entonces pesa ≈ 72.7 GB, no 70.6. Mide los safetensors (los archivos de pesos) que vas a servir.
  • «0.90 × 160 GB»: el 0.90 es el --gpu-memory-utilization que fija el manifest. Los ≈ 5 GB de overhead son una estimación; vLLM los mide al arrancar con un paso de prueba y los resta antes de armar el KV cache.
  • «92 enteras»: la cuenta supone todas las secuencias en su largo final. El «≈ 93» del curso es el cociente redondeado (92.77).
  • Lo que el docstring aclara después: «80 GB» es el nominal. nvidia-smi muestra 81,559 MiB (≈ 85.5 GB) en una H100 SXM, y la fracción se aplica sobre eso: en la práctica, ≈ 78 GB para KV y 106 secuencias. El número que vale es el del log de arranque.

Cómo recordarla

Puerto 8000, bloques de 16 y --gpu-memory-utilization 0.92 si no la fijas. KV por token = 2 × capas × KV heads × head_dim × bytes: 320 KiB en el 70B del C10. Una réplica de 2 × H100: 144 GB usables − 70.6 de pesos − ≈ 5 de overhead = ≈ 68 GB de KV ≈ 92 conversaciones de 2,250 tokens. Y dos SLOs: TTFT ≤ 1 s, TPOT ≤ 50 ms.

Trampa

Confundir parámetros con bytes, y la cuenta con la capacidad. «70B» son 70.6 mil millones de parámetros: 141 GB en BF16 y 70.6 en FP8, más ≈ 2 GB por las tablas del vocabulario que un checkpoint FP8 suele dejar en BF16. Y la cuenta de servilleta no es tu capacidad: la que vale es GPU KV cache size: N tokens, Maximum concurrency for 8,192 tokens per request: Kx en el log. Además, --gpu-memory-utilization se aplica sobre la memoria que reporta la GPU (81,559 MiB ≈ 85.5 GB en una H100 «de 80 GB»), así que vLLM suele dar más KV que la cuenta nominal: ≈ 78 GB en vez de 68.

Laboratorio (opcional)3 pasos

Qué vas a provocar: Sacar tú los números de bolsillo del C10 desde el config.json del modelo: parámetros, pesos por dtype, KV por token, lugar para KV, secuencias, la línea que imprimiría vLLM y la ley de Little.

Necesitas: Python 3 de macOS (solo stdlib); sin GPU ni internet.

  1. Paso 1
    mkdir -p ~/lab-vllm && cd ~/lab-vllm
    cat > bolsillo.py <<'EOF'
    # bolsillo.py: los números de bolsillo del C10, desde el config.json del modelo (solo stdlib)
    import math
    
    # config.json de Llama 3.3 70B Instruct: la arquitectura con que el C10 hace sus cuentas
    d, capas, heads, kv_heads, ffn, vocab = 8192, 80, 64, 8, 28672, 128256
    head_dim = d // heads  # 8192 ÷ 64 = 128
    por_capa = (2 * d * d                      # q y o
                + 2 * d * kv_heads * head_dim  # k y v: solo 8 KV heads (GQA)
                + 3 * d * ffn                  # gate, up y down del MLP
                + 2 * d)                       # las dos RMSNorm
    P = capas * por_capa + 2 * vocab * d + d   # + embeddings y lm_head (no comparten pesos) + la norma final
    print(f"parámetros: {P:,} ≈ {P / 1e9:.1f} B (embeddings + lm_head: {2 * vocab * d / 1e9:.1f} B)")
    for nombre, b in (("BF16", 2), ("FP8", 1), ("INT4", 0.5)):
        print(f"  pesos en {nombre}: {P * b / 1e9:.1f} GB")
    
    kv = 2 * capas * kv_heads * head_dim * 2   # K y V × capas × KV heads × head_dim × 2 B (BF16)
    print(f"KV por token: {kv:,} B = {kv // 1024} KiB en BF16, {kv // 2 // 1024} KiB en FP8; "
          f"con un KV head por head ({heads}): {kv * heads // kv_heads / 2**20:.1f} MiB")
    
    # Una réplica del C10 (los números redondos del caso): 2 × H100 de 80 GB, TP=2, pesos FP8
    libre = 2 * 80e9 * 0.90 - 70.6e9 - 5e9     # gpu_memory_utilization 0.90, ≈ 5 GB de overhead (estimación)
    seq = 2_250 * kv                           # 2,000 de prompt + 250 de salida
    print(f"lugar para KV: {libre / 1e9:.1f} GB; una secuencia de 2,250 tokens: {seq / 1e9:.3f} GB; "
          f"caben {libre / seq:.2f} → {math.floor(libre / seq)}")
    bloques = math.floor((libre / 2) // (16 * kv / 2))  # por GPU: la mitad del lugar y la mitad de los KV heads
    print(f"como lo cuenta vLLM: {bloques:,} bloques de 16 → GPU KV cache size: {bloques * 16:,} tokens, "
          f"Maximum concurrency for 8,192 tokens per request: {bloques / (8192 // 16):.2f}x")
    
    ttft, tpot, n = 0.5, 0.040, 250
    e2e = ttft + (n - 1) * tpot
    print(f"E2E = {ttft} + {n - 1} × {tpot} = {e2e:.2f} s; Little: 60 rps × 10.5 s = {60 * 10.5:.0f} vivas "
          f"÷ 16 réplicas = {60 * 10.5 / 16:.1f} por réplica")
    EOF

    Qué deberías ver: Sin salida: el archivo queda escrito. Los enteros del principio son los del config.json de Llama 3.3 70B Instruct (hidden_size, num_hidden_layers, num_attention_heads, num_key_value_heads, intermediate_size y vocab_size).

  2. Paso 2
    python3 bolsillo.py

    Qué deberías ver: Ocho líneas: parámetros: 70,553,706,496 ≈ 70.6 B (embeddings + lm_head: 2.1 B); pesos en BF16 141.1 GB, en FP8 70.6 GB y en INT4 35.3 GB; KV por token: 327,680 B = 320 KiB en BF16, 160 KiB en FP8, y 2.5 MiB con un KV head por head (sin GQA); lugar para KV: 68.4 GB, una secuencia de 2,250 tokens 0.737 GB, caben 92.77 → 92; como lo cuenta vLLM: 13,046 bloques de 16 → GPU KV cache size: 208,736 tokens, Maximum concurrency for 8,192 tokens per request: 25.48x; y E2E = 0.5 + 249 × 0.04 = 10.46 s con Little: 630 vivas ÷ 16 réplicas = 39.4 por réplica. Los 141.1 GB salen de la cuenta exacta; el C10 redondea a 70.6 × 2 = 141.2.

  3. Paso 3
    cd ~ && rm -rf ~/lab-vllm

    Qué deberías ver: Nada: no queda rastro del lab.

Cómo arreglarlo y confirmarlo

Si un número no te da, revisa los enteros del config.json: head_dim es hidden_size ÷ num_attention_heads solo si el config no trae head_dim (Qwen3-32B trae 128 aunque 5,120 ÷ 64 = 80), y los KV heads son num_key_value_heads, no num_attention_heads. Si la diferencia está en los bloques, recuerda que vLLM cuenta bloques enteros por GPU: 208,736 tokens, no 208,740.

Probado de verdad en macOS (Apple M4), con Python 3.14.3 y 3.9.6. La misma salida, byte a byte. Los 70,553,706,496 parámetros son los 70.6 B del C10; los bloques y la línea de vLLM siguen vllm/v1/core/kv_cache_utils.py de v0.30.0 (bloques enteros por GPU; tokens = concurrencia × max_model_len).

↳

La cadena: de un prompt a un stream de tokens

¿Qué le pasa a un request de chat, en orden, desde que llega a vLLM hasta que la última palabra sale por SSE, y qué parte de la latencia pone cada eslabón?

Sintaxis con variables

curl -sN url/v1/chat/completions -H 'content-type: application/json' -d '{"model": "modelo", "messages": [{"role": "user", "content": "texto"}], "stream": true, "max_tokens": n}'

El request entero. -N le quita el buffer a curl: ves cada evento SSE (Server-Sent Events: líneas data: {…} que el servidor manda sin cerrar la conexión) apenas llega, y al final data: [DONE].

curl -s url/tokenize -H 'content-type: application/json' -d '{"model": "modelo", "messages": [{"role": "user", "content": "texto"}]}'

El primer eslabón, solo: chat template y tokenizer. count es lo que entra de verdad, con las marcas de la plantilla y el encabezado del asistente.

Engine 000: Avg prompt throughput: p tokens/s, Avg generation throughput: g tokens/s, Running: r reqs, Waiting: w reqs, GPU KV cache usage: k%, Prefix cache hit rate: h%

La línea que vLLM escribe en su log cada 10 s (VLLM_LOG_STATS_INTERVAL; en DEBUG si está ocioso): la cadena entera en una línea. Si en esos 10 s hubo preemptions, agrega Preemptions: N antes de GPU KV cache usage.

curl -s url/metrics | grep -E '^vllm:request_(queue|prefill|decode)_time_seconds_(sum|count)'

Dónde se fue el tiempo, por fase: cola (waiting), prefill y decode. _sum ÷ _count da el promedio; para percentiles, histogram_quantile sobre los _bucket.

Variables

VariableQué esEjemplo
urlla base del servidor, vista desde donde corres curllocalhost:8000 con kubectl port-forward a un pod
modeloel --served-model-namechat-70b-fp8-2026-09-28
textoel mensaje del usuario¿Dónde está mi pedido?
ntope de tokens de salida del request512 en el ejemplo del README; el gateway corta en 1,024
p, g, r, w, k, htokens por segundo de prompt y de salida, requests corriendo y esperando, KV usado y aciertos del prefix cacheuna línea cada 10 s
tcolat_{\text{cola}}lo que espera en waiting hasta entrar al batch≈ 0 con lugar libre; lo primero que crece en sobrecarga
tprefillt_{\text{prefill}}lo que tarda en leer los tokens del prompt que no estaban en caché≈ 1,000 de 2,000 con el system prompt en caché
Tprompt, TcacheˊT_{\text{prompt}},\ T_{\text{caché}}tokens del prompt y tokens que ya estaban en el prefix cache2,000 y ≈ 1,000
tredt_{\text{red}}red, gateway y proxies entre vLLM y quien midelo que separa el TTFT del servidor del que mide el gateway
NoutN_{\text{out}}tokens de salida del request250 en promedio en el C10

Fórmulas

TTFT≈tcola+tprefill(Tprompt−Tcacheˊ)+tred\text{TTFT} \approx t_{\text{cola}} + t_{\text{prefill}}(T_{\text{prompt}} - T_{\text{caché}}) + t_{\text{red}}
TPOT=E2E−TTFTNout−1(la de vllm bench serve; con Nout=1 no se calcula)\text{TPOT} = \frac{\text{E2E} - \text{TTFT}}{N_{out} - 1} \qquad (\text{la de vllm bench serve; con } N_{out} = 1 \text{ no se calcula})
E2E=TTFT+(Nout−1)×TPOT≈0.5 s+249×40 ms≈10.5 s\text{E2E} = \text{TTFT} + (N_{out} - 1) \times \text{TPOT} \approx 0.5\ \text{s} + 249 \times 40\ \text{ms} \approx 10.5\ \text{s}
TTFTcliente=E2Esi un proxy junta toda la respuesta antes de reenviarla\text{TTFT}_{\text{cliente}} = \text{E2E} \qquad \text{si un proxy junta toda la respuesta antes de reenviarla}

Las piezas

PiezaQué haceEjemplo
POST /v1/chat/completionsEntra el request: messages, max_tokens y stream: true. En el C10, vLLM no autentica (con --api-key solo pediría una llave compartida, o varias, sin tenants ni cuotas): eso lo hace el gateway.el gateway agrega priority 0 y stream_options.include_usage
API serverEl proceso que atiende HTTP: aplica el chat template, tokeniza, valida el largo y, al final, convierte los tokens en texto y manda el stream. No toca la GPU.1 de los 4 procesos de una réplica TP=2: 1 API server, 1 engine core y 2 workers
Chat templateUne los messages en un solo texto con las marcas del modelo: inicio, rol y fin de turno.<|start_header_id|>user<|end_header_id|> … <|eot_id|> en Llama 3.3
TokenizerParte el texto en tokens del vocabulario del modelo y los convierte en números.≈ 2,000 tokens de prompt
Validación de largoprompt + max_tokens contra --max-model-len. En v0.30, si se pasa, un 400 antes de la cola; desde v0.32 solo cuenta el prompt.8,192 en el C10
Engine core: schedulerEl proceso que decide, en cada paso, qué secuencias corren y cuántos tokens procesa cada una: primero los decodes y, con el presupuesto que sobra, trozos de prefill.--max-num-batched-tokens 8192 y hasta 128 secuencias
Cola (waiting)Donde espera un request si no hay lugar en el batch o bloques de KV libres.vllm:num_requests_waiting y vllm:request_queue_time_seconds
Prefix cacheSi el prompt empieza con bloques que ya están en la GPU, los reusa: el prefill calcula solo lo nuevo.≈ 1,000 tokens de system prompt por producto
PrefillLee todos los tokens del prompt de una vez y escribe su KV. Termina con el primer token: marca el TTFT.piso ≈ 71 ms para 2,000 tokens (≈ 36 con la mitad en caché); el README estima ≈ 0.3–1 s con la cola
KV cacheGuarda la K y la V de cada token leído o generado, en bloques de 16; crece un token por paso.2,250 tokens × 320 KiB ≈ 0.74 GB al final
Workers, uno por GPUCorren el modelo. Con TP=2, cada GPU tiene la mitad de cada capa y la mitad de los KV heads, y las dos se sincronizan por NVLink en cada capa.2 H100 del mismo p5.48xlarge
DecodeUn paso por token: lee los pesos y el KV de todo el batch y saca un token por secuencia. Marca el TPOT.≈ 40 ms por paso en la estimación del C10
Sampling y paradaElige el token siguiente (temperature, top_p) y para con el fin de turno del modelo, con un stop o con max_tokens.finish_reason stop; length si llegó a max_tokens
Detokenizer y SSEConvierte los tokens nuevos en texto y los manda como un evento data: por paso; al final, data: [DONE].el gateway reenvía cada evento apenas llega
CancelaciónSi el cliente cierra la conexión, vLLM aborta la secuencia y libera sus bloques.el gateway la cierra y la cuenta como 499

el cliente

1Cliente

API server

2Chat template + tokenizer

engine core

3Cola4Scheduler5Prefix cache

workers de GPU

6Prefill7KV cache8Decode

API server

9Detokenizer + SSE

engine core

10Fin

rama aparte

·Métricas

  1. Clienteel cliente

    HTTPPOST /v1/chat/completions

    stream: true y max_tokens 512. Antes, el gateway ya autenticó, cobró la cuota y admitió el request.

  2. Chat template + tokenizerAPI server

    HTTPPOST /tokenize

    Los messages pasan a un texto con marcas y a ≈ 2,000 números. 2,000 + 512 ≤ 8,192: entra.

  3. Colaengine core

    PromQLvllm:num_requests_waiting

    Espera si no hay lugar ni bloques libres. En sobrecarga, este eslabón se come el TTFT antes que cualquier otro.

  4. Schedulerengine core

    flag--max-num-batched-tokens 8192

    En cada paso, primero los decodes de los que ya corren; con lo que sobra del presupuesto, trozos de prefill.

  5. Prefix cacheengine core

    PromQLvllm:prefix_cache_hits_total

    Los ≈ 1,000 tokens del system prompt ya están en bloques: no se recalculan.

  6. Prefillworkers de GPU

    PromQLvllm:request_prefill_time_seconds

    Lee el resto del prompt de una vez, escribe su KV y saca el primer token. Es cómputo: marca el TTFT.

  7. KV cacheworkers de GPU

    PromQLvllm:kv_cache_usage_perc

    Bloques de 16 tokens; al final, 2,250 × 320 KiB ≈ 0.74 GB para esta conversación.

  8. Decodeworkers de GPU

    PromQLvllm:inter_token_latency_seconds

    Un token por paso para cada secuencia del batch, cada ≈ 40 ms. Es memoria: marca el TPOT.

  9. Detokenizer + SSEAPI server

    evento SSEdata: {"choices": [{"delta": {"content": "…"}}]}

    En cada paso, el texto nuevo sale como un evento. Al final: un evento con finish_reason, el de usage si se pidió y data: [DONE].

  10. Finengine core

    JSONfinish_reason: "stop"

    El modelo cerró su turno. Sus bloques se liberan; los del prefijo pueden quedar en el prefix cache para el próximo request.

rama aparteMétricas

PromQLvllm:e2e_request_latency_seconds

Cada fase quedó en un histograma de /metrics; Prometheus lo lee cada 15 s con el PodMonitor del C10.

HTTPel request HTTP que llegaPromQLuna consulta: va en Prometheus o en Grafanaflaguna flag de vllm serveevento SSEun evento del stream (Server-Sent Events): lo manda vLLM, no lo escribesJSONun campo del JSON de la respuesta

Un request de soporte del C10 dentro de una réplica de vLLM v0.30, con los números del README (estimaciones: vLLM no corrió en la referencia). El chat template, el tokenizer, el detokenizer y el SSE corren en el proceso del API server; la cola, el scheduler y el prefix cache, en el engine core; el prefill y el decode, en los dos workers de GPU. Si el cliente se va a mitad, vLLM aborta la secuencia y libera su KV.

Ejemplo real · Un request de soporte del C10, de punta a punta

textoreference/c10/README.md (líneas 62–79, «Un request de punta a punta»)
t = 0       soporte manda POST /v1/chat/completions {model: "chat-default", stream: true, max_tokens: 512}
            con su API key al llm-gateway
+ < 1 ms    key → sha256 → tenant support-assistant (interactive, 5.4 M tokens/min, tope 1,024, sin vendor)
            validación: alias permitido, n = 1, max_tokens ≤ 1,024, prompt estimado ≤ 6,000
            cuota: cobra ≈ 2,000 (prompt estimado) + 512 del token bucket de esta réplica (5.4 M ÷ 4 por minuto)
+ ≤ 0.5 s   admisión: un lugar interactivo de los 288 de esta réplica, o cola (≤ 64, ≤ 0.5 s), o 503 + Retry-After
            → upstream vllm-70b (Service de los pods estables) con model = chat-70b-fp8-2026-09-28,
              priority = 0, stream_options.include_usage = true; conexión TCP nueva (sin keep-alive hacia vLLM)
+ ≈ 0.3–1 s vLLM: prefix cache del system prompt, prefill troceado del resto, primer token
            el gateway espera el primer evento con CONTENIDO (≤ 10 s): hasta acá una falla todavía es un 502/504
            limpio, incluido un stream que termina sin texto (así llega un abort antes del primer token, como en
            un drain: el rol, el usage y [DONE], sin chunk de abort)
            TTFT = desde que el request llegó al gateway hasta el primer evento con contenido
cada ≈ 40 ms un evento SSE por token, reenviado apenas llega (TPOT p95 ≤ 50 ms)
fin         [DONE]; el chunk de usage que pidió el gateway no se reenvía (el cliente no lo pidió)
            cierre: libera el lugar, concilia la cuota con usage (devuelve lo cobrado de más), métricas y una línea
            de log sin prompt ni respuesta
si el cliente se va: se cierra la conexión con vLLM (vLLM aborta y libera el KV), status 499 en las métricas
  • Las primeras líneas pasan en el gateway (sección 11): la llave, la cuota en tokens y la admisión. vLLM recibe el request ya con priority = 0, el nombre de la revisión en model y stream_options.include_usage = true.
  • «prefix cache del system prompt, prefill troceado del resto, primer token»: los eslabones del prefix cache y del prefill. El «≈ 0.3–1 s» es una estimación del caso: suma la cola de vLLM y el prefill de los ≈ 1,000 tokens que no estaban en caché.
  • «TTFT = desde que el request llegó al gateway hasta el primer evento con contenido»: el C10 mide donde lo ve el producto. El primer evento de vLLM puede traer solo el rol, sin texto. vllm bench serve, en cambio, marca el TTFT en el primer evento con choices, tenga texto o no.
  • «cada ≈ 40 ms un evento SSE por token»: el decode. Con el SLO de TPOT p95 ≤ 50 ms son ≈ 20 tokens por segundo por usuario, más rápido de lo que se lee.
  • «si el cliente se va»: el gateway cierra la conexión con vLLM, vLLM aborta la secuencia y libera su KV; en las métricas del gateway queda un 499.

Cómo recordarla

Entra texto, se vuelve números, espera su turno, se lee de una vez (prefill), se escribe de a un token (decode) y vuelve a ser texto, evento por evento. El TTFT es la cola más el prefill; el TPOT es un paso de decode; el KV cache une las dos fases. Si algo tarda, recorre la cadena con sus métricas: num_requests_waiting → request_queue_time_seconds → request_prefill_time_seconds → inter_token_latency_seconds.

Trampa

Mirar el TTFT del servidor y creer que es el que ve la persona. Entre vLLM y el usuario hay un gateway, un load balancer y a veces un proxy: si alguno junta la respuesta antes de reenviarla (buffering), el primer token llega pegado al último. Un TTFT de 0.5 s se vuelve uno de ≈ 10 s, y vllm:time_to_first_token_seconds sigue en verde. Por eso el C10 mide el TTFT en el gateway, hasta el primer evento con contenido. El lab de esta sección lo muestra con un proxy de juguete.

Laboratorio (opcional)4 pasos

Qué vas a provocar: Recorrer la cadena de un request con streaming en un motor de juguete: ver cada eslabón con su hora y los eventos SSE que llegan al cliente, y medir TTFT, TPOT, ITL y E2E con las fórmulas de vllm bench serve. Después, poner un proxy con buffer en el medio y ver qué le pasa al TTFT.

Necesitas: Python 3 de macOS (solo stdlib); sin GPU ni internet.

  1. Paso 1
    mkdir -p ~/lab-vllm && cd ~/lab-vllm
    cat > cadena.py <<'EOF'
    # cadena.py: un request de chat con streaming recorriendo la cadena, en miniatura (solo stdlib; tiempos simulados)
    import json, queue, sys, threading, time
    
    COLA_S, PREFILL_S, PASO_S, MAX_TOKENS = 0.10, 0.40, 0.040, 30  # TTFT ≈ 0.5 s y ≈ 40 ms por token, como el C10
    CON_PROXY = "--proxy" in sys.argv
    t0 = time.perf_counter()
    
    
    def log(eslabon, texto):
        print(f"  [vLLM  +{time.perf_counter() - t0:5.2f} s] {eslabon:<10} {texto}", flush=True)
    
    
    def motor(salida):  # lo que pasa dentro de vLLM, simulado con sleeps
        log("tokenizer", "chat template + tokenizer: 2,000 tokens de prompt (2,000 + 30 ≤ 8,192: entra)")
        time.sleep(COLA_S)
        log("scheduler", "sale de waiting y entra al batch")
        time.sleep(PREFILL_S)
        log("prefill", "los 2,000 tokens, de una vez: su KV queda en 125 bloques de 16; sale el token 1")
        reloj = time.perf_counter()
        for i in range(1, MAX_TOKENS + 1):
            if i > 1:  # un paso de decode: relee los pesos y el KV de todo el batch
                reloj += PASO_S
                time.sleep(max(0.0, reloj - time.perf_counter()))  # contra el reloj: sleep solo se pasa de largo
            fin = "length" if i == MAX_TOKENS else None  # llegó a max_tokens
            salida.put("data: " + json.dumps({"choices": [{"index": 0, "delta": {"content": f" t{i}"}, "finish_reason": fin}]}))
        salida.put("data: " + json.dumps({"choices": [], "usage": {"prompt_tokens": 2000, "completion_tokens": MAX_TOKENS,
                                                                    "total_tokens": 2000 + MAX_TOKENS}}))
        salida.put("data: [DONE]")
        log("fin", f"{MAX_TOKENS} tokens = max_tokens → finish_reason length; se liberan sus bloques")
    
    
    def proxy_con_buffer(entrada, salida):  # un proxy que junta toda la respuesta antes de reenviarla
        eventos = []
        while not eventos or eventos[-1] != "data: [DONE]":
            eventos.append(entrada.get())
        for e in eventos:
            salida.put(e)
    
    
    hacia_motor, hacia_cliente = queue.Queue(), queue.Queue()
    st = time.perf_counter()  # como vllm bench serve: el reloj arranca al mandar el request
    if CON_PROXY:
        threading.Thread(target=motor, args=(hacia_motor,)).start()
        threading.Thread(target=proxy_con_buffer, args=(hacia_motor, hacia_cliente)).start()
    else:
        threading.Thread(target=motor, args=(hacia_cliente,)).start()
    
    ttft, ultimo, itl, n = None, st, [], 0
    while True:
        linea = hacia_cliente.get()
        ahora = time.perf_counter()
        if linea == "data: [DONE]":
            print(f"  [cliente +{ahora - st:5.2f} s] {linea}")
            break
        evento = json.loads(linea[len("data: "):])
        if not evento["choices"]:  # el chunk de usage (stream_options.include_usage)
            usage = evento["usage"]
            print(f"  [cliente +{ahora - st:5.2f} s] data: {{\"choices\": [], \"usage\": {json.dumps(usage)}}}")
            continue
        if ttft is None:
            ttft = ahora - st  # el primer chunk con choices
        else:
            itl.append(ahora - ultimo)  # el hueco desde el chunk anterior
        ultimo, n = ahora, n + 1
        if n <= 2 or n == MAX_TOKENS:
            print(f"  [cliente +{ahora - st:5.2f} s] {linea}")
        elif n == 3:
            print("  [cliente   ...  ] (un evento cada ≈ 40 ms)" if not CON_PROXY else "  [cliente   ...  ] (todos juntos)")
    
    e2e = ultimo - st
    tpot = (e2e - ttft) / (usage["completion_tokens"] - 1)  # la fórmula de vllm bench serve
    itl.sort()
    print(f"TTFT {ttft:.2f} s · TPOT {tpot * 1e3:.1f} ms · ITL p50 {itl[len(itl) // 2] * 1e3:.1f} ms · E2E {e2e:.2f} s"
          f" · {usage['completion_tokens'] / e2e:.1f} tokens/s{' (con proxy)' if CON_PROXY else ''}")
    EOF

    Qué deberías ver: Sin salida: el archivo queda escrito. Es una simulación: no hay modelo ni GPU. vLLM de verdad manda antes un evento que solo trae el rol, sin texto (sección 5); aquí se omite. Los tiempos son los del C10 (cola 0.1 s y prefill 0.4 s, para un TTFT ≈ 0.5 s, y 40 ms por token), con una respuesta de 30 tokens para que dure menos de 2 s.

  2. Paso 2
    python3 cadena.py

    Qué deberías ver: Tres líneas [vLLM …] (tokenizer en +0.00 s, scheduler en ≈ +0.10 s y prefill en ≈ +0.50 s), los eventos data: con " t1", " t2"…, uno cada ≈ 40 ms, la línea [vLLM … fin] y el último evento con " t30" y "finish_reason": "length" en ≈ +1.67–1.69 s, el de usage (prompt_tokens 2000, completion_tokens 30) y data: [DONE]. Al final: TTFT 0.50–0.52 s · TPOT ≈ 40 ms · ITL p50 ≈ 40 ms · E2E ≈ 1.67–1.69 s · ≈ 18 tokens/s.

  3. Paso 3
    python3 cadena.py --proxy

    Qué deberías ver: El motor hace lo mismo, pero el cliente no ve nada hasta ≈ +1.67–1.69 s: entonces llegan todos los eventos juntos. Al final: TTFT ≈ 1.67–1.69 s · TPOT 0.0 ms · ITL p50 0.0 ms · E2E igual al TTFT. El servidor generó igual de rápido; la persona esperó toda la respuesta para ver la primera palabra.

  4. Paso 4
    cd ~ && rm -rf ~/lab-vllm

    Qué deberías ver: Nada: no queda rastro del lab.

Cómo arreglarlo y confirmarlo

Si los tiempos salen más largos (por ejemplo, un TPOT de ≈ 44 ms), la Mac está ocupada o el sleep se pasa de largo; el motor duerme contra un reloj para compensar, así que repite con la Mac quieta. Si ves el TTFT bien y el E2E mal, el problema es el decode; si ves el TTFT igual al E2E, hay un buffer en el camino.

Probado de verdad en un Mac. Ejecutado con Python 3.14.3 y 3.9.6 en macOS (Apple M4), varias veces (y 14 más en la revisión): directo, TTFT 0.50–0.52 s, TPOT 40.0–40.3 ms y E2E 1.67–1.69 s; con el proxy, TTFT = E2E = 1.67–1.69 s. Las fórmulas de TTFT, ITL, TPOT y E2E son las de vllm/benchmarks/serve.py y vllm/benchmarks/lib/endpoint_request_func.py de v0.30.0.

1

Por qué servir un LLM es distinto y qué hace vLLM

¿Qué cambia cuando el modelo escribe su respuesta token por token, y qué piezas trae vLLM para que una GPU atienda muchas conversaciones a la vez?

Sintaxis con variables

vllm serve modelo --tensor-parallel-size tp

Un comando levanta el servidor compatible con OpenAI en el puerto 8000: carga los pesos, mide la memoria, arma el KV cache (la memoria de lo que el modelo ya leyó) y empieza a aceptar requests. Los flags están en la sección 4.

curl -sN localhost:8000/v1/chat/completions -H 'content-type: application/json' -d '{"model": "nombre", "messages": [{"role": "user", "content": "texto"}], "stream": true}'

Con stream: true se ve que la respuesta es un loop: un evento por token. Sin streaming, esperas al último token para ver el primero.

curl -s localhost:8000/metrics | grep -E '^vllm:num_requests_(running|waiting)'

El continuous batching (el batch se rearma en cada paso) a la vista: cuántas conversaciones comparten la GPU en este paso (running) y cuántas esperan lugar (waiting).

Variables

VariableQué esEjemplo
modeloruta local o repo de Hugging Face del modelo/models/chat-70b-fp8-2026-09-28 en el C10
tpcuántas GPUs reparten cada capa (tensor parallelism)2
nombreel nombre servidochat-70b-fp8-2026-09-28
textoel mensajeResume esta política en tres puntos
P, TpromptP,\ T_{\text{prompt}}parámetros del modelo y tokens del prompt70.6 B y 2,000
bwb_{w}bytes por peso1 (FP8)
SSsecuencias en el batch1, 40 o 92
tpasot_{\text{paso}}lo que tarda un paso de decode: leer los pesos y el KV de todo el batch≥ 14.9 ms con 40 secuencias (el lab)
IdecodeI_{\text{decode}}intensidad aritmética: operaciones por cada byte que se trae de la memoria≈ 2 FLOPs/B con una secuencia, contra 591 que pide la H100 para estar ocupada
NiN_{i}largo de la respuesta i de un batch estáticode 50 a 1,000 tokens en el ejemplo de M7

Fórmulas

FLOPsprefill≈2×P×Tprompt=2×70.6 B×2,000≈2.8×1014  ⇒  t≳2.8×10142×1,979 TFLOPS≈71 ms\text{FLOPs}_{\text{prefill}} \approx 2 \times P \times T_{\text{prompt}} = 2 \times 70.6\ \text{B} \times 2{,}000 \approx 2.8 \times 10^{14} \;\Rightarrow\; t \gtrsim \frac{2.8 \times 10^{14}}{2 \times 1{,}979\ \text{TFLOPS}} \approx 71\ \text{ms}
tpaso≳pesos+KV del batchNgpu×ancho de banda=70.6 GB+0.74 GB2×3.35 TB/s≈10.6 ms(una secuencia)t_{\text{paso}} \gtrsim \frac{\text{pesos} + \text{KV del batch}}{N_{gpu} \times \text{ancho de banda}} = \frac{70.6\ \text{GB} + 0.74\ \text{GB}}{2 \times 3.35\ \text{TB/s}} \approx 10.6\ \text{ms} \qquad (\text{una secuencia})
throughput≲Stpaso=4014.94 ms≈2,678 tokens/s(y por usuario, ≤67)\text{throughput} \lesssim \frac{S}{t_{\text{paso}}} = \frac{40}{14.94\ \text{ms}} \approx 2{,}678\ \text{tokens/s} \qquad (\text{y por usuario, } \le 67)
Idecode≈2×P×SP×bw=2Sbw  ≪  1,979 TFLOPS3.35 TB/s≈591 FLOPs/BI_{\text{decode}} \approx \frac{2 \times P \times S}{P \times b_w} = \frac{2S}{b_w} \;\ll\; \frac{1{,}979\ \text{TFLOPS}}{3.35\ \text{TB/s}} \approx 591\ \text{FLOPs/B}
uˊtilestaˊtico=∑iNiS×max⁡iNi=2,4008×1,000=30 %\text{útil}_{\text{estático}} = \frac{\sum_i N_i}{S \times \max_i N_i} = \frac{2{,}400}{8 \times 1{,}000} = 30\ \%
reservando 8,192 por secuencia: ⌊13,046512⌋=25en bloques de 16: ⌊13,046141⌋=92\text{reservando 8{,}192 por secuencia: } \left\lfloor \frac{13{,}046}{512} \right\rfloor = 25 \qquad \text{en bloques de 16: } \left\lfloor \frac{13{,}046}{141} \right\rfloor = 92

Las piezas

PiezaQué haceEjemplo
AutoregresivoEl modelo escribe un token por pasada, y cada token nuevo depende de todos los anteriores: la respuesta es un loop, no una llamada.250 tokens de salida = 1 prefill + 249 pasos de decode
PrefillLeer todo el prompt de una vez: cada peso se usa para los 2,000 tokens, así que manda el cómputo (compute-bound).piso ≈ 71 ms en 2 × H100 con FP8; ≈ 143 ms en BF16
DecodeEscribir de a un token: cada paso relee todos los pesos para sacar un token por secuencia, así que manda el ancho de banda de la memoria (memory-bound).piso ≈ 10.6 ms por paso con TP=2; el C10 estima ≈ 40 ms
KV cacheLo que el modelo ya leyó de cada conversación (las keys y values de cada capa), guardado en la GPU para no recalcularlo en cada token nuevo.320 KiB por token: ≈ 0.74 GB por conversación de 2,250 tokens
Static batchingUn batch fijo: no termina hasta su respuesta más larga, y los lugares de las que ya terminaron quedan vacíos.30 % de lugares útiles en el ejemplo de M7
Continuous batchingEl batch se rearma en cada paso: una secuencia que termina deja su lugar y otra entra en el mismo paso. La idea viene de Orca (OSDI 2022).hasta 128 secuencias por réplica en el C10 (--max-num-seqs)
PagedAttentionGuarda el KV cache en bloques chicos, como las páginas de la memoria virtual, en vez de reservar por secuencia el largo máximo. Su paper (SOSP 2023) reporta 2–4× más throughput a igual latencia.bloques de 16 tokens: 92 secuencias en vez de 25
Chunked prefillParte un prompt largo en trozos que comparten cada paso con los decodes de los demás: un prompt de 8k no congela al resto.activado por defecto en V1
Prefix cachingReusa los bloques de KV de un comienzo de prompt que ya calculó.el system prompt de ≈ 1,000 tokens de cada producto
Servidor compatible con OpenAIvllm serve expone la API de OpenAI: el cliente oficial funciona cambiando la base_url.base_url del gateway: http://vllm-70b.c10.svc.cluster.local:8000/v1
Motor V1La arquitectura actual de vLLM, la única desde v0.11: un proceso para HTTP y tokens, un engine core con el scheduler y un worker por GPU.TP=2: 4 procesos
SGLangLa alternativa más cercana. Su RadixAttention guarda los prefijos en un árbol y los reusa: rinde cuando muchos prompts comparten comienzo.candidata a medir contra vLLM con la mezcla del C10
TensorRT-LLMEl de NVIDIA: kernels propios, FP8 y FP4, y trtllm-serve compatible con OpenAI. Más atado a la GPU, el driver y el contenedor.el C10 no lo elige para empezar
TGIEl de Hugging Face. Está en modo mantenimiento y su repositorio está archivado (el último cambio es del 21 de marzo de 2026); su README recomienda vLLM o SGLang.no para un sistema nuevo
llama.cpp · MLXPara correr modelos en local, en CPU o en Apple Silicon; el README de TGI los recomienda para eso.tu laptop, no la flota del C10
NVIDIA Dynamo · llm-dNo son motores: orquestan varios (prefill y decode en GPUs distintas, ruteo según el KV cache de cada réplica).una palanca para después en el C10

El request entero, a escala (de 0 a 15 s)

  • 0.5 s prefill: 2,000 tokens a la vez, más la cola · cómputo
  • 9.96 s decode: 249 pasos de ≈ 40 ms, un token cada uno · memoria
  • TTFT ≈ 0.5 s: la primera palabra
  • E2E ≈ 10.5 s: la última
  • 1 s el SLO del TTFT (p95) · 13.45 s el peor caso dentro de los dos SLOs: 1 s + 249 × 50 ms

el primer segundo: el prefill y los primeros 13 tokens, cada 40 ms (a escala, de 0 a 1 s)

  • piso teórico del prefill: ≈ 71 ms (el real: 500 ms, con la cola)
  • piso teórico de un paso de decode: ≈ 10.6 ms (el real: 40 ms)

prefill: lee el prompt (cómputo)decode: un token por paso (memoria)TTFT y E2E de este requestel SLO

Con los dos SLOs en su límite p95, el peor caso dentro del SLO es 1.0 + 249 × 0.050 ≈ 13.5 s. Con streaming no importa: la persona empieza a leer en el TTFT.

Un request de soporte del C10, a escala: 2,000 tokens de prompt y 250 de salida, con TTFT ≈ 0.5 s y ≈ 40 ms por token (estimaciones del caso). El prefill es un bloque de cómputo; el decode, 249 pasos chicos de memoria. Los pisos teóricos en 2 × H100: ≈ 71 ms de prefill y ≈ 10.6 ms por paso.

Ejemplo real · Un request de LLM dura segundos y ocupa su lugar todo ese tiempo

Pythonreference/c10/capacity/fleet_calc.py (líneas 136–143)
def request_seconds(ttft_s: float, output_tokens: float, tpot_s: float) -> float:
    """Duración de un request de streaming ≈ TTFT + tokens de salida × TPOT."""
    return ttft_s + output_tokens * tpot_s


def littles_law_concurrency(rps: float, seconds_per_request: float) -> float:
    """L = λ × W (#m03.little): secuencias vivas a la vez = llegadas por segundo × segundos que dura cada una."""
    return rps * seconds_per_request
  • Un request de LLM no es un forward: es un TTFT y después un paso por token. Con los números del caso, 0.5 + 250 × 0.040 = 10.5 s. El ResNet-50 del C01 usa ≈ 5 ms de GPU por imagen.
  • La función cuenta un paso de más: con la definición de vllm bench serve son 249 pasos después del primero (10.46 s). La diferencia no cambia ninguna decisión.
  • littles_law_concurrency es la ley de Little: 60 rps × 10.5 s = 630 secuencias vivas en la flota, ≈ 39.4 por réplica. Cada una ocupa su lugar en el batch y su KV durante esos 10 segundos.
  • Por eso un servidor de LLMs vive del continuous batching: con secuencias que entran y salen en cada paso, las ≈ 40 de una réplica comparten cada lectura de los pesos. Y por eso la memoria del KV, no el cómputo, decide cuántas caben (sección 3).

Cómo recordarla

Prefill es leer la pregunta entera de una vez; decode es contestar palabra por palabra. Leer es cómputo (TTFT); contestar es memoria (TPOT). Y el continuous batching es un bus que deja subir y bajar pasajeros en cada parada, en vez de esperar a que todos lleguen al final del recorrido.

Trampa

Servir un LLM como una CNN: FastAPI con un model.generate() por batch. El batch es estático: no termina hasta la respuesta más larga, los lugares vacíos esperan (30 % útil en el ejemplo de M7), cada secuencia reserva su KV entero y no hay streaming si no lo escribes. Y medir la carga con nvidia-smi: un decode memory-bound marca ≈ 100 % de utilización con los tensor cores (las unidades que multiplican matrices) casi ociosos. La capacidad se lee en la cola y en el KV: vllm:num_requests_waiting y vllm:kv_cache_usage_perc.

Laboratorio (opcional)3 pasos

Qué vas a provocar: Calcular, con la réplica del C10 y la hoja de datos de la H100, por qué el prefill es cómputo y el decode es memoria: operaciones por byte, el piso del prefill y el de cada paso de decode con 1, 40 y 92 secuencias.

Necesitas: Python 3 de macOS (solo stdlib); sin GPU ni internet.

  1. Paso 1
    mkdir -p ~/lab-vllm && cd ~/lab-vllm
    cat > roofline.py <<'EOF'
    # roofline.py: por qué el prefill es cómputo y el decode es memoria, en una réplica del C10 (solo stdlib)
    P, B_PESO, TP = 70.6e9, 1, 2   # parámetros, bytes por peso (FP8) y GPUs de la réplica
    FLOPS = 1_979e12               # H100 SXM, FP8 denso, por GPU (la hoja dice 3,958 "with sparsity")
    BW = 3.35e12                   # H100 SXM: 3.35 TB/s de memoria, por GPU
    KV = 327_680                   # bytes de KV por token en BF16 (sección 0)
    print(f"punto de quiebre de una H100 en FP8: {FLOPS / BW:.0f} FLOPs por byte leído")
    
    # Prefill: 2 FLOPs por parámetro y por token; los pesos se leen UNA vez para todo el prompt
    T = 2_000
    flops, bytes_ = 2 * P * T, P * B_PESO + T * KV          # + escribir el KV del prompt
    print(f"prefill de {T:,} tokens: {flops / bytes_:,.0f} FLOPs/B → manda el cómputo; "
          f"piso {flops / (TP * FLOPS) * 1e3:.0f} ms ({flops / (TP * FLOPS / 2) * 1e3:.0f} ms en BF16)")
    
    # Decode: un token por secuencia por paso; cada paso relee TODOS los pesos y el KV de cada secuencia
    for B in (1, 40, 92):
        flops, bytes_ = 2 * P * B, P * B_PESO + B * 2_250 * KV  # KV de B secuencias de 2,250 tokens (peor caso)
        paso = bytes_ / (TP * BW)
        print(f"decode, batch de {B:>2}: {flops / bytes_:5.1f} FLOPs/B → manda la memoria; paso ≥ {paso * 1e3:4.1f} ms"
              f" → ≤ {1 / paso:3.0f} tokens/s por secuencia, ≤ {B / paso:5,.0f} en total")
    EOF

    Qué deberías ver: Sin salida: el archivo queda escrito.

  2. Paso 2
    python3 roofline.py

    Qué deberías ver: punto de quiebre de una H100 en FP8: 591 FLOPs por byte leído; prefill de 2,000 tokens: 3,963 FLOPs/B → manda el cómputo; piso 71 ms (143 ms en BF16); y tres líneas de decode: batch de 1, 2.0 FLOPs/B y paso ≥ 10.6 ms (≤ 94 tokens/s); batch de 40, 56.4 FLOPs/B y paso ≥ 14.9 ms (≤ 67 tokens/s por secuencia, ≤ 2,678 en total); batch de 92, 93.8 FLOPs/B y paso ≥ 20.7 ms (≤ 48 por secuencia, ≤ 4,453 en total). Aun con 92 secuencias, el decode queda ≈ 6× por debajo del punto de quiebre: más batch sube el total, no la velocidad de cada usuario.

  3. Paso 3
    cd ~ && rm -rf ~/lab-vllm

    Qué deberías ver: Nada: no queda rastro del lab.

Cómo arreglarlo y confirmarlo

Son cotas teóricas, no promesas: el cómputo real no llega al pico y el paso real del C10 se estima en ≈ 40 ms, no en 15. El número medido sale del barrido de vllm bench serve (sección 7). Si tus cifras no coinciden, revisa los TFLOPS: la hoja de NVIDIA da 3,958 with sparsity, y el denso es la mitad.

Probado de verdad en macOS (Apple M4), con Python 3.14.3 y 3.9.6. La misma salida, byte a byte. Datos de la H100 SXM de la página de NVIDIA: 80 GB, 3.35 TB/s y 3,958 TFLOPS FP8 with sparsity.

2

Tokens y contexto: tokenizer, chat template, max_model_len, costo por token, español contra inglés

¿Qué cuenta vLLM como token, qué entra en la ventana de contexto, qué pasa cuando un request no cabe y por qué el mismo texto cuesta más en español que en inglés?

Sintaxis con variables

curl -s localhost:8000/tokenize -H 'content-type: application/json' -d '{"model": "modelo", "messages": [{"role": "system", "content": "system"}, {"role": "user", "content": "texto"}], "return_token_strs": true}'

Cuenta como el modelo servido: su tokenizer (el que parte el texto en tokens) y su chat template (la plantilla que une los mensajes con las marcas del modelo). count incluye las marcas de la plantilla y el encabezado del asistente (add_generation_prompt, true por defecto); token_strs muestra cada pedazo.

curl -s localhost:8000/tokenize -H 'content-type: application/json' -d '{"model": "modelo", "prompt": "texto"}'

Solo el texto, sin plantilla: la diferencia entre los dos count es lo que agrega el chat template. Con prompt, add_special_tokens es true por defecto: suma el token de inicio.

"max_completion_tokens": n

El tope de salida de un request; max_tokens sigue funcionando, pero está deprecado a favor de este. Si no lo mandas, en chat vLLM puede generar hasta llenar la ventana: max_model_len − prompt; en /v1/completions, el default es 16.

vllm serve modelo --max-model-len ctx

La ventana de contexto: cuántos tokens caben entre prompt y respuesta. Acepta 8192, 8K (= 8,192) u 8k (= 8,000), y auto: el máximo que entra en la memoria.

Variables

VariableQué esEjemplo
modeloel nombre servidochat-70b-fp8-2026-09-28
system, textoel system prompt y el mensaje del usuarioEres el asistente de soporte del marketplace. · ¿Dónde está mi pedido?
ntope de tokens de salida1,024 en soporte, 600 en listings y 400 en catálogo
ctxla ventana de contexto8192
Tprompt, Tin, ToutT_{\text{prompt}},\ T_{\text{in}},\ T_{\text{out}}tokens del prompt (los de entrada) y de la salida2,000 y 250 en promedio
pin, poutp_{\text{in}},\ p_{\text{out}}precio por millón de tokens de entrada y de salidaUS$2.50 y US$10 (lista ilustrativa del C10)
fffertilidad: tokens por palabra de un tokenizer en un idiomaLlama 3.1: 1.23 en inglés y 1.61 en español
T^gateway\hat{T}_{\text{gateway}}lo que estima el gateway del C10 antes de tokenizar, a propósito pesimista66 tokens para un chat de 174 caracteres (el lab)

Fórmulas

Tprompt+max_tokens≤max_model_len:2,000+1,024≤8,192(v0.30 responde 400 si no)T_{\text{prompt}} + \texttt{max\_tokens} \le \texttt{max\_model\_len}: \quad 2{,}000 + 1{,}024 \le 8{,}192 \qquad (\text{v0.30 responde 400 si no})
max_tokensefectivo=min⁡(max_model_len−Tprompt, max_tokens)(v0.32: 7,500+1,024→692)\texttt{max\_tokens}_{\text{efectivo}} = \min(\texttt{max\_model\_len} - T_{\text{prompt}},\ \texttt{max\_tokens}) \qquad (\text{v0.32: } 7{,}500 + 1{,}024 \to 692)
costo=Tin×pin+Tout×pout106=2,000×2.50+250×10106=US$0.0075\text{costo} = \frac{T_{in} \times p_{in} + T_{out} \times p_{out}}{10^6} = \frac{2{,}000 \times 2.50 + 250 \times 10}{10^6} = \text{US}\$0.0075
T≈palabras×f:200×1.61≈322  (espan˜ol)contra200×1.23≈246  (ingleˊs)T \approx \text{palabras} \times f: \quad 200 \times 1.61 \approx 322 \;(\text{español}) \quad \text{contra} \quad 200 \times 1.23 \approx 246 \;(\text{inglés})
T^gateway=⌈caracteres3⌉+4×mensajes\hat{T}_{\text{gateway}} = \left\lceil \frac{\text{caracteres}}{3} \right\rceil + 4 \times \text{mensajes}

Las piezas

PiezaQué haceEjemplo
TokenUn pedazo de texto del vocabulario del modelo (una palabra corta, parte de una palabra o un signo), con su número.Llama 3: 128,256 entradas en el vocabulario
TokenizerParte el texto en tokens y los convierte en números, y al revés. Cada familia de modelos tiene el suyo: el mismo texto da cantidades distintas en cada una.el de Llama 3.1: ≈ 1.23 tokens por palabra en inglés y ≈ 1.61 en español
Tokens especialesMarcas que no son texto: inicio, encabezado de rol y fin de turno. Cada una es un solo token.<|begin_of_text|> · <|start_header_id|> · <|end_header_id|> · <|eot_id|>
Chat templateLa plantilla Jinja (un lenguaje de plantillas) del tokenizer_config.json que convierte la lista de messages en un solo texto con esas marcas. vLLM usa la del modelo y --chat-template la reemplaza; sin plantilla, los requests de chat fallan.la de Llama 3.3 agrega siempre un bloque system con «Cutting Knowledge Date: December 2023» y «Today Date: 26 Jul 2024»
add_generation_promptAgrega al final el encabezado del asistente, para que el modelo empiece a contestar ahí. /tokenize lo pone por defecto con messages.<|start_header_id|>assistant<|end_header_id|> y un salto doble
max_model_lenLa ventana: cuántos tokens caben entre prompt y respuesta. Si no la fijas, vale la del config del modelo.8,192 en el C10; 131,072 en el config de Llama 3.3
max_tokens · max_completion_tokensEl tope de tokens de salida de un request. Sin él, en chat, vLLM puede generar hasta llenar la ventana; en /v1/completions el default es 16.el gateway lo fija en 1,024 (soporte), 600 (listings) y 400 (catálogo)
Prompt + salida > ventanaEn v0.30, un 400 que empieza con «This model's maximum context length is». Desde v0.32, si el prompt cabe por sí solo, entra y la salida se recorta a lo que queda.7,500 + 1,024: un 400 en v0.30; como mucho 692 tokens en v0.32
usageLo que la respuesta dice que gastó: prompt_tokens, completion_tokens y total_tokens. En streaming llega al final si lo pides con stream_options.include_usage.el gateway concilia la cuota con esto
finish_reasonPor qué terminó: stop (el modelo cerró su turno o encontró un stop), length (llegó a max_tokens o a la ventana), tool_calls (pidió una herramienta) o abort (vLLM canceló la secuencia).length en soporte = una respuesta cortada: mide cuántas hay
Prompt contra salidaLos tokens de entrada se leen juntos en el prefill; cada token de salida es un paso de decode y agranda el KV.≈ 8 de entrada por cada 1 de salida en el interactivo del C10
Costo por tokenLas APIs cobran aparte la entrada y la salida, y la salida suele costar varias veces más.US$2.50 y US$10 por millón: un request de soporte cuesta US$0.0075, un tercio por la salida
Español contra inglésEl mismo tokenizer parte el español en más pedazos por palabra: más tokens, más costo, más KV y más pasos de decode.200 palabras: ≈ 246 tokens en inglés y ≈ 322 en español (Llama 3.1)
Estimar sin tokenizerDividir los caracteres: rápido y aproximado. Sirve para cobrar una cuota por adelantado, no para facturar.el gateway del C10: caracteres ÷ 3 + 4 por mensaje, y concilia con usage
/tokenizeEl endpoint de vLLM que cuenta con el tokenizer y el chat template del modelo servido.devuelve count, max_model_len y tokens

Tokens por palabra, a escala de 0 a 2 · texto formal: noticias de XL-Sum en cada idioma

  1. Llama 3.1-70B

    1.231.61

    +31 %

  2. Qwen 2.5-72B

    1.251.61

    +29 %

  3. DeepSeek V3

    1.231.55

    +26 %

  4. Mistral Small 24B

    1.271.42

    +12 %

  5. GPT-4o

    1.221.36

    +11 %

  6. Aya Expanse 32B

    1.241.33

    +7 %

200 palabras de respuesta con el tokenizer de Llama 3.1: 246 tokens en inglés contra 322 en español (+31 %)

246322

Fuente: Nayeem et al., «Beyond Fertility: Analyzing STRR as a Metric for Multilingual Tokenization Evaluation», 2025, tabla 1 (arxiv.org/abs/2510.09947)

El chat template de Llama 3.3 70B Instruct: lo que de verdad lee el modelo

<|begin_of_text|><|start_header_id|>system<|end_header_id|>↵↵Cutting Knowledge Date: December 2023↵Today Date: 26 Jul 2024↵↵Eres el asistente de soporte del marketplace.<|eot_id|><|start_header_id|>user<|end_header_id|>↵↵¿Dónde está mi pedido?<|eot_id|><|start_header_id|>assistant<|end_header_id|>↵↵

<|…|>marca especial: 1 token (aquí, 9)rolel rol del turnotextolo agrega la plantillatextotus mensajes↵salto de línea

Cada marca especial es un solo token: 9 en este prompt. El texto, tuyo o de la plantilla, lo cuenta el tokenizer: POST /tokenize da el total. Si no pasas date_string en chat_template_kwargs, la plantilla dice que hoy es 26 Jul 2024.

Fuente: el chat_template del tokenizer_config.json (copia pública: huggingface.co/unsloth/Llama-3.3-70B-Instruct; el repo de Meta pide aceptar la licencia)

La ventana de 8,192 tokens de un request (a escala)

  • 1,000 system prompt del producto (prefijo compartido)
  • 1,000 historial y mensaje del usuario
  • 250 respuesta promedio
  • 774 resto del tope de salida de soporte (1,024)
  • 5,168 libre hasta 8,192
  • 6,000 max_prompt_tokens de soporte en el gateway
  • 7,024 6,000 + 1,024: lo máximo que pide un request de soporte

inglésespañolprompt (lo lee el prefill)salida (la escribe el decode)reservado para la salidalibre

Arriba, cuántos tokens por palabra da cada tokenizer en inglés y en español (medido por Nayeem et al., 2025, en noticias): con el de Llama 3.1, la familia del modelo del C10, el español usa ≈ 31 % más. Al centro, el chat template de Llama 3.3 aplicado a dos mensajes cortos: 9 tokens especiales antes de que el modelo escriba nada. Abajo, la ventana de 8,192 tokens de un request de soporte del C10.

Ejemplo real · Cómo estima los tokens el gateway del C10 antes de tokenizar

Pythonreference/c10/gateway/quota.py (líneas 32–48)
# Caracteres por token para ESTIMAR sin tokenizer. ≈ 4 es la regla para inglés con tokenizers BPE; en
# español suele haber más tokens por carácter. 3.0 sobrestima a propósito (el error se devuelve al conciliar).
# Mídelo con tu tokenizer sobre una muestra de tus prompts: es una estimación, no un dato del modelo.
CHARS_PER_TOKEN = 3.0
TOKENS_PER_MESSAGE = 4  # overhead del chat template por mensaje (rol, separadores): estimación


def estimate_prompt_tokens(messages: list[dict]) -> int:
    """Tokens de entrada estimados a partir de los mensajes (texto y partes de texto; ignora imágenes)."""
    chars = 0
    for m in messages:
        content = m.get("content")
        if isinstance(content, str):
            chars += len(content)
        elif isinstance(content, list):  # formato multi-parte: [{"type": "text", "text": "..."}, ...]
            chars += sum(len(p.get("text", "")) for p in content if isinstance(p, dict))
    return math.ceil(chars / CHARS_PER_TOKEN) + TOKENS_PER_MESSAGE * len(messages)
  • CHARS_PER_TOKEN = 3.0: la regla de ≈ 4 caracteres por token es para inglés, y en español salen más tokens. 3.0 sobrestima a propósito: el gateway cobra de más al entrar y devuelve la diferencia al terminar, con el usage real.
  • TOKENS_PER_MESSAGE = 4 es lo que agrega la plantilla por mensaje, estimado. En Llama 3.3, cada mensaje lleva tres tokens especiales (<|start_header_id|>, <|end_header_id|> y <|eot_id|>) más el rol y un salto de línea doble, y la plantilla agrega un bloque system con dos líneas de fecha aunque no mandes ninguno. El número exacto lo da /tokenize.
  • Por qué estimar: la cuota se cobra al entrar (prompt estimado + max_tokens) y el costo real se conoce al final. Con ≈ 2,000 de prompt y max_tokens 1,024, el gateway cobra 3,024 y, si se generaron 250, devuelve 774 (el docstring de quota.py).
  • El gateway también pone los topes de la ventana: max_prompt_tokens 6,000 y max_tokens_cap 1,024 en soporte (tenants.yaml), porque 6,000 + 1,024 < 8,192. Así, mientras la estimación no se quede corta, ningún request de soporte llega a vLLM pidiendo más de lo que cabe; si se queda corta, vLLM responde el 400 y el gateway lo reenvía.

Cómo recordarla

Cuenta lo que cuenta el tokenizer del modelo, después del chat template. Prompt + salida ≤ max_model_len. La salida es la parte cara (4× en la lista del C10) y la lenta (un paso por token). Y en español, con el tokenizer de Llama 3.1, ≈ 1.3× más tokens por palabra que en inglés.

Trampa

Dejar max_tokens sin fijar. En chat, vLLM toma entonces todo lo que queda de la ventana (8,192 − prompt): una respuesta que no para ocupa su lugar y su KV hasta llenarla, y la cuota no sabe cuánto cobrar. El gateway del C10 lo fija en el tope del tenant. Y la trampa del upgrade: en v0.30, el pin del C10, un prompt de chat de 7,500 tokens con max_tokens 1,024 recibe un 400 («This model's maximum context length is 8192 tokens. However, you requested 1024 output tokens and your prompt contains at least 7169 input tokens, for a total of at least 8193 tokens. …»). Dice «at least 7169» y no 7,500 porque vLLM deja de tokenizar un token después del límite. Desde v0.32 el mismo request entra, y la respuesta se corta en 692 tokens como mucho, con finish_reason: "length" si llega. Si un cliente dependía del 400, el upgrade lo cambia sin avisar: valida en el gateway.

Laboratorio (opcional)4 pasos

Qué vas a provocar: Ver cómo valida la ventana vLLM v0.30 y cómo lo hace v0.32 (con sus mensajes reales), cuánto cuesta un request del C10, cuánto más pesa una respuesta en español y qué estima el gateway antes de tokenizar.

Necesitas: Python 3 de macOS (solo stdlib); sin GPU ni internet.

  1. Paso 1
    mkdir -p ~/lab-vllm && cd ~/lab-vllm
    cat > presupuesto.py <<'EOF'
    # presupuesto.py: la ventana de contexto como la valida vLLM, lo que cuesta un token y cuánto pesa el español (solo stdlib)
    import math
    
    CTX = 8192  # --max-model-len del C10
    
    
    def v030(prompt, max_tokens):  # vllm/renderers/params.py en v0.30.0: max_tokens se RESERVA de la ventana
        if max_tokens is not None and prompt > CTX - max_tokens:
            return (f"400 This model's maximum context length is {CTX} tokens. However, you requested {max_tokens} output "
                    f"tokens and your prompt contains {prompt} input tokens, for a total of {prompt + max_tokens} tokens. "
                    "Please reduce the length of the input prompt or the number of requested output tokens.")
        return f"entra: genera hasta {min(x for x in (CTX - prompt, max_tokens) if x is not None):,} tokens"
    
    
    def v032(prompt, max_tokens):  # desde v0.32.0: max_tokens es un tope; solo el prompt tiene que caber
        if prompt > CTX:
            return (f"400 This model's maximum context length is {CTX} tokens. However, your prompt contains {prompt} "
                    "input tokens, which exceeds this limit even without any output tokens. Please reduce the length of the input prompt.")
        return f"entra: genera hasta {min(x for x in (CTX - prompt, max_tokens) if x is not None):,} tokens"
    
    
    for prompt, max_tokens in ((2_000, 1_024), (7_500, 1_024), (2_000, None), (8_300, 256)):
        print(f"prompt {prompt:,} + max_tokens {max_tokens}:")
        print(f"  v0.30 → {v030(prompt, max_tokens)}")
        print(f"  v0.32 → {v032(prompt, max_tokens)}")
    
    # Precios de lista ILUSTRATIVOS del C10 (US$ por millón de tokens)
    P_IN, P_OUT = 2.50, 10.00
    costo = (2_000 * P_IN + 250 * P_OUT) / 1e6
    print(f"un request de soporte (2,000 de entrada + 250 de salida): US${costo:.4f}; la salida es el "
          f"{250 * P_OUT / 1e6 / costo:.0%} del costo con el {250 / 2_250:.0%} de los tokens")
    
    # Fertilidad del tokenizer de Llama 3.1-70B en noticias (Nayeem et al., 2025): tokens por palabra
    for idioma, f in (("inglés", 1.23), ("español", 1.61)):
        t = round(200 * f)
        print(f"una respuesta de 200 palabras en {idioma}: ≈ {t} tokens → US${t * P_OUT / 1e6:.5f}, "
              f"≈ {t * 0.040:.1f} s de decode a 40 ms por token, {t * 327_680 / 1e6:.0f} MB de KV")
    
    # La estimación del gateway del C10 (gateway/quota.py), copiada tal cual
    CHARS_PER_TOKEN = 3.0
    TOKENS_PER_MESSAGE = 4
    
    
    def estimate_prompt_tokens(messages):
        chars = sum(len(m["content"]) for m in messages)
        return math.ceil(chars / CHARS_PER_TOKEN) + TOKENS_PER_MESSAGE * len(messages)
    
    
    chats = {
        "español": [{"role": "system", "content": "Eres el asistente de soporte del marketplace. Responde breve y con amabilidad."},
                    {"role": "user", "content": "Hola, compré unos audífonos hace diez días y todavía no llegan. ¿Me ayudas a rastrear mi pedido?"}],
        "inglés": [{"role": "system", "content": "You are the marketplace's support assistant. Answer briefly and kindly."},
                   {"role": "user", "content": "Hi, I bought some headphones ten days ago and they still haven't arrived. Can you help me track my order?"}],
    }
    for idioma, msgs in chats.items():
        palabras = sum(len(m["content"].split()) for m in msgs)
        f = 1.61 if idioma == "español" else 1.23
        print(f"chat en {idioma}: {sum(len(m['content']) for m in msgs)} caracteres, {palabras} palabras → gateway "
              f"{estimate_prompt_tokens(msgs)} tokens (caracteres ÷ 3 + 4 por mensaje); por palabra, ≈ {round(palabras * f)}")
    EOF

    Qué deberías ver: Sin salida: el archivo queda escrito. Las dos funciones de validación siguen vllm/renderers/params.py de v0.30.0 y de v0.32.0, con sus mensajes; estimate_prompt_tokens es la de gateway/quota.py del C10 (la versión del lab solo acepta content de texto). Con un prompt de texto, el vLLM real deja de tokenizar un token después del límite y su mensaje dice «at least 7169 … at least 8193»; el lab muestra el largo completo, como cuando mandas token IDs a /v1/completions.

  2. Paso 2
    python3 presupuesto.py | head -n 12

    Qué deberías ver: Cuatro casos. 2,000 + 1,024: entra en las dos y genera hasta 1,024. 7,500 + 1,024: v0.30 → 400 This model's maximum context length is 8192 tokens. However, you requested 1024 output tokens and your prompt contains 7500 input tokens, for a total of 8524 tokens. Please reduce the length of the input prompt or the number of requested output tokens.; v0.32 → entra: genera hasta 692 tokens. 2,000 sin max_tokens: las dos generan hasta 6,192. 8,300 + 256: las dos dan 400, y el texto de v0.32 dice which exceeds this limit even without any output tokens.

  3. Paso 3
    python3 presupuesto.py | tail -n 5

    Qué deberías ver: un request de soporte (2,000 de entrada + 250 de salida): US$0.0075, y la salida es el 33% del costo con el 11% de los tokens; 200 palabras en inglés ≈ 246 tokens (US$0.00246, ≈ 9.8 s de decode, 81 MB de KV) y en español ≈ 322 (US$0.00322, ≈ 12.9 s, 106 MB); y los dos chats: en español, 174 caracteres y 29 palabras → gateway 66 tokens, ≈ 47 por palabra; en inglés, 176 caracteres y 30 palabras → gateway 67, ≈ 37 por palabra. Las dos son estimaciones: los tokens reales los da POST /tokenize con el modelo servido.

  4. Paso 4
    cd ~ && rm -rf ~/lab-vllm

    Qué deberías ver: Nada: no queda rastro del lab.

Cómo arreglarlo y confirmarlo

Si quieres contar tokens de verdad, levanta cualquier vLLM y usa POST /tokenize con messages (cuenta la plantilla) y con prompt (no la cuenta). Para el upgrade, el gateway del C10 ya topa el prompt estimado (6,000) y max_tokens (1,024), que suman menos de 8,192: mientras la estimación no se quede corta, su comportamiento no depende de la versión de vLLM.

Probado de verdad en macOS (Apple M4), con Python 3.14.3 y 3.9.6. La misma salida, byte a byte. Los mensajes de error son los de vllm/renderers/params.py en las etiquetas v0.30.0 y v0.32.0 (PR #57035), sin el «at least» que vLLM agrega cuando corta la tokenización; la fertilidad, la de la tabla 1 de Nayeem et al. (2025).

3

Memoria: pesos + KV cache, la fórmula con GQA, gpu_memory_utilization, bloques, max_num_seqs y preemption

¿En qué se va la memoria de la GPU, cuántas conversaciones caben a la vez en una réplica y qué hace vLLM cuando se le acaban los bloques de KV?

Sintaxis con variables

vllm serve modelo --tensor-parallel-size tp --gpu-memory-utilization u --max-model-len ctx --max-num-seqs s --kv-cache-dtype dtype

Los cinco flags de la cuenta de memoria. u es la fracción de la memoria TOTAL de cada GPU que vLLM se permite usar (0.92 si no la escribes); dtype es auto (el del modelo) o fp8.

Available KV cache memory: gib GiB

Lo que imprime después de cargar los pesos y de perfilar un paso de prueba: lo que quedó para KV, por GPU y en GiB. Ya descontó pesos, activaciones y CUDA graphs (los pasos de la GPU grabados para repetirlos rápido).

GPU KV cache size: tokens tokens, Maximum concurrency for ctx tokens per request: xx

La capacidad real de la réplica: los tokens de KV (los mismos en las dos GPUs con TP) y cuántas secuencias del largo máximo caben a la vez. Manda sobre cualquier cuenta.

curl -s localhost:8000/metrics | grep -E '^vllm:(kv_cache_usage_perc|num_preemptions_total|num_requests_waiting)'

Las tres señales de que el KV se llenó: uso cerca de 1, preemptions (secuencias que vLLM saca del batch para hacer lugar) que suben y requests esperando. Con cola, lo primero que se rompe es el TTFT.

--kv-cache-memory-bytes bytes

Fija el KV por GPU en bytes y se salta la medición de memoria: ignora --gpu-memory-utilization. Acepta los sufijos de --max-model-len: la minúscula es decimal y la mayúscula binaria (30g = 30 × 10⁹ bytes, 30G = 30 × 2³⁰).

Variables

VariableQué esEjemplo
modeloruta o repo del modelo/models/chat-70b-fp8-2026-09-28
tpGPUs que reparten cada capa2
ufracción de la memoria de cada GPU para vLLM0.90
ctxcontexto máximo por secuencia8192
stecho de secuencias por paso128
dtypetipo de número del KV cacheauto (BF16) o fp8
gib, tokens, xlo que imprime vLLM: GiB libres por GPU, tokens de KV y concurrencia para {ctx}con la cuenta del C10: ≈ 31.9 GiB, 208,736 y 25.48
bytestamaño fijo del KV por GPUno lo usa el C10
M_GPU, u\texttt{M\_GPU},\ umemoria total de una GPU (la que reporta el dispositivo) y la fracción80 GB nominales, 81,559 MiB reportados; 0.90
L, H, Hkv, gL,\ H,\ H_{\text{kv}},\ gcapas, heads de atención, KV heads y cuántos heads comparten cada KV head80, 64, 8 y 8
dhead, bkvd_{\text{head}},\ b_{\text{kv}}tamaño de cada head y bytes por número del KV128 y 2
Nbloques, TseqN_{\text{bloques}},\ T_{\text{seq}}bloques de 16 tokens que caben y tokens por secuencia13,046 y 2,250
Nvivas, Tpref, NprefN_{\text{vivas}},\ T_{\text{pref}},\ N_{\text{pref}}secuencias vivas, tokens del prefijo compartido y prefijos distintos40, 1,000 y 3 (el mejor caso del C10)

Fórmulas

MvLLM=⌈MGPU×u⌉ por GPUKVlibre=MvLLM−pesos−activaciones−CUDA graphsM_{\text{vLLM}} = \lceil M_{\text{GPU}} \times u \rceil \text{ por GPU} \qquad \text{KV}_{\text{libre}} = M_{\text{vLLM}} - \text{pesos} - \text{activaciones} - \text{CUDA graphs}
KVtoken=2×L×Hkv×dhead×bkv,Hkv=Hg=648=8  ⇒  320 KiB en vez de 2.5 MiB\text{KV}_{\text{token}} = 2 \times L \times H_{kv} \times d_{head} \times b_{kv}, \qquad H_{kv} = \frac{H}{g} = \frac{64}{8} = 8 \;\Rightarrow\; 320\ \text{KiB en vez de } 2.5\ \text{MiB}
Nbloques=⌊KVlibre/TP16×KVtoken/TP⌋=⌊34.2 GB16×163,840 B⌋=13,046  ⇒  208,736 tokensN_{\text{bloques}} = \left\lfloor \frac{\text{KV}_{\text{libre}} / \text{TP}}{16 \times \text{KV}_{\text{token}} / \text{TP}} \right\rfloor = \left\lfloor \frac{34.2\ \text{GB}}{16 \times 163{,}840\ \text{B}} \right\rfloor = 13{,}046 \;\Rightarrow\; 208{,}736 \text{ tokens}
Nseq=⌊Nbloques⌈Tseq/16⌉⌋=⌊13,046141⌋=92concurrencia=13,046⌈8,192/16⌉=25.48N_{seq} = \left\lfloor \frac{N_{\text{bloques}}}{\lceil T_{seq} / 16 \rceil} \right\rfloor = \left\lfloor \frac{13{,}046}{141} \right\rfloor = 92 \qquad \text{concurrencia} = \frac{13{,}046}{\lceil 8{,}192 / 16 \rceil} = 25.48
KVvivo=Nvivas×(Tseq−Tpref)+Npref×Tpref=40×1,250+3×1,000=53,000 tokens (en vez de 90,000)\text{KV}_{\text{vivo}} = N_{vivas} \times (T_{seq} - T_{pref}) + N_{pref} \times T_{pref} = 40 \times 1{,}250 + 3 \times 1{,}000 = 53{,}000 \text{ tokens (en vez de } 90{,}000)

Las piezas

PiezaQué haceEjemplo
PesosLos números del modelo: parámetros × bytes. Con TP=2 se reparten entre las GPUs, no se copian.70.6 GB en FP8: 35.3 por GPU
Activaciones y CUDA graphsLo que ocupa calcular un paso (resultados intermedios) y los CUDA graphs (los pasos de la GPU grabados una vez, para repetirlos sin el costo de lanzarlos). vLLM los mide al arrancar con un paso de prueba.≈ 5 GB en la réplica (estimación del C10)
KV cacheTodo lo que queda dentro del presupuesto: se convierte en bloques para K y V.68.4 GB por réplica
Margen (1 − u)Lo que vLLM no toca: picos, fragmentación, otros procesos.10 % con 0.90: 16 GB en la réplica
GQAOcho heads de atención comparten cada par de K y V: el KV es 8× más chico que con un par por head.320 KiB por token en vez de 2.5 MiB
--gpu-memory-utilizationLa fracción de la memoria TOTAL de cada GPU (la que reporta el dispositivo) que vLLM se permite usar. Si al arrancar hay menos libre, falla con «Free memory on device … is less than desired GPU memory utilization».0.90 explícito en el C10
--kv-cache-memory-bytesFija el KV por GPU en bytes y se salta la medición de memoria: ignora gpu_memory_utilization.no lo usa el C10
Bloque (--block-size 16)La unidad en que se reparte el KV: 16 tokens de una secuencia. Una secuencia pide bloques a medida que crece.2,250 tokens = 141 bloques
--max-model-lenEl largo máximo de una secuencia. vLLM se niega a arrancar si no cabe ni una.8,192: una sola secuencia de 8k ocupa 2.7 GB
--max-num-seqsCuántas secuencias corren en el mismo paso, como máximo. No reserva KV cache: es un techo (sí dimensiona algunos buffers y los CUDA graphs).128 en el C10; 1,024 por defecto en la H100
PreemptionSi a una secuencia que corre le falta un bloque y no hay, vLLM saca a otra del batch: libera sus bloques y la devuelve a la cola (al frente, con fcfs). Al volver, recalcula su KV desde cero, salvo lo que siga en el prefix cache.con priority sale primero la de menor prioridad: el batch nocturno
vllm:num_preemptions_totalEl counter de preemptions. También aparece «Preemptions: N» en la línea periódica del log cuando hubo; el scheduler de V1 no escribe un warning al desalojar.LLMPreemptionsRising: > 0.05 por request terminado, 15 min, de día
vllm:kv_cache_usage_percLa fracción del KV ocupada (1 = 100 %).KVCacheSaturation: > 95 % y requests esperando
--kv-cache-dtype fp8Guarda K y V en 1 byte: la mitad de KV por token y el doble de secuencias.185 en vez de 92; el C10 lo deja en auto hasta pasar el golden set
--watermarkLa fracción de bloques que el scheduler deja libre al admitir requests, para no tener que sacar a nadie después. Por defecto 0.0: apagado.existe en v0.30 y en v0.32; el C10 no lo usa
Prefix caching y memoriaLas secuencias con el mismo comienzo comparten esos bloques: no se duplican.40 × 1,250 + 3 × 1,000 = 53,000 tokens en vez de 90,000

La réplica: 2 GPUs, 160 GB (a escala)

  • 70.6 GB pesos FP8
  • 5 GB activaciones, CUDA graphs y buffers (estimación)
  • 68.4 GB KV cache
  • 16 GB margen que vLLM no toca (10 %)
  • 144 GB el tope de vLLM: 0.9 × 160 GB

Adentro de los 68.4 GB de KV (a escala)

  • 40 conversaciones vivas de 2,250 tokens (Little en el pico)

    29.5 GB · 43 %
  • 92 de 2,250 tokens: el máximo

    67.8 GB · 99 %
  • 25 de 8,192 tokens: el contexto máximo

    67.1 GB · 98 %

Cada GPU: 80 GB (a escala)

13,046 bloques de 16 tokens = 208,736 tokens de KV, los mismos en las 2 GPUs

La misma cuenta, cambiando una cosa (KV a escala, de 0 a 80 GB)

  • KV en FP8 (160 KiB por token)

    185 secuencias
  • la memoria que reporta nvidia-smi: 81,559 MiB ≈ 85.52 GB por GPU

    106 secuencias
  • pesos en BF16 (141.2 GB) en las mismas 2 GPUs

    no cabe: −2.2 GB0 secuencias

pesosoverhead: activaciones, CUDA graphs y buffersKV cache (adentro: la parte en uso)KV librereservado: vLLM no lo tocael tope de --gpu-memory-utilization

Los números son los de capacity/vram_calc.py y sus tests. Con TP=2, cada GPU guarda la mitad de los KV heads de todas las capas: los 13,046 bloques son los mismos tokens en las dos GPUs.

Una réplica del C10 (2 × H100 de 80 GB, TP=2, --gpu-memory-utilization 0.90), a escala: 70.6 GB de pesos FP8, ≈ 5 GB de overhead (estimación), 68.4 GB de KV y 16 GB que vLLM no toca. Sobre el KV, lo que ocupan 40 conversaciones (la ley de Little en el pico), el máximo de 92 de 2,250 tokens y 25 secuencias de 8,192. Abajo, tres variantes de la misma cuenta.

Ejemplo real · Lugar para KV y secuencias por réplica, en vram_calc.py

Pythonreference/c10/capacity/vram_calc.py (líneas 110–152)
def kv_room_per_replica(
    m: ModelConfig,
    gpu: GPU,
    tensor_parallel: int,
    gpu_memory_utilization: float,
    overhead_bytes: float,
) -> float:
    """Bytes que quedan para KV cache en UNA réplica (tensor_parallel GPUs que sirven juntas un modelo).

    gpu_memory_utilization: fracción de la VRAM que vLLM se permite usar (pesos + activaciones + KV). El
      default es 0.92 desde vLLM v0.20 (0.9 hasta v0.19): el caso fija 0.90 explícito en el manifest para
      que la capacidad no cambie con un upgrade.
    overhead_bytes: activaciones del forward, workspace, CUDA graphs y buffers de NCCL, sumados en la
      réplica. Es una estimación (≈ 5 GB en el caso); vLLM la mide al arrancar con un forward de prueba.
    """
    if tensor_parallel < 1:
        raise ValueError("tensor_parallel debe ser >= 1")
    if not 0 < gpu_memory_utilization <= 1:
        raise ValueError("gpu_memory_utilization debe estar en (0, 1]")
    if m.kv_heads % tensor_parallel != 0:
        # Con TP, cada GPU guarda los K/V de SUS KV heads. Si no se dividen exacto, el runtime replica heads
        # y la cuenta de abajo (KV total repartido sin duplicar) deja de valer: mejor fallar que mentir.
        raise ValueError(f"{m.kv_heads} KV heads no se dividen entre TP={tensor_parallel}")
    budget = tensor_parallel * gpu.vram_bytes * gpu_memory_utilization  # lo que vLLM puede usar en total
    room = budget - weight_bytes(m) - overhead_bytes  # los pesos se reparten entre las GPUs (TP), no se copian
    if room <= 0:
        raise ValueError(
            f"{m.name} no cabe: presupuesto {budget / GB:.1f} GB, pesos {weight_bytes(m) / GB:.1f} GB, "
            f"overhead {overhead_bytes / GB:.1f} GB"
        )
    return room


def max_concurrent_sequences(room_bytes: float, m: ModelConfig, tokens_per_sequence: int) -> int:
    """Cuántas secuencias de ese largo caben a la vez, todas en su largo máximo (peor caso, entero)."""
    return math.floor(room_bytes / kv_bytes_per_sequence(m, tokens_per_sequence))


def max_kv_tokens(room_bytes: float, m: ModelConfig) -> int:
    """Tokens totales de KV que caben. Al arrancar vLLM imprime un poco menos como "GPU KV cache size": cuenta
    bloques ENTEROS de 16 tokens por GPU (con TP=2 cada GPU guarda la mitad de los KV heads de cada token). En el
    caso: 208,740 aquí; 13,046 bloques × 16 = 208,736 en el log de vLLM."""
    return math.floor(room_bytes / kv_bytes_per_token(m))
  • budget = tensor_parallel × vram × utilización: la memoria que vLLM se permite usar en la réplica. Los pesos se restan una sola vez porque TP los reparte entre las GPUs; con 2 × 80 GB y 0.90, 144 − 70.6 − 5 = 68.4 GB.
  • Si kv_heads % tensor_parallel no da 0, la cuenta falla a propósito: con TP cada GPU guarda los K y V de sus propios KV heads, y si no se dividen exacto el runtime replica heads y el KV ya no se reparte sin duplicar. Con 8 KV heads, TP puede ser 1, 2, 4 u 8.
  • «no cabe»: con pesos BF16 en dos H100, 144 − 141.2 − 5 = −2.2 GB. vLLM fallaría al arrancar con un error de memoria; la cuenta lo dice antes de pedir las GPUs.
  • max_concurrent_sequences usa el peor caso: todas las secuencias en su largo final. max_kv_tokens da 208,740 tokens; vLLM cuenta bloques enteros de 16 por GPU y diría 208,736.

Cómo recordarla

Pesos fijos, KV variable: lo que queda después de los pesos decide cuántas conversaciones atiendes. 144 GB usables − 70.6 − ≈ 5 = 68.4 GB = 13,046 bloques de 16 tokens = 92 conversaciones de 2,250. Cuando a una secuencia que corre le falta un bloque, vLLM saca a otra (preemption) y la recalcula después: trabajo tirado.

Trampa

Subir --max-num-seqs creyendo que compra capacidad. No reserva KV cache: es un techo. Si dejas entrar más secuencias de las que el KV aguanta en su largo final (128 × 141 bloques = 18,048 > 13,046), vLLM admite mientras haya bloques para los prompts (104 en el lab) y, cuando crecen, empieza a sacar y recalcular: el lab cuenta 130 preemptions y 273,100 tokens recalculados para el mismo throughput que con 92. El scheduler de V1 no escribe un warning al desalojar: lo ves en vllm:num_preemptions_total y en Preemptions: N de la línea periódica. La documentación todavía muestra el warning de V0 («Sequence group 0 is preempted by PreemptionMode.RECOMPUTE mode…»).

Laboratorio (opcional)3 pasos

Qué vas a provocar: Simular el pool de bloques de KV de una réplica del C10: cuántos bloques hay, cuántas secuencias caben y qué pasa con 72, 92 y 128 requests en vuelo, con el KV en BF16 y en FP8, y con fcfs (por orden de llegada) contra priority (por prioridad).

Necesitas: Python 3 de macOS (solo stdlib); sin GPU ni internet.

  1. Paso 1
    mkdir -p ~/lab-vllm && cd ~/lab-vllm
    cat > bloques.py <<'EOF'
    # bloques.py: el pool de bloques de KV de una réplica del C10 y la preemption, en miniatura (solo stdlib)
    # Modelo simplificado del scheduler de vLLM V1: bloques de 16 tokens, primero los que ya corren, después la cola;
    # si a uno que corre le falta un bloque, sale el último admitido (fcfs) o el de menor prioridad (priority) y
    # después recalcula todo su prefill. Sin chunked prefill ni prefix caching: el peor caso.
    import math
    
    BLOQUE, TP = 16, 2
    LIBRE = 2 * 80e9 * 0.90 - 70.6e9 - 5e9  # 68.4 GB para KV en la réplica (sección 0)
    
    
    def bloques_totales(kv_token):  # como vLLM: por GPU (la mitad del lugar y de los KV heads), bloques enteros
        return math.floor((LIBRE / TP) // (BLOQUE * kv_token / TP))
    
    
    def simular(en_vuelo, kv_token=327_680, max_num_seqs=128, politica="fcfs", batch=0, prompt=2_000, salida=250, total=1_000):
        """Barrido cerrado, como vllm bench serve --max-concurrency: siempre hay `en_vuelo` requests vivos.
        `batch` de ellos tienen priority 10 (el batch nocturno) y el resto 0 (interactivo), como manda el gateway."""
        libres = bloques_totales(kv_token)
        nuevo = lambda i, prio: {"gen": 0, "bloques": 0, "prio": prio, "llegada": i}
        waiting, running = [nuevo(i, 10 if i >= en_vuelo - batch else 0) for i in range(en_vuelo)], []
        llegadas, hechos, preempt, por_prio, pasos, pico, recalc = en_vuelo, 0, 0, {0: 0, 10: 0}, 0, 0, 0
        while hechos < total:
            pasos += 1
            i = 0
            while i < len(running):  # 1) decode: cada secuencia que corre necesita lugar para su token nuevo
                r = running[i]
                falta = math.ceil((prompt + r["gen"]) / BLOQUE) - r["bloques"]
                while falta > libres:  # sin bloque libre: preemption
                    v = max(running, key=lambda x: (x["prio"], x["llegada"])) if politica == "priority" else running[-1]
                    running.remove(v)
                    libres += v["bloques"]
                    v["bloques"] = 0  # vuelve al frente de la cola; al reentrar recalcula prompt + lo generado
                    waiting.insert(0, v)
                    preempt += 1
                    por_prio[v["prio"]] += 1
                    if v is r:
                        break
                if r not in running:  # el que necesitaba lugar salió él mismo
                    continue
                i = running.index(r)  # con priority, el desalojado pudo estar antes en la lista
                libres -= falta
                r["bloques"] += falta
                r["gen"] += 1
                if r["gen"] == salida:  # terminó: libera sus bloques y entra otro request de la misma clase
                    running.remove(r)
                    libres += r["bloques"]
                    hechos += 1
                    waiting.append(nuevo(llegadas, r["prio"]))
                    llegadas += 1
                    continue
                i += 1
            if politica == "priority":
                waiting.sort(key=lambda x: (x["prio"], x["llegada"]))
            while waiting and len(running) < max_num_seqs:  # 2) admitir de la cola mientras haya bloques
                w = waiting[0]
                necesita = math.ceil((prompt + w["gen"]) / BLOQUE)
                if necesita > libres:
                    break
                waiting.pop(0)
                if w["gen"]:  # volvía de una preemption: su prefill se repite entero
                    recalc += prompt + w["gen"]
                libres -= necesita
                w["bloques"] = necesita
                w["gen"] += 1  # el prefill termina con un token
                running.append(w)
            pico = max(pico, len(running))
        return preempt, por_prio, pasos, pico, recalc
    
    
    B16 = bloques_totales(327_680)
    print(f"bloques de KV en la réplica: {B16:,} → GPU KV cache size: {B16 * BLOQUE:,} tokens, "
          f"Maximum concurrency for 8,192 tokens per request: {B16 / (8192 // BLOQUE):.2f}x")
    print(f"una secuencia de 2,250 tokens usa {math.ceil(2_250 / BLOQUE)} bloques → caben {B16 // math.ceil(2_250 / BLOQUE)}; "
          f"si cada una reservara 8,192 contiguos (512 bloques), {B16 // 512}")
    casos = [("72 en vuelo (el tope del gateway: 4 × 288 ÷ 16 réplicas)", dict(en_vuelo=72)),
             ("92 en vuelo", dict(en_vuelo=92)),
             ("128 en vuelo (= --max-num-seqs)", dict(en_vuelo=128)),
             ("128 en vuelo con KV en FP8", dict(en_vuelo=128, kv_token=163_840)),
             ("128 en vuelo, fcfs: 72 chat + 56 batch", dict(en_vuelo=128, batch=56)),
             ("128 en vuelo, priority: 72 chat + 56 batch", dict(en_vuelo=128, politica="priority", batch=56))]
    for nombre, kw in casos:
        p, prio, pasos, pico, recalc = simular(**kw)
        extra = f" (chat {prio[0]}, batch {prio[10]})" if kw.get("batch") else ""
        print(f"{nombre}: {p:,} preemptions{extra}, {recalc:,} tokens recalculados; {pasos:,} pasos para 1,000 requests;"
              f" hasta {pico} a la vez")
    EOF

    Qué deberías ver: Sin salida: el archivo queda escrito. Es un modelo simplificado del scheduler de V1: todos los prompts de 2,000 tokens, salidas de 250, sin chunked prefill ni prefix caching.

  2. Paso 2
    python3 bloques.py

    Qué deberías ver: bloques de KV en la réplica: 13,046 → GPU KV cache size: 208,736 tokens, Maximum concurrency for 8,192 tokens per request: 25.48x; una secuencia de 2,250 tokens usa 141 bloques → caben 92, y reservando 8,192 contiguos (512 bloques), 25. Después: con 72 en vuelo, 0 preemptions (3,487 pasos para 1,000 requests); con 92, 0 (2,740 pasos); con 128, 130 preemptions y 273,100 tokens recalculados, hasta 104 a la vez y los mismos 2,740 pasos; con 128 y el KV en FP8, 0 (1,993 pasos, 128 a la vez); con 72 chats y 56 batch en fcfs, 130 preemptions (chat 72, batch 58); y en priority, 130 (chat 0, batch 130).

  3. Paso 3
    cd ~ && rm -rf ~/lab-vllm

    Qué deberías ver: Nada: no queda rastro del lab.

Cómo arreglarlo y confirmarlo

Las cifras exactas son las de un modelo simplificado, no las de un vLLM real; la forma sí vale: hasta 92 en vuelo no hay preemptions y con 128 sí, más en vuelo no da más throughput, y con priority el costo cae sobre el batch. Si quieres ver las de verdad, mira vllm:num_preemptions_total o la parte Preemptions: N del log de un vLLM con GPU.

Probado de verdad en macOS (Apple M4), con Python 3.14.3 y 3.9.6. La misma salida, byte a byte. Las reglas del scheduler (desalojar al último admitido en fcfs, o al de menor prioridad y llegada más tardía en priority; liberar sus bloques, volverlo al frente de la cola y recalcular desde 0) son las de vllm/v1/core/sched/scheduler.py en v0.30.0; la cuenta de bloques, la de vllm/v1/core/kv_cache_utils.py.

4

Arrancar vLLM: vllm serve y sus flags, la imagen de Docker, el manifest del C10 en Kubernetes (GPUs, shm, probes, caché del modelo, secreto de HF)

¿Qué le pasas a vllm serve para que un 70B arranque en 2 H100, atienda solo lo que debe y se apague sin cortarle la respuesta a nadie?

Sintaxis con variables

vllm serve modelo \
  --served-model-name nombre \
  --tensor-parallel-size tp \
  --gpu-memory-utilization fracción \
  --max-model-len contexto \
  --max-num-seqs techo \
  --port 8000

El arranque mínimo que fijarías en producción. modelo es un repo de Hugging Face (org/nombre) o una carpeta local con config.json, el tokenizer y los pesos. Todo lo demás tiene default, pero los defaults cambian con la versión y con la GPU: fija lo que tu cuenta de capacidad supone. La lista completa de flags está en la página de vllm serve de docs.vllm.ai, que tiene una versión por release.

docker run --runtime nvidia --gpus all --ipc=host -p 8000:8000 \
  -v ~/.cache/huggingface:/root/.cache/huggingface \
  --env "HF_TOKEN=$HF_TOKEN" \
  vllm/vllm-openai:versión modelo flags

La imagen oficial ya trae ENTRYPOINT ["vllm", "serve"]: después del nombre de la imagen van solo el modelo y los flags. --ipc=host (o --shm-size) le da memoria compartida de sobra: los procesos de cada GPU y NCCL (la librería de NVIDIA que comunica GPUs) se pasan datos por /dev/shm, y los 64 MB que Docker da por defecto no alcanzan con TP (el modelo partido entre varias GPUs). El volumen guarda la caché de Hugging Face entre arranques; HF_TOKEN solo hace falta para repos gated (con una licencia que aceptar antes de bajar).

env:
  - name: HF_TOKEN
    valueFrom:
      secretKeyRef: {name: secreto, key: token}
volumeMounts:
  - {name: shm, mountPath: /dev/shm}
volumes:
  - {name: shm, emptyDir: {medium: Memory, sizeLimit: tamaño}}

En Kubernetes, el token de Hugging Face sale de un Secret, nunca del YAML, y /dev/shm es un emptyDir en RAM que cuenta contra el límite de memoria del contenedor. El C10 no usa token: sus pesos vienen de su propio S3 y vLLM corre con HF_HUB_OFFLINE=1.

curl -s -o /dev/null -w '%{http_code}\n' host:8000/health
curl -s host:8000/v1/models
curl -s host:8000/version

Tres preguntas a una réplica: ¿el motor vive? (/health da 200, o 503 si el engine murió), ¿qué nombre sirve y con qué contexto? (/v1/models: id, root y max_model_len) y ¿qué versión corre? (/version). A un pod se llega con kubectl port-forward pod/pod 8000:8000 y host = localhost.

kubectl logs pod -c vllm | grep 'KV cache size'

La capacidad real la imprime vLLM al arrancar, en una línea: GPU KV cache size: N tokens, Maximum concurrency for M tokens per request: Kx. Ese número manda; la cuenta de la sección 3 sirve para saber antes de comprar. Para el C10, la cuenta espera ≈ 208,736 tokens y 25.48× para 8,192.

--shutdown-timeout s                 # 0 (default) = abortar todo; > 0 = drenar hasta s segundos
terminationGracePeriodSeconds: t     # ≥ preStop + s + margen

El apagado en orden: corre el preStop (un paso que Kubernetes corre antes de apagar), llega el SIGTERM (la señal de «termina»), vLLM aborta lo que llega y deja terminar lo que está en vuelo hasta s segundos; al vencer ese plazo mata su engine (lo que quedaba termina con un error, no con un abort). Si al vencer t el pod sigue vivo, SIGKILL (muerte inmediata).

Variables

VariableQué esEjemplo
modelorepo de Hugging Face o carpeta local con config.json, tokenizer y pesos/models/chat-70b-fp8-2026-09-28
nombreel nombre que los clientes ponen en modelchat-70b-fp8-2026-09-28
tpen cuántas GPUs se parte cada capa2
fracciónparte de la memoria de cada GPU que vLLM puede usar0.90
contextotokens máximos de prompt + respuesta por request8192
techosecuencias máximas en un paso128
versióntag de la imagen; en producción, con su digest (la huella sha256 de la imagen, que no cambia)v0.30.0@sha256:8a69ffad…
flagslos mismos flags de vllm servelos del manifest, abajo
secreto · tamañoSecret con el token de Hugging Face · tope de /dev/shmel C10 no usa token · 16Gi
host · poddónde escucha la réplica · el podlocalhost con port-forward · un pod de vllm-70b
s · tsegundos de drain (vaciado: terminar lo que está en vuelo sin aceptar trabajo nuevo) · plazo de gracia del pod60 · 90
tpreStopt_{\text{preStop}}espera antes del SIGTERM para que el pod salga de los endpoints15 s
margen\text{margen}lo que queda para cerrar procesos y soltar las GPUs15 s

Fórmulas

tarranque maˊx=failureThreshold×periodSeconds=90×10 s=900 s=15 mint_{\text{arranque máx}} = \texttt{failureThreshold} \times \texttt{periodSeconds} = 90 \times 10\ \text{s} = 900\ \text{s} = 15\ \text{min}
tgracia≥tpreStop+tdrain+margen15+60+15=90 st_{\text{gracia}} \ge t_{\text{preStop}} + t_{\text{drain}} + \text{margen} \qquad 15 + 60 + 15 = 90\ \text{s}
tdrain≥trequest maˊs largo≈1,024×50 ms≈51 s<60 st_{\text{drain}} \ge t_{\text{request más largo}} \approx 1{,}024 \times 50\ \text{ms} \approx 51\ \text{s} < 60\ \text{s}
nvidia.com/gpu=TP×PP=2×1=2\texttt{nvidia.com/gpu} = \text{TP} \times \text{PP} = 2 \times 1 = 2
memoria de vLLM=0.90×80 GB×2=144 GB(con el default 0.92: 147.2 GB)\text{memoria de vLLM} = 0.90 \times 80\ \text{GB} \times 2 = 144\ \text{GB} \qquad (\text{con el default } 0.92\text{: } 147.2\ \text{GB})

Las piezas

PiezaQué haceEjemplo
vllm/vllm-openai:v0.30.0La imagen que fija el C10 (22 de septiembre de 2026). Hoy la última es v0.32.0 (18 de octubre): defaults y mensajes cambian entre versiones, así que lee la página de vllm serve de la tuya.por digest: sha256:8a69ffad…
vllm serve modeloLevanta el servidor HTTP compatible con OpenAI, el engine (el motor que arma los batches y corre el modelo) y un proceso por GPU. El modelo va primero./models/chat-70b-fp8-2026-09-28: la carpeta que dejó el init container
--served-model-nameEl nombre que vale en el campo model. El pod atiende solo ese: otro da 404 «The model `x` does not exist.»chat-70b-fp8-2026-09-28 (el alias chat-default del gateway apunta aquí)
--load-format safetensorsSolo safetensors. El default auto prueba safetensors y, si no hay, carga los .bin de PyTorch, que son pickle: un formato que puede ejecutar código al abrirse. vLLM v0.30 los abre con weights_only=True, que lo limita; safetensors son solo datos.nunca un .bin
cuantizaciónSin flag: vLLM la lee del quantization_config del config.json. --quantization es para forzarla.checkpoint FP8: ≈ 70.6 GB de pesos
--tensor-parallel-sizeEn cuántas GPUs se parte cada capa (sección 8). El pod pide exactamente esas GPUs.2: dos H100 del mismo nodo
--gpu-memory-utilizationLa fracción de cada GPU que vLLM toma para pesos, activaciones y KV cache. Default 0.92 desde v0.20 (0.9 hasta v0.19).0.90 explícito: 144 GB entre las dos
--max-model-lenTope de prompt + respuesta. Sin el flag sale del config del modelo (131,072 en Llama 3.3 70B): un solo request podría pedir 40 GiB de KV.8192
--max-num-seqsTecho de secuencias por paso. Con vllm serve, el default es 1,024 en GPUs de 70 GiB o más (salvo la A100) y 256 en las demás.128: el KV da para ≈ 92 de 2,250 tokens
--max-num-batched-tokensTokens por paso, sumando decodes y trozos de prefill (sección 6). Con vllm serve en una H100, el default ya es 8,192.8192, explícito
--kv-cache-dtypeFormato del KV cache. auto = el dtype del modelo; fp8 lo achica a la mitad (sección 9).auto: KV en BF16
--enable-prefix-caching · --enable-chunked-prefillReusar prefijos y partir prompts largos. En v0.30 ya son default para modelos decoder; explícitos porque el plan cuenta con ellos.system prompts de ≈ 1,000 tokens
--scheduling-policy priorityLa cola se ordena por el priority de cada request (menor = antes; empate: el que llegó primero) y en una preemption (cuando falta KV y vLLM saca una secuencia del batch) sale primero el número más alto. Default fcfs: orden de llegada.0 interactivo, 10 batch
--generation-config vllmIgnora el generation_config.json del modelo y usa los defaults de vLLM. Con auto (default), una revisión nueva puede cambiar la temperatura por defecto sin que nadie lo note.los productos mandan su temperatura
--shutdown-timeoutCon SIGTERM, cuántos segundos deja terminar lo que está en vuelo. Default 0: aborta todo de inmediato.60 (un request de 1,024 tokens ≈ 51 s)
--portPuerto de la API, /health y /metrics. Default 8000.8000, con nombre http en el pod
sin --enable-log-requestsDefault False. Encendido, en INFO anota el id y los parámetros de cada request; en DEBUG, también el prompt.el log por request vive en el gateway, sin contenido
HF_HUB_OFFLINE=1 · VLLM_NO_USAGE_STATS=1Ninguna llamada a Hugging Face en runtime (si falta un archivo, error en vez de descarga) y sin telemetría de uso.pesos solo desde el S3 propio
HF_TOKENToken de Hugging Face para bajar modelos gated. En Kubernetes, de un Secret (secretKeyRef).el C10 no lo necesita
fetch-weights (initContainer)Corre antes que vLLM: baja la revisión al NVMe y verifica cada archivo con sha256. Si algo no coincide, el pod no arranca.aws s3 sync + sha256sum -c
emptyDir de los pesosDisco efímero del nodo: con Karpenter (el que crea los nodos) y su instanceStorePolicy RAID0, son los NVMe locales de la p5 sumados en un solo disco. Cada pod baja su copia; una caché por nodo ahorraría descargas a cambio de más piezas.sizeLimit: 200Gi, cuenta en ephemeral-storage
nvidia.com/gpuGPUs enteras, iguales en requests y limits: no se comparten ni se sobreasignan.2 = TP × PP
/dev/shmMemoria compartida en RAM (emptyDir medium: Memory). La usan los procesos de cada GPU y NCCL; cuenta contra el límite de memoria.sizeLimit: 16Gi (el default de un contenedor es 64 MiB)
startupProbe · readiness · livenessLas tres a /health: 200 si el engine vive, 503 si murió. La de startup da tiempo de carga; readiness saca del Service; liveness reinicia.90 × 10 s · 3 × 5 s · 6 × 10 s
preStop + terminationGracePeriodSecondsEsperar a salir de los endpoints (la lista de pods a los que el Service manda tráfico) antes del SIGTERM; el plazo total cubre preStop + drain + margen.sleep de 15 s · 90 s
runAsUser: 2000La imagen corre como root por defecto, pero trae el usuario vllm (UID 2000, GID 0, home /home/vllm) para correr sin root.HOME=/home/vllm

Kubernetes

1Scheduling2Init: los pesos

vLLM

3vllm serve4Pesos a las GPUs5Perfil de memoria y KV cache6CUDA graphs

en servicio

7Listo

apagado

8Apagado

  1. SchedulingKubernetes

    YAMLnodeSelector p5.48xlarge · toleration nvidia.com/gpu · nvidia.com/gpu: 2

    El pod espera un nodo con 2 GPUs libres. Sin GPUs libres queda Pending; un nodo nuevo tarda ≈ 2–5 min, si hay capacidad.

  2. Init: los pesosKubernetes

    terminalaws s3 sync … && sha256sum -c SHA256SUMS

    ≈ 70 GB de S3 al NVMe (≈ 1–3 min) y verificados (≈ 1 min de CPU). Un archivo cambiado o agregado: el pod no arranca.

  3. vllm servevLLM

    terminalvllm serve /models/chat-70b-fp8-2026-09-28 --tensor-parallel-size 2 …

    Arrancan el servidor HTTP, el engine y un worker por GPU. Lee el config.json y la cuantización FP8.

  4. Pesos a las GPUsvLLM

    valor≈ 35.3 GB por GPU

    Cada GPU carga su mitad de cada capa desde el NVMe local. Desde un disco de red, este paso se vuelve minutos.

  5. Perfil de memoria y KV cachevLLM

    logGPU KV cache size: … tokens, Maximum concurrency for 8,192 tokens per request: …x

    Un paso de prueba mide las activaciones (y compila con torch.compile si no hay caché); el KV cache recibe lo que queda de 0.90 × 80 GB por GPU. Si no alcanza ni para una secuencia de 8,192, no arranca.

  6. CUDA graphsvLLM

    Graba las secuencias de kernels de varios tamaños de batch para repetirlas sin overhead. Pasos 4 a 6: ≈ 1–3 min (est.).

  7. Listoen servicio

    HTTPGET /health → 200

    Pasa la startupProbe (tenía hasta 15 min) y la readiness lo agrega al Service.

  8. Apagadoapagado

    secuenciapreStop sleep 15 s → SIGTERM → drain ≤ 60 s → salida

    Sale de los endpoints, termina lo que está en vuelo y aborta lo que llega. Si a los 90 s sigue vivo, SIGKILL.

YAMLcampos del manifest (YAML)terminalun comando: va en la terminalvalorun número de la cuenta, no un comandologuna línea del log de vLLM: la buscas, no la escribesHTTPel request HTTP que llegasecuenciauna secuencia de pasos, no un comando

La vida de un pod de vllm-70b, del scheduling al SIGKILL. Los tiempos son estimaciones del C10: vLLM no corrió (no hay GPU). La línea del KV cache la imprime vLLM al arrancar, y es la que manda.

Ejemplo real · El contenedor vllm del C10: la imagen por digest, los args de vllm serve y su entorno

YAMLreference/c10/k8s/vllm-70b.yaml (líneas 175–221)
        - name: vllm
          # vLLM v0.30.0 (2026-09-22) por digest. ENTRYPOINT de la imagen: ["vllm", "serve"] → aquí van solo args.
          image: vllm/vllm-openai:v0.30.0@sha256:8a69ffad015f138d7170c4ddc429e230a3bc1c1719f67e14324749df200a4b90
          args:
            - /models/chat-70b-fp8-2026-09-28  # los pesos locales (checkpoint FP8: la cuantización se lee de su config)
            - --served-model-name
            - chat-70b-fp8-2026-09-28  # el nombre que ve el gateway: el alias chat-default apunta aquí
            - --load-format
            - safetensors  # solo safetensors: un .bin es un pickle. vLLM v0.30 lo abre con weights_only=True (no
                           # ejecuta código arbitrario), pero safetensors es solo tensores: no depende de esa guardia
            - --tensor-parallel-size
            - "2"  # 70.6 GB de pesos FP8 + KV no entran cómodos en 80 GB; en 2 GPUs quedan ≈ 68 GB para KV
            - --gpu-memory-utilization
            - "0.90"  # explícito: el default es 0.92 desde v0.20 (0.9 hasta v0.19); la capacidad no cambia con un upgrade
            - --max-model-len
            - "8192"  # contexto máximo del producto: más largo = menos secuencias a la vez (capacity/vram_calc.py)
            - --max-num-seqs
            - "128"  # techo de secuencias por réplica (el default en H100 es 1,024): el KV da ≈ 92 de 2,250 tokens
            - --kv-cache-dtype
            - auto  # KV en BF16. "fp8" duplica las secuencias; se activa solo si pasa el golden set
            - --enable-prefix-caching  # default en v0.30; explícito porque el plan cuenta con él (system prompts)
            - --enable-chunked-prefill  # default en v0.30: un prompt de 8k no frena el decode de los demás
            - --max-num-batched-tokens
            - "8192"  # tokens por paso (prefill troceado + decode): más = mejor TTFT y peor TPOT; se ajusta con el bench
            - --scheduling-policy
            - priority  # el gateway manda priority 0 (interactive) o 10 (batch). Sin este flag vLLM v0.30 NO falla
                        # (su documentación dice que sí): la cola FCFS ignora priority y el batch compite con el chat
            - --generation-config
            - vllm  # defaults de muestreo de vLLM, no del generation_config.json del modelo: una revisión nueva no
                    # cambia la temperatura por defecto en silencio (los productos mandan la suya)
            - --shutdown-timeout
            - "60"  # con SIGTERM, deja terminar lo que está en vuelo hasta 60 s (default 0 = abortar todo)
            - --port
            - "8000"
            # NO se agrega --enable-log-requests. En INFO (el nivel por defecto) no loguea prompts: solo el id y los
            # parámetros de muestreo de cada request (el prompt sale en DEBUG; la respuesta, con --enable-log-outputs).
            # Es una línea por request que no hace falta, y con un VLLM_LOGGING_LEVEL=DEBUG puesto para depurar
            # volcaría prompts con PII. El log por request vive en el gateway, sin contenido.
            # Tampoco --sse-keep-alive-interval (default 0): el gateway corta un stream tras 30 s sin bytes
            # (read_timeout_s); un ": keep-alive" reiniciaría ese reloj y un vLLM trabado nunca cortaría.
          env:
            - name: HOME
              value: /home/vllm  # caches de vLLM, torch.compile y Triton en un lugar escribible para UID 2000
            - name: HF_HUB_OFFLINE
              value: "1"  # nunca bajar nada de Hugging Face en runtime: los pesos vienen solo de nuestro S3
            - name: VLLM_NO_USAGE_STATS
              value: "1"  # sin telemetría de uso hacia afuera desde una VPC con PII
  • image: fija v0.30.0 por digest: es el del índice multi-arquitectura que Docker Hub publica para ese tag (22 de septiembre de 2026), y la variante amd64 pesa ≈ 8.7 GB comprimida. Como el ENTRYPOINT ya es vllm serve, args empieza por la ruta del modelo y no hay command.
  • Los pesos los trae el init container fetch-weights (líneas 121–149): aws s3 sync al NVMe local; echo "${MANIFEST_SHA256} SHA256SUMS" | sha256sum -c - (el manifiesto de hashes es el que se revisó: su hash está fijado en el YAML); sha256sum -c SHA256SUMS (cada archivo) y un comm -23 que rechaza archivos que no están en el manifiesto. Si algo falla, el pod no arranca: «mejor una réplica menos que un modelo corrupto respondiendo». El lab corre esos tres chequeos.
  • GPUs y memoria (líneas 225–233): nvidia.com/gpu: 2 en requests y limits (TP=2: dos H100 del mismo nodo), memory: 256Gi igual en los dos (pasarse es un OOMKilled claro: el kernel mata el proceso por memoria) y cpu: "40", porque tokenizar, el scheduler y el servidor HTTP son CPU. /dev/shm es un emptyDir con medium: Memory y sizeLimit: 16Gi (líneas 281–284) que cuenta dentro de esos 256Gi.
  • Las tres probes van a /health (líneas 238–261): la startupProbe da 90 × 10 s = 15 min para cargar ≈ 70 GB, perfilar la memoria y capturar CUDA graphs; la readiness (cada 5 s, 3 fallas) saca al pod del Service si el engine murió (503); la liveness (6 × 10 s) reinicia un engine colgado. Ninguna sabe si la réplica está llena: eso es la admisión del gateway.
  • El apagado (líneas 82–96 y 262–267): preStop con sleep: seconds: 15 (acción nativa, estable desde Kubernetes 1.34) para salir de los endpoints; con el SIGTERM, --shutdown-timeout 60 pone a vLLM en drain: el servidor HTTP sigue abierto, lo que está en vuelo termina y lo nuevo se aborta enseguida. Sin streaming, eso es un 200 con finish_reason: "abort" y sin texto; con streaming, un 200 con el chunk del rol, el usage (si lo pediste) y [DONE], sin ningún finish_reason, porque vLLM no manda chunks vacíos. El gateway del C10 trata los dos como falla (502 o el vendor). A los 60 s, vLLM mata el proceso del engine: lo que quede termina con un evento de error o con la conexión cortada, no con un abort. terminationGracePeriodSeconds: 90 = 15 + 60 + 15.
  • HOME=/home/vllm porque el pod corre como UID 2000 (el usuario vllm que trae la imagen, con GID 0): ahí escribe las cachés de torch.compile. HF_HUB_OFFLINE=1: ninguna llamada a Hugging Face en runtime. VLLM_NO_USAGE_STATS=1: nada de telemetría de uso hacia afuera.
  • Las líneas 200–201 lo dicen: sin --scheduling-policy priority, vLLM no rechaza un priority distinto de 0, aunque la documentación del campo diga que sí. En el código de v0.30.0 (y de v0.32.0) no hay esa validación: con fcfs, la cola es una fila por orden de llegada que no lee el campo. Sin la flag no hay error ni prioridad: el batch compite con el chat.
  • Las líneas 209–212 dejan apagado --enable-log-requests. Con el log en INFO (el default de VLLM_LOGGING_LEVEL) anotaría el id y los parámetros de cada request; el prompt aparece en DEBUG. No hace falta, porque el log por request vive en el gateway, y así subir el nivel de log para depurar no vuelca prompts. Las líneas 213–214 dejan apagado --sse-keep-alive-interval: un comentario keep-alive reiniciaría el read timeout con el que el gateway corta un stream trabado.

Cómo recordarla

modelo, nombre, GPUs, memoria y contexto; después, el apagado. Arrancar vLLM es como abrir un restaurante: llega la mercadería y se revisa contra la factura (init container y sha256), se ordena la cocina (pesos, perfil de memoria, KV cache, CUDA graphs) y recién ahí se abre la puerta (/health en 200). Para cerrar: se apaga el letrero (preStop), se termina de atender a quien ya está sentado (drain de 60 s) y se cierra con llave (SIGKILL a los 90 s).

Trampa

llamar vllm al Service de Kubernetes. El kubelet (el agente de Kubernetes en cada nodo) inyecta en cada pod nuevo del namespace variables con el nombre de cada Service, como VLLM_PORT=tcp://10.0.12.7:8000, y vLLM lee VLLM_PORT como suya: al final del arranque, ya con los pesos cargados, lee todas sus variables y se cae con VLLM_PORT 'tcp://…' appears to be a URI, con cualquier TP. La documentación de vLLM lo advierte. El Service del C10 se llama vllm-70b: sus variables (VLLM_70B_PORT y compañía) solo dejan el aviso Unknown vLLM environment variable detected. La otra: dejar defaults que cambian solos. --gpu-memory-utilization pasó de 0.9 a 0.92 en v0.20, y --max-num-seqs vale 1,024 con vllm serve en una H100 pero 256 en una A100: el mismo YAML se comporta distinto.

Laboratorio (opcional)6 pasos

Qué vas a provocar: Correr los pasos 2 a 4 del init container fetch-weights sobre una revisión falsa y ver qué atrapa cada uno: un archivo cambiado, un archivo agregado y un SHA256SUMS reescrito.

Necesitas: Una terminal de macOS o Linux con sh, sha256sum (o shasum), find, sed, sort, cut y comm. Nada que instalar, sin AWS ni GPU: el paso 1 del init container (aws s3 sync) se reemplaza por archivos falsos.

  1. Paso 1
    mkdir -p ~/lab-vllm/models/rev-1 && cd ~/lab-vllm/models/rev-1
    printf '{"architectures": ["LlamaForCausalLM"], "num_hidden_layers": 80}\n' > config.json
    printf '{"version": "1.0"}\n' > tokenizer.json
    head -c 1048576 /dev/urandom > model-00001-of-00002.safetensors
    head -c 1048576 /dev/urandom > model-00002-of-00002.safetensors
    sha256sum config.json model-0000*.safetensors tokenizer.json > SHA256SUMS
    export MANIFEST_SHA256="$(sha256sum SHA256SUMS | cut -c1-64)"
    wc -l < SHA256SUMS; echo "$MANIFEST_SHA256" | cut -c1-12

    Qué deberías ver: 4 (las líneas de SHA256SUMS: hash, dos espacios y nombre) y los primeros 12 caracteres del hash del manifiesto, distintos en cada corrida. En producción ese hash lo escribe el pipeline de release en el YAML. Los safetensors son 1 MB de bytes al azar: no hay modelo.

  2. Paso 2
    cat > ~/lab-vllm/verificar.sh <<'EOF'
    #!/bin/sh
    # Pasos 2 a 4 del init container fetch-weights (reference/c10/k8s/vllm-70b.yaml, líneas 133-149)
    set -eu
    cd "$1"
    echo "${MANIFEST_SHA256}  SHA256SUMS" | sha256sum -c -
    sha256sum -c SHA256SUMS
    find . -type f ! -name SHA256SUMS | sed 's|^\./||' | sort > ~/lab-vllm/descargados  # el YAML: find -printf '%P\n' (GNU)
    cut -c67- SHA256SUMS | sort > ~/lab-vllm/esperados
    extra="$(comm -23 ~/lab-vllm/descargados ~/lab-vllm/esperados)"
    if [ -n "$extra" ]; then
      echo "archivos que no están en SHA256SUMS: $extra" >&2
      exit 1
    fi
    echo "listo: vllm serve puede arrancar"
    EOF
    sh ~/lab-vllm/verificar.sh ~/lab-vllm/models/rev-1; echo "exit=$?"

    Qué deberías ver: SHA256SUMS: OK, después config.json, los dos safetensors y tokenizer.json: OK, y «listo: vllm serve puede arrancar» con exit=0.

  3. Paso 3
    cp model-00002-of-00002.safetensors ~/lab-vllm/respaldo.bin
    printf 'x' >> model-00002-of-00002.safetensors
    sh ~/lab-vllm/verificar.sh ~/lab-vllm/models/rev-1; echo "exit=$?"
    cp ~/lab-vllm/respaldo.bin model-00002-of-00002.safetensors

    Qué deberías ver: SHA256SUMS: OK, pero model-00002-of-00002.safetensors: FAILED y el aviso sha256sum: WARNING: 1 computed checksum did NOT match (en macOS puede salir antes de la lista), con exit=1. Un byte de más alcanza. El último cp deja el archivo como estaba.

  4. Paso 4
    printf '{%% for m in messages %%}{{ m.content }}{%% endfor %%}\n' > chat_template.jinja
    sh ~/lab-vllm/verificar.sh ~/lab-vllm/models/rev-1; echo "exit=$?"
    rm chat_template.jinja

    Qué deberías ver: Cinco OK (sha256sum -c solo revisa lo que está listado) y después archivos que no están en SHA256SUMS: chat_template.jinja, con exit=1. Un template de chat agregado podría cambiar cómo se arma cada prompt sin tocar un solo peso: para eso está el paso 4.

  5. Paso 5
    printf '{"version": "1.0", "added_tokens": ["<|tool|>"]}\n' > tokenizer.json
    sha256sum config.json model-0000*.safetensors tokenizer.json > SHA256SUMS
    sh ~/lab-vllm/verificar.sh ~/lab-vllm/models/rev-1; echo "exit=$?"

    Qué deberías ver: SHA256SUMS: FAILED (con el WARNING) y exit=1, antes de mirar ningún archivo. Quien cambia un archivo y regenera el manifiesto no puede cambiar el hash fijado en el YAML: eso agrega el paso 2.

  6. Paso 6
    cd ~ && rm -rf ~/lab-vllm

    Qué deberías ver: Sin salida.

Cómo arreglarlo y confirmarlo

Si tu sistema no trae sha256sum, cambia cada sha256sum por shasum -a 256: mismo formato y mismos resultados (el aviso empieza con shasum:). Si el paso 2 no da OK en SHA256SUMS, la variable MANIFEST_SHA256 no está en esta terminal: repite el export del paso 1.

Probado de verdad en un Mac. Ejecutado en macOS 26.6.2 con /bin/sh, el sha256sum de Darwin y el find de BSD; también se probó shasum -a 256. El YAML usa find -printf '%P\n' (GNU findutils, el de la imagen de aws-cli); aquí va find | sed, que da la misma lista en BSD y en GNU.

5

La API compatible con OpenAI: chat completions, streaming SSE, parámetros de sampling, structured outputs, el cliente de openai

¿Cómo le hablas a vLLM: qué endpoints tiene, cómo llega un stream token por token y qué parámetros son de OpenAI y cuáles solo de vLLM?

Sintaxis con variables

curl -s host:8000/v1/chat/completions -H 'Content-Type: application/json' -d '{
  "model": "nombre",
  "messages": [{"role": "system", "content": "instrucciones"},
               {"role": "user", "content": "pregunta"}],
  "max_completion_tokens": n, "temperature": t
}'

Un request y una respuesta JSON: choices[0].message.content, finish_reason y usage (prompt_tokens, completion_tokens, total_tokens). messages es la conversación con roles; vLLM la convierte en texto con el chat template del modelo (la plantilla que marca dónde empieza y termina cada turno) antes de tokenizar. /v1/completions recibe un prompt crudo, sin template.

curl -sN host:8000/v1/chat/completions -H 'Content-Type: application/json' -d '{
  "model": "nombre", "messages": [{"role": "user", "content": "pregunta"}],
  "stream": true, "stream_options": {"include_usage": true}
}'

Streaming con Server-Sent Events (SSE): por cada evento, una línea data: chunk y una línea vacía. El primero trae solo el rol; después, un delta.content (el texto nuevo) por cada paso de decode, la vuelta del modelo que genera un token; el último chunk trae el finish_reason con el último texto (o con content vacío, si el modelo paró con su token de fin); con include_usage, un chunk más con choices: [] y el usage; al final, data: [DONE]. -N le quita el buffer a curl para que veas cada evento al llegar.

client = OpenAI(base_url="http://host:8000/v1", api_key="llave")
client.chat.completions.create(model="nombre", messages=[…], stream=True,
    stream_options={"include_usage": True}, extra_body={"top_k": k, "priority": p})

El cliente oficial de openai sirve tal cual: cambias base_url. Lo que no existe en OpenAI va en extra_body. Por defecto el cliente reintenta 2 veces ante 408, 409, 429 y 5xx (respetando Retry-After) y espera hasta 600 s: fija timeout y max_retries.

"response_format": {"type": "json_schema",
                    "json_schema": {"name": "esquema", "schema": json_schema}}
"structured_outputs": {"choice": ["a", "b"]}     # o "regex", "json", "grammar"

Structured outputs (salidas con forma garantizada): el motor solo deja pasar tokens que mantienen la salida válida contra el schema. response_format es la forma de OpenAI; structured_outputs es la de vLLM, con una sola restricción por request. La librería que aplica la restricción (el backend) la elige vLLM: --structured-outputs-config.backend, default auto. Garantiza la forma, no que el contenido sea verdad.

curl -s host:8000/v1/models
curl -s -o /dev/null -w '%{http_code}\n' host:8000/health

/v1/models lista lo que sirve el pod: id (el --served-model-name), root (la ruta) y max_model_len. El mismo servidor expone también /v1/completions, /v1/responses, /health, /metrics y /version.

Variables

VariableQué esEjemplo
hostdónde escucha vLLMvllm-70b.c10.svc.cluster.local (el Service), o localhost con port-forward
nombreel --served-model-namechat-70b-fp8-2026-09-28 (el gateway traduce chat-default)
instrucciones · preguntael mensaje system y el de la persona≈ 1,000 tokens fijos por producto + ≈ 1,000 propios
ntope de tokens de la respuesta1,024 para soporte (tenants.yaml)
ttemperatura: 0 = siempre el token más probable; más alta = más variedadla manda cada producto
llavela API key; sin --api-key en vLLM, cualquier textoel gateway no le manda llave a vLLM: lo protege la NetworkPolicy
k · ptop_k (elegir solo entre los k tokens más probables) · priority— · 0 interactivo, 10 batch
esquema · json_schemanombre y JSON Schema de la salidaatributos del catálogo
a · blas únicas salidas permitidas"positivo" · "negativo"
noutn_{\text{out}}tokens de la respuesta (completion_tokens)≈ 250 en promedio en el C10

Fórmulas

eventos de un stream≈1rol+nout+1usage+1[DONE](--stream-interval 1, el default)\text{eventos de un stream} \approx 1_{\text{rol}} + n_{\text{out}} + 1_{\text{usage}} + 1_{\texttt{[DONE]}} \qquad (\texttt{--stream-interval 1}\text{, el default})
total_tokens=prompt_tokens+completion_tokens\texttt{total\_tokens} = \texttt{prompt\_tokens} + \texttt{completion\_tokens}
v0.30: prompt_tokens+max_tokens≤max_model_len (si no, 400)v0.32: max_tokens←min⁡(max_tokens, max_model_len−prompt_tokens)\text{v0.30: } \texttt{prompt\_tokens} + \texttt{max\_tokens} \le \texttt{max\_model\_len} \text{ (si no, 400)} \qquad \text{v0.32: } \texttt{max\_tokens} \leftarrow \min(\texttt{max\_tokens},\ \texttt{max\_model\_len} - \texttt{prompt\_tokens})
C10, soporte: 6,000+1,024=7,024≤8,192(max_prompt_tokens+max_tokens_cap)\text{C10, soporte: } 6{,}000 + 1{,}024 = 7{,}024 \le 8{,}192 \qquad (\texttt{max\_prompt\_tokens} + \texttt{max\_tokens\_cap})

Las piezas

PiezaQué haceEjemplo
POST /v1/chat/completionsConversación con roles → chat template → tokens. Es la que usa el C10.casi siempre con stream: true
POST /v1/completionsTexto crudo en prompt, sin chat template: tú pones las marcas de cada turno.benchmarks y modelos base
GET /v1/modelsLo que sirve el pod: id, root y max_model_len.id = chat-70b-fp8-2026-09-28
modelTiene que ser el --served-model-name. Otro nombre: 404 «The model `x` does not exist.»un pod canario sirve otro nombre
max_completion_tokens (max_tokens)Tope de la respuesta; OpenAI marcó max_tokens como obsoleto (deprecated) y vLLM acepta los dos. Sin tope, vLLM genera hasta llenar el contexto.si no viene, el gateway pone el tope del tenant
tope + prompt > max_model_lenEn v0.30 el tope se reserva entero: si no entra, un 400 («This model's maximum context length is…»). Desde v0.32 entra y la salida se recorta a lo que queda.7,500 + 1,024 > 8,192: 400 en v0.30; en v0.32, hasta 692 tokens
temperature · top_p · seedDe OpenAI: el azar del sampling (el sorteo de cada token; de 0 a 2), el núcleo de probabilidad (solo los tokens más probables hasta sumar top_p) y la semilla. Si faltan, valen los defaults del motor: con --generation-config vllm, temperature 1.0 y top_p 1.0.extracción: temperature 0
top_k · min_p · repetition_penaltySolo de vLLM (van en extra_body): los k más probables, un piso de probabilidad y un castigo por repetir.—
stop · stop_token_idsCortar al ver un texto o un token: finish_reason = stop.—
stream · stream_optionsSSE; include_usage agrega el chunk final con usage (choices vacío). stream_options sin stream: error.el gateway lo fuerza para conciliar la cuota
finish_reasonstop: terminó solo o por stop; length: llegó al tope (respuesta cortada); tool_calls; abort (de vLLM): lo cortó el servidor, como un pod en drain.length con texto cortado = tope chico
usageprompt_tokens, completion_tokens y total_tokens: lo que se cobra y lo que mide la cuota.≈ 2,000 + 250 en el C10
response_format json_schemaLa forma de OpenAI de pedir JSON contra un schema.atributos del catálogo
structured_outputsLa de vLLM: choice, regex, json, json_object, grammar o structural_tag; una sola por request.{"choice": ["positivo", "negativo"]}
guided_json y compañíaSe quitaron en v0.12: hoy se ignoran y la respuesta sale sin restricción (desde v0.30, con un warning en el log del servidor, una sola vez por proceso).tutoriales viejos
priority · X-Vllm-PriorityMenor = antes. Solo hace algo con --scheduling-policy priority. El header pisa al campo del cuerpo.0 interactivo, 10 batch
--api-key (VLLM_API_KEY)Una o varias llaves compartidas (sirve cualquiera) que protegen /v1, /v2, /inference y /cohere; el resto queda abierto, incluso /invocations, que también genera texto. No es autenticación por tenant.el C10 usa NetworkPolicy + gateway
uservLLM lo ignora; en OpenAI lo están reemplazando safety_identifier y prompt_cache_key.el gateway del C10 lo usa para elegir canario
cache_saltSolo de vLLM: requests con la misma sal comparten prefijos en caché; otra sal, otra caché.una sal por tenant (pendiente en el C10)

después del prefill

1t ≈ 0.30 s · el rol

un evento por token

2mismo instante · 1.er token3+40 ms por token4el último token

el cierre

5usage6fin

  1. t ≈ 0.30 s · el roldespués del prefill

    evento SSEdata: {"object": "chat.completion.chunk", "choices": [{"index": 0, "delta": {"role": "assistant", "content": ""}, "finish_reason": null}]}

    Sale cuando el prefill (la lectura del prompt entero) ya produjo el primer token. Trae choices, pero no texto.

  2. mismo instante · 1.er tokenun evento por token

    evento SSEdata: {… "delta": {"content": "Tu"}, "finish_reason": null}]}

    Aquí la persona empieza a leer: el TTFT que importa.

  3. +40 ms por tokenun evento por token

    evento SSEdata: {… "delta": {"content": " pedido"}, "finish_reason": null}]}

    Un evento por paso de decode (--stream-interval 1, el default). El espacio viaja pegado al token.

  4. el último tokenun evento por token

    evento SSEdata: {… "delta": {"content": " mañana"}, "finish_reason": "length"}]}

    El finish_reason viaja con el último texto. length: se cortó por max_completion_tokens (4).

  5. usageel cierre

    evento SSEdata: {… "choices": [], "usage": {"prompt_tokens": 4, "completion_tokens": 4, "total_tokens": 8}}

    Solo con include_usage. choices viene vacío: el código que lee choices[0] revienta aquí.

  6. finel cierre

    evento SSEdata: [DONE]

    No es JSON. Si la conexión se corta antes, no llegan ni el usage ni el [DONE].

evento SSEun evento del stream (Server-Sent Events): lo manda vLLM, no lo escribes

Un stream de 4 tokens con include_usage, evento por evento: la salida del mock del lab, con el orden de vLLM v0.30.0 (sin id, created ni model). Tiempos del mock: 0.3 s hasta el primer token y 40 ms entre tokens.

Ejemplo real · Un cliente de openai contra vLLM: streaming, usage y parámetros propios de vLLM

Pythonilustrativo, con los nombres del C10 (k8s/vllm-70b.yaml y gateway/tenants.yaml). Revisado contra el cliente openai-python v3.37.0 (OpenAI, chat.completions.create, stream_options, extra_body) y el protocolo de vLLM v0.30.0; no se ejecutó: el paquete openai no está instalado en esta máquina
from openai import OpenAI

# Sin --api-key en vLLM, cualquier texto sirve de llave; el cliente no arranca sin una.
client = OpenAI(base_url="http://localhost:8000/v1", api_key="EMPTY",
                timeout=120.0)  # default: 600 s; en un stream es el silencio máximo entre chunks

stream = client.chat.completions.create(
    model="chat-70b-fp8-2026-09-28",           # el --served-model-name, no el alias del gateway
    messages=[{"role": "system", "content": "Eres el asistente de soporte del marketplace."},
              {"role": "user", "content": "¿Cuándo llega mi pedido 4512?"}],
    max_completion_tokens=512,
    temperature=0.3,
    stream=True,
    stream_options={"include_usage": True},    # un chunk final con usage y choices vacío
    extra_body={"top_k": 20, "priority": 0},    # solo de vLLM: viajan en el JSON tal cual
)
for chunk in stream:
    if chunk.choices:                           # el chunk de usage trae choices = []
        choice = chunk.choices[0]
        if choice.delta.content:
            print(choice.delta.content, end="", flush=True)
        if choice.finish_reason == "length":
            print("\n[cortada por max_completion_tokens]")
    if chunk.usage:
        print(f"\n{chunk.usage.prompt_tokens} + {chunk.usage.completion_tokens} tokens")
  • base_url termina en /v1: el cliente le agrega /chat/completions. En el C10 los productos no llaman a vLLM: llaman al llm-gateway con model="chat-default", y el gateway pone la revisión, el priority y el include_usage (_self_body, en gateway/llm_gateway.py, líneas 574–578). Directo a vLLM es para probar una réplica con port-forward.
  • extra_body mezcla esas claves en el JSON y vLLM las lee como suyas (top_k, priority, structured_outputs, cache_salt). Un servidor que no las conoce puede rechazarlas: ahí el código deja de ser portable.
  • priority solo cambia algo si vLLM corre con --scheduling-policy priority. La documentación del campo dice que, sin esa política, un valor distinto de 0 da error; en el código de v0.30.0 no encontré esa validación (la cola fcfs no lo lee). No cuentes con el error: pon la flag.
  • El TTFT (el tiempo hasta el primer token) que siente la persona es el primer delta.content con texto: el primer chunk trae solo el rol (en vLLM sale junto con el primer token). Para medir con este cliente, toma la hora justo antes de create (sección 7).
  • finish_reason == "length" es una respuesta cortada por el tope, no un error del servidor. "abort", propio de vLLM, es un request que cortó el servidor (por ejemplo, un pod que se está apagando): el gateway del C10 lo trata como una falla del servidor de atrás (el upstream). Si lo corta antes del primer token, un stream no trae ese finish_reason: solo el rol, el usage y [DONE] (vLLM v0.30 no manda chunks vacíos). Por eso el gateway del C10 trata un [DONE] sin texto como falla (502 o el vendor), no como una respuesta vacía.

Cómo recordarla

es la API de OpenAI con otra base_url. Lo que existe en OpenAI va en el cuerpo tal cual; lo que es solo de vLLM (top_k, priority, structured_outputs, cache_salt) va en extra_body. Un stream es una fila de sobres data: …: el primero trae el rol, cada uno de los siguientes un pedazo de texto, el penúltimo (si lo pediste) la cuenta de tokens, y el último dice [DONE].

Trampa

copiar un tutorial viejo con extra_body={"guided_json": schema}. Los campos guided_* se quitaron en v0.12: vLLM contesta 200 con texto libre, y desde v0.30 solo deja un warning en su propio log, una sola vez. Usa response_format con json_schema o structured_outputs. La otra: leer chunk.choices[0] sin mirar. Con include_usage, el chunk de usage trae choices: [] y el cliente revienta justo al final de una respuesta que salió bien.

Laboratorio (opcional)6 pasos

Qué vas a provocar: Levantar un servidor falso con la forma de la API de vLLM y leerlo: con curl, un request normal, un stream con include_usage y un modelo equivocado; con un cliente de 30 líneas, el mismo stream parseado evento por evento.

Necesitas: Python 3 sin paquetes extra y curl, que vienen en macOS y en Linux. Ni vLLM ni GPU: el mock responde palabras fijas, no es un modelo.

  1. Paso 1
    mkdir -p ~/lab-vllm && cd ~/lab-vllm
    cat > mock_vllm.py <<'EOF'
    # mock_vllm.py: un servidor con la forma de la API de vLLM (solo stdlib). NO es un modelo: responde
    # palabras fijas, una por "token", con 0.3 s hasta el primer token y 40 ms entre tokens.
    import json, os, time, uuid
    from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
    
    MODEL = "chat-70b-fp8-2026-09-28"           # el --served-model-name del C10
    RESPUESTA = "Tu pedido sale mañana y llega en dos o tres días hábiles .".split()
    TTFT_S, TPOT_S = 0.3, 0.04
    
    class Handler(BaseHTTPRequestHandler):
        def log_message(self, *args):            # sin una línea de log por request
            pass
    
        def _json(self, code, obj):
            body = json.dumps(obj, ensure_ascii=False).encode()
            self.send_response(code)
            self.send_header("Content-Type", "application/json")
            self.send_header("Content-Length", str(len(body)))
            self.end_headers()
            self.wfile.write(body)
    
        def do_GET(self):
            if self.path == "/health":            # 200 si el motor vive (vLLM: 503 si murió)
                self.send_response(200); self.send_header("Content-Length", "0"); self.end_headers()
            elif self.path == "/v1/models":
                self._json(200, {"object": "list", "data": [{"id": MODEL, "object": "model",
                                 "root": "/models/" + MODEL, "max_model_len": 8192}]})
            else:
                self._json(404, {"error": {"message": "Not Found", "type": "NotFoundError", "code": 404}})
    
        def do_POST(self):
            req = json.loads(self.rfile.read(int(self.headers["Content-Length"])))
            if req.get("model") != MODEL:         # el mismo texto que devuelve vLLM v0.30.0
                return self._json(404, {"error": {"message": f"The model `{req.get('model')}` does not exist.",
                                                  "type": "NotFoundError", "param": "model", "code": 404}})
            tope = req.get("max_completion_tokens") or req.get("max_tokens") or len(RESPUESTA)
            palabras = RESPUESTA[:tope]
            fin = "length" if len(RESPUESTA) > tope else "stop"   # length: se cortó por el tope
            prompt = sum(len(m["content"].split()) for m in req["messages"])   # palabras como tokens
            usage = {"prompt_tokens": prompt, "completion_tokens": len(palabras),
                     "total_tokens": prompt + len(palabras)}
            base = {"id": "chatcmpl-" + uuid.uuid4().hex[:8], "created": int(time.time()), "model": MODEL}
            if not req.get("stream"):
                time.sleep(TTFT_S + TPOT_S * (len(palabras) - 1))
                return self._json(200, dict(base, object="chat.completion", usage=usage, choices=[
                    {"index": 0, "message": {"role": "assistant", "content": " ".join(palabras)},
                     "finish_reason": fin}]))
            self.send_response(200)
            self.send_header("Content-Type", "text/event-stream")
            self.send_header("Cache-Control", "no-cache")
            self.end_headers()
            def evento(obj):                      # un evento SSE: "data: {json}" y una línea vacía
                self.wfile.write(f"data: {json.dumps(obj, ensure_ascii=False)}\n\n".encode())
                self.wfile.flush()
            chunk = dict(base, object="chat.completion.chunk")
            try:
                time.sleep(TTFT_S)                # cola + prefill
                evento(dict(chunk, choices=[{"index": 0, "delta": {"role": "assistant", "content": ""},
                                             "finish_reason": None}]))
                for i, p in enumerate(palabras):
                    if i:
                        time.sleep(TPOT_S)        # un paso de decode
                    ultimo = i == len(palabras) - 1
                    evento(dict(chunk, choices=[{"index": 0, "delta": {"content": (" " if i else "") + p},
                                                 "finish_reason": fin if ultimo else None}]))
                if (req.get("stream_options") or {}).get("include_usage"):
                    evento(dict(chunk, choices=[], usage=usage))   # choices vacío: solo la cuenta
                self.wfile.write(b"data: [DONE]\n\n")
            except (BrokenPipeError, ConnectionResetError):
                print(f"[mock] el cliente cortó en el token {i + 1}: vLLM abortaría el request y liberaría su KV",
                      flush=True)
    
    if __name__ == "__main__":
        puerto = int(os.environ.get("PORT", "18000"))
        print(f"[mock] escuchando en http://localhost:{puerto}", flush=True)
        ThreadingHTTPServer(("127.0.0.1", puerto), Handler).serve_forever()
    EOF
    python3 mock_vllm.py & echo $! > ~/lab-vllm/mock.pid
    sleep 1
    curl -s -o /dev/null -w '%{http_code}\n' localhost:18000/health
    curl -s localhost:18000/v1/models; echo

    Qué deberías ver: [mock] escuchando en http://localhost:18000, después 200 y la lista con id chat-70b-fp8-2026-09-28, su root y max_model_len 8192. El mock usa el puerto 18000 para no chocar con un Triton o un vLLM que tengas en el 8000.

  2. Paso 2
    curl -s localhost:18000/v1/chat/completions -H 'Content-Type: application/json' \
      -d '{"model": "chat-70b-fp8-2026-09-28", "messages": [{"role": "user", "content": "¿Cuándo llega mi pedido?"}], "max_completion_tokens": 5}'; echo

    Qué deberías ver: A los ≈ 0.46 s, un JSON con message.content "Tu pedido sale mañana y", finish_reason "length" (se cortó en 5) y usage con prompt_tokens 4, completion_tokens 5 y total_tokens 9. El mock cuenta palabras como si fueran tokens.

  3. Paso 3
    curl -sN localhost:18000/v1/chat/completions -H 'Content-Type: application/json' \
      -d '{"model": "chat-70b-fp8-2026-09-28", "messages": [{"role": "user", "content": "¿Cuándo llega mi pedido?"}], "stream": true, "stream_options": {"include_usage": true}, "max_completion_tokens": 4}'

    Qué deberías ver: Siete eventos data:, separados por líneas vacías: el rol (content vacío), Tu, pedido, sale, mañana (este con finish_reason length), el del usage con choices: [] y data: [DONE]. Los dos primeros (el rol y Tu) llegan juntos a los ≈ 0.3 s; cada token siguiente, ≈ 40 ms después; el usage y el [DONE], enseguida del último.

  4. Paso 4
    cat > leer_sse.py <<'EOF'
    # leer_sse.py: un cliente mínimo de SSE, como el lector del gateway del C10 (solo stdlib).
    import http.client, json
    
    def eventos(resp):                     # junta líneas data: hasta una línea vacía: eso es un evento
        datos = []
        for linea in resp:
            linea = linea.decode().rstrip("\r\n")
            if linea == "":
                if datos:
                    yield "\n".join(datos)
                    datos = []
            elif linea.startswith("data:"):
                datos.append(linea[5:].lstrip())   # "data: {...}" → "{...}"
            # las líneas ": comentario", "event:" e "id:" no las usa la API de OpenAI
    
    conn = http.client.HTTPConnection("localhost", 18000)
    conn.request("POST", "/v1/chat/completions", json.dumps({
        "model": "chat-70b-fp8-2026-09-28", "stream": True, "stream_options": {"include_usage": True},
        "messages": [{"role": "user", "content": "¿Cuándo llega mi pedido?"}]}),
        {"Content-Type": "application/json"})
    texto, fin, usage = [], None, None
    for ev in eventos(conn.getresponse()):
        if ev == "[DONE]":                 # no es JSON: el fin del stream
            break
        chunk = json.loads(ev)
        if chunk.get("usage"):
            usage = chunk["usage"]
        for choice in chunk["choices"]:     # en el chunk de usage, la lista viene vacía
            texto.append(choice["delta"].get("content") or "")
            fin = choice.get("finish_reason") or fin
    print("texto:", "".join(texto))
    print("finish_reason:", fin, "· usage:", usage)
    try:
        chunk["choices"][0]                 # lo que hace un cliente apurado con el último chunk
    except IndexError as e:
        print("choices[0] en el chunk de usage:", type(e).__name__ + ":", e)
    EOF
    python3 leer_sse.py

    Qué deberías ver: texto: Tu pedido sale mañana y llega en dos o tres días hábiles . (el punto es un token aparte), finish_reason: stop · usage: {'prompt_tokens': 4, 'completion_tokens': 13, 'total_tokens': 17} y, al final, choices[0] en el chunk de usage: IndexError: list index out of range. El lector junta líneas data: hasta una línea vacía, corta en [DONE] y recorre choices sin suponer que trae algo.

  5. Paso 5
    curl -s -w '\nHTTP %{http_code}\n' localhost:18000/v1/chat/completions -H 'Content-Type: application/json' \
      -d '{"model": "chat-default", "messages": [{"role": "user", "content": "hola"}]}'

    Qué deberías ver: {"error": {"message": "The model `chat-default` does not exist.", "type": "NotFoundError", "param": "model", "code": 404}} y HTTP 404: chat-default es un alias del gateway, no un nombre que conozca vLLM. Es el mismo mensaje que devuelve vLLM v0.30.0.

  6. Paso 6
    kill "$(cat ~/lab-vllm/mock.pid)"
    cd ~ && rm -rf ~/lab-vllm

    Qué deberías ver: Sin salida, o un aviso de la terminal de que el proceso en segundo plano terminó. El puerto 18000 queda libre.

Cómo arreglarlo y confirmarlo

Si el puerto 18000 está ocupado, arranca el mock con PORT=18001 python3 mock_vllm.py & y cambia el puerto en cada curl y en leer_sse.py. Si un curl se queda esperando, el mock no está corriendo: mira la salida de su terminal. Para ver un corte del cliente, repite el paso 3 sin el max_completion_tokens (son 13 tokens, ≈ 0.8 s) y con curl --max-time 0.45: el mock avisa en qué token le falló la escritura (el 5 o el 6). vLLM, en ese caso, aborta el request y libera su KV.

Probado de verdad en un Mac. Ejecutado en macOS 26.6.2 con Python 3.14.3 y curl 8.7.1 (leer_sse.py también con 3.13.16 y 3.9.6): las salidas son las de arriba. El texto del 404 y el orden de los eventos (rol, tokens, finish_reason en el último texto, usage con choices vacío y [DONE]) siguen el código de vLLM v0.30.0; los chunks reales traen más campos. El corte de la sección fix se probó 8 veces: el aviso sale siempre, en el token 5 o 6.

6

Continuous batching y el scheduler: prefill contra decode en el mismo batch, chunked prefill, max_num_batched_tokens, latencia contra throughput

¿Qué decide el scheduler de vLLM en cada paso, por qué un prompt largo frena a los que ya están generando y qué perilla mueve el TTFT contra el TPOT?

Sintaxis con variables

--max-num-seqs S --max-num-batched-tokens B

Los dos techos de cada paso (una pasada del modelo por la GPU): como mucho S secuencias corriendo y B tokens procesados, sumando el token de cada decode (una secuencia que ya genera: 1 token por paso) y los trozos de prefill (la lectura de un prompt nuevo). El scheduler (la parte de vLLM que decide quién entra en cada paso) arma el batch de nuevo en cada paso.

--enable-chunked-prefill --long-prefill-token-threshold u

Chunked prefill: un prompt que no entra en el presupuesto del paso se parte en trozos. u limita el trozo de un prompt largo para que no se coma el paso entero; 0, el default, no pone límite. Trozos grandes le dan mejor TTFT (tiempo hasta el primer token) al prompt nuevo y más tirón a los demás; trozos chicos, al revés.

--scheduling-policy priority          # y en cada request: "priority": p

Con priority, la cola de espera se ordena por número (menor = antes; empate: el que llegó primero) y, si faltan bloques de KV cache (la memoria de lo que cada secuencia ya leyó), se desaloja primero el número más alto. No saca a nadie del batch para que entre otro: eso lo resuelven los lugares reservados del gateway.

sum by (pod) (vllm:num_requests_running)
sum by (pod) (vllm:num_requests_waiting)

El batch de verdad (running) y la cola (waiting) de cada réplica. Si waiting sube con running pegado al techo, falta capacidad o sobra contexto.

histogram_quantile(0.5, sum by (le) (rate(vllm:iteration_tokens_total_bucket[5m])))

Tokens por paso. Una mediana cerca del número de secuencias corriendo es decode puro; los pasos de miles de tokens son prefills. Ojo: vLLM anota el prompt entero (sin lo que estaba en caché) en el paso donde termina su prefill, aunque lo haya partido en trozos. Los bordes de este histograma van de 1 a 16,384, con 8,192 incluido.

Variables

VariableQué esEjemplo
Smáximo de secuencias corriendo en un paso128 en el C10 (default 1,024 en una H100)
Bmáximo de tokens por paso: decodes + trozos de prefill8,192
utope del trozo de un prompt largo; 0 = sin tope0 (default)
pprioridad del request: menor = antes0 interactivo, 10 batch
ndecoden_{\text{decode}}secuencias que generan: 1 token cada una por paso≈ 39 planeadas, 72 en el codo
cj, Pc_{j},\ Ptrozo de prefill del request j en este paso · tokens de su prompt que no están en cachéP ≈ 1,000 con el prefijo en caché
L, λ, WL,\ \lambda,\ Wsecuencias en vuelo · requests por segundo · segundos por request≈ 39 · 3.75 · 10.5
nin_{i}tokens que genera el request iA = 3 … B = 8 en la figura

Fórmulas

ndecode⏟1 token c/u+∑jcj≤Bndecode+nprefill≤S\underbrace{n_{\text{decode}}}_{1\ \text{token c/u}} + \sum_j c_j \le B \qquad n_{\text{decode}} + n_{\text{prefill}} \le S
C10 en el codo: 72+c≤8,192  ⇒  c≤8,120 tokens de prefill por paso\text{C10 en el codo: } 72 + c \le 8{,}192 \;\Rightarrow\; c \le 8{,}120 \text{ tokens de prefill por paso}
pasos de un prefill=⌈PB−ndecode⌉⌈6,0002,048−72⌉=4\text{pasos de un prefill} = \left\lceil \frac{P}{B - n_{\text{decode}}} \right\rceil \qquad \left\lceil \frac{6{,}000}{2{,}048 - 72} \right\rceil = 4
L=λ×W60 rps×10.5 s16≈39 por reˊplica6.06×11.89≈72L = \lambda \times W \qquad \frac{60\ \text{rps} \times 10.5\ \text{s}}{16} \approx 39 \text{ por réplica} \qquad 6.06 \times 11.89 \approx 72
ocupacioˊn=∑inilugares×pasosestaˊtico: 254×12≈52 %continuo: 254×8≈78 %\text{ocupación} = \frac{\sum_i n_i}{\text{lugares} \times \text{pasos}} \qquad \text{estático: } \frac{25}{4 \times 12} \approx 52\,\% \qquad \text{continuo: } \frac{25}{4 \times 8} \approx 78\,\%

Las piezas

PiezaQué haceEjemplo
continuous batchingEl batch se rearma en cada paso: cuando una secuencia termina, otra entra en el paso siguiente. También se llama iteration-level scheduling.cada réplica del C10
dynamic batching (Triton)Junta requests completos y los ejecuta juntos; para generar token a token, el grupo esperaría al más largo.hoja de Triton: el bus que espera 2 ms
paso (step)Una pasada del modelo: cada secuencia en decode suma 1 token y cada prefill, su trozo.≈ 45 ms con 72 secuencias en los fixtures
scheduler de vLLM V1V1 es el motor actual. No tiene fase de prefill ni de decode: en cada paso le da tokens a cada request para que lo calculado alcance a su prompt más lo generado.num_computed_tokens alcanza a num_tokens
primero los que correnPrimero entran los que ya están en el batch (decodes y prefills a medias); con el presupuesto que sobra, los que esperan.por eso un decode no espera detrás de un prompt nuevo
max_num_seqsTecho de secuencias corriendo. Más alto = más throughput y más TPOT para cada una, hasta que se acaba el KV.128 > las 92 que caben de 2,250 tokens
max_num_batched_tokensPresupuesto de tokens por paso. Chico (≈ 2,048) mejora el ITL; grande mejora el TTFT y el throughput (la doc sugiere más de 8,192 para throughput).8,192 de partida; se ajusta con el barrido
chunked prefillParte el prompt según el presupuesto que queda en el paso. Default en v0.30.un prompt de 6,000 entra en un paso de 8,192
long_prefill_token_thresholdTope por request para el trozo de un prompt largo. 0 = sin tope.0
prefix cachingLos tokens del prefijo en caché no se recalculan: el prefill arranca donde termina el acierto.≈ 1,000 de 2,000 tokens
preemptionSi faltan bloques de KV para seguir, el scheduler saca una secuencia, libera sus bloques y la recalcula después (sección 3). Con fcfs sale la última que entró; con priority, el número más alto.el batch, de noche
max_num_queued_reqsTope propio de vLLM para esperando + corriendo: lo que pasa recibe 503. Default: sin tope.el C10 hace la admisión en el gateway
vllm:num_requests_running · waitingGauges (valores que suben y bajan) del batch y de la cola de cada réplica, ahora.KVCacheSaturation mira waiting > 0
vllm:iteration_tokens_totalHistograma de tokens por paso.muestras de miles = prefills que terminan
latencia contra throughputMás secuencias por paso = más tokens/s de la réplica y cada persona más lenta: el decode lee pesos + KV de todas en cada paso.72 → 1,514 tokens/s con TPOT p95 49.6 ms (fixtures)

6 requests, 4 lugares: A 3 tokensB 8 tokensC 2 tokensD 5 tokensE 4 tokensF 3 tokens

Estático: el grupo sale junto y espera al más largo

12 pasos · 25 de 48 lugares con trabajo = 52 % · primer token de E en el paso 9 y de F en el paso 9

Continuo: en cada paso sube el que espera

8 pasos · 25 de 32 lugares con trabajo = 78 % · primer token de E en el paso 3 y de F en el paso 4

  • cada paso, cada secuencia del batch saca un token
  • estático: el grupo siguiente entra cuando termina el más largo
  • continuo: un lugar que se libera se ocupa en el paso siguiente

X1 el paso del prefill: sale el primer tokenXn un paso de decode: un token más· lugar vacío: la GPU no tiene trabajo para él

6 requests que llegan juntos, 4 lugares en el batch, un token por paso (el primero sale del paso de su prefill). Estático: el grupo espera al más largo (B, 8 tokens) y E y F recién empiezan en el paso 9. Continuo: en el lugar que libera C entra E en el paso 3, y todo termina en 8 pasos. Es la salida de bus.py, del lab.

Ejemplo real · Cuántas secuencias hay en el batch: la ley de Little en fleet_calc.py

Pythonreference/c10/capacity/fleet_calc.py (líneas 136–143 y 214–216; # … marca líneas omitidas)
def request_seconds(ttft_s: float, output_tokens: float, tpot_s: float) -> float:
    """Duración de un request de streaming ≈ TTFT + tokens de salida × TPOT."""
    return ttft_s + output_tokens * tpot_s


def littles_law_concurrency(rps: float, seconds_per_request: float) -> float:
    """L = λ × W (#m03.little): secuencias vivas a la vez = llegadas por segundo × segundos que dura cada una."""
    return rps * seconds_per_request
# …
    secs = request_seconds(0.5, inter.output_tokens, 0.040)
    conc = littles_law_concurrency(CASE_DESIGN_RPS, secs)
    print(f"Little: {CASE_DESIGN_RPS:.0f} rps × {secs:.1f} s = {conc:.0f} secuencias vivas ≈ {conc / fl.replicas_deployed:.1f} por réplica")
  • Con continuous batching no fijas el tamaño del batch: lo fija el tráfico. request_seconds(0.5, 250, 0.040) da 10.5 s por request, y 60 rps × 10.5 s = 630 secuencias vivas en la flota: ≈ 39.4 por réplica. python3 capacity/fleet_calc.py lo imprime así.
  • Cuatro números de batch del C10, de menor a mayor: ≈ 39 planeadas (Little); 72 en el codo del barrido (sección 7); ≈ 92 que caben en el KV con 2,250 tokens (sección 3); y 128 de --max-num-seqs. El que manda es un quinto: el tope del gateway, 288 en vuelo por cada una de sus 4 réplicas, o sea 288 × 4 ÷ 16 = 72 por réplica de vLLM, justo el codo. El techo de vLLM queda arriba como margen.
  • --max-num-batched-tokens 8192 (manifest, línea 198): «más = mejor TTFT y peor TPOT; se ajusta con el bench». Con 72 decodes por paso sobran 8,120 tokens para prefill: un prompt de 6,000 sin caché entra en un solo paso, y ese paso tarda más para las 72 secuencias que esperaban su token.
  • La alerta LLMTPOTHigh lista las causas de batching que suben el TPOT (monitoring/alerts.yml, línea 107): «¿Subió la concurrencia por réplica (vllm:num_requests_running), hay preemptions (LLMPreemptionsRising) o prefills largos intercalados (max-num-batched-tokens)?».
  • Lo que no está aquí: el tiempo por request sale de un TTFT y un TPOT supuestos. Con los del barrido (sección 7), Little da justo la concurrencia del punto: 6.06 req/s × 11.89 s ≈ 72.

Cómo recordarla

continuous batching es un bus que deja bajar y subir pasajeros en cada parada, en vez de esperar a que todos lleguen al final. El de Triton espera 2 ms en la terminal y sale con todos; este no vuelve nunca a la terminal, pero cada parada tiene cupo: max_num_seqs asientos y max_num_batched_tokens de equipaje. Un prompt largo es equipaje grande: o sube entero y esa parada tarda (buen TTFT para él, tirón para los demás), o sube en partes (chunked prefill).

Trampa

subir --max-num-batched-tokens para que «vaya más rápido». El throughput sube, pero cada paso con un prefill grande frena a todas las secuencias que están generando: el TPOT promedio casi no cambia y el p99 del ITL se dispara (los tirones). En el modelo de juguete del lab, 8,192 da un TTFT de 495 ms y un hueco de 495 ms para los 72 que generaban; 2,048 da 630 ms y 193 ms. Decídelo con el barrido, mirando el p99 del ITL y no solo el promedio.

Laboratorio (opcional)3 pasos

Qué vas a provocar: Ver paso a paso por qué continuous batching ocupa más la GPU que el batching estático, y cómo el presupuesto de tokens por paso mueve el TTFT de un prompt nuevo contra el hueco que sienten los demás.

Necesitas: Python 3, sin paquetes extra. Ni vLLM ni GPU.

  1. Paso 1
    mkdir -p ~/lab-vllm && cd ~/lab-vllm
    cat > bus.py <<'EOF'
    # bus.py: batching estático contra continuous batching, paso a paso (solo stdlib).
    # 6 requests llegan juntos; cada una necesita n tokens de salida; hay 4 lugares en el batch.
    # En cada paso, cada secuencia del batch saca 1 token (el primero sale del paso de su prefill).
    PEDIDOS = {"A": 3, "B": 8, "C": 2, "D": 5, "E": 4, "F": 3}
    LUGARES = 4
    
    def estatico():
        filas, cola = [[] for _ in range(LUGARES)], list(PEDIDOS)
        while cola:
            grupo, cola = cola[:LUGARES], cola[LUGARES:]       # el bus sale lleno y vuelve vacío
            largo = max(PEDIDOS[r] for r in grupo)            # espera al más lento del grupo
            for lugar in range(LUGARES):
                r = grupo[lugar] if lugar < len(grupo) else None
                filas[lugar] += [f"{r}{t + 1}" if r and t < PEDIDOS[r] else "·" for t in range(largo)]
        return filas
    
    def continuo():
        filas, cola = [[] for _ in range(LUGARES)], list(PEDIDOS)
        activo, hecho = [None] * LUGARES, {}
        while cola or any(activo):
            for lugar in range(LUGARES):                     # en cada paso: sube el que espera
                if activo[lugar] is None and cola:
                    r = cola.pop(0)
                    activo[lugar], hecho[r] = r, 0
            for lugar, r in enumerate(activo):
                if r is None:
                    filas[lugar].append("·")
                    continue
                hecho[r] += 1
                filas[lugar].append(f"{r}{hecho[r]}")
                if hecho[r] == PEDIDOS[r]:
                    activo[lugar] = None                     # baja al terminar: el lugar queda libre
        return filas
    
    for nombre, filas in (("estático", estatico()), ("continuo", continuo())):
        pasos = len(filas[0])
        utiles = sum(c != "·" for f in filas for c in f)
        print(f"{nombre}: {pasos} pasos, {utiles} de {pasos * LUGARES} lugares-paso con trabajo ({utiles / (pasos * LUGARES):.0%})")
        for lugar, f in enumerate(filas):
            print(f"  lugar {lugar + 1}: " + " ".join(f"{c:>2}" for c in f))
        primero = {c[0]: i + 1 for f in filas for i, c in enumerate(f) if c.endswith("1") and len(c) == 2}
        print("  primer token (paso):", " ".join(f"{r}={primero[r]}" for r in PEDIDOS))
    EOF
    python3 bus.py

    Qué deberías ver: estático: 12 pasos, 25 de 48 lugares-paso con trabajo (52%), con la grilla del lugar 1 A1 A2 A3 · · · · · E1 E2 E3 E4 y primer token E=9 F=9. continuo: 8 pasos, 25 de 32 (78%), lugar 1 A1 A2 A3 F1 F2 F3 · · y primer token E=3 F=4. Las mismas 25 unidades de trabajo, un tercio menos de pasos.

  2. Paso 2
    cat > trozos.py <<'EOF'
    # trozos.py: chunked prefill con un modelo de juguete del tiempo de un paso (ILUSTRATIVO, no medido):
    #   paso = 45 ms (72 decodes, ≈ el TPOT del codo del C10) + 0.075 ms por token de prefill
    #   (≈ 150 ms por 2,000 tokens, el número de ejemplo de M14).
    # Llega un prompt de 6,000 tokens sin caché mientras 72 secuencias generan.
    import math
    
    DECODES, PROMPT = 72, 6000
    
    def paso_ms(tokens_prefill):
        return 45 + 0.075 * tokens_prefill
    
    print(" budget  pasos de prefill  TTFT del nuevo  peor hueco de los 72")
    for budget in (8192, 2048, 512):
        trozo = budget - DECODES                    # primero entran los 72 decodes, el resto es prefill
        pasos = math.ceil(PROMPT / trozo)
        trozos = [trozo] * (pasos - 1) + [PROMPT - trozo * (pasos - 1)]
        tiempos = [paso_ms(t) for t in trozos]
        print(f"{budget:>7} {pasos:>17} {sum(tiempos):>12.0f} ms {max(tiempos):>17.0f} ms")
    print(f"sin el prompt nuevo: un paso de decode = {paso_ms(0):.0f} ms para los 72")
    EOF
    python3 trozos.py

    Qué deberías ver: 8192: 1 paso de prefill, TTFT del nuevo 495 ms y peor hueco de los 72 495 ms · 2048: 4 pasos, 630 ms y 193 ms · 512: 14 pasos, 1080 ms y 78 ms; sin el prompt nuevo, un paso de decode = 45 ms. Presupuesto chico: los que generan casi no lo sienten, el nuevo espera más.

  3. Paso 3
    cd ~ && rm -rf ~/lab-vllm

    Qué deberías ver: Sin salida.

Cómo arreglarlo y confirmarlo

Cambia PEDIDOS en bus.py (por ejemplo, B = 30) y mira cómo el estático desperdicia más mientras el continuo sigue ocupado. En trozos.py prueba DECODES = 39 (lo planeado) o PROMPT = 8000: el trade-off es el mismo y cambian los números. Los ms de trozos.py son de juguete; los de verdad salen del barrido de la sección 7.

Probado de verdad en un Mac. Ejecutado en macOS 26.6.2 con Python 3.14.3, 3.13.16 y 3.9.6: salidas idénticas. bus.py simplifica (un token por paso y por secuencia, el primero en el paso del prefill); el orden de trozos.py, primero los decodes y después el prefill hasta llenar el presupuesto, es el del scheduler de vLLM V1 (v0.30.0).

7

Medir: TTFT, TPOT/ITL, E2E, tokens/s, goodput; métricas vllm: en Prometheus; el barrido de concurrencia del C10

¿Cuántos requests aguanta una réplica dentro del SLO, cómo se mide sin engañarse y qué métricas de vLLM miras en producción?

Sintaxis con variables

vllm bench serve --backend openai-chat --endpoint /v1/chat/completions \
  --base-url url --model nombre --tokenizer ruta \
  --dataset-name dataset --num-prompts N --max-concurrency c --request-rate inf \
  --percentile-metrics ttft,tpot,itl,e2el --metric-percentiles 50,95,99 \
  --goodput ttft:ms tpot:ms --save-result --result-dir dir --result-filename archivo

Barrido cerrado: como mucho c requests en vuelo; el siguiente sale cuando termina uno. Lo repites con varios c y te quedas con el último que cumple. Sin --metric-percentiles solo hay p99 (el tiempo bajo el que queda el 99 % de los requests; el default es 99), y sin --percentile-metrics no hay E2E (el default es ttft,tpot,itl).

vllm bench serve … --request-rate λ --burstiness 1.0

Barrido abierto: llegadas de Poisson (al azar, con un promedio de λ por segundo), como el tráfico real. Sin --max-concurrency, la cola se forma en el servidor y se ve en el TTFT; el cerrado nunca deja que esa cola crezca (coordinated omission: el cliente deja de mandar justo cuando el servidor se frena, y esa espera nunca se mide).

python3 bench/pick_concurrency.py results/sweep-c*.json --ttft-ms 1000 --tpot-ms 50 --percentile 95

El lector del C10: ordena los puntos, anota las fallas de cada uno y elige el último bueno antes del primero malo. Sale con código 2 si a un JSON le falta el percentil pedido.

histogram_quantile(0.95, sum by (le) (rate(vllm:time_to_first_token_seconds_bucket[5m])))

El p95 de toda la flota: rate, sum by (le) y recién ahí el cuantil, como en la hoja de Prometheus. Por pod: sum by (le, pod). Nunca promedies los p95 de cada pod.

sum(rate(vllm:generation_tokens_total[5m]))                         # tokens de salida/s
sum(rate(vllm:request_time_per_output_token_seconds_sum[5m]))
  / sum(rate(vllm:request_time_per_output_token_seconds_count[5m]))  # TPOT medio

Los counters (contadores que solo suben) llevan _total en Prometheus y se leen con rate. La media de un histograma es _sum ÷ _count; la cola (p95, p99) sale de los buckets.

curl -s host:8000/metrics | grep -E '^vllm:(num_requests_(running|waiting)|kv_cache_usage_perc|num_preemptions_total)'

Lo que expone una réplica, sin Prometheus de por medio. Cada serie trae los labels model_name y engine; el PodMonitor del C10 agrega pod y track.

Engine 000: Avg prompt throughput: x tokens/s, Avg generation throughput: y tokens/s, Running: r reqs, Waiting: w reqs, GPU KV cache usage: k%, Prefix cache hit rate: h%

La línea de estadísticas que vLLM escribe en su log cada 10 s (VLLM_LOG_STATS_INTERVAL; se apaga con --disable-log-stats). Si en ese intervalo hubo preemptions, agrega Preemptions: N antes del KV: en V1 es el único rastro en el log, porque el scheduler no escribe una línea por cada preemption. Con la réplica ociosa, la línea pasa a DEBUG y no aparece.

Variables

VariableQué esEjemplo
TTFT\text{TTFT}time to first token: de que llega el request al primer tokenp95 ≤ 1 s (el SLO, la meta de servicio, medido en el gateway)
TPOT\text{TPOT}time per output token: el promedio entre tokens de un request, sin contar el primerop95 ≤ 50 ms
ITL\text{ITL}inter-token latency: el hueco entre dos tokens seguidos; el TPOT es su promedio dentro de un requestp99 109 ms a c = 72 (fixtures)
E2E\text{E2E}end-to-end: el request completo≈ 11.9 s a c = 72
noutn_{\text{out}}tokens de salida del request (usage.completion_tokens)250, con --ignore-eos
ccrequests en vuelo (--max-concurrency)16 … 96
λ\lambdarequests por segundo completados (request_throughput)6.06 a c = 72
WWsegundos que dura cada request11.89
goodput\text{goodput}requests por segundo que cumplen todos los SLOs, uno por uno (request_goodput)5.75
url · nombre · rutala réplica · su --served-model-name · el tokenizer localhttp://localhost:8000 · chat-70b-fp8-2026-09-28 · /models/chat-70b-fp8-2026-09-28
datasetde dónde salen los promptsprefix_repetition: 3 prefijos de 1,000 + 1,000 propios, 250 de salida
Nrequests por punto1,000 (con menos de 500, el p95 es ruido)
msumbrales del SLO para el goodput, en msttft:1000 tpot:50
λtasa del barrido abierto4.2 (lo planeado)
dir · archivodónde guardar cada JSONresults · sweep-c072.json
x · y · r · w · k · htokens/s de prompt y de generación del intervalo · requests corriendo · esperando · % del KV ocupado · % de aciertos del prefix cacheRunning: 72 reqs con el tope del gateway lleno

Fórmulas

TTFT=tprimer token−tllegadaE2E=tuˊltimo token−tllegada\text{TTFT} = t_{\text{primer token}} - t_{\text{llegada}} \qquad \text{E2E} = t_{\text{último token}} - t_{\text{llegada}}
TPOT=E2E−TTFTnout−1(nout>1)ITLk=tk+1−tk\text{TPOT} = \frac{\text{E2E} - \text{TTFT}}{n_{\text{out}} - 1} \quad (n_{\text{out}} > 1) \qquad \text{ITL}_k = t_{k+1} - t_k
E2E≈TTFT+(nout−1)×TPOT=0.56+249×0.0455=11.89 s(c=72)\text{E2E} \approx \text{TTFT} + (n_{\text{out}} - 1) \times \text{TPOT} = 0.56 + 249 \times 0.0455 = 11.89\ \text{s} \quad (c = 72)
λ=cW=7211.89 s=6.06 req/stokens/s=λ×nout=6.06×250≈1,514\lambda = \frac{c}{W} = \frac{72}{11.89\ \text{s}} = 6.06\ \text{req/s} \qquad \text{tokens/s} = \lambda \times n_{\text{out}} = 6.06 \times 250 \approx 1{,}514
goodput=#{requests con TTFT≤1 s y TPOT≤50 ms}duracioˊn=5.75 req/s=95 % de 6.06\text{goodput} = \frac{\#\{\text{requests con TTFT} \le 1\ \text{s y TPOT} \le 50\ \text{ms}\}}{\text{duración}} = 5.75\ \text{req/s} = 95\,\% \text{ de } 6.06
planeado=0.70×6.06=4.24 req/s por reˊplica\text{planeado} = 0.70 \times 6.06 = 4.24\ \text{req/s por réplica}

Las piezas

PiezaQué haceEjemplo
vllm bench serveEl cliente de carga de vLLM: manda requests de verdad a la API y mide del lado del cliente.viene con vLLM: corre donde lo tengas instalado
--max-concurrencyBarrido cerrado: tope de requests en vuelo. Lo que un request espera turno en el cliente no entra en su TTFT: v0.30 lo guarda aparte (la lista queue_times del JSON; sus percentiles, solo si agregas client_queue_time a --percentile-metrics).16, 32, 48, 64, 72, 80, 96
--request-rateinf (default): todos salen en t = 0 y los frena --max-concurrency. Un número: llegadas de Poisson (con --burstiness 1).inf en el cerrado; 4.2 en el abierto
--dataset-name prefix_repetitionPrompts sintéticos con prefijos compartidos, para que el prefix caching pese como en producción.3 prefijos, 1,000 + 1,000 tokens, 250 de salida
--ignore-eosQue cada request genere sus 250 tokens aunque el modelo quiera parar.sin esto, el texto al azar corta en largos raros
--percentile-metrics · --metric-percentilesQué métricas y qué percentiles van al JSON. Defaults: ttft,tpot,itl y 99.ttft,tpot,itl,e2el y 50,95,99
--goodput ttft:1000 tpot:50Cuenta los requests que cumplen todos los umbrales (en ms), uno por uno.request_goodput = 5.75 a c = 72
--save-resultUn JSON plano por corrida: request_throughput, output_throughput, request_goodput, mean_, median_, std_ y p<P>_ (uno por percentil pedido) de cada métrica, max_concurrency, request_rate…sweep-c072.json
TTFT de vllm benchDesde antes del POST hasta el primer chunk con choices (cuenta el del rol).incluye red, cola y prefill
TPOT de vllm bench(latencia − TTFT) ÷ (tokens − 1), por request; los de 1 token no entran. Los tokens salen de usage.promedio por request
ITL de vllm benchTodos los huecos entre chunks de todos los requests, en una sola lista.su p99 muestra los tirones
vllm:time_to_first_token_secondsHistograma: de que el request llega al motor hasta el primer token. No ve la cola del gateway ni la red.llm:vllm_ttft_p95:rate5m, por pod
vllm:inter_token_latency_secondsHistograma del ITL. Antes se llamaba vllm:time_per_output_token_seconds: obsoleta desde v0.11, oculta en v0.12 y borrada en v0.15.bordes 0.01, 0.025, 0.05, 0.075… s
vllm:request_time_per_output_token_secondsHistograma del TPOT de cada request: su tiempo de decode ÷ (tokens − 1).la contraparte del SLO
vllm:e2e_request_latency_seconds · vllm:request_queue_time_secondsEl request completo y lo que esperó turno dentro de vLLM. También hay request_prefill_time_seconds y request_decode_time_seconds.¿la espera está en vLLM o antes?
vllm:num_requests_running · vllm:num_requests_waitingGauges: el batch y la cola de la réplica, ahora.KVCacheSaturation; absent() en LLMCapacityHeadroomLow
vllm:kv_cache_usage_percGauge de 0 a 1: el KV cache ocupado. Antes vllm:gpu_cache_usage_perc (oculto en v0.11, borrado en v0.12).> 0.95 con waiting > 0: KVCacheSaturation
vllm:num_preemptions_total · vllm:request_num_preemptionsCounter de preemptions (eventos) e histograma por request; su primer bucket es le="1" y junta 0 y 1.LLMPreemptionsRising: eventos ÷ requests > 0.05
vllm:prompt_tokens_total · vllm:generation_tokens_totalCounters de tokens de entrada y de salida.tokens/s de la réplica, con rate()
vllm:prefix_cache_queries_total · vllm:prefix_cache_hits_totalCounters en tokens: hits ÷ queries = la tasa de aciertos del prefix caching.cae cuando alguien cambia un system prompt
vllm:request_success_totalRequests terminados, con el label finished_reason (stop, length, abort…).el denominador de las preemptions por request
vllm:admission_rejections_totalNueva en v0.32: requests rechazados con 503 por un tope de cola de vLLM, con el label reason (max_num_queued_reqs o max_num_queued_tokens). En v0.30 los topes ya existen, pero esta métrica no.el pin del C10 no la tiene
la línea de estadísticas del logCada 10 s: tokens/s de prompt y de generación, Running, Waiting, % de KV y aciertos del prefix cache; Preemptions: N solo si hubo.en V1, el scheduler no escribe una línea por preemption
01,0002,0003,00016324864728096en vuelo (c)TTFT p95 (ms)SLO 1,000 ms97002040608016324864728096en vuelo (c)TPOT p95 (ms)SLO 50 ms49.601,0002,0003,00016324864728096en vuelo (c)TTFT p95 (ms)SLO 1,000 ms97002040608016324864728096en vuelo (c)TPOT p95 (ms)SLO 50 ms49.6
  • 72el codo: el último punto que cumple
  • 80TTFT y TPOT fuera
En vueloreq/sgoodputtokens/sTTFT p95TPOT p95
16 (cumple)2.62.6650310 ms27 ms
32 (cumple)4.014.011,003450 ms35 ms
48 (cumple)4.884.831,221660 ms42 ms
64 (cumple)5.725.541,429860 ms47 ms
72 (cumple)6.065.751,514970 ms49.6 ms
80 (no cumple)6.195.021,5481,340 ms53.8 ms
96 (no cumple)6.382.681,5952,400 ms64 ms

Se planea al 70 %: 6.06 × 0.7 = 4.24 req/s por réplica.

Datos: reference/c10/bench/tests/fixtures/sweep-c*.json (sintéticos)

cumple su SLOfuera del SLOla zona que cumpleel elegido: 72 en vuelo

pasado el codo, más concurrencia casi no da throughput (6.06 → 6.38 req/s) y sí da cola (TTFT p95 970 → 2,400 ms)

El barrido de una réplica con los fixtures del C10: SINTÉTICOS (números inventados con la forma típica y las claves reales de vLLM v0.30.0), no una medición. Hasta 72 en vuelo se cumplen TTFT p95 ≤ 1 s y TPOT p95 ≤ 50 ms; en 80 se rompen los dos, y en 96 hay 5 % más de requests/s que en 72 con 2.5× el TTFT p95.

Ejemplo real · El barrido del C10: run_sweep.sh y el lector que elige la concurrencia

bash · zshreference/c10/bench/run_sweep.sh (líneas 18–49)
for c in 16 32 48 64 72 80 96; do  # alrededor del codo esperado (≈ 40 planeado × 1/0.7 ≈ 57, y más allá)
  vllm bench serve \
    --backend openai-chat \
    --endpoint /v1/chat/completions \
    --base-url "$BASE_URL" \
    --model "$MODEL" \
    --tokenizer "$TOKENIZER" \
    `# Mezcla sintética del caso: 3 productos (3 prefijos) × ≈ 1,000 tokens compartidos + ≈ 1,000 propios,` \
    `# 250 de salida. Los prompts se barajan (default). Mejor aún: reproducir prompts reales redactados.` \
    --dataset-name prefix_repetition \
    --prefix-repetition-num-prefixes 3 \
    --prefix-repetition-prefix-len 1000 \
    --prefix-repetition-suffix-len 1000 \
    --prefix-repetition-output-len 250 \
    `# ignore_eos: que cada request genere sus 250 tokens; sin esto, texto aleatorio corta en largos raros.` \
    --ignore-eos \
    --num-prompts "$NUM_PROMPTS" \
    `# Barrido CERRADO: hasta c requests en vuelo, el siguiente sale cuando termina uno.` \
    --max-concurrency "$c" \
    --request-rate inf \
    `# El default de --metric-percentiles es "99": sin esto no hay p95 en el JSON.` \
    --percentile-metrics ttft,tpot,itl,e2el \
    --metric-percentiles 50,95,99 \
    `# goodput: req/s que cumplen TTFT ≤ 1,000 ms Y TPOT ≤ 50 ms uno por uno (valores en ms).` \
    --goodput ttft:1000 tpot:50 \
    --save-result \
    --result-dir "$OUT" \
    --result-filename "sweep-c$(printf '%03d' "$c").json"
done

# Elegir: el último punto que cumple antes del primero que no, y la capacidad planeada al 70 %.
python "$(dirname "$0")/pick_concurrency.py" "$OUT"/sweep-c*.json --ttft-ms 1000 --tpot-ms 50 --percentile 95
  • Contra UNA réplica (port-forward a un pod), no contra el gateway: se mide la unidad que escala. Y con la mezcla del caso, 3 prefijos de 1,000 tokens + 1,000 propios y 250 de salida: con otra mezcla, el número no sirve.
  • Cada flag está en la página de vllm bench serve de v0.30.0. El script esquiva dos trampas: --metric-percentiles vale 99 por defecto (sin 50,95,99 no hay p95_ttft_ms y pick_concurrency.py se niega a adivinar) y, sin --ignore-eos, las respuestas cortan en largos al azar.
  • pick_concurrency.py elige el último punto bueno antes del primero malo: si 80 falla y 96 «cumple», 96 es ruido (pocos requests, un warmup, otro proceso en la GPU) y lo avisa. Con los fixtures elige 72: 6.06 req/s, planeado al 70 % = 4.24. De ahí sale el ≈ 4.2 del plan de flota.
  • Los fixtures (bench/tests/fixtures/sweep-c*.json) son SINTÉTICOS: claves reales de v0.30.0 (request_throughput, output_throughput, request_goodput, p95_ttft_ms…) con números inventados para que el codo caiga en ≈ 6 rps. Los 12 tests de test_pick_concurrency.py pasan.
  • Un JSON real de v0.30.0 con este comando trae más claves que los fixtures: las de E2E (p95_e2el_ms y compañía, que el script sí pide), max_output_tokens_per_s, max_concurrent_requests, rtfx (0 fuera de los benchmarks de audio) y dos listas por request que quedan aunque no pidas --save-detailed: latencies y queue_times. Los percentiles de client_queue_time solo salen si los pides en --percentile-metrics. El lector solo lee las que usa.
  • Las líneas 51–53 lo dicen: después se confirma con un barrido ABIERTO a la tasa planeada (--request-rate 4.2, sin --max-concurrency), porque el cerrado nunca deja que se forme la cola que en producción rompe el TTFT.

Cómo recordarla

TTFT es cuánto tardas en empezar a hablar; TPOT, a qué ritmo hablas; E2E, cuánto dura todo. Para el plan de capacidad, sube la concurrencia de a poco hasta que el primer SLO se rompa: el punto anterior es la capacidad (el codo), y planeas al 70 % de eso. En producción, con los histogramas: rate, sum by (le) y recién ahí el cuantil.

Trampa

creerle al p99 de un barrido cerrado. Cuando la réplica se frena, el cliente también deja de mandar, y la cola que en producción rompe el TTFT nunca se forma (coordinated omission); además, lo que un request espera turno en el cliente no entra en su TTFT. Por eso el C10 confirma con un barrido abierto a 4.2 req/s. La otra: el TPOT p95 puede estar en 49.6 ms con el ITL p99 en 109 ms (c = 72, en los fixtures). El promedio por request esconde los tirones, y una alerta que solo mira el TPOT no los ve.

Laboratorio (opcional)5 pasos

Qué vas a provocar: Leer el barrido del C10 como lo hace pick_concurrency.py (comprobar Little y el throughput, y elegir el codo) y ver por qué un TPOT que cumple el SLO puede esconder tirones que el ITL sí muestra.

Necesitas: Python 3 sin paquetes extra y el repo del curso (el paso 1 copia los fixtures y pick_concurrency.py). Ni vLLM ni GPU.

  1. Paso 1
    mkdir -p ~/lab-vllm/results
    cp apps/ml-system-design/reference/c10/bench/tests/fixtures/sweep-c*.json ~/lab-vllm/results/
    cp apps/ml-system-design/reference/c10/bench/tests/fixtures/p99-only.json apps/ml-system-design/reference/c10/bench/pick_concurrency.py ~/lab-vllm/
    cd ~/lab-vllm && ls results

    Qué deberías ver: sweep-c016.json, sweep-c032.json … sweep-c096.json: los siete puntos sintéticos del barrido. Este paso se corre desde la raíz del repo del curso.

  2. Paso 2
    cat > barrido.py <<'EOF'
    # barrido.py: lee los JSON de vllm bench serve --save-result (uno por concurrencia) y comprueba
    # Little, el throughput y el codo con los SLOs del C10 (solo stdlib).
    import json, sys
    
    TTFT_SLO, TPOT_SLO = 1000, 50                    # ms, p95 (los del C10)
    puntos = sorted((json.load(open(f)) for f in sys.argv[1:]), key=lambda d: d["max_concurrency"])
    print("   c  req/s  W=c/λ (s)  TTFT+249·TPOT (s)  tok/s  λ·250  TTFT p95  TPOT p95  goodput")
    elegido, roto = None, False
    for d in puntos:
        c, lam = d["max_concurrency"], d["request_throughput"]
        n_out = d["total_output_tokens"] / d["completed"]                 # 250 tokens por request
        w_little = c / lam                                                # Little: W = L / λ
        w_medido = (d["mean_ttft_ms"] + (n_out - 1) * d["mean_tpot_ms"]) / 1000
        ok = d["p95_ttft_ms"] <= TTFT_SLO and d["p95_tpot_ms"] <= TPOT_SLO
        roto = roto or not ok             # después del primer punto malo no se elige otro: sería ruido
        if ok and not roto:
            elegido = d
        print(f"{c:>4} {lam:>6.2f} {w_little:>10.2f} {w_medido:>18.2f} {d['output_throughput']:>6.0f} "
              f"{lam * n_out:>6.0f} {d['p95_ttft_ms']:>6.0f} ms {d['p95_tpot_ms']:>5.1f} ms "
              f"{d['request_goodput'] / lam:>7.0%}  {'ok' if ok else 'fuera'}")
    lam = elegido["request_throughput"]
    print(f"codo: c = {elegido['max_concurrency']} → {lam:.2f} req/s; planeado al 70 %: {lam * 0.7:.2f} req/s por réplica")
    EOF
    python3 barrido.py results/sweep-c*.json

    Qué deberías ver: Siete filas: W = c/λ y TTFT + 249 × TPOT dan lo mismo en cada una (6.16, 7.98 … 15.04 s: los fixtures se armaron con Little), tok/s = λ × 250, el goodput baja de 100 % a 95 % en 72 y a 42 % en 96, y 80 y 96 salen fuera. Al final: codo: c = 72 → 6.06 req/s; planeado al 70 %: 4.24 req/s por réplica.

  3. Paso 3
    python3 pick_concurrency.py results/sweep-c*.json --ttft-ms 1000 --tpot-ms 50 --percentile 95
    python3 pick_concurrency.py p99-only.json; echo "exit=$?"

    Qué deberías ver: La misma elección: ← elegida en 72, capacidad: 6.06 req/s a x=72; planeada al 70%: 4.24 req/s por réplica y goodput: 95% de los requests cumplen el SLO uno por uno. Con p99-only.json: error: p99-only.json: faltan ['p95_ttft_ms', 'p95_tpot_ms'] (hay ['p99_ttft_ms']) y la orden de correr vllm bench serve con --metric-percentiles 50,95,99, con exit=2. Es lo que pasa si olvidas --metric-percentiles.

  4. Paso 4
    cat > tirones.py <<'EOF'
    # tirones.py: por qué el TPOT puede cumplir el SLO mientras la persona siente tirones (solo stdlib).
    # 100 requests de 250 tokens = 249 huecos cada uno. Un hueco normal dura ≈ 43 ms; un paso que trae
    # un prefill grande, ≈ 150 ms. Las cuentas son las de vllm bench serve: TPOT por request (promedio de
    # sus huecos) e ITL con los huecos de todos los requests juntos. Números de juguete, no medidos.
    import random, statistics
    
    def pct(xs, q):                                  # percentil con interpolación lineal, como numpy
        xs = sorted(xs)
        k = (len(xs) - 1) * q / 100
        i = int(k)
        return xs[i] + (xs[min(i + 1, len(xs) - 1)] - xs[i]) * (k - i)
    
    def barrido(frac_saltos, semilla=7):
        rnd = random.Random(semilla)
        tpots, itl = [], []
        for _ in range(100):
            huecos = [rnd.gauss(150, 20) if rnd.random() < frac_saltos else max(30.0, rnd.gauss(43, 3))
                      for _ in range(249)]
            tpots.append(statistics.fmean(huecos))  # = (E2E − TTFT) / (250 − 1)
            itl += huecos
        return tpots, itl
    
    for frac in (0.02, 0.06):
        tpots, itl = barrido(frac)
        veredicto = "cumple" if pct(tpots, 95) <= 50 else "no cumple"
        print(f"{frac:.0%} de pasos con prefill: TPOT p50 {pct(tpots, 50):.1f} · p95 {pct(tpots, 95):.1f} ms ({veredicto} 50 ms)"
              f" | ITL p50 {pct(itl, 50):.1f} · p95 {pct(itl, 95):.1f} · p99 {pct(itl, 99):.1f} ms")
    EOF
    python3 tirones.py

    Qué deberías ver: Con 2 % de pasos con prefill: TPOT p50 45.0 · p95 47.0 ms (cumple 50 ms) e ITL p50 43.1 · p95 48.6 · p99 150.6 ms. Con 6 %: TPOT p95 51.9 ms (no cumple) e ITL p95 130.6 · p99 168.8 ms. El TPOT promedia los tirones dentro de cada request; el ITL junta los huecos de todos y su p99 los muestra, como en los fixtures (TPOT p95 49.6 ms con ITL p99 109 ms en c = 72).

  5. Paso 5
    cd ~ && rm -rf ~/lab-vllm

    Qué deberías ver: Sin salida.

Cómo arreglarlo y confirmarlo

Si cp no encuentra los fixtures, corre el paso 1 desde la raíz del repo del curso. Para ver moverse el codo, prueba un SLO más exigente: python3 pick_concurrency.py results/sweep-c*.json --ttft-ms 700 --tpot-ms 45 elige 48 (3.42 req/s planeados). En tirones.py, cambia 150 por 300 (prefills más largos) y mira qué percentil se entera primero.

Probado de verdad en un Mac. Ejecutado en macOS 26.6.2 con Python 3.14.3, 3.13.16 y 3.9.6: salidas idénticas (tirones.py usa una semilla fija). Las cuentas de tirones.py son las de vllm/benchmarks/serve.py en v0.30.0: TPOT por request y la lista de ITL de todos los requests juntos, con percentiles interpolados. Los 12 tests de bench/tests también pasan (pytest 9.1.1, Python 3.14.3).

8

Paralelismo: tensor, pipeline y réplicas; NVLink contra PCIe; cuándo un 70B necesita TP

¿Cuándo hay que partir un modelo entre GPUs, de qué formas se parte y por qué el C10 usa TP=2 dentro de un nodo, y no una GPU por réplica ni un TP más grande?

Sintaxis con variables

vllm serve modelo --tensor-parallel-size tp

Tensor parallelism (TP): cada capa se parte entre tp GPUs; cada una tiene un pedazo de cada matriz y, al final de la atención y del MLP (las dos partes de cada capa), juntan resultados con un all-reduce (una suma entre todas las GPUs). Son dos por capa: con 80 capas, 160 por paso. Necesita una conexión muy rápida entre GPUs: NVLink, el enlace directo entre las GPUs de un mismo servidor.

vllm serve modelo --tensor-parallel-size gpus_por_nodo --pipeline-parallel-size nodos \
  --distributed-executor-backend ray

Pipeline parallelism (PP): grupos de capas en GPUs distintas (etapas); entre etapas viajan las activaciones de cada token (en vLLM, dos vectores: hidden_states y residual), una vez por paso. Para un modelo que no cabe en un nodo, la regla de la documentación es TP = GPUs por nodo y PP = número de nodos. También sirve dentro de un nodo sin NVLink.

vllm serve modelo --data-parallel-size dp --tensor-parallel-size tp    # dp × tp GPUs

Data parallelism (DP): copias completas e independientes, cada una con su KV cache. Con --data-parallel-size, un solo vllm serve reparte los requests entre las copias; para un modelo denso es lo mismo que N servidores separados. El C10 usa réplicas de Kubernetes: 16 pods de TP=2.

resources: {limits: {nvidia.com/gpu: tp × pp}}    # + nodeSelector a un nodo con NVLink

Un pod pide TP × PP GPUs, y todas tienen que estar en el mismo nodo. En el C10: 2, en un p5.48xlarge (8 H100 unidas por NVSwitch: cualquier par sirve).

nvidia-smi topo -m               # NV# = NVLink; PIX, PXB, PHB, NODE, SYS = caminos por PCIe
NCCL_DEBUG=INFO vllm serve …     # por dónde va cada canal: P2P, SHM o NET

Antes de culpar al modelo, mira el cable. En H100 SXM con NVSwitch, topo -m muestra NV18 entre cada par (así sale en un DGX H100). Con NCCL_DEBUG=INFO, NCCL (la librería de NVIDIA que comunica GPUs) dice qué transporte usa: P2P es directo entre GPUs (por NVLink o PCIe), SHM pasa por la memoria de la CPU (el host) y NET/Socket es TCP, lento para TP entre nodos.

Variables

VariableQué esEjemplo
tpGPUs que se reparten cada capa2
gpus_por_nodo · nodospara un modelo que no cabe en un nodo: TP y PPno aplica al C10
dpcopias completas dentro de un vllm serveel C10 usa 16 pods en vez de --data-parallel-size
TP, PP, DP\text{TP},\ \text{PP},\ \text{DP}tensor, pipeline y data parallelism2 · 1 · 16 réplicas
ntokensn_{\text{tokens}}tokens del paso72 decodes, o un paso de 8,192
8, 192, 2 B\text{8},\ \text{192},\ \text{2 B}hidden size del modelo (el largo del vector de cada token) · bytes por valor en BF16 (las activaciones)config.json de Llama 3.3 70B
450 GB/s, 64 GB/s\text{450 GB/s},\ \text{64 GB/s}ancho de banda por sentido: el NVLink de la H100 SXM (900 sumando los dos) y PCIe Gen5 x16 (128)hoja de datos de NVIDIA
2.5 GB\text{2.5 GB}runtime y activaciones por GPU≈ 5 GB por réplica (estimación del C10)

Fórmulas

pesos por GPU=70.6 GBTP×PPTP=2: 35.3 GB\text{pesos por GPU} = \frac{70.6\ \text{GB}}{\text{TP} \times \text{PP}} \qquad \text{TP} = 2:\ 35.3\ \text{GB}
1 GPU: 0.90×80−70.6−2.5=−1.1 GB⇒no cabeTP=2: 0.90×160−70.6−5=68.4 GB de KV\text{1 GPU: } 0.90 \times 80 - 70.6 - 2.5 = -1.1\ \text{GB} \Rightarrow \text{no cabe} \qquad \text{TP} = 2:\ 0.90 \times 160 - 70.6 - 5 = 68.4\ \text{GB de KV}
KV heads por GPU=max⁡(1,⌊8TP⌋)64 mod TP=0⇒TP∈{1,2,4,8} en un nodo de 8\text{KV heads por GPU} = \max\left(1, \left\lfloor \frac{8}{\text{TP}} \right\rfloor\right) \qquad 64 \bmod \text{TP} = 0 \Rightarrow \text{TP} \in \{1, 2, 4, 8\} \text{ en un nodo de 8}
all-reduce por paso (TP=2)=2×80×ntokens×8,192×2 Bn=72: 189 MBn=8,192: 21.5 GB\text{all-reduce por paso (TP=2)} = 2 \times 80 \times n_{\text{tokens}} \times 8{,}192 \times 2\ \text{B} \qquad n = 72:\ 189\ \text{MB} \qquad n = 8{,}192:\ 21.5\ \text{GB}
t≈bytesancho de banda189 MB: 0.42 ms por NVLink (450 GB/s)  contra  2.9 ms por PCIe Gen5 (64 GB/s)t \approx \frac{\text{bytes}}{\text{ancho de banda}} \qquad 189\ \text{MB}:\ 0.42\ \text{ms por NVLink (450 GB/s)} \;\text{contra}\; 2.9\ \text{ms por PCIe Gen5 (64 GB/s)}
GPUs=DP×TP×PP16×2×1=32=4 nodos de 8\text{GPUs} = \text{DP} \times \text{TP} \times \text{PP} \qquad 16 \times 2 \times 1 = 32 = 4 \text{ nodos de } 8

Las piezas

PiezaQué haceEjemplo
--tensor-parallel-size (-tp)Parte cada capa entre GPUs. Default 1.2
--pipeline-parallel-size (-pp)Parte el modelo por capas, en etapas. Default 1.1
--data-parallel-size (-dp)Copias del modelo dentro de un mismo servidor. Default 1.1 por pod; 16 pods
regla de TPLas heads de atención (64) tienen que dividirse por TP; si no: «Total number of attention heads (64) must be divisible by tensor parallel size (3).»TP ∈ {1, 2, 4, 8} en un nodo
KV heads por GPUCon TP, cada GPU guarda max(1, 8 ÷ TP) KV heads: con TP=2, 4 de 8, o sea 160 KiB por token por GPU. Con más TP que KV heads, se replican.320 KiB por token entre las dos
all-reduceLa suma entre GPUs al final de la atención y del MLP de cada capa: 160 por paso. Cada paso espera a la GPU más lenta.lo hace NCCL o, con mensajes chicos, un kernel más rápido (de vLLM, FlashInfer o PyTorch)
--disable-custom-all-reduceApaga el kernel de all-reduce propio de vLLM. Default False. En v0.30 quedan los de FlashInfer y PyTorch: para que todo vaya por NCCL, también VLLM_ALLREDUCE_USE_FLASHINFER=0 y VLLM_ALLREDUCE_USE_SYMM_MEM=0.útil para descartar un bug
NCCLLa librería de NVIDIA para comunicar GPUs (all-reduce y demás). Dentro del nodo usa NVLink si lo hay y crea segmentos en /dev/shm.sin espacio: failed to extend /dev/shm/nccl-…
NVLink · NVSwitchConexión directa entre GPUs: 900 GB/s en la H100 SXM sumando los dos sentidos, ≈ 7× PCIe Gen5. NVSwitch une las 8 GPUs de un nodo.p5.48xlarge: cualquier par sirve
PCIeEl bus de la tarjeta: 128 GB/s en Gen5 x16, sumando sentidos. Con TP=2, el all-reduce de un paso de decode le suma ≈ 3 ms a un piso de ≈ 10.5; el de un prefill de 8,192, ≈ 0.34 s.GPUs sin NVLink (L40S): la doc sugiere PP
/dev/shmMemoria compartida: NCCL crea ahí sus segmentos y vLLM la usa para repartir cada paso entre los procesos de la réplica.emptyDir Memory de 16Gi
--distributed-executor-backendmp (procesos de Python) o ray. Por defecto, mp en un nodo; para varios nodos la doc usa ray, o mp con --nnodes y --node-rank.mp: todo dentro de un pod
PP y las burbujasLas etapas van en serie para cada token; los ratos en que una etapa espera vacía son las burbujas. vLLM las tapa con tantos batches en vuelo como etapas.piso del token ≈ 2 × 10.5 ms con PP=2
TP entre nodosCada paso cruza la red 160 veces: por NET/Socket (TCP) es muy lento. TP dentro del nodo, PP entre nodos.ninguna réplica del C10 cruza nodos
expert parallelism (EP)Para modelos MoE (mixture of experts: muchos expertos y pocos activos por token): reparte los expertos entre GPUs (--enable-expert-parallel). No aplica a un modelo denso.gpt-oss, si algún día lo eligen
H200 (141 GB)Con más memoria por GPU, el 70B en FP8 cabe en una con ≈ 54 GB de KV: TP=1.otra GPU, otra cuenta

p5.48xlarge: 8 × H100 SXM 80 GB · NVSwitch: 900 GB/s por GPU sumando sentidos (≈ 450 por sentido)

TP=2 (el C10)cabe

cada capa partida en 2: 32 de las 64 heads de query y 4 de las 8 KV heads por GPU

Cada GPU, a escala (de 0 a 80 GB)

KV por réplica: 68.4 GB · 92 secuencias de 2,250 tokens

Comunicación: 2 all-reduce por capa: 160 por paso

  • Un paso de decode con 72 tokens: 188.7 MB entre GPUs → 0.42 ms por NVLink contra 2.9 ms por PCIe Gen5; el piso del paso es 10.5 ms
  • Un paso de prefill con 8,192 tokens: 21,474.8 MB entre GPUs → 47.7 ms por NVLink contra 335.5 ms por PCIe Gen5; el piso del paso es 292.2 ms

elegido: el TP más chico donde caben los pesos y el KV

PP=2cabe

GPU 1: capas 1–40 · GPU 2: capas 41–80

Cada GPU, a escala (de 0 a 80 GB)

KV por réplica: 68.4 GB · 92 secuencias de 2,250 tokens

Comunicación: un envío por paso entre etapas (hidden_states y residual de cada token): 2.36 MB con 72 tokens

Latencia: las etapas van en serie: piso del token ≈ 2 × 10.5 = 21 ms, contra 10.5 con TP=2

para nodos sin NVLink o modelos que no caben en un nodo

TP=1 (réplica en 1 GPU)no cabe: −1.1 GB

el modelo entero en una GPU

Cada GPU, a escala (de 0 a 80 GB)

se pasa por 1.1 GB del tope (72 GB por GPU): sin lugar para el KV

Comunicación: ninguna

no cabe: 0.90 × 80 − 70.6 − 2.5 = −1.1 GB (en INT4 o en una H200, sí)

Con más TP, en el mismo nodo

TPKV por réplicaSecuencias de 2,250Réplicas por nodo
TP=4207.4 GB2812
TP=8485.4 GB6581

DP × TP × PP = 16 × 2 × 1 = 32 GPUs = 4 nodos; las réplicas son pods de Kubernetes

pesos FP8runtime y activaciones (≈ 2.5 GB, est.)KV cachelo que vLLM no toca: (1 − 0.90) × 80 GBlo que no cabeel tope de vLLM

Tres formas de repartir el 70B en FP8 en un p5 de 8 H100. TP=2 es la del C10: cada GPU tiene la mitad de cada capa y las dos se sincronizan 160 veces por paso por NVLink. PP=2 ocupa la misma memoria, pero las etapas van en serie. Una copia entera no entra en una H100 con 0.90 de utilización. Bytes y tiempos: la cuenta de tp.py, del lab (cotas: solo ancho de banda).

Ejemplo real · El paralelismo del C10 en el manifest: un nodo con NVLink, TP=2, dos GPUs por pod y /dev/shm

YAMLreference/c10/k8s/vllm-70b.yaml (líneas 97–102, 185–186, 225–233, 272–273 y 281–284; # … marca líneas omitidas)
      nodeSelector:
        node.kubernetes.io/instance-type: p5.48xlarge  # 8 × H100 SXM con NVLink: TP=2 dentro del nodo
      tolerations:
        - key: nvidia.com/gpu  # los nodos GPU tienen taint: nada que no use GPU ocupa una máquina de ≈ US$55/h
          operator: Exists
          effect: NoSchedule
# …
            - --tensor-parallel-size
            - "2"  # 70.6 GB de pesos FP8 + KV no entran cómodos en 80 GB; en 2 GPUs quedan ≈ 68 GB para KV
# …
          resources:
            requests:
              cpu: "40"  # p5.48xlarge: 192 vCPU para 4 réplicas; tokenización, scheduler y el API server son CPU
              memory: 256Gi  # 2 TiB en el nodo; page cache de los safetensors (mmap) + /dev/shm + buffers
              ephemeral-storage: 200Gi  # el emptyDir de los pesos cuenta aquí
              nvidia.com/gpu: 2  # TP=2: dos H100 del mismo nodo
            limits:
              memory: 256Gi  # = request: pasarse es un OOMKilled claro
              nvidia.com/gpu: 2  # las GPUs no se sobreasignan
# …
            - name: shm
              mountPath: /dev/shm  # NCCL y los procesos de TP comparten memoria por aquí
# …
        - name: shm
          emptyDir:
            medium: Memory  # el /dev/shm por defecto de un contenedor es 64 MiB: TP con NCCL necesita más
            sizeLimit: 16Gi  # cuenta contra el límite de memoria del contenedor
  • nodeSelector fija el tipo de nodo: un p5.48xlarge trae 8 H100 SXM unidas por NVSwitch (900 GB/s por GPU, sumando sentidos), así que cualquier par sirve para TP=2 y ninguna réplica cruza nodos. El taint nvidia.com/gpu (una marca del nodo que rechaza a los pods sin la toleration) deja esas máquinas de ≈ US$55/h solo para pods de GPU.
  • --tensor-parallel-size "2": con 0.90 de utilización, una H100 deja 72 GB para vLLM y los pesos FP8 ocupan 70.6: no queda lugar para el KV. En dos, quedan 68.4 GB de KV (≈ 92 secuencias de 2,250 tokens). Es el TP más chico que alcanza; después se agregan réplicas.
  • nvidia.com/gpu: 2 en requests y limits = TP × PP. El device plugin (el agente de NVIDIA que le presenta las GPUs a Kubernetes) entrega dos GPUs enteras del mismo nodo, que no se comparten con otros pods.
  • /dev/shm en RAM, de 16Gi: NCCL crea ahí sus segmentos de memoria compartida y vLLM reparte cada paso entre sus procesos por ahí. Con los 64 MiB por defecto, NCCL puede fallar con failed to extend /dev/shm/nccl-….
  • Por qué no TP=4 u 8: más TP baja el TPOT (el tiempo por token: cada GPU lee menos pesos por paso) y da más KV por réplica (207 y 485 GB), pero el all-reduce de cada capa no se achica (pesa más en cada paso) y deja menos réplicas: con TP=8, una réplica caída es 1/4 de la flota. El C10 se queda con TP=2 mientras el barrido no muestre una diferencia que pague eso.
  • Por qué no DP dentro de un pod (--data-parallel-size 4 con las 8 GPUs): para un modelo denso da lo mismo que 4 réplicas, pero un pod caído se lleva las 4 y un rolling update baja de a 4. Con pods de 2 GPUs, todo pasa de a 1/16.

Cómo recordarla

el TP más chico donde caben los pesos y el KV; después, réplicas. TP es partir cada página de un libro entre dos lectores que leen a la vez y se pasan notas en cada renglón: tienen que estar sentados juntos (NVLink). PP es darle a cada lector la mitad de los capítulos: se pasan la posta una vez por paso, pero uno espera al otro. Las réplicas son libros completos, uno por mesa.

Trampa

partir con TP entre dos nodos «porque sobran GPUs en el otro». Cada paso hace 160 all-reduce; por la red (NET/Socket en el log de NCCL es TCP) cada uno cuesta mucho más que por NVLink, y el TPOT se multiplica. Entre nodos va PP; dentro del nodo, TP. La otra: TP=3 o TP=6 para «usar las GPUs que quedan». 64 heads no se dividen por 3 ni por 6, y vLLM no arranca: Total number of attention heads (64) must be divisible by tensor parallel size (3).

Laboratorio (opcional)3 pasos

Qué vas a provocar: Calcular, para el 70B del C10 en un p5, qué TP vale, cuánta memoria queda para el KV, cuántas réplicas entran por nodo y cuánto pesa la comunicación de TP por NVLink contra PCIe.

Necesitas: Python 3, sin paquetes extra. Ni vLLM ni GPU: es la cuenta, no una medición.

  1. Paso 1
    mkdir -p ~/lab-vllm && cd ~/lab-vllm
    cat > tp.py <<'EOF'
    # tp.py: opciones de paralelismo para el 70B del C10 en un p5.48xlarge (8 × H100 80 GB), solo stdlib.
    # Uso: python3 tp.py [utilización] [GB de pesos]   (por defecto 0.90 y 70.6, los del C10)
    # Constantes: config.json de Llama 3.3 70B; pesos FP8 de vram_calc.py; GPU de la hoja de datos de NVIDIA.
    import sys
    
    CAPAS, HEADS, KV_HEADS, HEAD_DIM, HIDDEN = 80, 64, 8, 128, 8192
    UTIL = float(sys.argv[1]) if len(sys.argv) > 1 else 0.90       # --gpu-memory-utilization
    PESOS_GB = float(sys.argv[2]) if len(sys.argv) > 2 else 70.6   # 70.6 B parámetros × 1 B (FP8)
    VRAM_GB, RUNTIME_GB = 80, 2.5                                  # runtime ≈ 2.5 GB por GPU (C10)
    KV_TOKEN_B = 2 * CAPAS * KV_HEADS * HEAD_DIM * 2                 # K y V, BF16: 327,680 B
    NVLINK, PCIE = 450e9, 64e9      # B/s por sentido: 900 GB/s NVLink y 128 GB/s PCIe Gen5, sumando los dos sentidos
    HBM = 3.35e12                   # B/s de la HBM3 de una H100 SXM
    
    print("TP  ¿vale?  KV heads/GPU  pesos/GPU  KV por réplica  secuencias de 2,250  réplicas por nodo")
    for tp in (1, 2, 3, 4, 8):
        if HEADS % tp:
            print(f"{tp:>2}  no: Total number of attention heads ({HEADS}) must be divisible by tensor parallel size ({tp}).")
            continue
        kv_gb = UTIL * VRAM_GB * tp - PESOS_GB - RUNTIME_GB * tp
        seqs = int(kv_gb * 1e9 // (2250 * KV_TOKEN_B)) if kv_gb > 0 else 0
        kv = f"{kv_gb:6.1f} GB" if kv_gb > 0 else " no cabe"
        print(f"{tp:>2}  sí     {max(1, KV_HEADS // tp):>8}      {PESOS_GB / tp:6.1f} GB  {kv:>14}  {seqs:>19}  {8 // tp:>17}")
    
    print("\nTP=2: lo que cada GPU manda en all-reduces en un paso (2 por capa, anillo), sin contar latencias")
    for tokens, nombre, piso_ms in ((72, "decode de 72", PESOS_GB / 2 * 1e9 / HBM * 1e3),
                                    (8192, "prefill de 8,192", 2 * PESOS_GB * 1e9 * 8192 / (2 * 1.979e15) * 1e3)):
        b = 2 * CAPAS * tokens * HIDDEN * 2                           # para TP=2, cada GPU manda S por all-reduce
        print(f"  {nombre:>16}: {b / 1e6:8.1f} MB → NVLink {b / NVLINK * 1e3:6.2f} ms · PCIe {b / PCIE * 1e3:6.1f} ms"
              f"  (piso del paso ≈ {piso_ms:.1f} ms)")
    print(f"\nPP=2 (TP=1): pasa {2 * 72 * HIDDEN * 2 / 1e6:.2f} MB entre etapas por paso de 72 (hidden_states y residual); pero las dos etapas van"
          f" en serie: piso del token ≈ 2 × {PESOS_GB / 2 * 1e9 / HBM * 1e3:.1f} ms")
    EOF
    python3 tp.py

    Qué deberías ver: TP=1: no cabe (0 secuencias); TP=2: 4 KV heads por GPU, 35.3 GB de pesos, 68.4 GB de KV, 92 secuencias y 4 réplicas por nodo; TP=3: el error de vLLM; TP=4: 207.4 GB y 281; TP=8: 485.4 GB, 658 y 1 réplica. Después: decode de 72 → 188.7 MB, 0.42 ms por NVLink contra 2.9 ms por PCIe (piso del paso ≈ 10.5 ms); prefill de 8,192 → 21474.8 MB, 47.72 ms contra 335.5 ms (piso ≈ 292.2 ms). PP=2 mueve 2.36 MB por paso (dos vectores por token: hidden_states y residual), pero el piso del token se duplica.

  2. Paso 2
    python3 tp.py 0.92 | head -6

    Qué deberías ver: Con 0.92 (el default desde v0.20), TP=1 tendría 0.5 GB de KV y 0 secuencias de 2,250: ni una de 8,192 (2.5 GiB), así que vLLM no arrancaría («To serve at least one request with the model's max seq len (8192)…»). TP=2 sube a 71.6 GB y 97 secuencias: el C10 fija 0.90 para que un upgrade no le cambie la capacidad.

  3. Paso 3
    cd ~ && rm -rf ~/lab-vllm

    Qué deberías ver: Sin salida.

Cómo arreglarlo y confirmarlo

Prueba python3 tp.py 0.90 141.2, el mismo modelo en BF16: TP=2 ya no cabe y el primer TP que alcanza es 4 (136.8 GB de KV), como la réplica TP=4 que el C10 usa para evaluar la versión BF16. Para otro modelo, cambia CAPAS, HEADS, KV_HEADS, HEAD_DIM y HIDDEN con su config.json.

Probado de verdad en un Mac. Ejecutado en macOS 26.6.2 con Python 3.14.3 y 3.9.6. Las memorias coinciden con la tabla de TP del C10 (≈ 68, 207 y 485 GB; ≈ 93, 281 y 658 secuencias) y con vram_calc.py (92 con TP=2); el mensaje de TP=3 es el de vllm/config/model.py en v0.30.0 y los anchos de banda, los de la hoja de datos de la H100 SXM. Los ms son cotas: solo ancho de banda, sin la latencia de cada all-reduce ni solapamientos. Lo que cruza entre etapas con PP sale de vllm/model_executor/models/llama.py en v0.30.0 (IntermediateTensors con hidden_states y residual).

9

Cuantización: FP8, INT8, INT4 (AWQ y GPTQ), KV cache en FP8 y la calidad

¿Cuántos bytes por parámetro necesita el 70B del C10, qué formato corre en qué GPU, cómo se lo dices a vLLM y cómo sabes que la calidad no se rompió?

Sintaxis con variables

vllm serve checkpoint --served-model-name nombre --tensor-parallel-size tp

Cuantizar es guardar los pesos con menos bits: cada número se vuelve un número chico (8 o 4 bits) multiplicado por una escala que comparten muchos pesos. Un checkpoint (la carpeta con los pesos y su config) ya cuantizado (pre-quantized) trae su receta en config.json, bajo quantization_config, y vLLM la lee sola: no pasas --quantization. Si lo pasas y no coincide con la del checkpoint, no arranca. Así sirve el C10 sus pesos FP8.

python3 -c "import json; print(json.load(open('carpeta/config.json')).get('quantization_config'))"

Lee la receta antes de servir: quant_method (compressed-tensors, fp8, awq, gptq…), los bits y el tipo, si las activaciones se cuantizan al vuelo (dynamic: true), qué capas quedaron en 16 bits (ignore, casi siempre lm_head, la última capa, la que elige el token) y si trae escalas para el KV (kv_cache_scheme; null = no trae).

vllm serve checkpoint_bf16 --quantization fp8

Cuantización online (al cargar): vLLM baja un checkpoint BF16 a FP8 sin datos de calibración, con una escala por tensor. Sirve para probar rápido; la doc actual avisa que así la latencia mejora poco. Para producción, un checkpoint hecho con llm-compressor (la librería de vLLM para cuantizar; escalas por canal en los pesos y por token en las activaciones). Desde v0.31, fp8 redirige a fp8_per_tensor, el nombre que usa la doc actual.

vllm serve checkpoint --kv-cache-dtype tipo_kv

El KV cache (lo que el modelo ya leyó de cada conversación, guardado en la GPU para no recalcularlo) pasa de 2 bytes a 1 por valor: caben ≈ el doble de tokens. auto, el default y el del C10, usa el dtype del modelo (BF16). fp8 es fp8_e4m3. Si un checkpoint cuantizado, como el del C10, no trae escalas para K y V, vLLM usa 1.0 y lo avisa en el log; con uno sin cuantizar usa 1.0 sin avisar.

Variables

VariableQué esEjemplo
checkpointcarpeta o repo con los pesos ya cuantizados y su config.json/models/chat-70b-fp8-2026-09-28
nombreel nombre del modelo que ven los clienteschat-70b-fp8-2026-09-28
tpentre cuántas GPUs se reparte cada capa2
carpetadónde quedaron los safetensors/models/chat-70b-fp8-2026-09-28
checkpoint_bf16los pesos originales, en 16 bitsmeta-llama/Llama-3.3-70B-Instruct (≈ 141 GB)
tipo_kven qué tipo se guarda el KV cacheauto en el C10; fp8 solo si pasa el golden set
PPparámetros del modelo70.6 B
bbbytes por parámetro del formato2 en BF16 · 1 en FP8 o INT8 · 0.5 en INT4
KV libre\text{KV libre}memoria para KV en una réplica: GPUs × 80 GB × gpu_memory_utilization − pesos − runtime (≈ 2.5 GB por GPU, estimado)68.4 GB con pesos FP8
ssla escala que comparten muchos pesosmax|x| ÷ 448 en FP8 E4M3
qqel número chico que se guarda en lugar de xun entero de −127 a 127, o un float de 8 bits

Fórmulas

pesos=P×b70.6 B×{2, 1, 0.5} B=141.2, 70.6, 35.3 GB\text{pesos} = P \times b \qquad 70.6\ \text{B} \times \{2,\ 1,\ 0.5\}\ \text{B} = 141.2,\ 70.6,\ 35.3\ \text{GB}
KV libre=2×80×0.90−pesos−5  ⇒  BF16: −2.2FP8: 68.4INT4: 103.7 GB\text{KV libre} = 2 \times 80 \times 0.90 - \text{pesos} - 5 \;\Rightarrow\; \text{BF16: } -2.2 \quad \text{FP8: } 68.4 \quad \text{INT4: } 103.7\ \text{GB}
secuencias=⌊68.4 GB2,250×320 KiB⌋=92con KV en FP8 (160 KiB): 185\text{secuencias} = \left\lfloor \frac{68.4\ \text{GB}}{2{,}250 \times 320\ \text{KiB}} \right\rfloor = 92 \qquad \text{con KV en FP8 (160 KiB): } 185
x≈s⋅qsINT8=max⁡∣x∣127sFP8 E4M3=max⁡∣x∣448x \approx s \cdot q \qquad s_{\text{INT8}} = \frac{\max|x|}{127} \qquad s_{\text{FP8 E4M3}} = \frac{\max|x|}{448}

Las piezas

PiezaQué haceEjemplo
BF1616 bits con el rango de FP32. Es el formato en que se entrenan y se publican los LLMs, y la referencia de calidad.141.2 GB de pesos: en 2 × H100 casi no deja KV
FP8 W8A8Pesos y activaciones en 8 bits de punto flotante (W8A8: weights y activations en 8 bits). La mitad de memoria que BF16 y tensor cores (las unidades que multiplican matrices) FP8 para el cómputo.los pesos del C10: ≈ 70.6 GB
E4M3 · E5M2Los dos FP8: 4 bits de exponente y 3 de mantisa (más precisión, máximo 448) o 5 y 2 (más rango). Como es punto flotante, un valor atípico casi no le quita resolución a los chicos.--kv-cache-dtype fp8 = fp8_e4m3
FP8 weight-only (W8A16)Solo los pesos en FP8; el cómputo sigue en 16 bits. vLLM lo usa en GPUs sin FP8 nativo (Turing y Ampere) con los kernels Marlin: ahorra memoria, no cómputo.el mismo checkpoint en una A100
INT8 W8A8Enteros de 8 bits con escala, en pesos y activaciones (hechos con llm-compressor). Corre desde Turing.una salida para A100 y A10G, que no tienen FP8
INT4 W4A16 (AWQ, GPTQ)Pesos de 4 bits con una escala por grupo de pesos (por ejemplo, cada 128); se descuantizan al vuelo y el cómputo va en 16 bits. AWQ y GPTQ son dos formas de elegir esos 4 bits perdiendo poco.el 70B en ≈ 39.5 GB: cabe en una sola H100
GPTQ con act-order (desc_act)Una variante de GPTQ que cuantiza las columnas en otro orden y guarda un índice (g_idx) para deshacerlo. vLLM v0.30.0 dejó de soportarla: un checkpoint así ni arranca (GPTQ group activation ordering (desc_act=True) is no longer supported).usa GPTQ sin act-order de grupo (llm-compressor usa static por defecto) o AWQ
MarlinKernels rápidos (los programas que corren en la GPU) para pesos de 4 u 8 bits con activaciones de 16: GPTQ, AWQ y FP8 weight-only. Con FP8 en una GPU sin soporte nativo, vLLM los elige solo.la A100 con el checkpoint FP8 del C10
quantization_configLa receta dentro del config.json: método, bits, grupos y capas que no se tocaron. vLLM la lee y elige el método.quant_method: compressed-tensors · ignore: [lm_head]
--quantization (-q)Fuerza un método. Con un checkpoint ya cuantizado no hace falta; con uno BF16 y fp8, cuantiza al cargar.el C10 no lo pasa
--kv-cache-dtypeEl tipo del KV cache, aparte de los pesos: auto (el del modelo), fp8 (= fp8_e4m3), fp8_e5m2 y otros más nuevos.auto en el C10: KV en BF16
k_scale · v_scaleLas escalas del KV en FP8 que la calibración guarda en el checkpoint. Sin ellas, vLLM usa 1.0 (y lo avisa solo si el checkpoint está cuantizado).kv_cache_scheme: null en un checkpoint FP8-dynamic
Calibración (PTQ)Pasar unos cientos de ejemplos reales para medir rangos y fijar escalas, después del entrenamiento (post-training quantization).FP8_DYNAMIC no la necesita; INT4 y el KV sí
Compute capabilityLa generación de la GPU como número: 7.5 Turing (T4), 8.0 Ampere (A100), 8.9 Ada (L4, L40S), 9.0 Hopper (H100). FP8 nativo desde 8.9.H100 = 9.0
Golden set y gateEl examen fijo de cada producto y la regla que decide si una revisión pasa. Ninguna cuantización entra sin él.eval/golden_eval.py del C10

El 70B del C10 (arquitectura de Llama 3.x 70B: 70.6 B de parámetros, 80 capas, 8 KV heads × 128). Una réplica: 2 × 80 GB × 0.9 = 144 GB para vLLM, con ≈ 2.5 GB de runtime por GPU. KV por token: 320 KiB en BF16, 160 KiB en FP8.

Cada formato, a escala (todas las barras van de 0 a 150 GB)

  1. BF162 B por parámetropesos 141.2 GB · checkpoint real 141.1 GB

    2 × H100 (tope 144 GB)

    no cabe: −2.2 GB sin lugar para el KV

    1 × H100 (tope 72 GB)

    no aplica: los pesos solos pasan la memoria de la GPU

    no cabe con KV útil en 2 × H100: la cuenta nominal da −2.2 GB y, con la memoria que reporta la GPU (≈ 85.5 GB), quedan ≈ 7.7 GB (≈ 10 secuencias). Pide TP=4

  2. FP8 (W8A8)1 B por parámetropesos 70.6 GB · checkpoint real 72.7 GB

    2 × H100 (tope 144 GB)

    KV libre 68.4 GB → 92 secuencias de 2,250 tokens con KV en BF16, 185 con KV en FP8

    1 × H100 (tope 72 GB)

    no cabe: −1.1 GB

    el C10; en 1 H100 no deja KV útil (nominal −1.1 GB; con la memoria reportada, ≈ 2–4 GB)

  3. INT4 (W4A16)0.5 B por parámetropesos 35.3 GB · checkpoint real 39.5 GB

    2 × H100 (tope 144 GB)

    KV libre 103.7 GB → 140 secuencias de 2,250 tokens con KV en BF16, 281 con KV en FP8

    1 × H100 (tope 72 GB)

    KV libre 34.2 GB

    cabe en 1 H100; más riesgo de calidad

Los checkpoints reales

  • meta-llama/Llama-3.3-70B-InstructBF16 · 70,553,706,496 parámetros · 141.1 GB
  • RedHatAI/Llama-3.3-70B-Instruct-FP8-dynamic68.45 B en FP8 E4M3 + 2.11 B en BF16 · 72.7 GB
  • RedHatAI/Llama-3.3-70B-Instruct-quantized.w4a16INT4 con escalas por grupo + embeddings en BF16 · 39.5 GB

Redondear 0.1 a 8 bits: lo que se guarda y su error relativo (a escala, de 0 a 100 %)

  • sin un valor atípico en el tensor

    • INT8 → 0.09841.6 %
    • FP8 → 0.10040.4 %
  • con un atípico de 25 en el mismo tensor

    • INT8 → 0.196996.9 %
    • FP8 → 0.09772.3 %
  • mediana del error en 4,096 pesos con un atípico

    • INT8 por tensor6.8 %
    • FP8 por tensor2.2 %
    • INT4 por grupo de 12814.3 %

pesosruntime: activaciones, CUDA graphs y buffersKV librelo que no cabeel tope de vLLM

Bytes por parámetro y memoria del 70B del C10 en una réplica de 2 × H100 (0.90 × 160 GB = 144 GB) y en una sola H100: pesos, KV libre y secuencias de 2,250 tokens que caben. Los pesos son la cuenta del caso (70.6 B × bytes); los checkpoints reales en FP8 e INT4 pesan algo más porque dejan embeddings y lm_head en BF16 (el BF16 real pesa algo menos: 70.55 B de parámetros). Números del lab de esta sección.

Ejemplo real · C10: el gate que decide si una cuantización (o el KV en FP8) entra

Pythonreference/c10/eval/golden_eval.py (líneas 218–228, 238–245 y 251–261; las líneas # … resumen lo omitido)
@dataclass(frozen=True)
class GateThresholds:
    """Umbrales ILUSTRATIVOS: los fija cada producto con su dueño. Se versionan con el código."""

    min_json_validity: float = 0.99  # extracción: 1 % de JSON roto ya es 20k items por noche (de 2 M)
    min_field_f1: float = 0.90  # extracción: piso absoluto
    max_f1_drop: float = 0.01  # extracción: contra la revisión actual (si se da baseline)
    min_win_rate_lower: float = 0.45  # chat: no-inferioridad con margen de 5 puntos (borde inferior de Wilson)
    min_position_consistency: float = 0.80  # chat: debajo de esto el juez decide por posición, no por calidad
    min_judge_kappa: float = 0.60  # chat: acuerdo juez-humano medido en la muestra de calibración
    min_pairs: int = 300  # chat: con menos, el intervalo es demasiado ancho para decidir


def gate_extraction(rep: ExtractionReport, t: GateThresholds, baseline_f1: float | None = None) -> GateResult:
    reasons = []
    if rep.json_validity < t.min_json_validity:
        reasons.append(f"JSON válido {rep.json_validity:.3f} < {t.min_json_validity}")
    if rep.f1 < t.min_field_f1:
        reasons.append(f"F1 por campo {rep.f1:.3f} < {t.min_field_f1}")
    if baseline_f1 is not None and baseline_f1 - rep.f1 > t.max_f1_drop:
        reasons.append(f"F1 cae {baseline_f1 - rep.f1:.3f} contra la revisión actual (> {t.max_f1_drop})")
    # … return GateResult(not reasons, reasons, {…})


def gate_pairwise(rep: PairwiseReport, t: GateThresholds, judge_kappa: float) -> GateResult:
    lo, hi = wilson_interval(rep.wins + 0.5 * rep.ties, rep.pairs)
    reasons = []
    if rep.pairs < t.min_pairs:
        reasons.append(f"solo {rep.pairs} pares (< {t.min_pairs})")
    if judge_kappa < t.min_judge_kappa:
        reasons.append(f"kappa juez-humano {judge_kappa:.2f} < {t.min_judge_kappa}: recalibra el juez antes de decidir")
    if rep.position_consistency < t.min_position_consistency:
        reasons.append(f"consistencia de posición {rep.position_consistency:.2f} < {t.min_position_consistency}")
    if lo < t.min_win_rate_lower:
        reasons.append(f"win rate {rep.win_rate:.3f}, IC95 [{lo:.3f}, {hi:.3f}]: el borde inferior no pasa {t.min_win_rate_lower}")
    # … return GateResult(not reasons, reasons, {…})
  • En el C10 los pesos FP8 son una revisión del modelo más: chat-70b-fp8-2026-09-28. Pasa el mismo gate que un modelo distinto, por producto, y el reporte va al model registry. Para comparar FP8 contra el original, el caso corre la versión BF16 en una réplica TP=4 temporal: en 2 H100 no cabe con KV.
  • Chat: gate_pairwise compara contra la revisión actual con un juez calibrado. El borde inferior del intervalo de Wilson tiene que pasar 0.45: con 1,000 pares, un win rate de 0.50 da [0.469, 0.531] y pasa; con 100, [0.404, 0.596] y no pasa (es la salida de python eval/golden_eval.py).
  • Extracción: JSON válido ≥ 0.99, F1 por campo ≥ 0.90 y no más de 1 punto debajo de la revisión actual. Con 2 M items por noche, 1 % de JSON roto son 20,000 items.
  • El KV en FP8 espera este gate. El manifest lo dice al lado del flag: auto # KV en BF16. "fp8" duplica las secuencias; se activa solo si pasa el golden set. Córrelo también con los prompts largos del set: M14 avisa que el riesgo del KV en FP8 crece con el contexto.
  • Lo que el gate no mide es la velocidad. Una revisión cuantizada vuelve al barrido de vllm bench serve (bench/run_sweep.sh), porque el «≈ 6 rps por réplica» cambia con el formato.

Cómo recordarla

2, 1, ½: bytes por parámetro en BF16, FP8 e INT4, o sea 141, 71 y 35 GB para el 70B. FP8 nativo desde la generación 8.9 (Ada, Hopper, Blackwell); en Turing y Ampere vLLM lo corre solo como pesos (W8A16). Y el orden del C10: pesos en FP8 primero; KV en FP8 solo si pasa el golden set.

Trampa

tratar la cuantización como un cambio de memoria y no de modelo. Cambia los números que el modelo calcula: se mide con el golden set de cada producto, en sus casos difíciles (prompts largos, español de México, JSON), no con un benchmark público ni con el promedio. Y dos de configuración: pasar --quantization a un checkpoint que ya trae la suya (si no coincide, vLLM no arranca), y activar --kv-cache-dtype fp8 sin escalas calibradas: corre con 1.0 y, como mucho, te avisa en el log.

Laboratorio (opcional)3 pasos

Qué vas a provocar: Calcular cuánto pesa el 70B del C10 en BF16, FP8 e INT4, qué cabe en 2 H100 y en 1, y ver con números por qué FP8 aguanta mejor un peso atípico que INT8 (y cuándo INT8 gana).

Necesitas: Python 3 sin paquetes extra (el python3 de macOS sirve). Ni GPU ni Docker.

  1. Paso 1
    mkdir -p ~/lab-vllm && cd ~/lab-vllm
    cat > quant.py <<'EOF'
    # quant.py: bytes por parámetro del 70B del C10, qué cabe en 2 × H100 y en 1, y el error de redondeo de INT8, FP8 e INT4 (solo stdlib)
    import math, random, statistics
    
    GB = 10**9
    P = 70.6e9                                         # parámetros (capacity/vram_calc.py del C10)
    KV = {"BF16": 2 * 80 * 8 * 128 * 2, "FP8": 2 * 80 * 8 * 128 * 1}   # bytes de KV por token: 320 y 160 KiB
    SEQ = 2_250                                        # 2,000 de prompt + 250 de salida
    
    def libre(pesos_gb, gpus):                         # 0.90 × 80 GB por GPU − pesos − ≈ 2.5 GB por GPU (runtime, activaciones)
        return gpus * 80 * 0.90 - pesos_gb - gpus * 2.5
    
    print("formato  B/param   pesos   KV libre 2×H100   secuencias de 2,250 (KV BF16 · KV FP8)   KV libre 1×H100")
    for nombre, b in (("BF16", 2), ("FP8", 1), ("INT4", 0.5)):
        pesos = P * b / GB
        l2, l1 = libre(pesos, 2), libre(pesos, 1)
        seqs = lambda l, kv: math.floor(l * GB / (SEQ * kv)) if l > 0 else 0
        txt2 = f"{l2:6.1f} GB" if l2 > 0 else " no cabe"
        txt1 = f"{l1:5.1f} GB" if l1 > 0 else "no cabe"
        print(f"{nombre:7}  {b:>6}  {pesos:6.1f} GB  {txt2:>14}   {seqs(l2, KV['BF16']):>12} · {seqs(l2, KV['FP8']):<4}               {txt1}")
    
    # Lo que pesan los checkpoints reales de Llama 3.3 70B (API de Hugging Face: safetensors.parameters y usedStorage)
    reales = (("BF16   meta-llama/Llama-3.3-70B-Instruct", 70_553_706_496 * 2),
              ("FP8    RedHatAI/Llama-3.3-70B-Instruct-FP8-dynamic", 68_451_041_280 * 1 + 2_109_382_656 * 2),
              ("INT4   RedHatAI/Llama-3.3-70B-Instruct-quantized.w4a16", 39_542_797_099))
    for nombre, b in reales:
        print(f"real: {nombre:52} {b / GB:6.1f} GB")
    
    # Redondeo: x ≈ escala × q. INT8 guarda un entero; FP8 E4M3 guarda un float de 8 bits (máx. 448)
    def int_q(x, s, qmax):
        return max(-qmax, min(qmax, round(x / s))) * s
    def e4m3(v):                                       # al valor FP8 E4M3 más cercano (empates al par)
        if v == 0:
            return 0.0
        a = min(abs(v), 448.0)
        e = max(math.floor(math.log2(a)), -6)          # bajo 2^-6, subnormales: el paso queda en 2^-9
        paso = 2.0 ** (e - 3)                          # 3 bits de mantisa: 8 pasos por potencia de 2
        return math.copysign(min(round(a / paso) * paso, 448.0), v)
    
    for titulo, pesos in (("sin atípico", [-2.5, 2.5]), ("con un peso atípico de 25", [-2.5, 2.5, 25.0])):
        m = max(abs(w) for w in pesos)
        i8 = int_q(0.1, m / 127, 127)
        f8 = e4m3(0.1 / (m / 448)) * (m / 448)
        print(f"0.1 {titulo:26} INT8 → {i8:.4f} (error {abs(i8 - 0.1) / 0.1:6.1%})   FP8 → {f8:.4f} (error {abs(f8 - 0.1) / 0.1:6.1%})")
    
    random.seed(7)
    w = [random.gauss(0, 1) for _ in range(4095)] + [25.0]     # 4,096 pesos y un atípico, como en una capa real
    m = max(abs(x) for x in w)
    def err(q):
        return statistics.median(abs(q(x) - x) / abs(x) for x in w[:-1])
    print(f"mediana del error relativo en 4,096 pesos (el más grande vale {m:g}): "
          f"INT8 por tensor {err(lambda x: int_q(x, m / 127, 127)):.1%} · "
          f"FP8 por tensor {err(lambda x: e4m3(x / (m / 448)) * (m / 448)):.1%} · "
          f"INT4 con escala cada 128 pesos {statistics.median(abs(int_q(x, max(abs(y) for y in w[i // 128 * 128:i // 128 * 128 + 128]) / 7, 7) - x) / abs(x) for i, x in enumerate(w[:-1])):.1%}")
    EOF
    python3 quant.py

    Qué deberías ver: La tabla, con los 80 GB nominales de la cuenta del caso: BF16 141.2 GB y no cabe ni en 2 ni en 1 H100; FP8 70.6 GB, 68.4 GB de KV libre, 92 · 185 secuencias de 2,250 (KV en BF16 · en FP8) y no cabe en 1 H100; INT4 35.3 GB, 103.7 GB, 140 · 281 y 34.2 GB en 1 H100. Los checkpoints reales: 141.1, 72.7 y 39.5 GB. El 0.1 sin atípico: INT8 0.0984 (error 1.6 %), FP8 0.1004 (0.4 %); con un peso atípico de 25: INT8 0.1969 (96.9 %), FP8 0.0977 (2.3 %). La mediana en 4,096 pesos: INT8 por tensor 6.8 %, FP8 2.2 %, INT4 con escala cada 128 pesos 14.3 %.

  2. Paso 2
    sed -i '' 's/\[25\.0\]/[2.5]/' quant.py && python3 quant.py | tail -1

    Qué deberías ver: Sin el atípico (el más grande vale 3.47177): INT8 por tensor 0.9 %, FP8 2.2 %, INT4 13.7 %. INT8 tiene pasos parejos y gana cuando el rango es chico; FP8 tiene un error relativo casi fijo (≈ 2–3 %) y por eso aguanta los atípicos de los LLMs.

  3. Paso 3
    cd ~ && rm -rf ~/lab-vllm

    Qué deberías ver: Sin salida.

Cómo arreglarlo y confirmarlo

Prueba otras GPUs cambiando los números de libre(): una H200 (141 GB) o TP=4. Si tu cuenta no coincide con lo que imprime vLLM al arrancar (GPU KV cache size: N tokens), gana vLLM: midió activaciones y CUDA graphs, y aplica gpu_memory_utilization a la memoria que reporta la GPU (≈ 85.5 GB en una H100), no a los 80 nominales.

Probado de verdad en un Mac. Ejecutado en macOS con Python 3.14.3, 3.13.16 y 3.9.6: salidas idénticas. El redondeo a E4M3 del script coincide con torch.float8_e4m3fn (PyTorch 2.14.1 de un venv existente) en 60,401 valores. 68.4 GB, 92 y 185 son los de capacity/vram_calc.py; los tamaños reales salen de la API de Hugging Face (safetensors.parameters y usedStorage, octubre de 2026); la tabla de formatos por GPU, de la doc de cuantización de vLLM v0.30.0.

10

Optimizaciones: prefix caching, speculative decoding, LoRA múltiple, autoscaling por cola y cold start

Ya cabe y ya cumple: ¿qué palanca baja el TTFT, cuál el TPOT, cuál sirve varios fine-tunings con una sola flota, cuál acorta el arranque, y por qué el C10 no escala con KEDA?

Sintaxis con variables

sum(rate(vllm:prefix_cache_hits_total[ventana])) / sum(rate(vllm:prefix_cache_queries_total[ventana]))

Prefix caching (reusar el KV de un comienzo de prompt que ya se calculó) viene activado en v0.30 (--enable-prefix-caching; lo apaga --no-enable-prefix-caching). Guarda bloques completos de 16 tokens, cada uno identificado por un hash (una huella numérica) de todo lo anterior: solo acierta lo idéntico desde el primer token. Ahorra prefill (TTFT y GPU), no decode. Esta query da la fracción de tokens de prompt que salieron del caché.

vllm serve modelo --speculative-config '{"method": "ngram", "num_speculative_tokens": k, "prompt_lookup_max": n_max}'
vllm serve modelo --speculative-config '{"method": "metodo", "model": "draft", "num_speculative_tokens": k}'

Speculative decoding (algo barato propone k tokens y el modelo grande los verifica todos en una sola pasada). method dice de dónde salen las propuestas: ngram (copia trozos del prompt), draft_model (un modelo chico con el mismo tokenizer), eagle3, mtp o suffix. La doc de vLLM lo recomienda para bajar el tiempo entre tokens con QPS medio o bajo, cuando el decode es memory-bound (lo limita leer los pesos, no calcular). En teoría la salida es la misma: cambia la velocidad, no la calidad.

vllm serve modelo_base --enable-lora --max-loras n --max-lora-rank r \
  --lora-modules nombre=ruta
# el cliente lo pide como cualquier modelo: "model": "nombre"

LoRA (un adaptador chico entrenado encima del modelo base): un solo vLLM sirve el base y varios adaptadores. --max-loras = cuántos adaptadores distintos entran en un mismo batch (default 1); --max-lora-rank = el rank (el ancho de las matrices del adaptador) más grande que aceptas (default 16): uno mayor no carga. En /v1/models aparecen con el modelo base como parent.

triggers:
  - type: prometheus
    metadata:
      serverAddress: url_prometheus
      query: sum(vllm:num_requests_waiting{namespace="ns"})
      threshold: "umbral"

Con KEDA (el autoscaler de Kubernetes que escala con métricas de afuera, como una query de Prometheus), escala por cola (requests esperando adentro de vLLM), no por utilización de GPU, que está cerca de 100 % con 5 secuencias o con 90. Con el metricType por defecto (AverageValue), el HPA (el autoscaler de pods de Kubernetes) que crea KEDA pide ceil(cola total ÷ umbral) réplicas. Escribe minReplicaCount: su default es 0, y un LLM que baja a cero paga minutos de arranque en el primer request.

vllm serve modelo --enforce-eager
# en el log de arranque, sin --enforce-eager:
#   Model loading took gib GiB memory and seg seconds
#   torch.compile took seg s in total
#   Graph capturing finished in seg secs, took gib GiB

CUDA graphs graba la secuencia de kernels de un paso y la repite sin el costo de lanzarlos uno por uno: decode más rápido, a cambio de segundos de captura y algo de memoria al arrancar. --enforce-eager apaga torch.compile y CUDA graphs: arranca antes y genera más lento. La caché de torch.compile (el grafo del modelo compilado a kernels propios) vive en ~/.cache/vllm/torch_compile_cache (VLLM_CACHE_ROOT); con ella, el segundo arranque no recompila.

Variables

VariableQué esEjemplo
ventanaventana de rate()5m
modeloel modelo que sirves/models/chat-70b-fp8-2026-09-28
kcuántos tokens propone el método por paso4
n_maxel trozo más largo que ngram busca en el prompt5 (si no fijas ni el mínimo ni el máximo, los dos quedan en 5)
metodode dónde salen las propuestasdraft_model · eagle3 · mtp
draftel modelo chico o la cabeza entrenada para tu modelouno de la misma familia, con el mismo tokenizer
modelo_baseel modelo sobre el que se entrenaron los adaptadoresel 70B del C10 (el caso no usa LoRA)
nadaptadores distintos en un mismo batch1 por defecto
rel rank más grande que aceptas16 por defecto; hasta 512
nombre=rutael nombre que piden los clientes y dónde está el adaptadorlistings-lora=/models/adapters/listings (ilustrativo)
url_prometheusel Prometheus que lee KEDAhttp://prometheus-operated.monitoring.svc:9090
nsnamespace de vLLMc10
umbralrequests esperando por réplica que aceptas5 (el tutorial de KEDA del production-stack de vLLM)
gib, segmemoria y segundos que vLLM informa al arrancarlos del log de tu pod
λ\lambdarequests por segundo que recibe una réplica60 ÷ 16 = 3.75
α\alphaprobabilidad de que se acepte cada token propuesto0.5–0.8 según la tarea

Fórmulas

prefill=λ×(nprompt−ncacheˊ)=3.75×(2,000−992)≈3,780 tokens/s por reˊplica(sin cacheˊ: 7,500)\text{prefill} = \lambda \times (n_{\text{prompt}} - n_{\text{caché}}) = 3.75 \times (2{,}000 - 992) \approx 3{,}780\ \text{tokens/s por réplica} \quad (\text{sin caché: } 7{,}500)
E[tokens por paso]=1−αk+11−αα=0.7, k=4  ⇒  2.77E[\text{tokens por paso}] = \frac{1 - \alpha^{k+1}}{1 - \alpha} \qquad \alpha = 0.7,\ k = 4 \;\Rightarrow\; 2.77
mean acceptance length=1+aceptadosdraftsacceptance rate=aceptadospropuestos=2.77−14≈44 %\text{mean acceptance length} = 1 + \frac{\text{aceptados}}{\text{drafts}} \qquad \text{acceptance rate} = \frac{\text{aceptados}}{\text{propuestos}} = \frac{2.77 - 1}{4} \approx 44\ \%
reˊplicas=⌈∑num_requests_waitingumbral⌉tarranque≈2–5⏟nodo+1–3⏟imagen+1–3⏟pesos+1–3⏟carga y graphs min\text{réplicas} = \left\lceil \frac{\sum \text{num\_requests\_waiting}}{\text{umbral}} \right\rceil \qquad t_{\text{arranque}} \approx \underbrace{2\text{–}5}_{\text{nodo}} + \underbrace{1\text{–}3}_{\text{imagen}} + \underbrace{1\text{–}3}_{\text{pesos}} + \underbrace{1\text{–}3}_{\text{carga y graphs}}\ \text{min}

Las piezas

PiezaQué haceEjemplo
Prefix caching (APC)Reusa los bloques de KV de un comienzo de prompt idéntico. Ahorra prefill: baja el TTFT y libera GPU; no acelera el decode.≈ 1,000 tokens de instrucciones por producto: ≈ la mitad de cada prompt
Bloque completo de 16 tokensSolo se cachean bloques llenos, encadenados por hash desde el primer token. Si todo el prompt acierta, vLLM recalcula igual el último token para tener los logits (en la práctica, su bloque completo).un prefijo de 1,000 tokens deja 62 bloques (992 tokens) en caché
Lo fijo primeroEl nombre de la persona, la hora exacta o un id al comienzo cambian el hash de todos los bloques que siguen: la tasa de aciertos cae a ≈ 0.la regla de prompts del C10
cache_saltUn texto por request que entra en el hash del primer bloque: solo comparten caché los requests con la misma sal. Evita que un tenant adivine por tiempos lo que mandó otro.una sal por tenant (pendiente en la referencia)
Speculative decodingAlgo barato propone k tokens y el modelo grande los verifica juntos. Baja el TPOT con poca concurrencia; con la GPU llena, lo rechazado es trabajo tirado.fuera del diseño base del C10 (≈ 40 secuencias por réplica)
ngram · draft_model · eagle3 · mtp · suffixDe dónde salen las propuestas: copiar del prompt, un modelo chico, una cabeza entrenada para tu modelo, capas de predicción del propio modelo o un árbol de sufijos.ngram para extracción: la salida copia trozos del input
Mean acceptance lengthTokens por paso del modelo grande: 1 + aceptados ÷ drafts. La acceptance rate de vLLM es otra cosa: aceptados ÷ propuestos.2.77 tokens por paso con α = 0.7 y k = 4
Multi-LoRAUn modelo base con varios adaptadores encima; cada request elige el suyo por nombre. Cuesta memoria y algo de latencia por adaptador.una opción para el C10 si cada producto tuviera su fine-tuning
--max-loras · --max-lora-rank · --max-cpu-lorasCuántos adaptadores distintos por batch (default 1), el rank máximo (default 16) y cuántos quedan en memoria de CPU (al menos max-loras).rank 32 con el default: LoRA rank 32 is greater than max_lora_rank 16.
Autoscaling por colaEscalar con vllm:num_requests_waiting (o el tiempo en cola), no con la utilización de GPU, que está cerca de 100 % con 5 o con 90 secuencias.el C10 tiene réplicas fijas: el porqué está en la trampa
Cold startEl tiempo hasta servir: nodo, imagen, pesos, carga a la GPU, torch.compile y CUDA graphs.≈ 5–10 min según el C10 (la suma de sus tramos, estimados: mídelo)
Pesos en NVMe localCopiar los safetensors a un disco del nodo antes de arrancar: vLLM los lee por mmap y un disco de red convierte la carga en minutos.init container: aws s3 sync + sha256sum -c
Caché de torch.compileLo compilado se guarda en ~/.cache/vllm/torch_compile_cache; con caché, el arranque siguiente no recompila. VLLM_DISABLE_COMPILE_CACHE=1 la apaga.con HOME=/home/vllm, en el sistema de archivos del contenedor
CUDA graphs · --enforce-eagerGraphs: el decode repite pasos grabados sin lanzar kernels uno por uno. --enforce-eager apaga graphs y torch.compile: arranca antes y genera más lento.el C10 deja los graphs (sin --enforce-eager)
PalancaQué bajaQué cuestaEn el C10
--enable-prefix-cachingprefill: TTFT y GPU (≈ 50 % del prompt en el C10)nada si los prompts empiezan igual; no ayuda si empiezan distintoactivado (default en v0.30)
--speculative-configTPOT con poca concurrenciaGPU en lo rechazado; memoria si hay un draft modelno: ≈ 40 secuencias por réplica
--enable-loraflotas: un modelo base para varios productosmemoria y algo de latencia por adaptadorno (sin fine-tunings)
KEDA con num_requests_waitingGPUs ociosas en el vallearranques de minutos y H100 que quizá no existanno: flota fija; la admisión absorbe los picos
Pesos en NVMe y caché de compileminutos de arranquedisco local y más piezaspesos sí (init container); caché de compile no
CUDA graphs (default) · --enforce-eagergraphs: tiempo por paso de decode; eager: tiempo de arranquegraphs: memoria y segundos al arrancar; eager: TPOTgraphs (sin --enforce-eager)

Ninguna se deja prendida sin medirla con el barrido de goodput y la mezcla real (bench/run_sweep.sh).

Las palancas de vLLM una vez que el modelo cabe: qué baja cada una, qué cuesta y qué decidió el C10.

Ejemplo real · C10: por qué la flota es fija y qué se hizo para que arranque rápido

YAMLreference/c10/k8s/vllm-70b.yaml (líneas 12–17, 195, 215–217 y 238–245; las líneas # … resumen lo omitido)
# Por qué réplicas FIJAS (sin HPA/KEDA): un nodo p5 nuevo + la imagen (≈ 8.7 GB comprimida) + ≈ 70 GB de pesos +
# CUDA graphs ≈ 5–10 min (est., mídelo). Un autoscaler no sigue un pico de 1 minuto con eso; y de noche la
# flota no queda ociosa, porque la usa el batch de catálogo. Se mantiene la flota del pico prendida y los picos
# los absorbe la admisión del gateway (gateway/admission.py). Los nodos los da la capacidad reservada
# (On-Demand Capacity Reservation o Capacity Blocks for ML; verifica disponibilidad de p5 en tu región), no
# "on-demand cuando haga falta": la H100 es el recurso escaso, no el dinero.
# …
            - --enable-prefix-caching  # default en v0.30; explícito porque el plan cuenta con él (system prompts)
# …
          env:
            - name: HOME
              value: /home/vllm  # caches de vLLM, torch.compile y Triton en un lugar escribible para UID 2000
# …
          startupProbe:
            # Cargar ≈ 70 GB desde NVMe, perfilar memoria, capturar CUDA graphs y (sin caché) torch.compile: minutos.
            # 90 × 10 s = 15 min antes de que cuente la liveness. Si se pasa, el pod reinicia (y lo ves).
            httpGet:
              path: /health
              port: http
            periodSeconds: 10
            failureThreshold: 90
  • Sin autoscaling: el arranque de una réplica (≈ 5–10 min estimados) es más largo que cualquier pico de minutos, y las H100 son el recurso escaso. La flota del pico queda prendida, el 30 % de margen y la admisión del gateway absorben los picos, y de noche la usa el batch.
  • Prefix caching ya viene activado en v0.30; el flag explícito documenta que el plan cuenta con él. Con 1,000 tokens de instrucciones por producto, cada réplica baja de 7,500 a ≈ 3,780 tokens/s de prefill en el pico, y 40 secuencias ocupan 53,000 tokens de KV en vez de 90,000 (kv_tokens_with_shared_prefix en capacity/vram_calc.py).
  • HOME=/home/vllm pone las cachés de vLLM y de torch.compile en el sistema de archivos del contenedor: un contenedor nuevo empieza sin caché y vuelve a compilar. Un volumen montado en ~/.cache/vllm (o en VLLM_CACHE_ROOT) la conservaría entre reinicios del contenedor dentro del mismo pod; el C10 no lo hace.
  • La startupProbe da 15 min y, en el C10, es el único límite: con un solo API server, vLLM v0.30 espera al engine sin plazo. VLLM_ENGINE_READY_TIMEOUT_S (600 s por defecto) solo corta cuando el API corre en procesos aparte (--api-server-count mayor que 1, o data parallel), con Timed out waiting for engine core processes to start; si un día los usas y el arranque puede pasar de 10 min, sube esa variable.
  • Para reiniciar más rápido hay piezas nuevas. Desde v0.30, un daemon por GPU guarda los pesos ya cuantizados y repartidos, y un engine que reinicia los toma por CUDA IPC (memoria de la GPU compartida entre procesos) con --load-format ipc_cache; en v0.30 el daemon se lanza con python -m vllm.model_executor.model_loader.weight_cache.daemon y desde v0.31 con vllm preload. En v0.32, vllm download-kernels instala antes los kernels precompilados de FlashInfer que se bajarían o compilarían al arrancar. El C10 no usa ninguna.

Cómo recordarla

prefix caching baja el TTFT, speculative decoding baja el TPOT, LoRA ahorra flotas, KEDA ahorra GPUs ociosas y la caché ahorra arranques. Cada una se paga: aciertos que dependen de cómo armas el prompt, GPU tirada en lo rechazado, memoria, minutos de arranque.

Trampa

copiar el autoscaling del C01. Una réplica del C10 tarda ≈ 5–10 min en servir (nodo p5, imagen, 70 GB de pesos, CUDA graphs) y la H100 puede no existir en ese momento: un pico de un minuto termina antes de que llegue la réplica nueva. Por eso hay 16 réplicas fijas y la admisión del gateway absorbe los picos. La otra: un prompt que empieza con el nombre de la persona o la hora exacta cambia el hash de todos sus bloques, y la tasa de aciertos cae a 0 aunque el resto sea idéntico.

Laboratorio (opcional)3 pasos

Qué vas a provocar: Medir la tasa de aciertos del prefix cache con bloques de 16 tokens en la mezcla del C10, ver cómo la rompe un dato variable al principio y cómo baja con más texto propio, y calcular cuántos tokens por paso da speculative decoding.

Necesitas: Python 3 sin paquetes extra (el python3 de macOS sirve). Ni GPU ni Docker.

  1. Paso 1
    mkdir -p ~/lab-vllm && cd ~/lab-vllm
    cat > prefijos.py <<'EOF'
    # prefijos.py: la tasa de aciertos del prefix cache con bloques de 16 tokens y los tokens por paso de speculative decoding (solo stdlib)
    import hashlib, random, sys
    
    BLOQUE = 16                                    # tokens por bloque de KV (default de vLLM)
    
    def hashes(tokens, sal=""):
        """Un hash por bloque COMPLETO, encadenado con el anterior: el bloque 5 solo coincide si los bloques 0–4 coinciden."""
        h, out = sal, []
        for i in range(0, len(tokens) // BLOQUE * BLOQUE, BLOQUE):
            h = hashlib.sha256(f"{h}|{tokens[i:i + BLOQUE]}".encode()).hexdigest()
            out.append(h)
        return out
    
    def simular(n, armar, sal=lambda i: ""):
        cache, consultados, acertados = set(), 0, 0
        for i in range(n):
            tokens = armar(i)
            hs = hashes(tokens, sal(i))
            tope = (len(tokens) - 1) // BLOQUE     # si todo acierta, vLLM recalcula el último token para tener los logits
            aciertos = 0
            for h in hs[:tope]:
                if h not in cache:
                    break                           # el primer bloque distinto corta la cadena
                aciertos += 1
            cache.update(hs)
            consultados += len(tokens)
            acertados += aciertos * BLOQUE
        return acertados / consultados
    
    random.seed(3)
    productos = [[random.randrange(128_000) for _ in range(1_000)] for _ in range(3)]   # instrucciones fijas por producto
    PROPIO = int(sys.argv[1]) if len(sys.argv) > 1 else 1_000                          # tokens propios de cada request
    propio = lambda: [random.randrange(128_000) for _ in range(PROPIO)]                 # historial, contexto y pregunta
    usuario = lambda i: [95_000 + i, 96_000 + i]                                          # "Hola, Ana": otro usuario en cada request
    
    casos = {
        "fijo primero (C10)": lambda i: productos[i % 3] + propio(),
        "el nombre del usuario al principio": lambda i: usuario(i) + productos[i % 3] + propio(),
        "el nombre después de las instrucciones": lambda i: productos[i % 3] + usuario(i) + propio(),
    }
    for nombre, armar in casos.items():
        print(f"{nombre:40} tasa de aciertos {simular(300, armar):6.1%}")
    print(f"{'una sal por tenant (2 tenants)':40} tasa de aciertos {simular(300, casos['fijo primero (C10)'], sal=lambda i: 'tenant-' + str(i % 2)):6.1%}")
    print(f"un prefijo de 1,000 tokens deja {1_000 // BLOQUE} bloques completos = {1_000 // BLOQUE * BLOQUE} tokens en caché")
    
    print("\nspeculative decoding: tokens por paso del modelo grande = (1 − α^(k+1)) ÷ (1 − α)")
    print("  α     k=2    k=4    k=8    acceptance rate de vLLM con k=4 = (tokens por paso − 1) ÷ 4")
    for a in (0.5, 0.7, 0.8):
        t = {k: (1 - a ** (k + 1)) / (1 - a) for k in (2, 4, 8)}
        print(f"  {a}   {t[2]:.2f}   {t[4]:.2f}   {t[8]:.2f}   {(t[4] - 1) / 4:.0%}")
    EOF
    python3 prefijos.py

    Qué deberías ver: fijo primero (C10) 49.1 %; el nombre del usuario al principio 0.0 %; el nombre después de las instrucciones 49.1 %; una sal por tenant (2 tenants) 48.6 % (cada tenant paga su primer request de cada producto). Un prefijo de 1,000 tokens deja 62 bloques = 992 tokens en caché. La tabla de speculative decoding: con α = 0.7 y k = 4, 2.77 tokens por paso y una acceptance rate de 44 %; con α = 0.5 y k = 8, apenas 2.00.

  2. Paso 2
    python3 prefijos.py 3000 | head -1
    python3 prefijos.py 200 | head -1

    Qué deberías ver: 24.6 % con 3,000 tokens propios por request (un historial largo) y 81.8 % con 200: el ahorro es la tasa de aciertos por la fracción del prompt que es prefijo.

  3. Paso 3
    cd ~ && rm -rf ~/lab-vllm

    Qué deberías ver: Sin salida.

Cómo arreglarlo y confirmarlo

Prueba un prefijo de 1,008 tokens (múltiplo de 16) en lugar de 1,000, o tres tenants con sal. La tasa real la da la query de la plantilla con los counters de vLLM, no esta simulación.

Probado de verdad en un Mac. Ejecutado en macOS con Python 3.14.3, 3.13.16 y 3.9.6: salidas idénticas. El modelo del caché sigue el diseño de vLLM v0.30.0: solo bloques completos, hash encadenado desde el primer bloque, la sal en el hash del primero y el último token recalculado si todo acierta (vllm/v1/core/kv_cache_manager.py). La fórmula de speculative decoding es la de M14 (Leviathan et al.); las métricas de aceptación, las de vllm/v1/spec_decode/metrics.py.

11

El gateway de LLMs del C10: cuotas en tokens, admisión por prioridad, streaming detrás de proxies, cancelación y PII

¿Qué le pasa a un request en el llm-gateway antes de llegar a vLLM, qué código recibe el producto cuando no hay lugar, y cómo evitas que un proxy trabe el stream o que la GPU siga generando para alguien que ya se fue?

Sintaxis con variables

cobro = min(prompt_estimado + max_tokens, max_model_len)   # prompt_estimado = caracteres ÷ 3 + 4 por mensaje
ok, espera_s = balde[tenant].try_take(cobro)
if not ok:
    return 429, {"Retry-After": str(max(1, math.ceil(espera_s)))}
# … al terminar, con el usage del último chunk:
balde[tenant].settle(cobro, usage["prompt_tokens"] + usage["completion_tokens"])

Un token bucket (un balde de fichas que se recarga a ritmo fijo) contado en tokens, no en requests: un prompt de 8,000 tokens cuesta ≈ 27 veces uno de 300. El costo real se sabe al final (usage), así que al entrar se cobra una estimación pesimista y al salir se concilia: se devuelve lo que no se usó. Cada réplica del gateway tiene su propio balde con la cuota ÷ 4.

try:
    await admision.acquire(clase)       # interactive o batch: un lugar libre, o esperar en una cola con tope
except Overloaded as e:                   # cola llena o se esperó demasiado
    return 503, {"Retry-After": str(math.ceil(e.retry_after_s))}   # o el vendor, si el tenant puede
try:
    …                                     # el request a vLLM
finally:
    admision.release(clase)

El control de admisión (decidir en la puerta quién entra a la flota) limita los requests en vuelo: 288 por réplica del gateway, de los cuales 72 nunca los ocupa el batch. Cuando se libera un lugar, pasa primero un interactivo. La espera tiene tope de tamaño y de tiempo: 64 y 0.5 s el interactivo, 256 y 30 s el batch. 429 = te pasaste de tu cuota; 503 = la plataforma está llena.

location /v1/ {
    proxy_pass         http://gateway;
    proxy_buffering    off;              # o que la respuesta traiga el header X-Accel-Buffering: no
    proxy_read_timeout silencio_maxs;  # default 60s: lo máximo sin bytes entre dos lecturas
}

Con SSE (Server-Sent Events: líneas data: … que llegan una por una en la misma respuesta HTTP), cada proxy del camino tiene que reenviar al instante. nginx guarda la respuesta en buffers (memoria intermedia) por defecto (proxy_buffering on): el texto llega a ráfagas o todo al final, y el TTFT que ve la persona se vuelve el tiempo total (la cadena lo muestra con un proxy de prueba). Además un proxy corta una conexión callada: nginx a los 60 s (proxy_read_timeout) y el ALB a los 60 s (idle timeout). vLLM puede mandar comentarios : keep-alive con --sse-keep-alive-interval (0 = apagado). El gateway del C10 no los reenvía ni los necesita: su silencio hacia el cliente ya queda debajo de 60 s (≤ 30 s entre eventos; antes del primer byte, la cola + conectar + 10 s), y en vLLM los deja apagados porque un comentario reiniciaría el read timeout con el que corta un stream trabado.

from openai import OpenAI
client = OpenAI(base_url="url_gateway/v1", api_key="key", max_retries=reintentos, timeout=timeout_s)
stream = client.chat.completions.create(model="alias", messages=mensajes, stream=True, max_tokens=max_tokens)
for chunk in stream:
    if chunk.choices and chunk.choices[0].delta.content:   # el chunk de usage trae choices vacío
        print(chunk.choices[0].delta.content, end="", flush=True)

Los productos usan el cliente de openai contra el gateway, con el alias en model. Ojo con sus defaults (openai-python 3.37): reintenta 2 veces los 408, 409, 429 y 5xx, respeta Retry-After hasta 2 min y espera hasta 600 s (5 s para conectar). En el camino interactivo usa max_retries=0 o 1: si no, un 503 de admisión vuelve solo, dos veces, justo cuando la flota está llena.

Variables

VariableQué esEjemplo
tenantel producto que llama, sacado de su API keysupport-assistant
clasela prioridad del tenant (la decide la plataforma, no el cliente)interactive · batch
gatewaya dónde reenvía el proxylas réplicas del llm-gateway
silencio_maxsegundos sin bytes que el proxy toleramás que el primer token más lento (10 s en el C10) y que cualquier pausa
url_gatewayla URL del gateway, nunca la de vLLMhttp://llm-gateway.c10.svc:8080 (ilustrativa: el C10 no trae el manifest del gateway)
keyla API key del tenant; el gateway guarda solo su sha256sk-c10-demo-support (solo en la demo)
aliasel nombre estable del modelochat-default
reintentosreintentos automáticos del cliente de openai0 o 1 en interactivo (el default es 2)
timeout_sespera máxima del cliente entre byteslo que acepte tu producto (el default es 600)
max_tokenstope de salida de este request≤ 1,024 en soporte; si no lo mandas, el gateway pone el tope
QQcuota total del tenant en tokens por minuto5.4 M (soporte)
NNréplicas del gateway4
B, rB,\ rcapacidad del balde y recarga por segundo, en una réplica1.35 M y 22,500
p^\hat{p}prompt estimado: caracteres ÷ 3 + 4 por mensaje≈ 2,000
mmmax_tokens del request1,024
LLmax_model_len8,192

Fórmulas

B=QNr=Q60 N5.4 M4=1.35 M tokens,r=22,500 tokens/sB = \frac{Q}{N} \qquad r = \frac{Q}{60\,N} \qquad \frac{5.4\ \text{M}}{4} = 1.35\ \text{M tokens},\quad r = 22{,}500\ \text{tokens/s}
cobro=min⁡(p^+m, L)=2,000+1,024=3,024devolucioˊn=3,024−2,250=774\text{cobro} = \min(\hat{p} + m,\ L) = 2{,}000 + 1{,}024 = 3{,}024 \qquad \text{devolución} = 3{,}024 - 2{,}250 = 774
Retry-After=⌈cobro−saldor⌉⌊1,350,0003,024⌋=446 en una raˊfaga⌊1,350,0002,250⌋=600 conciliando\text{Retry-After} = \left\lceil \frac{\text{cobro} - \text{saldo}}{r} \right\rceil \qquad \left\lfloor \frac{1{,}350{,}000}{3{,}024} \right\rfloor = 446\ \text{en una ráfaga} \qquad \left\lfloor \frac{1{,}350{,}000}{2{,}250} \right\rfloor = 600\ \text{conciliando}
tope=16×724=288L=λW=60×10.5≈630  ⇒  6304×288≈55 %\text{tope} = \frac{16 \times 72}{4} = 288 \qquad L = \lambda W = 60 \times 10.5 \approx 630 \;\Rightarrow\; \frac{630}{4 \times 288} \approx 55\ \%

Las piezas

PiezaQué haceEjemplo
API key → sha256 → tenantEl gateway no guarda las keys: guarda su hash y busca al tenant con él. Sin tenant, 401, que igual se cuenta (tenant unknown) y deja su línea de log.sk-c10-demo-support → support-assistant
AliasEl nombre estable que piden los productos; el gateway lo cambia por la revisión servida. Cambiar de modelo es editar tenants.yaml.chat-default → chat-70b-fp8-2026-09-28
Pin por tenant (canario)Manda a un tenant, o a un % de sus usuarios finales por hash del campo user, a otro Service y otra revisión.{upstream: vllm-70b-canary, model: chat-70b-fp8-2026-10-12, percent: 5}
Cuota en tokens/minUn balde por tenant y por réplica del gateway: capacidad = 1 min de cuota; recarga = cuota ÷ 60 por segundo.soporte: 5.4 M ÷ 4 = 1.35 M por réplica
Cobro estimado y conciliaciónAl entrar cobra prompt estimado + max_tokens; al final devuelve lo que no se usó según usage, o cobra lo que faltó.cobra 3,024, usa 2,250, devuelve 774
max_tokens obligatorioSi el cliente no lo manda, el gateway pone el tope del tenant: sin tope, vLLM genera hasta llenar el contexto.1,024 soporte · 600 publicaciones · 400 catálogo
Admisión por prioridadUn tope de requests en vuelo hacia la flota, con lugares que el batch nunca ocupa. Al liberarse uno, pasa primero un interactivo; dentro de cada clase, el que llegó antes.288 por réplica del gateway (16 × 72 ÷ 4), 72 reservados
Cola acotadaEsperar un lugar con tope de tamaño y de tiempo. Lo que no entra recibe 503 con Retry-After.interactivo: 64 y 0.5 s · batch: 256 y 30 s
priority hacia vLLMEl gateway manda 0 (interactive) o 10 (batch). Con --scheduling-policy priority, vLLM atiende antes el número menor y, si falta KV, desaloja primero al mayor; nunca saca a uno que corre para meter a uno que espera.la documentación dice que sin el flag da error; en el código de v0.30.0 no se encontró esa validación (la cola fcfs no lo lee): no cuentes con el error, pon el flag
Primer evento con contenidoHasta leer el primer token con texto (o un fin normal), el gateway no le manda nada al cliente: una falla todavía puede ser un 502/504 limpio o ir al vendor, también un stream que llega a [DONE] sin texto (un abort antes del primer token).first_token_timeout_s: 10
[DONE] e include_usageEl stream termina con data: [DONE]. El gateway pide stream_options.include_usage para conciliar, y esconde ese chunk si el cliente no lo pidió.el chunk de usage trae choices: []
Cancelación y 499Si el cliente se va, el gateway cierra su conexión con vLLM, y vLLM aborta el request y libera su KV. Cuenta como 499 (la convención de nginx), fuera de la tasa de errores.curl --max-time 0.5 → 499 e inflight vuelve a 0
stream_errorEl upstream falló con el stream ya empezado: el cliente vio un 200 incompleto y un evento de error. Sí cuenta como error.un nodo que muere a mitad de 4 streams
Sin keep-alive hacia vLLMCada request abre su conexión TCP: kube-proxy reparte conexiones, no requests, y una reusada seguiría yendo a un pod que ya salió.max_keepalive_connections=0 · max_connections=None
Fallback gobernadoSolo tenants con vendor_fallback, solo campos de una lista permitida y con la PII redactada. Un 429 del vendor se vuelve nuestro 503; un 401/403, un 502.listing-assistant sí; support-assistant no
Redacción de PIIRegex para correos, teléfonos, tarjetas, CLABE, CURP y RFC (tarjetas y CLABE, además, con su dígito verificador), con marcadores estables. No ve nombres ni direcciones.ana.p@mail.com → [EMAIL_1]
  1. El cliente · soporte

    POST /v1/chat/completions {model: chat-default, stream: true, max_tokens: 512}

  2. 1Auth< 1 ms

    Bearer key → sha256 → tenant support-assistant (interactive, 5.4 M tokens/min, tope 1,024, sin vendor)

    Si falla: 401

  3. 2Validación≈ 1–5 ms

    alias permitido, n = 1, max_tokens ≤ 1,024, prompt estimado ≤ 6,000

    Si falla: 400403404

  4. 3Cuota en tokens< 1 ms

    cobra ≈ 2,000 + 512 del balde de esta réplica (5.4 M ÷ 4 por minuto)

    Si falla: 429 + Retry-After

  5. 4Admisión≤ 0.5 s

    un lugar interactivo de los 288, o cola (≤ 64, ≤ 0.5 s)

    Si falla: 503 + Retry-After(publicaciones: vendor con la PII redactada)

  6. 5vLLM≈ 0.3–1 s al primer token

    Service vllm-70b, model = chat-70b-fp8-2026-09-28, priority 0, include_usage; conexión TCP nueva

    Si falla: 502 / 504(antes del primer byte)

  7. 6Stream SSE≈ 40 ms por token

    cada evento se reenvía al llegar; el TTFT se mide en el primer evento con contenido

    Si falla: evento de error(stream_error)499 si el cliente se va

  8. 7Cierre

    [DONE]; libera el lugar, concilia la cuota con usage, métricas y una línea de log sin prompt

Los topes

  • 288 en vuelo por réplica del gateway
  • 72 reservados para interactive
  • 64 · 0.5 s cola de interactive (lugares · espera máxima)
  • 256 · 30 s cola de batch (lugares · espera máxima)
  • Retry-After: 2 s en interactive · 60 s en batch

Los timeouts del gateway, a escala (de 0 a 120 s)

  • conectar2 s
  • hasta el primer token10 s
  • silencio entre dos eventos30 s
  • un request sin stream120 s

Los tenants (tenants.yaml)

TenantClaseTokens por minutomax_tokensPrompt máximo¿Vendor?
support-assistantinteractive5,400,0001,0246,000no
listing-assistantinteractive3,000,0006006,000sí
catalog-enrichmentbatch6,000,0004002,000no

espera en la colavLLMel stream: tokens que vuelvencierreel código si falla

Un request de soporte por el llm-gateway del C10: cada etapa con su tiempo y el código que devuelve si falla; el stream vuelve por el mismo camino. Números del README del C10 y de tenants.yaml.

Ejemplo real · C10: el camino de un request en llm_gateway.py, de la key a vLLM

Pythonreference/c10/gateway/llm_gateway.py (Gateway.chat_completions, líneas 597–606, 619–626, 635–668 y 673–678; las líneas # … resumen lo omitido)
    async def chat_completions(self, request: Request) -> Response:
        t0 = self.clock()
        rid = uuid.uuid4().hex
        auth = request.headers.get("authorization", "")
        key = auth[7:] if auth.lower().startswith("bearer ") else ""
        tenant = self.cfg.by_key.get(hashlib.sha256(key.encode()).hexdigest()) if key else None
        if tenant is None:
            # También cuenta y deja su línea (tenant "unknown"): una key rotada en un producto es una ola de 401
            return self._reject(rid, t0, "unknown", "unknown", 401, "API key inválida", "authentication_error",
                                "invalid_api_key")
        # … el cuerpo tiene que ser un objeto JSON (si no, 400; si el cliente corta a mitad de mandarlo, 499)
        alias_name = body.get("model")
        alias = self.cfg.aliases.get(alias_name) if isinstance(alias_name, str) else None
        if alias is None:
            return self._reject(rid, t0, tenant.name, "unknown", 404, f"el modelo {alias_name!r} no existe",
                                "invalid_request_error", "model_not_found")
        if alias.name not in tenant.allowed_aliases:
            return self._reject(rid, t0, tenant.name, alias.name, 403, f"el tenant no puede usar {alias.name}",
                                "permission_error", "model_not_allowed")
        # … messages no vacío y n = 1 (si no, 400)
        max_tokens = body.get("max_completion_tokens", body.get("max_tokens"))
        if max_tokens is None:
            max_tokens = tenant.max_tokens_cap
            body["max_completion_tokens"] = max_tokens  # sin tope, vLLM genera hasta llenar el contexto
        if not isinstance(max_tokens, int) or isinstance(max_tokens, bool) or not 1 <= max_tokens <= tenant.max_tokens_cap:
            return self._reject(rid, t0, tenant.name, alias.name, 400,
                                f"max_tokens debe estar entre 1 y {tenant.max_tokens_cap}", "invalid_request_error",
                                "max_tokens_exceeded")
        stream = bool(body.get("stream", False))

        # ---- cuota: prompt estimado + max_tokens, sin pasar del contexto (un request no puede usar más que eso)
        est_prompt = estimate_prompt_tokens(messages)
        if est_prompt > tenant.max_prompt_tokens:
            return self._reject(rid, t0, tenant.name, alias.name, 400,
                                f"prompt de ≈ {est_prompt} tokens (tope {tenant.max_prompt_tokens})",
                                "invalid_request_error", "prompt_too_long")
        charged = min(est_prompt + max_tokens, self.cfg.max_model_len)
        ok, wait_s = self.buckets[tenant.name].try_take(charged)
        if not ok:
            return self._reject(rid, t0, tenant.name, alias.name, 429, "cuota de tokens por minuto agotada",
                                "rate_limit_error", "rate_limit_exceeded",
                                {"Retry-After": str(max(1, math.ceil(wait_s)))})

        # ---- admisión a la flota
        priority = tenant.priority
        try:
            await self.admission.acquire(priority)
        except Overloaded as e:
            if self._can_fallback(tenant, alias):
                self.metrics.vendor.labels(tenant.name, "overloaded").inc()
                return await self._to_vendor(request, tenant, alias, body, stream, charged, est_prompt, t0, rid)
            self.buckets[tenant.name].settle(charged, 0)  # no se usó nada: se devuelve la cuota
            return self._reject(rid, t0, tenant.name, alias.name, 503, f"plataforma sin capacidad ({e.reason})",
                                "overloaded_error", "overloaded", {"Retry-After": str(math.ceil(e.retry_after_s))})
        # … si el cliente se va mientras espera lugar, también se devuelve la cuota
        up_name, model = tenant.route_for(alias, body.get("user"))
        upstream = self.upstreams[up_name]
        self_body = self._self_body(body, model, priority)
        if stream:
            return await self._stream_self(request, tenant, alias, body, upstream, self_body, charged, est_prompt, t0, rid)
        return await self._complete_self(request, tenant, alias, body, upstream, self_body, charged, est_prompt, t0, rid)
  • Quién eres: la key se convierte en sha256 y se busca en by_key. tenants.yaml solo guarda hashes: si se filtra, no sirve para llamar al gateway. Sin tenant, 401: también se cuenta (con tenant unknown) y deja su línea, así una key rotada en un producto se ve como una ola de 401.
  • Cuánto te queda: charged es pesimista (caracteres ÷ 3 + 4 por mensaje + max_tokens, sin pasar de 8,192) y se ajusta al final en finish() con el usage real. Si el cliente se fue o el stream falló sin usage, se cobra el prompt estimado: el prefill ya se pagó en la GPU.
  • Hay lugar: si no, publicaciones va al vendor (overloaded) y soporte recibe 503 con Retry-After: 2; la cuota se devuelve entera, porque no se usó nada.
  • A qué modelo: route_for cambia el Service y la revisión juntos, porque cada pod de vLLM sirve solo su --served-model-name y otro nombre da 404.
  • El stream (en _stream_self): _open_and_first espera el primer evento con contenido hasta 10 s, y un [DONE] o un corte antes de ese evento también es falla (502 o el vendor): así llega un abort antes del primer token, sin ningún chunk de abort. Después no hay fallback, porque no se pegan dos streams de dos modelos. race_disconnect y GuardedStream cierran la conexión con vLLM si el cliente se va: vLLM v0.30 atrapa la cancelación y aborta el request (vllm/v1/engine/async_llm.py), y el gateway anota 499.
  • Toda respuesta deja su conteo en llm_gateway_requests_total y una línea JSON sin contenido (_account): los 200, los rechazos (el 401 con tenant unknown), las fallas antes del primer byte (502, 504), los 4xx reenviados de vLLM, los 499 y los stream_error. La línea lleva el código del error, nunca su mensaje, y el mismo request_id que el header x-request-id de la respuesta. finish() concilia la cuota de lo que llegó a un upstream y después llama a _account.

Cómo recordarla

quién eres → cuánto te queda → hay lugar → a qué modelo → stream → cuenta final, o sea 401 · 429 · 503 · alias · SSE · conciliación. Y tres códigos: 429 = tu cuota; 503 = la plataforma llena; 499 = te fuiste y vLLM abortó.

Trampa

algo en el camino que bufferea, reintenta o no avisa. Un proxy con buffer convierte el stream en una respuesta al final: el TTFT se vuelve el tiempo total sin que cambie nada en vLLM. El cliente de openai con sus defaults reintenta por su cuenta los 429 y los 503, dos veces: el gateway ve el triple de intentos justo cuando está lleno. Y si un proxy intermedio no cierra la conexión con vLLM cuando el cliente se va, la GPU genera hasta max_tokens para nadie: suben los 499 y no bajan los tokens generados.

Laboratorio (opcional)4 pasos

Qué vas a provocar: Correr el TokenBucket y la PriorityAdmission reales del C10 con números chicos, y ver con un stream SSE local que la GPU solo deja de generar si la desconexión llega hasta ella.

Necesitas: El repo del curso (cuota.py y admision.py importan quota.py y admission.py del C10, que son solo biblioteca estándar) y Python 3 (el python3 de macOS sirve). Ni GPU ni Docker.

  1. Paso 1
    mkdir -p ~/lab-vllm
    cat > ~/lab-vllm/cuota.py <<'EOF'
    # cuota.py: el TokenBucket real del C10 (gateway/quota.py) con un reloj falso: cobro, conciliación, ráfaga y Retry-After
    import math
    from quota import TokenBucket, estimate_prompt_tokens
    
    class Reloj:                                       # el tiempo lo movemos a mano: nada duerme
        t = 0.0
        def __call__(self):
            return self.t
    reloj = Reloj()
    mensajes = [{"role": "system", "content": "x" * 3_000}, {"role": "user", "content": "y" * 2_976}]
    est = estimate_prompt_tokens(mensajes)             # caracteres ÷ 3 + 4 por mensaje
    cobro = est + 1_024                                # + max_tokens de soporte
    b = TokenBucket(5_400_000 / 4, 5_400_000 / 4 / 60, reloj)   # soporte en 1 de 4 réplicas: 1.35 M y 22,500 tokens/s
    print(f"prompt estimado {est:,} → cobra {cobro:,}: {b.try_take(cobro)}")
    b.settle(cobro, est + 250)                         # usage: el prompt y 250 generados
    print(f"concilia con usage: devuelve {cobro - est - 250:,}; saldo {b.tokens:,.0f} de {b.capacity:,.0f}")
    b = TokenBucket(1_350_000, 22_500, reloj)          # lleno otra vez: llega una ráfaga en el mismo instante
    pasan = 0
    while b.try_take(cobro)[0]:
        pasan += 1
    ok, espera = b.try_take(cobro)
    print(f"ráfaga: pasan {pasan}; el siguiente → ok={ok}, 429 con Retry-After: {max(1, math.ceil(espera))} s")
    reloj.t += 1.0
    print(f"1 s después vuelve a pasar: {b.try_take(cobro)[0]}")
    try:
        b.try_take(1_400_000)
    except ValueError as e:
        print(f"ValueError: {e}")
    EOF
    # desde la raíz del repo del curso (la carpeta que contiene apps/)
    PYTHONDONTWRITEBYTECODE=1 PYTHONPATH=apps/ml-system-design/reference/c10/gateway python3 ~/lab-vllm/cuota.py

    Qué deberías ver: prompt estimado 2,000 → cobra 3,024: (True, 0.0); concilia con usage: devuelve 774; saldo 1,347,750 de 1,350,000. Ráfaga: pasan 446; el siguiente → ok=False, 429 con Retry-After: 1 s; 1 s después vuelve a pasar: True. Y ValueError: un request de 1400000 tokens no entra en un bucket de 1350000: un request más grande que el balde es un 400, no una espera.

  2. Paso 2
    cat > ~/lab-vllm/admision.py <<'EOF'
    # admision.py: la PriorityAdmission real del C10 (gateway/admission.py) en chico: 4 lugares, 1 reservado para interactive
    import asyncio
    from admission import AdmissionConfig, Overloaded, PriorityAdmission
    
    async def main():
        adm = PriorityAdmission(AdmissionConfig(
            max_inflight=4, interactive_reserved=1,
            queue_max=(("interactive", 1), ("batch", 2)), max_wait_s=(("interactive", 0.5), ("batch", 0.5)),
            retry_after_s=(("interactive", 2.0), ("batch", 60.0))))
        for _ in range(3):
            await adm.acquire("batch")                 # el batch toma todo lo que puede: 4 − 1 reservado
        try:
            await adm.acquire("batch")                 # espera 0.5 s en la cola y se rinde
        except Overloaded as e:
            print(f"4.º batch: {e.reason} → 503 con Retry-After {e.retry_after_s:g} s")
        await adm.acquire("interactive")               # el lugar reservado sigue libre para un chat
        print(f"en vuelo: {adm.inflight('batch')} batch + {adm.inflight('interactive')} interactive (tope 4)")
        orden = []
        async def esperar(clase):
            try:
                await adm.acquire(clase)
                orden.append(clase)
            except Overloaded as e:
                orden.append(f"{clase} {e.reason}")
        tb = asyncio.create_task(esperar("batch"))     # llega primero
        await asyncio.sleep(0.01)
        ti = asyncio.create_task(esperar("interactive"))
        await asyncio.sleep(0.01)
        try:
            await adm.acquire("interactive")           # la cola interactiva (tope 1) ya está llena
        except Overloaded as e:
            print(f"otro interactive: {e.reason}, sin esperar → 503 con Retry-After {e.retry_after_s:g} s")
        adm.release("batch")                           # se libera un lugar: ¿quién pasa?
        await asyncio.gather(tb, ti)
        print(f"se liberó un lugar y pasó: {orden[0]}; el batch que esperaba: {orden[1]}")
    
    asyncio.run(main())
    EOF
    PYTHONDONTWRITEBYTECODE=1 PYTHONPATH=apps/ml-system-design/reference/c10/gateway python3 ~/lab-vllm/admision.py

    Qué deberías ver: 4.º batch: queue_timeout → 503 con Retry-After 60 s (el batch no entra en el lugar reservado); en vuelo: 3 batch + 1 interactive (tope 4); otro interactive: queue_full, sin esperar → 503 con Retry-After 2 s; se liberó un lugar y pasó: interactive; el batch que esperaba: batch queue_timeout. El batch llegó primero y aun así pasó el chat.

  3. Paso 3
    cd ~/lab-vllm
    cat > desconexion.py <<'EOF'
    # desconexion.py: un cliente que se va a mitad del stream, directo y detrás de dos proxies (solo stdlib)
    import http.client, http.server, json, threading, time
    
    generados = []                                     # cuántos tokens escribió el "vLLM" en cada request
    
    class VLLM(http.server.BaseHTTPRequestHandler):    # un "vLLM" falso: 60 tokens por SSE, uno cada 50 ms
        def do_POST(self):
            self.rfile.read(int(self.headers.get("content-length", 0)))
            self.send_response(200)
            self.send_header("content-type", "text/event-stream")
            self.end_headers()
            n = 0
            try:
                for i in range(60):
                    chunk = {"choices": [{"index": 0, "delta": {"content": f" palabra{i}"}}]}
                    self.wfile.write(f"data: {json.dumps(chunk)}\n\n".encode())
                    self.wfile.flush()
                    n += 1
                    time.sleep(0.05)
                self.wfile.write(b"data: [DONE]\n\n")
            except (BrokenPipeError, ConnectionResetError):
                pass                                   # vLLM real: aborta el request y libera su KV
            generados.append(n)
        def log_message(self, *a):
            pass
    
    class Proxy(http.server.BaseHTTPRequestHandler):   # reenvía cada trozo apenas llega
        def do_POST(self):
            propaga = self.path.endswith("?propaga")
            body = self.rfile.read(int(self.headers.get("content-length", 0)))
            up = http.client.HTTPConnection("127.0.0.1", vllm.server_port)
            up.request("POST", "/v1/chat/completions", body, {"content-type": "application/json"})
            r = up.getresponse()
            self.send_response(r.status)
            self.send_header("content-type", r.getheader("content-type"))
            self.end_headers()
            while chunk := r.read1(65536):
                try:
                    self.wfile.write(chunk)
                    self.wfile.flush()
                except (BrokenPipeError, ConnectionResetError):
                    if propaga:                        # el cliente se fue: cerrar también la conexión con vLLM
                        r.close()
                        up.close()
                        return
                    # el proxy que no propaga se traga el error y sigue leyendo hasta el final
        def log_message(self, *a):
            pass
    
    def servir(handler):
        s = http.server.ThreadingHTTPServer(("127.0.0.1", 0), handler)
        threading.Thread(target=s.serve_forever, daemon=True).start()
        return s
    vllm, proxy = servir(VLLM), servir(Proxy)
    
    def cliente_que_se_va(puerto, ruta, tras=5):
        c = http.client.HTTPConnection("127.0.0.1", puerto)
        c.request("POST", ruta, json.dumps({"model": "chat-default", "stream": True}), {"content-type": "application/json"})
        r = c.getresponse()
        vistos = 0
        for linea in r:
            if linea.startswith(b"data: "):
                vistos += 1
                if vistos == tras:
                    r.close()                          # quien chatea cierra la pestaña
                    break
        time.sleep(3.5)                                # tiempo de sobra para que el "vLLM" termine o aborte
        return vistos
    
    for nombre, puerto, ruta in (("directo a vLLM", vllm.server_port, "/v1/chat/completions"),
                                 ("proxy que no propaga", proxy.server_port, "/v1/chat/completions"),
                                 ("proxy que propaga (como el C10)", proxy.server_port, "/v1/chat/completions?propaga")):
        vistos = cliente_que_se_va(puerto, ruta)
        n = generados[-1]
        print(f"{nombre:32} el cliente vio {vistos} y se fue; vLLM generó {n} de 60" + (" y abortó" if n < 60 else f": {60 - vistos} para nadie"))
    EOF
    python3 desconexion.py

    Qué deberías ver: directo a vLLM: el cliente vio 5 y se fue; vLLM generó 6 de 60 y abortó. proxy que no propaga: vLLM generó 60 de 60, 55 para nadie. proxy que propaga (como el C10): vLLM generó 8 de 60 y abortó. Tarda ≈ 12 s (espera a que cada caso termine); los números pueden moverse ±1.

  4. Paso 4
    cd ~ && rm -rf ~/lab-vllm

    Qué deberías ver: Sin salida.

Cómo arreglarlo y confirmarlo

Para ver el gateway completo (con 499 en /metrics), la demo del README del C10: un venv con requirements-dev.txt, DEMO_WORDS=200 uvicorn --factory llm_gateway:demo_app --port 8080 y un curl --max-time 0.5 que corta el stream. Con el venv, también corren sus tests de desconexión: pytest gateway/tests -k disconnect.

Probado de verdad en un Mac. Ejecutado con Python 3.14.3, 3.13.16 y 3.9.6 contra los archivos del repo: salidas idénticas. Aparte, los tests de gateway/tests corrieron en un venv existente (FastAPI 0.143.0, Starlette 1.7.0, uvicorn 0.54.0) con httpx 0.28.1 detrás de un alias httpx2, porque httpx2 no estaba instalado. Con el gateway corregido (un stream que termina sin texto es una falla; cada respuesta, 401 incluido, con su conteo y su línea) son 59 y pasaron los 59 en seis corridas, también el del pool real con sus 288 streams; en una corrida anterior, con 56, ese abrió 256 y el resto dio ConnectTimeout. La demo con uvicorn dio el stream, [DONE], el 499 y inflight en 0. Que vLLM aborta al desconectarse se leyó en vllm/v1/engine/async_llm.py de v0.30.0.

12

Diagnóstico: el método y la tabla de síntomas

Alguien dice «tarda» o «se cortó la respuesta», o el pod no arranca: ¿qué miras primero, qué después y qué te dice cada número?

Sintaxis con variables

curl -si http://host:puerto/health | head -1
kubectl get pods -n ns -l app.kubernetes.io/name=vllm-70b -o wide

¿Está vivo? /health responde 200 con el cuerpo vacío si el engine (el proceso que corre el modelo) está vivo, y 503 si murió (EngineDeadError): eso no se cura solo, se reinicia. No dice si está lleno: un pod sano puede tener 50 requests esperando. Llegas al puerto con kubectl port-forward; la NetworkPolicy del C10 solo deja entrar al gateway y a Prometheus.

curl -s http://host:puerto/metrics | grep -E '^vllm:(num_requests_(running|waiting)|kv_cache_usage_perc|num_preemptions_total)'

¿Hay cola adentro y alcanza la memoria? Tres gauges (números que suben y bajan) y un counter (uno que solo sube): corriendo, esperando, la fracción del KV ocupada (1 = 100 %) y las preemptions (secuencias que vLLM sacó de la GPU por falta de KV y va a recalcular) desde que arrancó el pod; para la tasa, rate() en Prometheus. Las series traen model_name y engine; Prometheus les suma pod, namespace y track.

kubectl logs -n ns pod -c vllm | grep -E 'Model loading took|KV cache size|Graph capturing finished'
kubectl logs -n ns pod -c vllm --since=ventana | grep 'Engine 000' | tail -1

¿Qué midió al arrancar y qué hace ahora? Al arrancar, vLLM escribe cuánto pesó el modelo, GPU KV cache size: N tokens, Maximum concurrency for 8,192 tokens per request: X.XXx (tu capacidad real) y la captura de CUDA graphs. Después, cada 10 s (VLLM_LOG_STATS_INTERVAL), una línea con el throughput de prompt y de generación, corriendo, esperando, Preemptions: N (solo si hubo), el uso del KV y la tasa de aciertos del prefix cache. V1 no escribe una línea por cada preemption.

kubectl describe pod -n ns pod | sed -n '/^Events:/,$p'
kubectl logs -n ns pod -c vllm --previous | tail -50
kubectl exec -n ns pod -c vllm -- nvidia-smi --query-gpu=index,memory.used,memory.total,utilization.gpu --format=csv

¿Qué le pasó al pod? Los eventos dicen Pending (Insufficient nvidia.com/gpu), probes que fallan, OOMKilled (la RAM del contenedor, no la GPU) o reinicios; --previous trae el log del intento que murió. nvidia-smi muestra ≈ 90 % de memoria ocupada desde el arranque: vLLM la reserva entera para pesos y KV.

Variables

VariableQué esEjemplo
host, puertola réplica vista desde donde corres curllocalhost:8000 (kubectl port-forward a un pod)
nsnamespace de vLLMc10
podun pod de vllm-70b (o deploy/vllm-70b, que toma uno)vllm-70b-6c9f7d8b4-q7k2m (ilustrativo)
ventanacuánto log reciente lees1m
GPU KV cache size\text{GPU KV cache size}tokens de KV que caben en la réplica, según vLLM al arrancar208,736 en la cuenta del C10
max_model_len\texttt{max\_model\_len}el contexto máximo configurado8,192
WadmisioˊnW_{\text{admisión}}espera en la cola del gateway≤ 0.5 s en interactivo
WcolaW_{\text{cola}}espera adentro de vLLM: vllm:request_queue_time_seconds≈ 0 si num_requests_waiting = 0
tprefillt_{\text{prefill}}procesar el prompt hasta el primer token≈ 0.3–1 s en el C10, con prefix cache
Δ\Deltalo que creció un counter entre dos lecturasen 60 s

Fórmulas

concurrencia maˊx.=GPU KV cache sizemax_model_len=208,7368,192≈25.48x208,7362,250≈92 secuencias tıˊpicas\text{concurrencia máx.} = \frac{\text{GPU KV cache size}}{\text{max\_model\_len}} = \frac{208{,}736}{8{,}192} \approx 25.48\text{x} \qquad \frac{208{,}736}{2{,}250} \approx 92\ \text{secuencias típicas}
TTFTgateway≈Wadmisioˊn+Wcola+tprefillTTFTvLLM≈Wcola+tprefill\text{TTFT}_{\text{gateway}} \approx W_{\text{admisión}} + W_{\text{cola}} + t_{\text{prefill}} \qquad \text{TTFT}_{\text{vLLM}} \approx W_{\text{cola}} + t_{\text{prefill}}
Δ vllm:num_preemptions_totalΔ vllm:request_success_total=24207≈0.12>0.05  ⇒  LLMPreemptionsRising\frac{\Delta\,\text{vllm:num\_preemptions\_total}}{\Delta\,\text{vllm:request\_success\_total}} = \frac{24}{207} \approx 0.12 > 0.05 \;\Rightarrow\; \text{LLMPreemptionsRising}
KV de una secuencia=(nprompt+nsalida)×320 KiB=5,300×320 KiB≈1.74 GB  ⇒  40×1.74≈69.5>68.4 GB\text{KV de una secuencia} = (n_{\text{prompt}} + n_{\text{salida}}) \times 320\ \text{KiB} = 5{,}300 \times 320\ \text{KiB} \approx 1.74\ \text{GB} \;\Rightarrow\; 40 \times 1.74 \approx 69.5 > 68.4\ \text{GB}

Las piezas

PiezaQué haceEjemplo
/health 200El engine responde. No dice nada de cola ni de KV.la readinessProbe del C10
/health 503EngineDeadError: el engine murió y no se recupera solo. Reiniciar es lo correcto.la liveness reinicia tras 6 × 10 s
GPU KV cache size: N tokensCuántos tokens de KV caben en la réplica: tu capacidad real, medida por vLLM al arrancar.208,736 en la cuenta del C10
Maximum concurrency for 8,192 tokens per request: X.XXxCuántas secuencias de largo máximo caben a la vez. Las más cortas caben más.25.48x (≈ 92 de 2,250 tokens)
vllm:num_requests_waiting > 0Requests dentro de vLLM esperando lugar: falta KV o se llegó a max_num_seqs.KVCacheSaturation lo pide junto con KV > 95 %
vllm:kv_cache_usage_percFracción del KV ocupada (1 = 100 %). Cerca de 1 de día y con cola: la memoria limita.de noche, 100 % es el batch: normal
Preemptions: NEn la línea de stats, solo si hubo: vLLM sacó secuencias de la GPU por falta de KV y las va a recalcular.LLMPreemptionsRising: > 0.05 por request terminado
finish_reason lengthLa respuesta paró en max_tokens o en el contexto. No es un error HTTP: es un 200 corto.max_completion_tokens = 1,024 en soporte
finish_reason abortvLLM abortó: el pod entró en drain o el cliente se fue.antes del primer token, el gateway lo convierte en 502 o en el vendor: sin streaming lo ve en el finish_reason; con streaming, v0.30 no manda ese chunk y lo delata un [DONE] sin texto
TTFT alto en el gateway, normal en vLLMLa espera está antes de vLLM: admisión, red, el propio gateway.llm_gateway_queue_depth
TTFT alto también en vLLMCola dentro de la réplica o prefill largo.vllm:request_queue_time_seconds · vllm:request_prompt_tokens
nvidia-smi con ≈ 90 % de memoria sin tráficoNormal: vLLM reserva gpu_memory_utilization al arrancar. La ocupación real es kv_cache_usage_perc.≈ 72 de 80 GB por GPU con 0.90
EngineCore encountered an issueEl engine murió con el servidor corriendo; la causa está más arriba en el log.kubectl logs --previous
Timed out waiting for engine core processes to startEl engine tardó más de 600 s (VLLM_ENGINE_READY_TIMEOUT_S) en estar listo. En v0.30 solo pasa con varios procesos de API (--api-server-count > 1); con uno solo, como el C10, el tope es la startupProbe.pesos lentos o torch.compile sin caché

el cliente

1Cliente

el gateway

2Gateway3Admisión

vLLM

4/health5cola y KV6log

la GPU

7GPU

Kubernetes

8Pod y nodo

  1. Clienteel cliente

    ¿Qué recibió: un código, una demora o un texto cortado?

    terminalcurl -sN "$URL/v1/chat/completions" -H "Authorization: Bearer $KEY" -H 'content-type: application/json' -d @req.json | tail -3

    429 = la cuota del tenant; 503 = la admisión llena; 502/504 = vLLM no contestó o terminó sin texto (un abort antes del primer token, como en un drain); un 200 sin [DONE], o con finish_reason length, = cortado.

  2. Gatewayel gateway

    ¿La espera está antes de vLLM o adentro?

    PromQLllm:gateway_ttft_p95:rate5m

    Compáralo con max(llm:vllm_ttft_p95:rate5m): si solo sube el del gateway, la espera es la admisión o la red.

  3. Admisiónel gateway

    ¿Quedan lugares en vuelo?

    PromQLsum(llm_gateway_inflight) / sum(llm_gateway_max_inflight)

    Más de 0.85 = LLMCapacityHeadroomLow. Mira también llm_gateway_queue_depth por clase.

  4. vLLM: /healthvLLM

    ¿El engine está vivo?

    terminalcurl -si localhost:8000/health | head -1

    200 = vivo; 503 = EngineDeadError: lee kubectl logs --previous.

  5. vLLM: cola y KVvLLM

    ¿Hay cola adentro y alcanza la memoria?

    terminalcurl -s localhost:8000/metrics | grep -E '^vllm:(num_requests_waiting|kv_cache_usage_perc)'

    Esperando > 0 con KV > 0.95 = la memoria limita; esperando > 0 con KV bajo = mira max-num-seqs y el tope del gateway.

  6. vLLM: logvLLM

    ¿Hay preemptions? ¿Acierta el prefix cache?

    terminalkubectl logs -n c10 deploy/vllm-70b -c vllm --since=1m | grep 'Engine 000' | tail -1

    Preemptions: N aparece solo si hubo. Si la tasa de aciertos cae a la mitad, alguien cambió un prompt.

  7. GPUla GPU

    ¿La GPU tiene errores o la ocupa otro proceso?

    terminalkubectl exec -n c10 deploy/vllm-70b -c vllm -- nvidia-smi --query-gpu=index,memory.used,memory.total,utilization.gpu --format=csv

    ≈ 90 % de memoria siempre (vLLM la reserva); los errores XID (fallas de la GPU que reporta el driver) salen en el DCGM exporter, el que pasa las métricas de la GPU a Prometheus.

  8. Pod y nodoKubernetes

    ¿Kubernetes lo dejó correr?

    terminalkubectl describe pod -n c10 -l app.kubernetes.io/name=vllm-70b | grep -A12 '^Events:'

    Pending sin GPU, probes que fallan, OOMKilled (la RAM, no la VRAM) o reinicios.

El primer eslabón que falla es la causa.

terminalun comando: va en la terminalPromQLuna consulta: va en Prometheus o en Grafana

El método: recorre la cadena en orden, del cliente al nodo. El primer eslabón que falla es la causa; lo que viene después son consecuencias.

Ejemplo real · C10: un TTFT alto que era memoria (y la memoria, prompts más largos)

bash · zshComandos reales con los nombres de reference/c10 (namespace c10, PodMonitor de k8s/vllm-70b.yaml, recording rules de monitoring/alerts.yml); las salidas son ilustrativas, con los nombres y el formato de vLLM v0.30.0 (vllm/v1/metrics/loggers.py)
# 12:10 suena LLMTTFTHigh para chat-default (TTFT p95 interactivo en el gateway > 1 s durante 10 min).
# 1) ¿La espera está antes de vLLM o adentro? El Prometheus del cluster:
kubectl port-forward -n monitoring svc/prometheus-operated 9090 &
curl -s localhost:9090/api/v1/query --data-urlencode 'query=llm:gateway_ttft_p95:rate5m' | jq -r '.data.result[].value[1]'
# 1.62
curl -s localhost:9090/api/v1/query --data-urlencode 'query=max(llm:vllm_ttft_p95:rate5m)' | jq -r '.data.result[].value[1]'
# 1.48        ← vLLM también subió: es la réplica, no la cola del gateway

# 2) Adentro de una réplica: cola, KV y la línea de stats
kubectl port-forward -n c10 pod/vllm-70b-6c9f7d8b4-q7k2m 8000 &
curl -s localhost:8000/metrics | grep -E '^vllm:(num_requests_(running|waiting)|kv_cache_usage_perc)\{'
# vllm:num_requests_running{engine="0",model_name="chat-70b-fp8-2026-09-28"} 38.0
# vllm:num_requests_waiting{engine="0",model_name="chat-70b-fp8-2026-09-28"} 7.0
# vllm:kv_cache_usage_perc{engine="0",model_name="chat-70b-fp8-2026-09-28"} 0.98
kubectl logs -n c10 vllm-70b-6c9f7d8b4-q7k2m -c vllm --since=1m | grep 'Engine 000' | tail -1
# … Engine 000: Avg prompt throughput: 15108.4 tokens/s, Avg generation throughput: 702.3 tokens/s, Running: 38 reqs, Waiting: 7 reqs, Preemptions: 4, GPU KV cache usage: 98.0%, Prefix cache hit rate: 21.4%

# 3) ¿Por qué se llenó? El largo de los prompts, en toda la flota:
curl -s localhost:9090/api/v1/query --data-urlencode 'query=sum(rate(vllm:request_prompt_tokens_sum[30m])) / sum(rate(vllm:request_prompt_tokens_count[30m]))' | jq -r '.data.result[].value[1]'
# 5050        ← el plan supone ≈ 2,000
  • El orden importa: primero la comparación entre el TTFT del gateway y el de vLLM, que alerts.yml ya graba (llm:gateway_ttft_p95:rate5m y llm:vllm_ttft_p95:rate5m). Si solo subiera el del gateway, la espera estaría en la admisión o en la red, y la réplica estaría sana.
  • Los gauges dicen memoria: 7 esperando con el KV al 98 %. La línea de stats lo confirma con Preemptions: 4 en 10 s y una tasa de aciertos del prefix cache de 21 % (lo normal es ≈ 50 %).
  • La causa: prompts de ≈ 5,050 tokens contra ≈ 2,000 del plan. Cada secuencia pide (5,050 + 250) × 320 KiB ≈ 1.74 GB de KV; las ≈ 40 en vuelo que da la ley de Little (3.75 req/s × ≈ 10.5 s) piden ≈ 69.5 GB y la réplica tiene 68.4. Con ese largo, los 208,736 tokens alcanzan para 39 secuencias, no para 92.
  • El arreglo va en orden: que el tenant que alargó los prompts los recorte (un max_prompt_tokens más bajo en tenants.yaml es un cambio revisado), bajar el tope en vuelo mientras tanto, KV en FP8 si ya pasó su golden set, y réplicas al final. Reiniciar no sirve: el mismo tráfico vuelve a llenar el KV.
  • Las salidas son de ejemplo. El formato de la línea de stats sale de vllm/v1/metrics/loggers.py de v0.30.0; el número de línea de cada log y el prefijo del proceso dependen de la versión.

Cómo recordarla

cliente → gateway → admisión → /health → cola y KV → log → GPU → pod. Tres números cuentan casi todo: num_requests_waiting (¿hay cola adentro?), kv_cache_usage_perc (¿falta memoria?) y Preemptions: N en el log (¿ya se está recalculando trabajo?).

Trampa

mirar nvidia-smi y concluir. Muestra ≈ 90 % de memoria ocupada desde el arranque (vLLM la reserva) y una utilización cerca de 100 % con 5 secuencias o con 90: ninguno de los dos dice si la réplica está llena. La otra: reiniciar el pod por preemptions o por un TTFT alto. El reinicio tarda minutos, se lleva el log del intento (usa --previous) y el problema vuelve con el mismo tráfico: se arregla con más KV por réplica o con menos secuencias.

Laboratorio (opcional)3 pasos

Qué vas a provocar: Leer dos fotos de /metrics de una réplica, tomadas con 60 s de diferencia, y su log de arranque, y sacar el diagnóstico: cola, KV, preemptions por request, aciertos del prefix cache y el largo real de los prompts.

Necesitas: Python 3 sin paquetes extra (el python3 de macOS sirve). Ni GPU ni cluster: las fotos de /metrics y el log son sintéticos.

  1. Paso 1
    mkdir -p ~/lab-vllm && cd ~/lab-vllm
    cat > antes.txt <<'EOF'
    vllm:num_requests_running{engine="0",model_name="chat-70b-fp8-2026-09-28"} 36.0
    vllm:num_requests_waiting{engine="0",model_name="chat-70b-fp8-2026-09-28"} 5.0
    vllm:kv_cache_usage_perc{engine="0",model_name="chat-70b-fp8-2026-09-28"} 0.97
    vllm:num_preemptions_total{engine="0",model_name="chat-70b-fp8-2026-09-28"} 1520.0
    vllm:request_success_total{engine="0",finished_reason="stop",model_name="chat-70b-fp8-2026-09-28"} 40100.0
    vllm:request_success_total{engine="0",finished_reason="length",model_name="chat-70b-fp8-2026-09-28"} 2200.0
    vllm:request_success_total{engine="0",finished_reason="abort",model_name="chat-70b-fp8-2026-09-28"} 310.0
    vllm:prefix_cache_queries_total{engine="0",model_name="chat-70b-fp8-2026-09-28"} 9.1e+07
    vllm:prefix_cache_hits_total{engine="0",model_name="chat-70b-fp8-2026-09-28"} 4.3e+07
    vllm:request_prompt_tokens_sum{engine="0",model_name="chat-70b-fp8-2026-09-28"} 9.6e+07
    vllm:request_prompt_tokens_count{engine="0",model_name="chat-70b-fp8-2026-09-28"} 42610.0
    EOF
    cat > despues.txt <<'EOF'
    vllm:num_requests_running{engine="0",model_name="chat-70b-fp8-2026-09-28"} 38.0
    vllm:num_requests_waiting{engine="0",model_name="chat-70b-fp8-2026-09-28"} 7.0
    vllm:kv_cache_usage_perc{engine="0",model_name="chat-70b-fp8-2026-09-28"} 0.98
    vllm:num_preemptions_total{engine="0",model_name="chat-70b-fp8-2026-09-28"} 1544.0
    vllm:request_success_total{engine="0",finished_reason="stop",model_name="chat-70b-fp8-2026-09-28"} 40283.0
    vllm:request_success_total{engine="0",finished_reason="length",model_name="chat-70b-fp8-2026-09-28"} 2222.0
    vllm:request_success_total{engine="0",finished_reason="abort",model_name="chat-70b-fp8-2026-09-28"} 312.0
    vllm:prefix_cache_queries_total{engine="0",model_name="chat-70b-fp8-2026-09-28"} 9.20454e+07
    vllm:prefix_cache_hits_total{engine="0",model_name="chat-70b-fp8-2026-09-28"} 4.321954e+07
    vllm:request_prompt_tokens_sum{engine="0",model_name="chat-70b-fp8-2026-09-28"} 9.704535e+07
    vllm:request_prompt_tokens_count{engine="0",model_name="chat-70b-fp8-2026-09-28"} 42817.0
    EOF
    cat > log.txt <<'EOF'
    (EngineCore pid=271) INFO 10-24 06:41:12 [kv_cache_utils.py:2395] GPU KV cache size: 208,736 tokens, Maximum concurrency for 8,192 tokens per request: 25.48x
    INFO 10-24 12:10:08 [loggers.py:315] Engine 000: Avg prompt throughput: 15108.4 tokens/s, Avg generation throughput: 702.3 tokens/s, Running: 38 reqs, Waiting: 7 reqs, Preemptions: 4, GPU KV cache usage: 98.0%, Prefix cache hit rate: 21.4%
    EOF
    cat > triage.py <<'EOF'
    # triage.py: lee dos fotos de /metrics de una réplica de vLLM tomadas con 60 s de diferencia y dice dónde está el problema (solo stdlib)
    import re, sys
    
    def leer(ruta):
        m = {}
        for linea in open(ruta):
            if linea.startswith("#") or not linea.strip():
                continue
            nombre, valor = linea.rsplit(" ", 1)
            base, _, labels = nombre.partition("{")
            razon = re.search(r'finished_reason="(\w+)"', labels)
            clave = base + (":" + razon.group(1) if razon else "")
            m[clave] = m.get(clave, 0.0) + float(valor)
        return m
    
    a, b, dt = leer(sys.argv[1]), leer(sys.argv[2]), 60.0
    d = lambda k: b[k] - a[k]                                     # lo que creció un counter entre las dos fotos
    terminados = sum(d(k) for k in b if k.startswith("vllm:request_success_total"))
    print(f"gauges ahora: corriendo {b['vllm:num_requests_running']:.0f} · esperando {b['vllm:num_requests_waiting']:.0f} · "
          f"KV {b['vllm:kv_cache_usage_perc']:.0%}")
    print(f"terminados: {terminados / dt:.2f} req/s · por motivo: " +
          ", ".join(f"{k.split(':')[-1]} {d(k):.0f}" for k in b if k.startswith("vllm:request_success_total")))
    pre = d("vllm:num_preemptions_total") / terminados
    print(f"preemptions por request terminado: {pre:.2f} (LLMPreemptionsRising suena sobre 0.05)")
    print(f"aciertos del prefix cache: {d('vllm:prefix_cache_hits_total') / d('vllm:prefix_cache_queries_total'):.0%} (el plan del C10: ≈ 50 %)")
    prompt = d("vllm:request_prompt_tokens_sum") / d("vllm:request_prompt_tokens_count")
    kv_seq = (prompt + 250) * 327_680                              # bytes de KV de una secuencia promedio (320 KiB por token)
    print(f"prompt promedio: {prompt:,.0f} tokens (el plan: ≈ 2,000) → una secuencia ocupa {kv_seq / 1e9:.2f} GB de KV; "
          f"40 en vuelo piden {40 * kv_seq / 1e9:.1f} GB y la réplica tiene 68.4")
    if len(sys.argv) > 3:                                          # el log del pod: la capacidad que midió vLLM al arrancar
        log = open(sys.argv[3]).read()
        kv = int(re.search(r"KV cache size: ([\d,]+) tokens", log).group(1).replace(",", ""))
        pre_log = re.findall(r"Preemptions: (\d+)", log)
        print(f"log: {kv:,} tokens de KV = {kv // 2_250} secuencias de 2,250 tokens, pero solo {kv // int(prompt + 250)} de {prompt + 250:,.0f}"
              + (f"; Preemptions: {pre_log[-1]} en la última línea de stats" if pre_log else ""))
    if b["vllm:num_requests_waiting"] > 0 and b["vllm:kv_cache_usage_perc"] > 0.95:
        print("diagnóstico: la memoria limita (KV lleno con cola adentro de vLLM). Busca quién alargó los prompts antes de tocar la flota")
    EOF
    python3 triage.py antes.txt despues.txt log.txt

    Qué deberías ver: gauges ahora: corriendo 38 · esperando 7 · KV 98 %; terminados: 3.45 req/s · por motivo: stop 183, length 22, abort 2; preemptions por request terminado: 0.12; aciertos del prefix cache: 21 %; prompt promedio: 5,050 tokens → una secuencia ocupa 1.74 GB de KV; 40 en vuelo piden 69.5 GB y la réplica tiene 68.4; log: 208,736 tokens de KV = 92 secuencias de 2,250 tokens, pero solo 39 de 5,300; Preemptions: 4 en la última línea de stats; y el diagnóstico: la memoria limita.

  2. Paso 2
    grep -oE 'KV cache size: [0-9,]+ tokens|Maximum concurrency for [0-9,]+ tokens per request: [0-9.]+x|Preemptions: [0-9]+' log.txt

    Qué deberías ver: Tres líneas: KV cache size: 208,736 tokens, Maximum concurrency for 8,192 tokens per request: 25.48x y Preemptions: 4. Son las tres frases que buscas en un log real (grep -o muestra solo lo que coincide; en el log dice GPU KV cache size).

  3. Paso 3
    cd ~ && rm -rf ~/lab-vllm

    Qué deberías ver: Sin salida.

Cómo arreglarlo y confirmarlo

Con un pod de verdad, cambia los archivos por dos curl -s localhost:8000/metrics separados 60 s (grep '^vllm:') y por kubectl logs -c vllm. Si triage.py dice memoria, sigue la tabla de síntomas: KV saturado y preemptions.

Probado de verdad en un Mac. Ejecutado en macOS con Python 3.14.3, 3.13.16 y 3.9.6: salidas idénticas. Las fotos de /metrics y el log son sintéticos, con los nombres, las labels (model_name, engine, finished_reason) y los formatos de vLLM v0.30.0 (vllm/v1/metrics/loggers.py, vllm/v1/core/kv_cache_utils.py, el formato de vllm/logger.py con los números de línea de esa versión y el prefijo que vLLM le pone al proceso del engine); los 208,736 tokens y el 25.48x son los de la cuenta del C10.

13

Errores que debes reconocer rápido

Lees el mensaje: ¿quién lo dijo (vLLM al arrancar, vLLM al atender, CUDA o PyTorch, NCCL, Hugging Face, el cliente de openai o el gateway), qué significa, qué corres primero y dónde está la explicación?

Sintaxis con variables

NIVEL mm-dd hh:mm:ss [archivo.py:linea] mensaje

Una línea del log de vLLM (kubectl logs … -c vllm): el nivel, la fecha sin año y el archivo de vLLM que la escribió. Las del engine y de los workers llevan adelante el proceso, como (EngineCore pid=271). Al arrancar (kv_cache_utils.py, gpu_model_runner.py) te dice capacidad y tiempos; después, cada 10 s, viene la línea Engine 000: … (loggers.py).

modulo.Excepcion: mensaje
…
RuntimeError: Engine core initialization failed. See root cause above. Failed core proc(s): procesos

Un traceback de Python (el rastro de llamadas que se imprime con un error). El engine de vLLM corre en otro proceso, así que el error del final es un envoltorio: la causa es la primera excepción más arriba, casi siempre un ValueError de configuración o un OutOfMemoryError.

{"error": {"message": "mensaje", "type": "TipoError", "param": param, "code": status}}

El cuerpo de un error HTTP de vLLM, con la forma de OpenAI: message, type (BadRequestError para un 400, NotFoundError para un 404), param y code (el status). El gateway del C10 contesta con la misma forma, sin param y con code en texto (rate_limit_exceeded, overloaded).

openai.Clase: Error code: status - cuerpo

Lo que ve el producto: el cliente de openai convierte el status en una clase (400 BadRequestError, 404 NotFoundError, 429 RateLimitError, 5xx InternalServerError) y le pega el cuerpo JSON entero, escrito como dict de Python: {'error': {'message': …}}. El texto suelto queda en e.body['message'] y el code en e.code. Sin respuesta, APIConnectionError o APITimeoutError. Antes de lanzarla ya reintentó 2 veces los 429 y 5xx.

NCCL WARN detalle
huggingface_hub.errors.Error: status Client Error. … url

Dos fuentes de afuera de vLLM: NCCL (la librería de NVIDIA que comunica las GPUs de una réplica) escribe NCCL WARN, y cuenta más con NCCL_DEBUG=INFO o TRACE; huggingface_hub dice qué pasó al buscar el modelo en el Hub.

Variables

VariableQué esEjemplo
NIVELqué tan graveINFO, WARNING o ERROR
archivo.py:lineade qué parte de vLLM saliókv_cache_utils.py · loggers.py
mensajequé pasóGPU KV cache size: 208,736 tokens
modulo.Excepcionla clase del errortorch.OutOfMemoryError · ValueError
procesoslos procesos del engine que murieron y su código de salidaun diccionario con el nombre del proceso y su código
TipoError, statusla clase del error HTTP y su códigoBadRequestError, 400 · NotFoundError, 404
paramel campo culpable, si se sabe"input_tokens" o null
Clasela excepción del cliente de openaiRateLimitError (429) · InternalServerError (5xx)
Error, urlel error de Hugging Face y la URL que pidióGatedRepoError · …/resolve/main/config.json

Las piezas

PiezaQué haceEjemplo
Log de arranque de vLLMLo que midió al cargar: pesos, KV, CUDA graphs. Si el arranque falla, la causa está aquí.GPU KV cache size: 208,736 tokens
Línea de stats (Engine 000)Cada 10 s: throughput, corriendo, esperando, Preemptions (si hubo), KV y prefix cache.Preemptions: 4, GPU KV cache usage: 98.0%
ValueError al arrancarvLLM revisó la configuración contra el modelo y la GPU y algo no cierra: memoria, contexto, TP, cuantización. Los que salen de revisar la configuración llegan envueltos por pydantic: 1 validation error for ModelConfig (o VllmConfig) y, abajo, Value error, con el texto.Total number of attention heads (64) must be divisible by tensor parallel size (3).
Envoltorios del EngineCoreEl engine corre en otro proceso; si muere, ves un mensaje genérico y la causa queda más arriba.Engine core initialization failed. See root cause above.
torch.OutOfMemoryErrorPyTorch no consiguió memoria de GPU: otro proceso, gpu_memory_utilization alto o un modelo que no cabe.CUDA out of memory. Tried to allocate …
NCCL WARNLa comunicación entre las GPUs de la réplica (TP=2) falló: /dev/shm, P2P o NVLink, o una GPU que murió.failed to extend /dev/shm/nccl-… to … bytes
400 de vLLM (BadRequestError)El request no entra: el contexto, un parámetro o un campo inválido. Repetirlo igual falla igual.This model's maximum context length is 8192 tokens…
404 de vLLM (NotFoundError)El model del request no es el --served-model-name de ese pod (ni un LoRA cargado).The model `chat-default` does not exist.
Errores del cliente de openaiLa clase dice el status; sin respuesta, APIConnectionError o APITimeoutError. Ya reintentó 2 veces.openai.RateLimitError: Error code: 429 - …
Errores del llm-gatewayLa forma de OpenAI con su code: invalid_api_key, model_not_allowed, rate_limit_exceeded, overloaded, upstream_timeout.429: cuota de tokens por minuto agotada
huggingface_hubLa descarga o la búsqueda del modelo en el Hub: repo gated, token, nombre o modo offline.GatedRepoError · LocalEntryNotFoundError
finish_reasonNo es un error HTTP: dice por qué terminó la respuesta: stop, length, abort, error o repetition.length = max_tokens o max_model_len
Eventos del podKubernetes: Pending sin GPU, probes que fallan, OOMKilled de la RAM del contenedor.Insufficient nvidia.com/gpu

Quiz · un mensaje a la vez

Mensaje 1 de 26

Value error, Model architectures ['…ForCausalLM'] are not supported for now. Supported architectures: … [type=value_error, …]
Quiz: el mensaje tal como lo ves; responde quién lo dijo, qué significa, qué corres primero y en qué sección está.

Los 26 mensajes

26 de 26

  1. Value error, Model architectures ['…ForCausalLM'] are not supported for now. Supported architectures: … [type=value_error, …]

    vLLM no tiene implementación para la arquitectura que pide el config.json del modelo (el campo architectures), ni propia ni de respaldo. Pasa con modelos muy nuevos. Llega envuelto por pydantic (la librería con la que vLLM valida su configuración): la línea de arriba dice pydantic_core._pydantic_core.ValidationError: 1 validation error for ModelConfig. No es tu configuración: necesitas una versión de vLLM que la soporte, otro motor (SGLang, TensorRT-LLM) u otro modelo.

    Primero: jq '.architectures, .model_type' config.json

    → Sección 1 · Por qué servir un LLM es distinto y qué hace vLLM

  2. This model's maximum context length is 8192 tokens. However, you requested 1024 output tokens and your prompt contains at least 7169 input tokens, for a total of at least 8193 tokens. Please reduce the length of the input prompt or the number of requested output tokens.

    Un 400 de vLLM v0.30: reserva max_tokens dentro del contexto, y 7,500 + 1,024 no entran en 8,192. El gateway del C10 lo reenvía tal cual (es un 4xx: sin fallback). Desde v0.32 el mismo request se acepta y la salida se corta en 692 tokens, con finish_reason length.

    Primero: curl -s localhost:8000/tokenize -H 'content-type: application/json' -d '{"model": "chat-70b-fp8-2026-09-28", "messages": [{"role": "user", "content": "…"}]}' | jq '.count, .max_model_len'

    → Sección 2 · Tokens y contexto: tokenizer, chat template, max_model_len, costo por token, español contra inglés

  3. As of transformers v4.44, default chat template is no longer allowed, so you must provide a chat template if the tokenizer does not define one.

    El tokenizer del modelo no trae plantilla de chat (el chat template: cómo se juntan system, user y assistant en un solo texto), y /v1/chat/completions no sabe armar el prompt. Típico de un modelo base, no instruct.

    Primero: jq 'has("chat_template")' tokenizer_config.json; ls chat_template.jinja

    → Sección 2 · Tokens y contexto: tokenizer, chat template, max_model_len, costo por token, español contra inglés

  4. torch.OutOfMemoryError: CUDA out of memory. Tried to allocate … GiB. GPU 0 has a total capacity of … GiB of which … MiB is free. …

    PyTorch no consiguió memoria de GPU. Al arrancar: otro proceso usa la GPU, los pesos no caben con ese TP o la captura de CUDA graphs no entró. Con tráfico casi nunca es el KV: vLLM lo reservó entero al arrancar.

    Primero: nvidia-smi --query-compute-apps=pid,used_memory --format=csv

    → Sección 3 · Memoria: pesos + KV cache, la fórmula con GQA, gpu_memory_utilization, bloques, max_num_seqs y preemption

  5. ValueError: Free memory on device … (…/… GiB) on startup is less than desired GPU memory utilization (0.9, … GiB). Decrease GPU memory utilization or reduce GPU memory used by other processes.

    Al arrancar, vLLM mide la memoria libre de cada GPU y exige que alcance para su fracción (0.90 × el total en el C10). Algo más ya la ocupa: un pod viejo, otro vLLM, un proceso colgado.

    Primero: nvidia-smi --query-compute-apps=pid,used_memory --format=csv

    → Sección 3 · Memoria: pesos + KV cache, la fórmula con GQA, gpu_memory_utilization, bloques, max_num_seqs y preemption

  6. ValueError: To serve at least one request with the model's max seq len (131072), (… GiB KV cache is needed, which is larger than the available KV cache memory (… GiB). Based on the available memory, the estimated maximum model length is …. Try increasing `gpu_memory_utilization` (which also controls CPU memory on the CPU backend) or decreasing `max_model_len` when initializing the engine. See https://docs.vllm.ai/en/latest/configuration/conserving_memory/ for more details.

    El KV libre no alcanza para una sola secuencia del largo máximo. Casi siempre es no haber fijado --max-model-len: vLLM toma el del config.json (131,072 en los Llama 3.x), y un 8B en una L4 necesitaría 16 GiB de KV para una sola. El mensaje trae el largo que sí cabe. En el 70B del C10 una de 131,072 sí cabe (40 GiB), pero apenas ≈ 1.6 a la vez.

    Primero: kubectl logs -n c10 deploy/vllm-70b -c vllm --previous | grep -E 'max seq len|Available KV cache memory'

    → Sección 3 · Memoria: pesos + KV cache, la fórmula con GQA, gpu_memory_utilization, bloques, max_num_seqs y preemption

  7. TimeoutError: Timed out waiting for engine core processes to start. This is often caused by slow weight loading for large models. Waited 600s (configured by VLLM_ENGINE_READY_TIMEOUT_S). To increase the timeout, set the environment variable: VLLM_ENGINE_READY_TIMEOUT_S=<seconds>

    Un proceso del API esperó 10 minutos a que el engine terminara de arrancar (cargar pesos, perfilar, compilar, capturar CUDA graphs) y se rindió. En v0.30 solo pasa cuando el API corre en procesos aparte (--api-server-count mayor que 1, o data parallel, que lo sube solo): con un único API server, como en el C10, vLLM espera al engine sin límite y el tope es la startupProbe (15 min). Causas típicas: pesos en un disco lento o torch.compile sin caché.

    Primero: kubectl logs -n c10 deploy/vllm-70b -c vllm --previous | grep -E 'Model loading took|torch.compile took|Graph capturing'

    → Sección 4 · Arrancar vLLM: vllm serve y sus flags, la imagen de Docker, el manifest del C10 en Kubernetes (GPUs, shm, probes, caché del modelo, secreto de HF)

  8. RuntimeError: Engine core initialization failed. See root cause above. Failed core proc(s): {…}

    Un envoltorio: el engine corre en otro proceso y murió al arrancar. La causa real es la primera excepción más arriba en el log: un ValueError de configuración, un OutOfMemoryError, un error de NCCL.

    Primero: kubectl logs -n c10 deploy/vllm-70b -c vllm --previous | grep -m1 -B2 -A8 -E 'Error|Exception'

    → Sección 4 · Arrancar vLLM: vllm serve y sus flags, la imagen de Docker, el manifest del C10 en Kubernetes (GPUs, shm, probes, caché del modelo, secreto de HF)

  9. Value error, User-specified max_model_len (262144) is greater than the derived max_model_len (max_position_embeddings=131072 or model_max_length=… in model's config.json). To allow overriding this maximum, set the env var VLLM_ALLOW_LONG_MAX_MODEL_LEN=1. … [type=value_error, …]

    Pediste un contexto más largo que el que el modelo dice soportar. vLLM se niega porque las posiciones de más dan salidas malas o errores de CUDA; VLLM_ALLOW_LONG_MAX_MODEL_LEN=1 lo permite, bajo tu riesgo. Llega envuelto por pydantic: arriba dice 1 validation error for ModelConfig.

    Primero: jq '.max_position_embeddings, .rope_scaling' config.json

    → Sección 4 · Arrancar vLLM: vllm serve y sus flags, la imagen de Docker, el manifest del C10 en Kubernetes (GPUs, shm, probes, caché del modelo, secreto de HF)

  10. The model `chat-default` does not exist.

    Un 404 de vLLM: el model del request no es el --served-model-name de ese pod (ni un LoRA cargado). Pasa si alguien llama a vLLM con el alias en vez de la revisión, o si un Service mezcla pods de dos revisiones (por eso el canario va en su propio Service).

    Primero: curl -s localhost:8000/v1/models | jq -r '.data[].id'

    → Sección 5 · La API compatible con OpenAI: chat completions, streaming SSE, parámetros de sampling, structured outputs, el cliente de openai

  11. openai.APIConnectionError: Connection error.

    El cliente de openai no pudo hablar con el servidor: nadie escucha en ese puerto, el nombre no resuelve o falló TLS. No hubo respuesta HTTP, así que no hay status. Antes de lanzarla ya reintentó 2 veces.

    Primero: curl -sv "$OPENAI_BASE_URL/models" -H "Authorization: Bearer $OPENAI_API_KEY" 2>&1 | head -20

    → Sección 5 · La API compatible con OpenAI: chat completions, streaming SSE, parámetros de sampling, structured outputs, el cliente de openai

  12. openai.APITimeoutError: Request timed out.

    No llegó nada a tiempo: el default es 600 s entre bytes y 5 s para conectar. Si el destino descarta paquetes en silencio (una NetworkPolicy), también sale timeout y no Connection error. El gateway del C10 corta antes: 504 si vLLM no da el primer token en 10 s.

    Primero: curl -s -o /dev/null -w 'conecta %{time_connect} s · primer byte %{time_starttransfer} s\n' "$OPENAI_BASE_URL/models"

    → Sección 5 · La API compatible con OpenAI: chat completions, streaming SSE, parámetros de sampling, structured outputs, el cliente de openai

  13. The engine is currently busy and cannot accept new requests. Please try again later or on a different instance.

    Un 503 de vLLM por su propio tope: solo pasa si alguien puso --max-num-queued-reqs (cuántos requests puede tener a la vez, esperando o corriendo; sin límite por defecto). El C10 no lo usa: la admisión vive en el gateway, que trataría este 503 como una falla del upstream (502 o el vendor).

    Primero: kubectl get deploy vllm-70b -n c10 -o jsonpath='{.spec.template.spec.containers[0].args}'

    → Sección 6 · Continuous batching y el scheduler: prefill contra decode en el mismo batch, chunked prefill, max_num_batched_tokens, latencia contra throughput

  14. error: bench/tests/fixtures/p99-only.json: faltan ['p95_ttft_ms', 'p95_tpot_ms'] (hay ['p99_ttft_ms']). Corre vllm bench serve con --percentile-metrics ttft,tpot,itl,e2el --metric-percentiles 50,95,99

    Lo dice bench/pick_concurrency.py del C10 (sale con código 2): el JSON de vllm bench serve no tiene p95 porque el default de --metric-percentiles es solo 99. El script no reemplaza p95 por p99 en silencio.

    Primero: jq -r 'keys[] | select(test("^p[0-9]+_(ttft|tpot)_ms$"))' results/sweep-c072.json

    → Sección 7 · Medir: TTFT, TPOT/ITL, E2E, tokens/s, goodput; métricas vllm: en Prometheus; el barrido de concurrencia del C10

  15. Value error, Total number of attention heads (64) must be divisible by tensor parallel size (3). [type=value_error, …]

    Con TP, cada GPU se queda con una parte de los heads de atención: los 64 heads (y 8 KV heads) del 70B se reparten parejo entre 1, 2, 4 u 8 GPUs de un nodo. Llega envuelto por pydantic: arriba dice 1 validation error for VllmConfig.

    Primero: jq '.num_attention_heads, .num_key_value_heads' config.json

    → Sección 8 · Paralelismo: tensor, pipeline y réplicas; NVLink contra PCIe; cuándo un 70B necesita TP

  16. NCCL WARN Error: failed to extend /dev/shm/nccl-03v824 to 4194660 bytes

    NCCL (la librería que comunica las GPUs de una réplica con TP=2) usa memoria compartida en /dev/shm y no le alcanzó: el /dev/shm de un contenedor es de 64 MiB por defecto. Según la doc de NCCL, así no logra inicializarse: vLLM falla al arrancar o se queda colgado esperando a la otra GPU.

    Primero: kubectl exec -n c10 deploy/vllm-70b -c vllm -- df -h /dev/shm

    → Sección 8 · Paralelismo: tensor, pipeline y réplicas; NVLink contra PCIe; cuándo un 70B necesita TP

  17. Value error, The quantization method fp8 is not supported for the current GPU. Minimum capability: 75. Current capability: 70. [type=value_error, …]

    La GPU es más vieja de lo que pide el método. En vLLM v0.30, FP8 corre desde Turing (7.5) como pesos (W8A16) y nativo desde 8.9 (Ada, Hopper, Blackwell); una V100 (7.0) no puede. Llega envuelto por pydantic: arriba dice 1 validation error for VllmConfig.

    Primero: nvidia-smi --query-gpu=name,compute_cap --format=csv

    → Sección 9 · Cuantización: FP8, INT8, INT4 (AWQ y GPTQ), KV cache en FP8 y la calidad

  18. Using KV cache scaling factor 1.0 for fp8_e4m3. If this is unintended, verify that k/v_scale scaling factors are properly set in the checkpoint.

    Un warning al arrancar con --kv-cache-dtype fp8: el checkpoint cuantizado (como el FP8 del C10) no trae escalas para K y V (kv_cache_scheme: null) y vLLM usa 1.0. Corre, pero sin calibrar: mide la calidad con el golden set antes de dejarlo. Con un checkpoint sin cuantizar también usa 1.0, pero sin avisar.

    Primero: jq '.quantization_config.kv_cache_scheme' config.json

    → Sección 9 · Cuantización: FP8, INT8, INT4 (AWQ y GPTQ), KV cache en FP8 y la calidad

  19. LoRA rank 32 is greater than max_lora_rank 16.

    El adaptador tiene rank 32 y vLLM arrancó con el default de --max-lora-rank (16). vLLM reserva memoria para el rank máximo, así que no lo carga.

    Primero: jq .r adapter_config.json

    → Sección 10 · Optimizaciones: prefix caching, speculative decoding, LoRA múltiple, autoscaling por cola y cold start

  20. Skipping CUDA graph capture. To turn on CUDA graph capture, ensure `cudagraph_mode` was not manually set to `NONE`

    Un warning: vLLM arrancó sin CUDA graphs porque cudagraph_mode quedó en NONE, casi siempre a mano (-cc.cudagraph_mode=none) o con -O0. Con --enforce-eager no sale este aviso sino Enforce eager set, disabling torch.compile and CUDAGraphs. En los dos casos arranca antes y decodifica más lento: en cada paso de decode la GPU espera a que la CPU lance los kernels.

    Primero: kubectl get deploy vllm-70b -n c10 -o jsonpath='{.spec.template.spec.containers[0].args}' | tr ',' '\n' | grep -E 'eager|compilation|optimization|-O|-cc'

    → Sección 10 · Optimizaciones: prefix caching, speculative decoding, LoRA múltiple, autoscaling por cola y cold start

  21. openai.RateLimitError: Error code: 429 - {'error': {'message': 'cuota de tokens por minuto agotada', 'type': 'rate_limit_error', 'code': 'rate_limit_exceeded'}}

    Lo dice el llm-gateway del C10 (code rate_limit_exceeded): el balde de tokens del tenant en esa réplica no alcanza para el prompt estimado + max_tokens. Trae Retry-After; el cliente de openai ya reintentó 2 veces respetándolo.

    Primero: sum by (tenant) (rate(llm_gateway_requests_total{status="429"}[5m]))

    → Sección 11 · El gateway de LLMs del C10: cuotas en tokens, admisión por prioridad, streaming detrás de proxies, cancelación y PII

  22. {"error":{"message":"plataforma sin capacidad (queue_timeout)","type":"overloaded_error","code":"overloaded"}}

    Un 503 del gateway: no hubo lugar en vuelo y el request esperó más de 0.5 s (queue_timeout; queue_full = la cola ya estaba llena). Es la plataforma, no el tenant: cuenta en LLMGatewayErrorRate. Publicaciones iría al vendor; soporte recibe esto con Retry-After: 2.

    Primero: sum(llm_gateway_inflight) / sum(llm_gateway_max_inflight)

    → Sección 11 · El gateway de LLMs del C10: cuotas en tokens, admisión por prioridad, streaming detrás de proxies, cancelación y PII

  23. {"error":{"message":"el modelo no respondió a tiempo","type":"api_error","code":"upstream_timeout"}}

    Un 504 del gateway antes del primer byte: vLLM no dio el primer token en 10 s, no se pudo conectar en 2 s, o el pool HTTP no tuvo una conexión libre (PoolTimeout). Con el default de httpx2 (100 conexiones) pasaría desde el request 101 en vuelo de una réplica; el C10 usa max_connections=None.

    Primero: max(llm:vllm_ttft_p95:rate5m) · sum(vllm:num_requests_waiting)

    → Sección 11 · El gateway de LLMs del C10: cuotas en tokens, admisión por prioridad, streaming detrás de proxies, cancelación y PII

  24. GatedRepoError: 401 Client Error. … Cannot access gated repo for url https://huggingface.co/meta-llama/Llama-3.3-70B-Instruct/resolve/main/config.json.

    huggingface_hub: el repo es gated (hay que pedir acceso y que lo aprueben). 401 = sin token; 403 = un token sin acceso aprobado o sin permiso de lectura.

    Primero: curl -s -o /dev/null -w '%{http_code}\n' -H "Authorization: Bearer $HF_TOKEN" https://huggingface.co/meta-llama/Llama-3.3-70B-Instruct/resolve/main/config.json

    → Sección 12 · Diagnóstico: el método y la tabla de síntomas

  25. LocalEntryNotFoundError: An error happened while trying to locate the file on the Hub and we cannot find the requested files in the local cache. Please check your connection and try again or make sure your Internet connection is on.

    huggingface_hub no pudo preguntarle al Hub (sin red, o con HF_HUB_OFFLINE=1 como en el C10) y el archivo no está en la caché local. En el C10 quiere decir que vLLM recibió un nombre de repo en vez de la carpeta /models/….

    Primero: kubectl get deploy vllm-70b -n c10 -o jsonpath='{.spec.template.spec.containers[0].args[0]}'

    → Sección 12 · Diagnóstico: el método y la tabla de síntomas

  26. EngineDeadError: EngineCore encountered an issue. See stack trace (above) for the root cause.

    El engine murió con el servidor ya corriendo (un XID, o sea una falla de la GPU que reporta el driver; un error de CUDA; un bug). No se recupera: /health pasa a 503, la liveness reinicia el pod y los streams en curso se cortan (stream_error en el gateway).

    Primero: kubectl logs -n c10 deploy/vllm-70b -c vllm --previous | grep -m1 -B5 -A20 Traceback

    → Sección 12 · Diagnóstico: el método y la tabla de síntomas

Ejemplo real · Cómo leer un 400 de contexto, del producto hasta vLLM

textoFormatos reales de vLLM v0.30.0 (vllm/renderers/params.py y el cuerpo de error de vllm/entrypoints/serve/exception_handling) y de openai-python 3.37.0 (_base_client.py); la conversación y los números son ilustrativos
$ python3 producto.py          # el asistente de soporte, con el cliente de openai contra el llm-gateway
Traceback (most recent call last):
  …
openai.BadRequestError: Error code: 400 - {'error': {'message': "This model's maximum context length is 8192 tokens. However, you requested 1024 output tokens and your prompt contains at least 7169 input tokens, for a total of at least 8193 tokens. Please reduce the length of the input prompt or the number of requested output tokens.", 'type': 'BadRequestError', 'param': 'input_tokens', 'code': 400}}

# El cuerpo que devolvió vLLM v0.30.0 y que el gateway reenvió sin tocar (es un 4xx: no hay fallback):
{"error":{"message":"This model's maximum context length is 8192 tokens. However, you requested 1024 output tokens and your prompt contains at least 7169 input tokens, for a total of at least 8193 tokens. Please reduce the length of the input prompt or the number of requested output tokens.","type":"BadRequestError","param":"input_tokens","code":400}}

# Lo que quedó en el gateway: la muestra del counter y una línea de log, sin el prompt ni el mensaje
llm_gateway_requests_total{alias="chat-default",status="400",tenant="support-assistant"} 1.0
{"request_id": "9f2c41d07b8e4a6d8c13e5b2a7f06d91", "tenant": "support-assistant", "alias": "chat-default", "backend": "self", "status": "400", "error": "upstream_rejected", "prompt_tokens": null, "completion_tokens": null, "ttft_s": null, "duration_s": 0.0412}
  • Léelo por partes: you requested 1024 output tokens es el max_tokens (aquí, el tope de soporte que puso el gateway porque el cliente no mandó uno); at least 7169 input tokens: vLLM deja de tokenizar un token después del límite (8,192 − 1,024 + 1 = 7,169), así que el prompt real, ya con la plantilla de chat, puede ser más largo (aquí, 7,500); at least 8193 ya no entra en 8,192.
  • ¿Por qué pasó la puerta del gateway? Estima caracteres ÷ 3 + 4 por mensaje: una conversación que pega una tabla de pedidos de ≈ 17,500 caracteres da ≈ 5,860 estimados (bajo el tope de 6,000), pero el tokenizer de Llama 3 cuenta los números de a 3 dígitos o menos y son 7,500 reales. La estimación pesimista falla justo con texto raro.
  • Quién lo dijo: vLLM (type BadRequestError, param input_tokens). El gateway reenvía los 4xx tal cual y no prueba el vendor: un request inválido no se arregla mandándolo afuera. El cliente de openai no lo reintenta: solo reintenta 408, 409, 429 y 5xx.
  • En vLLM v0.32 este mismo request ya no falla: max_tokens deja de reservarse y la salida se corta en 692 tokens (8,192 − 7,500), con finish_reason length. Un upgrade convierte un 400 claro en una respuesta corta, en silencio.
  • El gateway lo cuenta y deja su línea JSON: status 400, error upstream_rejected, backend self, sin el texto del error. Su request_id es el header x-request-id de la respuesta que recibió el producto: con él se cruzan el log del producto y el del gateway.

Cómo recordarla

quién habla dice dónde mirar. Una línea INFO … [archivo.py:N]: el log de vLLM. Un ValueError al arrancar (o un Value error, … de pydantic): tu configuración contra el modelo y la GPU. Engine core initialization failed o EngineCore encountered an issue: la causa está más arriba. openai.…Error: Error code: N: el status que devolvió el gateway o vLLM. NCCL WARN: las GPUs entre sí. GatedRepoError: el Hub.

Trampa

quedarte con la última línea del traceback. En vLLM el engine corre en otro proceso, y Engine core initialization failed. See root cause above no es la causa: es el aviso de que la causa está arriba. La otra: confiar en textos de versiones viejas. En V1 no existe la línea Sequence group … is preempted de V0 (las preemptions solo salen en el counter y en Preemptions: N), y el 400 por max_tokens de v0.30 desaparece en v0.32.

Laboratorio (opcional)4 pasos

Qué vas a provocar: Reconocer los 26 mensajes en 5 minutos y provocar dos de verdad: el de pick_concurrency.py del C10 y el 401 de un repo gated.

Necesitas: Una terminal de macOS (la ronda usa sort -R), el repo del curso para pick_concurrency.py (solo biblioteca estándar) y conexión para el curl a Hugging Face. Ni GPU ni cuenta en el Hub.

  1. Paso 1
    ronda() { printf '%s\n' 'Value error, Model architectures ['"'"'…ForCausalLM'"'"'] are not supported for now. Supported architectures: … [type=value_error, …]' 'This model'"'"'s maximum context length is 8192 tokens. However, you requested 1024 output tokens and your prompt contains at least 7169 input tokens, for a total of at least 8193 tokens. Please reduce the length of the input prompt or the number of requested output tokens.' 'As of transformers v4.44, default chat template is no longer allowed, so you must provide a chat template if the tokenizer does not define one.' 'torch.OutOfMemoryError: CUDA out of memory. Tried to allocate … GiB. GPU 0 has a total capacity of … GiB of which … MiB is free. …' 'ValueError: Free memory on device … (…/… GiB) on startup is less than desired GPU memory utilization (0.9, … GiB). Decrease GPU memory utilization or reduce GPU memory used by other processes.' 'ValueError: To serve at least one request with the model'"'"'s max seq len (131072), (… GiB KV cache is needed, which is larger than the available KV cache memory (… GiB). Based on the available memory, the estimated maximum model length is …. Try increasing `gpu_memory_utilization` (which also controls CPU memory on the CPU backend) or decreasing `max_model_len` when initializing the engine. See https://docs.vllm.ai/en/latest/configuration/conserving_memory/ for more details.' 'TimeoutError: Timed out waiting for engine core processes to start. This is often caused by slow weight loading for large models. Waited 600s (configured by VLLM_ENGINE_READY_TIMEOUT_S). To increase the timeout, set the environment variable: VLLM_ENGINE_READY_TIMEOUT_S=<seconds>' 'RuntimeError: Engine core initialization failed. See root cause above. Failed core proc(s): {…}' 'Value error, User-specified max_model_len (262144) is greater than the derived max_model_len (max_position_embeddings=131072 or model_max_length=… in model'"'"'s config.json). To allow overriding this maximum, set the env var VLLM_ALLOW_LONG_MAX_MODEL_LEN=1. … [type=value_error, …]' 'The model `chat-default` does not exist.' 'openai.APIConnectionError: Connection error.' 'openai.APITimeoutError: Request timed out.' 'The engine is currently busy and cannot accept new requests. Please try again later or on a different instance.' 'error: bench/tests/fixtures/p99-only.json: faltan ['"'"'p95_ttft_ms'"'"', '"'"'p95_tpot_ms'"'"'] (hay ['"'"'p99_ttft_ms'"'"']). Corre vllm bench serve con --percentile-metrics ttft,tpot,itl,e2el --metric-percentiles 50,95,99' 'Value error, Total number of attention heads (64) must be divisible by tensor parallel size (3). [type=value_error, …]' 'NCCL WARN Error: failed to extend /dev/shm/nccl-03v824 to 4194660 bytes' 'Value error, The quantization method fp8 is not supported for the current GPU. Minimum capability: 75. Current capability: 70. [type=value_error, …]' 'Using KV cache scaling factor 1.0 for fp8_e4m3. If this is unintended, verify that k/v_scale scaling factors are properly set in the checkpoint.' 'LoRA rank 32 is greater than max_lora_rank 16.' 'Skipping CUDA graph capture. To turn on CUDA graph capture, ensure `cudagraph_mode` was not manually set to `NONE`' 'openai.RateLimitError: Error code: 429 - {'"'"'error'"'"': {'"'"'message'"'"': '"'"'cuota de tokens por minuto agotada'"'"', '"'"'type'"'"': '"'"'rate_limit_error'"'"', '"'"'code'"'"': '"'"'rate_limit_exceeded'"'"'}}' '{"error":{"message":"plataforma sin capacidad (queue_timeout)","type":"overloaded_error","code":"overloaded"}}' '{"error":{"message":"el modelo no respondió a tiempo","type":"api_error","code":"upstream_timeout"}}' 'GatedRepoError: 401 Client Error. … Cannot access gated repo for url https://huggingface.co/meta-llama/Llama-3.3-70B-Instruct/resolve/main/config.json.' 'LocalEntryNotFoundError: An error happened while trying to locate the file on the Hub and we cannot find the requested files in the local cache. Please check your connection and try again or make sure your Internet connection is on.' 'EngineDeadError: EngineCore encountered an issue. See stack trace (above) for the root cause.' | sort -R | head -5; }; ronda

    Qué deberías ver: Cinco mensajes al azar. Para cada uno, en menos de 30 s y en voz alta: quién lo dijo (vLLM al arrancar o al atender, PyTorch, NCCL, Hugging Face, el cliente de openai o el gateway), la causa probable y el primer comando. Después compáralo con la lista.

  2. Paso 2
    ronda

    Qué deberías ver: Otra ronda de cinco. Repite hasta completar 5 minutos; la meta es 5 de 5 sin mirar.

  3. Paso 3
    cd apps/ml-system-design/reference/c10   # desde la raíz del repo del curso
    PYTHONDONTWRITEBYTECODE=1 python3 bench/pick_concurrency.py bench/tests/fixtures/p99-only.json; echo "exit=$?"

    Qué deberías ver: error: bench/tests/fixtures/p99-only.json: faltan ['p95_ttft_ms', 'p95_tpot_ms'] (hay ['p99_ttft_ms']). Corre vllm bench serve con --percentile-metrics ttft,tpot,itl,e2el --metric-percentiles 50,95,99 y exit=2: entrada inválida, distinto de «ningún punto cumple» (exit=1).

  4. Paso 4
    curl -s -o /dev/null -w '%{http_code}\n' https://huggingface.co/meta-llama/Llama-3.3-70B-Instruct/resolve/main/config.json

    Qué deberías ver: 401: el repo es gated y no mandaste token. Con un token sin acceso aprobado sería 403; huggingface_hub convierte esas respuestas en GatedRepoError.

Cómo arreglarlo y confirmarlo

Los que fallaste en la ronda: abre la sección de su tema (el enlace de la lista) y haz su laboratorio.

Probado de verdad en un Mac. Ejecutado aquí tal como está escrito: la ronda en zsh (los 26 mensajes salen idénticos a la lista), pick_concurrency.py con Python 3.14.3 (sale con código 2) y el curl, que dio 401. Los textos salen del código de vLLM v0.30.0 (y de v0.32.0 donde cambia), de PyTorch (c10/cuda/CUDACachingAllocator.cpp), huggingface_hub, openai-python 3.37.0, la doc de NCCL 2.32.3 y los archivos del C10.

⚙

Herramientas

Del síntoma al primer comando, y la cuenta de memoria de una réplica antes de tenerla.

Diagnosticador

Elige lo que ves. Te dice qué mirar primero y las causas típicas, cada una con cómo comprobarla y cómo arreglarla. Los comandos y las consultas de PromQL se copian.

Elige un síntoma para ver las causas.

Calculadora de VRAM y KV cache

La cuenta de capacity/vram_calc.py del C10, en el navegador: pesos + KV por token → lugar para KV en una réplica → cuántas secuencias caben. Sirve para elegir el hardware antes de tenerlo y para detectar un error de órdenes de magnitud después; el número real es el que imprime vLLM al arrancar.

Llenar con

Modelo
B (miles de millones)
B
B (miles de millones)
capas
heads
dims
B
Réplica
GB (10⁹ B)
GPUs
fracción
GB
tokens
Carga
tokens
tokens
secuencias
tokens
prefijos

La réplica, a escala:

Pesos
70.6GB
KV por token
320KiB
KV de una secuencia de ese largo
0.737GB
Memoria que vLLM se permite usar en la réplica
144.0GB
Lugar para KV cache (negativo: no cabe)
68.4GB
¿Caben los pesos y el overhead?
sí
¿TP divide los KV heads?
sí
Tokens de KV que caben (la cuenta del C10)
208,740tokens
Secuencias de ese largo (el cociente)
92.77secuencias
Secuencias de ese largo a la vez, enteras y en su largo final
92secuencias
Secuencias de max_model_len a la vez
25secuencias
Bloques de KV (enteros, como vLLM)
13,046bloques
GPU KV cache size, como lo imprime vLLM
208,736tokens
Maximum concurrency for max_model_len tokens per request
25.48x
Secuencias de ese largo en bloques enteros
92secuencias
Tokens de KV de las secuencias vivas (con el prefijo compartido)
90,000tokens
KV de las secuencias vivas
29.5GB
Fracción del lugar para KV que ocupan
43%

Las fórmulas, en el orden en que se calculan

  1. Pesos

    pesos=(P−P16)×bw+P16×2 B\text{pesos} = (P - P_{16}) \times b_w + P_{16} \times 2\ \text{B}
  2. KV por token

    KVtoken=2×L×Hkv×dhead×bkv\text{KV}_{\text{token}} = 2 \times L \times H_{kv} \times d_{head} \times b_{kv}
  3. Presupuesto de vLLM en la réplica

    MvLLM=Ngpu×Mgpu×uM_{\text{vLLM}} = N_{gpu} \times M_{gpu} \times u
  4. Lugar para KV

    KVlibre=MvLLM−pesos−overhead\text{KV}_{\text{libre}} = M_{\text{vLLM}} - \text{pesos} - \text{overhead}
  5. KV de una secuencia

    KVseq=Tseq×KVtoken\text{KV}_{\text{seq}} = T_{seq} \times \text{KV}_{\text{token}}
  6. Bloques de KV, como los cuenta vLLM

    Nbloques=⌊KVlibreB×KVtoken⌋N_{\text{bloques}} = \left\lfloor \frac{\text{KV}_{\text{libre}}}{B \times \text{KV}_{\text{token}}} \right\rfloor
  7. Tokens de KV de las secuencias vivas

    Tvivos=Nvivas×(Tseq−Tpref)+Npref×TprefT_{\text{vivos}} = N_{vivas} \times (T_{seq} - T_{pref}) + N_{pref} \times T_{pref}
  • La cuenta supone atención completa con GQA en todas las capas, como Llama, Qwen3 denso o Mistral. En modelos híbridos (capas de ventana o de atención lineal, como Qwen3.5, gpt-oss o Gemma 4) da números muy equivocados: confía en el KV que imprime vLLM.
  • vLLM aplica gpu_memory_utilization sobre la memoria que reporta cada GPU (81,559 MiB ≈ 85.52 GB en una H100 SXM) y redondea a bloques enteros por GPU. La línea GPU KV cache size: N tokens del arranque es el número que vale.
  • Unidades: GB = 10⁹ B y KiB = 2¹⁰ B, como en vram_calc.py. La tolerancia de cada check es absoluta, en la unidad del output.

De dónde salen los números de cada modelo