Instrumentar vLLM con OTel: medir lo que las optimizaciones realmente hacen

TL;DR

vLLM expone dos señales de observabilidad independientes: métricas Prometheus (pull, agregadas) y trazas OTel (push, por request). Para medir si chunked prefill, prefix caching, speculative decoding, KV cache FP8 y la concurrencia realmente están funcionando, necesitas ambas. Las métricas dicen qué está pasando en el sistema; las trazas dicen por qué un request concreto fue lento. Este artículo configura el pipeline completo y mapea cada optimización a su métrica diagnóstica.


La analogía

Un piloto de Fórmula 1 y sus ingenieros de telemetría. El piloto siente que el coche “va raro” en la curva 3, pero sin los datos de los sensores no sabe si es el neumático trasero, el diferencial o el combustible. Los ingenieros ven exactamente qué pasó en ese giro, temperatura por sensor, carga lateral por milisegundo.

vLLM sin OTel es el piloto solo: notas que el TTFT “parece alto” pero no sabes si es el prefill largo, un miss de prefix cache, o una preemption de KV cache. Con OTel tienes el cuadro completo: las métricas son el resumen de carrera (aggregated), las trazas son la telemetría vuelta a vuelta (per-request).


Arquitectura de las dos señales

vLLM separa intencionalmente sus dos canales de observabilidad:

                        ┌─────────────────────────────┐
                        │           vLLM              │
                        │                             │
  requests ────────────►│  SchedulerStats             │
                        │    │                        │
                        │    ├─► Prometheus /metrics  │◄── scrape (pull)
                        │    │   (agregado, ~15s)     │
                        │    │                        │
                        │    └─► OTLP exporter        │──► push (por request)
                        │        (spans, inmediato)   │
                        └─────────────────────────────┘
                                      │ OTLP gRPC/HTTP
                                      ▼
                          ┌─────────────────────┐
                          │   OTel Collector    │
                          │                     │
                          │  receivers:         │
                          │    otlp (traces)    │
                          │    prometheus       │
                          │  exporters:         │
                          │    langfuse         │
                          │    prometheus remote│
                          │    loki (logs)      │
                          └─────────────────────┘

Prometheus pull expone métricas con prefijo vllm: en :8000/metrics. Son histogramas, gauges y contadores actualizados cada iteración del scheduler. Buenos para dashboards y alertas sobre el sistema completo.

OTLP push envía un span por cada request, inmediatamente al completarse. Contiene atributos del request concreto: tokens de prompt, tokens generados, TTFT, modelo. Bueno para debuggear requests anómalos y para Langfuse.


Instalación y configuración básica

# vLLM con soporte OTel
pip install "vllm[otel]"
# Instala: opentelemetry-sdk, opentelemetry-api,
#          opentelemetry-exporter-otlp, opentelemetry-semantic-conventions-ai
# Arrancar vLLM con OTel habilitado
export OTEL_SERVICE_NAME="vllm-produccion"
export OTEL_EXPORTER_OTLP_TRACES_ENDPOINT="http://otel-collector:4317"
export OTEL_EXPORTER_OTLP_TRACES_PROTOCOL="grpc"
export OTEL_EXPORTER_OTLP_TRACES_INSECURE="true"   # en red interna sin TLS

vllm serve Qwen/Qwen2.5-7B-Instruct \
  --otlp-traces-endpoint http://otel-collector:4317 \
  --enable-chunked-prefill \
  --enable-prefix-caching \
  --kv-cache-dtype fp8 \
  --speculative-model Qwen/Qwen2.5-0.5B-Instruct \
  --num-speculative-tokens 5

Las métricas Prometheus no requieren configuración extra: siempre están en :8000/metrics.


OTel Collector: configuración mínima

# otel-collector-config.yaml
receivers:
  otlp:
    protocols:
      grpc:
        endpoint: 0.0.0.0:4317
      http:
        endpoint: 0.0.0.0:4318

  prometheus:
    config:
      scrape_configs:
        - job_name: vllm
          scrape_interval: 15s
          static_configs:
            - targets: ["vllm:8000"]

processors:
  batch:
    timeout: 5s
  resource:
    attributes:
      - key: deployment.environment
        value: "produccion"
        action: upsert

