Mide tu vLLM: TTFT, tokens/s y concurrencia sin engañarte
Un servidor que "funciona" no dice nada. La pregunta de un engineer es cuánto: cuánto tarda el primer token, cuántos tokens por segundo, cuántas personas a la vez. Este capítulo te deja midiendo tu vLLM con los mismos scripts que usé en mi PoC, y con la lección que aprendí equivocándome.
Los 4 números que importan
| Métrica | Qué mide | Qué significa para el usuario |
|---|---|---|
| TTFT | Tiempo hasta el primer token | Qué tan "instantánea" se siente la respuesta |
| Decode tok/s | Velocidad de generación | Fluidez; arriba de ~30 tok/s se lee cómodo |
| Prefill tok/s | Velocidad procesando el prompt | Cuánto cuesta mandar contexto enorme |
| Throughput agregado | Tokens/segundo de TODOS los usuarios | La capacidad real del servidor |
Y una regla de lectura: mira p50 y p90, no promedios. El promedio esconde al usuario que espera el doble.
1 usuario: benchmark_openai.py
Este script mide un request de principio a fin contra cualquier endpoint OpenAI-compatible:
# terminal del pod
python benchmark_openai.py \
--base-url http://localhost:8000/v1 \
--model qwen3-coder-30b \
--prompt "$(cat prompt_largo.txt)" \
--api-key $VLLM_API_KEY \
--out results_1user.csv
El CSV que sale tiene una fila por request con estas columnas:
ttft_ms,total_s,tok_s,prompt_tokens,output_tokens,ok,error
2541.2,3.31,120.3,15269,93,True,
Esa fila es real, de mi PoC: 15.269 tokens de prompt procesados y 93 de respuesta en 3,31 segundos, con un TTFT de 2.541 ms. Déjala con ese TTFT tan feo un momento, porque es la trampa número uno de este capítulo y la destapamos en la sección del gateway.
Nota que el prompt es largo (15K tokens). Si mides con "hola", todo es prefill instantáneo y los números son ruido. Usa un prompt del tamaño de tu workload real.
Muchos usuarios: load_test.py
Un benchmark de 1 usuario no responde la pregunta del negocio: ¿cuántos aguantan a la vez?
# terminal del pod
python load_test.py \
--base-url http://localhost:8000/v1 \
--model qwen3-coder-30b \
--concurrency 10 \
--context-tokens 16000 \
--max-tokens 300 \
--api-key $VLLM_API_KEY \
--session-mode unique
Mis resultados reales en la L40S (a través del gateway), para que tengas un piso de referencia:
| Escenario | Tiempo total | TTFT p50 | tok/s por usuario | Agregado |
|---|---|---|---|---|
| 1 usuario, 16K | 3,3 s | 2.541 ms | 120,3 | 120 tok/s |
| 5 usuarios, 16K | 9,1 s | 5.435 ms | 27,3 | 166 tok/s |
| 10 usuarios, 16K (sesiones distintas) | 16,1 s | 9.652 ms | 15,9 | 199 tok/s |
| 10 usuarios, 16K (prefijo compartido) | 5,2 s | 3.773 ms | 73,6 | 740 tok/s |
La lectura: el throughput agregado sube con la concurrencia (199 tok/s), pero el tok/s individual baja (15,9 por usuario). La GPU no hace magia, reparte. Si tu SLO es "40 tok/s por persona en hora pico", esa tabla te dice cuántos caben.
El bug que infla tus números
La fila con 740 tok/s es trampa y avance de trampa a la vez. Mi primer load_test.py mandaba el mismo prompt a todos los usuarios. Con prefix caching activo, vLLM procesó ese contexto UNA vez y las 10 sesiones lo reutilizaron: estaba midiendo la memoria de una sola sesión multiplicada por magia.
El fix fue --session-mode unique (un marcador distinto por sesión), y de ahí salió la fila honesta de 16,1 s. El post-mortem completo está en El error de mi propio benchmark.
El matiz que lo hace interesante: en un equipo real los agentes SÍ comparten prefijo (el mismo system prompt gigante). Por eso el escenario "prefijo compartido" no es trampa si tu workload es así; es trampa cuando mides para decidir capacidad y tu workload real no comparte nada.
La trampa de la red
Ese TTFT de 2.541 ms con 1 usuario era absurdo para una L40S. Al medir por tramo encontré esto:
| Tramo | Tiempo |
|---|---|
| El modelo responde | 56 ms |
| El gateway añade | 15 ms |
| La red pública hasta el pod | ~1.000 ms |
El 94% de la espera era internet, no el sistema. Si mides contra la URL pública del pod, estás midiendo a RunPod, no a vLLM. Para comparar hardware, mide por túnel SSH o dentro del pod. El caso completo, con la metodología, está en El cuello de botella no era la GPU: era la red.
Checkpoint y el error típico
Checkpoint: un CSV tuyo de 1 usuario y uno de 10, con tu prompt real. Ahora puedes responder la pregunta que abre este capítulo con números propios.
El error típico: benchmark con prompt de 10 tokens y un solo run. Prompt corto = mides el arranque de Python, no la GPU. Un solo run = mides la suerte. Tres corridas mínimo, prompt del tamaño real, y mira p90.
Para la teoría de por qué percentiles y no promedios, tengo Medir inferencia sin engañarte. Y vLLM expone todo esto también en /metrics formato Prometheus: curl localhost:8000/metrics | grep -i ttft.
Siguiente capítulo
Tu servidor sirve. Ahora lo convertimos en un servicio de equipo: llaves por persona, presupuestos, logs por request y la conversación de costos que hay que tener antes de migrar a producción.
¿Qué números te dieron tus 10 usuarios concurrentes? Ese es el dato que decide todo lo que viene. Déjalo en los comentarios.