exporters:
  otlphttp/langfuse:
    endpoint: "https://cloud.langfuse.com/api/public/otel"
    headers:
      Authorization: "Basic <base64(pk:sk)>"

  prometheusremotewrite:
    endpoint: "http://prometheus:9090/api/v1/write"

service:
  pipelines:
    traces:
      receivers: [otlp]
      processors: [batch, resource]
      exporters: [otlphttp/langfuse]
    metrics:
      receivers: [prometheus]
      processors: [batch]
      exporters: [prometheusremotewrite]

Las cinco métricas que importan

Cada optimización tiene una señal diagnóstica primaria. Si la métrica no se mueve como se espera después de activar el flag, hay un problema de configuración o de carga.

1. Chunked prefill → vllm:time_to_first_token_seconds

Chunked prefill debería reducir la varianza del TTFT, no necesariamente la mediana. Su objetivo principal es que los percentiles altos (p99) bajen aunque el p50 suba ligeramente.

# TTFT p50 y p99 — esperar que p99 baje con chunked prefill activo
histogram_quantile(0.50, rate(vllm:time_to_first_token_seconds_bucket[5m]))
histogram_quantile(0.99, rate(vllm:time_to_first_token_seconds_bucket[5m]))

Señal de que funciona: ratio p99/p50 se acerca a 1. Sin chunked prefill, un prefill largo de un request bloquea todos los demás y el p99 sube desproporcionadamente.

Señal de problema: p50 y p99 ambos suben. --max-num-batched-tokens demasiado bajo hace que los chunks sean tan pequeños que el prefill tarda muchos pasos en completarse aunque las demás requests no se bloqueen. Subir el budget.

También útil observar en las trazas OTel el atributo llm.usage.prompt_tokens por span: los requests con muchos tokens de prompt deberían tener TTFT proporcional, no bloqueante.


2. Prefix caching → vllm:gpu_prefix_cache_hit_rate

# Hit rate del prefix cache en GPU (0.0–1.0)
vllm:gpu_prefix_cache_hit_rate

# Evolución en ventana de 5 minutos
rate(vllm:gpu_prefix_cache_hit_rate[5m])

Señal de que funciona: hit rate sostenido > 0.5 en workloads con system prompt compartido. Con hit rate = 0.8, el 80% de los requests omite el prefill del prefijo; el TTFT de esos requests cae al coste del sufijo variable únicamente.

Señal de problema: hit rate cercano a cero pese a system prompts que “parecen” iguales. Causas habituales:

# ❌ Esto rompe el hash de prefix caching:
system_prompt = f"Hoy es {datetime.now()}. Eres un asistente..."
#                 ^^^ timestamp diferente en cada request

# ✅ El system prompt debe ser idéntico byte a byte:
system_prompt = "Eres un asistente especializado en infraestructura..."

Cualquier variación en el system prompt —timestamps, IDs de sesión, versiones de prompt interpoladas— produce un hash distinto y un miss de caché. Las trazas OTel no exponen directamente el hit/miss por request en la implementación actual; úsalas para correlacionar llm.usage.prompt_tokens alto con TTFT alto en el mismo request.


3. Speculative decoding → vllm:spec_decode_draft_acceptance_rate

# Acceptance rate del draft model (0.0–1.0)
vllm:spec_decode_draft_acceptance_rate

# Speedup efectivo estimado (con k=5 tokens propuestos)
# speedup ≈ (1 + α·k) / (1 + overhead_draft)
# Simplificado: si α=0.75 y k=5 → speedup ≈ 1 + 0.75×5×(1 - cost_ratio) 

Señal de que funciona: acceptance rate > 0.70 sostenido. Por debajo de 0.60, el overhead del draft model supera la ganancia de los tokens aceptados y el speculative decoding es contraproducente.

Señal de problema: acceptance rate < 0.50. Causas habituales:

  • Drafter de familia distinta al verifier (p.ej., Mistral 0.5B como draft de Qwen 7B).
  • Temperatura de generación alta (>0.9): a mayor temperatura, más diverge la distribución del draft de la del verifier.
  • Batch muy grande: a alta concurrencia, el draft puede quedar fuera del dominio de los requests actuales.
# Alerta: speculative decoding ineficiente
ALERT SpecDecodeIneficiente
  IF vllm:spec_decode_draft_acceptance_rate < 0.60
  FOR 5m
  LABELS { severity = "warning" }
  ANNOTATIONS { summary = "Draft acceptance rate bajo: desactivar spec decode o cambiar drafter" }

En las trazas OTel, el span completo del request incluye el tiempo total de decode. Sin acceptance rate por span, la forma de detectar spec decode funcionando es comparar el tiempo total de decode dividido por los tokens generados: si es significativamente menor que el baseline sin spec decode, está ayudando.


4. KV cache FP8 y concurrencia → vllm:gpu_cache_usage_perc + vllm:num_preemptions_total

Estas dos métricas son las dos caras de la gestión del KV cache:

# Utilización del KV cache (0.0–1.0)
# Con FP8 activo, el mismo hardware soporta más requests antes de saturar
vllm:gpu_cache_usage_perc

# Preemptions acumuladas (contador)
# Sube cuando vLLM no puede alojar más requests y pausa alguna
rate(vllm:num_preemptions_total[5m])

Señal de que FP8 funciona: con --kv-cache-dtype fp8 activo, gpu_cache_usage_perc debería saturar a niveles de concurrencia ~2× superiores respecto al baseline BF16 antes de que num_preemptions_total empiece a crecer.

Señal de problema: num_preemptions_total crece en tasas > 1/minuto con gpu_cache_usage_perc por debajo de 0.90. Indica que max-num-seqs está demasiado alto para el KV cache disponible: las requests entran al sistema pero no hay bloques libres para asignarles. Bajar max-num-seqs o reducir max-model-len.

# Alerta: KV cache saturado con preemptions
ALERT KVCacheSaturado
  IF rate(vllm:num_preemptions_total[2m]) > 0.5
     AND vllm:gpu_cache_usage_perc > 0.85
  FOR 3m
  LABELS { severity = "critical" }
  ANNOTATIONS { summary = "KV cache saturado: bajar max-num-seqs o max-model-len" }

El impacto del FP8 en la capacidad se puede cuantificar:

$$\Delta\text{capacity} = \frac{\text{tokens_max_FP8}}{\text{tokens_max_BF16}} \approx 2\times$$

Medir antes y después de activar --kv-cache-dtype fp8: el nivel de gpu_cache_usage_perc para una concurrencia dada debería caer a la mitad.


5. Concurrencia efectiva → vllm:num_running_seqs + vllm:num_waiting_seqs

# Requests activos en el motor (decode + prefill en curso)
vllm:num_running_seqs

# Requests en cola esperando slot
vllm:num_waiting_seqs

# Ratio de espera: si > 0.2 sostenido, hay cuello de concurrencia
vllm:num_waiting_seqs / (vllm:num_running_seqs + vllm:num_waiting_seqs)

Señal saludable: num_running_seqs estable cerca del valor de --max-num-seqs configurado, num_waiting_seqs bajo (< 10% de running).

Señal de problema: num_waiting_seqs elevado con gpu_cache_usage_perc bajo. Indica que el scheduler no está llenando los slots disponibles porque max-num-batched-tokens es demasiado bajo: el budget de tokens por paso no permite procesar los prefills pendientes rápido enough. Subir max-num-batched-tokens.


Dashboard de referencia: las 5 métricas en Grafana

{
  "panels": [
    {
      "title": "TTFT p50 / p99 (chunked prefill)",
      "targets": [
        {"expr": "histogram_quantile(0.50, rate(vllm:time_to_first_token_seconds_bucket[5m]))", "legendFormat": "p50"},
        {"expr": "histogram_quantile(0.99, rate(vllm:time_to_first_token_seconds_bucket[5m]))", "legendFormat": "p99"}
      ]
    },
    {
      "title": "Prefix cache hit rate",
      "targets": [{"expr": "vllm:gpu_prefix_cache_hit_rate", "legendFormat": "GPU hit rate"}]
    },
    {
      "title": "Spec decode acceptance rate",
      "targets": [{"expr": "vllm:spec_decode_draft_acceptance_rate", "legendFormat": "acceptance rate"}]
    },
    {
      "title": "KV cache usage + preemptions",
      "targets": [
        {"expr": "vllm:gpu_cache_usage_perc", "legendFormat": "cache uso"},
        {"expr": "rate(vllm:num_preemptions_total[2m]) * 60", "legendFormat": "preemptions/min"}
      ]
    },
    {
      "title": "Concurrencia efectiva",
      "targets": [
        {"expr": "vllm:num_running_seqs", "legendFormat": "running"},
        {"expr": "vllm:num_waiting_seqs", "legendFormat": "waiting"}
      ]
    }
  ]
}

Conectar trazas a Langfuse

Las trazas OTel de vLLM son spans GenAI semconv compatibles. Langfuse los acepta directamente via OTLP:

# En el OTel Collector (ya configurado arriba)
# El exporter otlphttp/langfuse envía trazas a Langfuse Cloud o self-hosted

# Para Langfuse self-hosted (ENS/soberano):
exporters:
  otlphttp/langfuse:
    endpoint: "http://langfuse-interno:3000/api/public/otel"
    headers:
      Authorization: "Basic <base64(pk_xxx:sk_xxx)>"

En Langfuse, cada request de vLLM aparece como una traza con:

  • gen_ai.system: modelo servido
  • gen_ai.usage.input_tokens: tokens de prompt
  • gen_ai.usage.output_tokens: tokens generados
  • Duración del span: latencia end-to-end

Lo que no aparece directamente en el span: acceptance rate de speculative decoding, prefix cache hit/miss, ni número de preemptions. Esos datos sólo están en Prometheus. El workflow correcto es:

  1. Langfuse identifica un request anómalo por latencia.
  2. Prometheus/Grafana muestra si en ese intervalo hubo preemptions elevadas, spec decode bajo, o prefix cache miss.
  3. Se correlacionan por timestamp.

Matriz de diagnóstico rápido

Síntoma observableMétrica PrometheusCausa probableAcción
TTFT p99 muy altottft p99/p50 >> 2Prefills largos bloqueantesSubir --max-num-batched-tokens
TTFT p50 alto, p99 idemttft p50 > 500msPrefix cache no funcionaVerificar hash del system prompt
Decode lento sin mejoraspec_decode_acceptance < 0.60Drafter incompatibleCambiar drafter o desactivar
OOM / crash esporádicogpu_cache_usage_perc = 1.0 + preemptionsKV cache llenoBajar max-num-seqs o activar FP8
Cola alta con cache librewaiting >> 0 + cache < 0.70Budget de tokens bajoSubir --max-num-batched-tokens

Implicaciones para inferencia on-premise soberana

En un despliegue ENS donde no puedes usar Langfuse Cloud ni DataDog, el stack self-hosted completo es:

# docker-compose.yml (o manifests K8s equivalentes)
services:
  otel-collector:
    image: otel/opentelemetry-collector-contrib:latest
    volumes: [./otel-config.yaml:/etc/otel/config.yaml]

  langfuse:
    image: langfuse/langfuse:latest
    environment:
      DATABASE_URL: postgres://...

  prometheus:
    image: prom/prometheus:latest

  grafana:
    image: grafana/grafana:latest

Todo el pipeline corre on-premise. Las trazas nunca salen del perímetro. El cumplimiento ENS no depende de qué observabilidad eliges: depende de que los datos de inferencia no salgan a terceros. Con stack local, ambas condiciones se cumplen.


Ver también

  • https://blog.lo0.es/posts/tracing-llm-otel-genai/ — los fundamentos de OTel GenAI semconv: qué son los spans, los atributos estándar y cómo fluyen desde el SDK al collector
  • https://blog.lo0.es/posts/prefill-optimizaciones-vllm/ — las optimizaciones de prefill que este artículo instrumenta: chunked prefill, prefix caching, FP8 KV
  • https://blog.lo0.es/posts/decode-optimizaciones-vllm/ — las optimizaciones de decode: speculative decoding, gpu-memory-utilization, max-num-seqs
  • https://blog.lo0.es/posts/anatomia-metricas-dcgm-vllm-anomalias/ — DCGM para las métricas de GPU debajo de vLLM: SM utilization, memory bandwidth, temperatura; la capa de hardware bajo las métricas de aplicación
  • https://blog.lo0.es/posts/observabilidad-gpu-dcgm-llm/ — cómo correlacionar métricas de GPU (DCGM) con métricas de aplicación (vLLM Prometheus) para diagnóstico completo
  • https://blog.lo0.es/posts/continuous-batching-fundamentos/ — el scheduler que produce las métricas de num_running_seqs y num_waiting_seqs; sin entender el scheduler, las métricas de concurrencia no tienen contexto

Referencias