Métriques LLM spécifiques
Les métriques d'un LLM en production sont fondamentalement différentes de celles d'une API REST classique. Les indicateurs standards (CPU, RAM, RPS) sont insuffisants — il faut des métriques spécifiques à l'inférence LLM pour détecter les problèmes réels.
Métriques vLLM exposées nativement (/metrics)
| Métrique | Type | Description | SLO cible |
|---|---|---|---|
| vllm:time_to_first_token_seconds | Histogram | Temps avant le premier token généré (TTFT) | P95 < 500ms |
| vllm:time_per_output_token_seconds | Histogram | Temps entre deux tokens consécutifs | P95 < 50ms |
| vllm:e2e_request_latency_seconds | Histogram | Latence totale de la requête | P95 < 30s |
| vllm:num_requests_running | Gauge | Requêtes en cours d'inférence | < max_num_seqs |
| vllm:num_requests_waiting | Gauge | Requêtes en attente dans la queue | < 50 |
| vllm:gpu_cache_usage_perc | Gauge | Utilisation du KV cache GPU (%) | < 90% |
| vllm:prompt_tokens_total | Counter | Total tokens d'entrée traités | — |
| vllm:generation_tokens_total | Counter | Total tokens générés | — |
| vllm:request_success_total | Counter | Requêtes réussies | Error rate < 0.1% |
Calcul du débit réel (tokens/seconde)
# Script de mesure du débit vLLM en production
import httpx
import time
import statistics
def benchmark_vllm(endpoint: str, api_key: str, n_requests: int = 50):
"""
Mesure TTFT, débit tokens/s et latence E2E pour les SLOs.
"""
results = []
for i in range(n_requests):
payload = {
"model": "elodie-32b",
"messages": [{"role": "user", "content": f"Explique le concept de {i} en 200 mots."}],
"max_tokens": 200,
"stream": False
}
start = time.perf_counter()
resp = httpx.post(
f"{endpoint}/v1/chat/completions",
json=payload,
headers={"Authorization": f"Bearer {api_key}"},
timeout=60
)
latency_ms = (time.perf_counter() - start) * 1000
data = resp.json()
usage = data.get("usage", {})
total_tokens = usage.get("total_tokens", 0)
results.append({
"latency_ms": latency_ms,
"tokens": total_tokens,
"throughput_tps": total_tokens / (latency_ms / 1000)
})
latencies = [r["latency_ms"] for r in results]
throughputs = [r["throughput_tps"] for r in results]
print(f"Latency P50: {statistics.median(latencies):.0f}ms")
print(f"Latency P95: {sorted(latencies)[int(0.95*n_requests)]:.0f}ms")
print(f"Latency P99: {sorted(latencies)[int(0.99*n_requests)]:.0f}ms")
print(f"Throughput median: {statistics.median(throughputs):.0f} tok/s")
return results
benchmark_vllm("http://vllm.llm-production", "your-api-key")
Métriques business et qualité
Au-delà des métriques techniques, les DSI doivent suivre des indicateurs business pour justifier l'investissement LLM et détecter les dérives de qualité.
# business_metrics_collector.py
# Collecte des métriques qualité via feedback utilisateur
from prometheus_client import Counter, Histogram, Gauge, start_http_server
import time
# Métriques business personnalisées
USER_SATISFACTION = Histogram(
'llm_user_satisfaction_score',
'Score de satisfaction (1-5) par utilisateur',
['department', 'model'],
buckets=[1, 2, 3, 4, 5]
)
HALLUCINATION_DETECTED = Counter(
'llm_hallucination_detected_total',
'Hallucinations détectées par le système de vérification',
['model', 'category']
)
SESSION_ABANDONMENT = Counter(
'llm_session_abandonment_total',
'Sessions abandonnées (utilisateur arrête avant la réponse complète)',
['department']
)
TOKEN_COST_EUR = Counter(
'llm_token_cost_eur_total',
'Coût total en euros (tokens × tarif interne)',
['department', 'model']
)
ACTIVE_USERS = Gauge(
'llm_active_users_current',
'Utilisateurs actifs en ce moment',
['department']
)
DAILY_UNIQUE_USERS = Gauge(
'llm_daily_unique_users',
'Utilisateurs uniques sur les dernières 24h'
)
start_http_server(9200) # Port métriques business
Stack complète : Prometheus + DCGM + Grafana
# monitoring-stack.yaml — Déploiement complet via kube-prometheus-stack
# Inclut Prometheus Operator, Grafana, AlertManager, node-exporter
helm repo add prometheus-community \
https://prometheus-community.github.io/helm-charts
cat > monitoring-values.yaml << 'EOF'
prometheus:
prometheusSpec:
retention: 30d
retentionSize: 100GB
resources:
requests:
cpu: 4
memory: 16Gi
limits:
cpu: 8
memory: 32Gi
storageSpec:
volumeClaimTemplate:
spec:
storageClassName: fast-nvme
resources:
requests:
storage: 200Gi
# Scrape configs additionnels
additionalScrapeConfigs:
- job_name: 'vllm-metrics'
scrape_interval: 10s
kubernetes_sd_configs:
- role: pod
namespaces:
names: [llm-production]
relabel_configs:
- source_labels: [__meta_kubernetes_pod_annotation_prometheus_io_scrape]
action: keep
regex: true
- source_labels: [__meta_kubernetes_pod_annotation_prometheus_io_port]
action: replace
target_label: __address__
regex: (.+)
replacement: ${1}
- job_name: 'dcgm-exporter'
scrape_interval: 15s
kubernetes_sd_configs:
- role: endpoints
namespaces:
names: [monitoring]
relabel_configs:
- source_labels: [__meta_kubernetes_endpoints_name]
action: keep
regex: dcgm-exporter
- job_name: 'business-metrics'
scrape_interval: 30s
static_configs:
- targets: ['business-metrics-collector:9200']
grafana:
enabled: true
adminPassword: "${GRAFANA_ADMIN_PASSWORD}"
persistence:
enabled: true
size: 20Gi
grafana.ini:
server:
root_url: https://monitoring.interne
auth.generic_oauth:
enabled: true
client_id: grafana
client_secret: "${KEYCLOAK_CLIENT_SECRET}"
auth_url: https://keycloak.interne/realms/intelligence-privee/protocol/openid-connect/auth
token_url: https://keycloak.interne/realms/intelligence-privee/protocol/openid-connect/token
alertmanager:
enabled: true
config:
route:
receiver: 'pagerduty-critical'
routes:
- match:
severity: critical
receiver: 'pagerduty-critical'
- match:
severity: warning
receiver: 'slack-warnings'
receivers:
- name: 'pagerduty-critical'
pagerduty_configs:
- service_key: "${PAGERDUTY_KEY}"
- name: 'slack-warnings'
slack_configs:
- api_url: "${SLACK_WEBHOOK_URL}"
channel: '#llm-ops'
EOF
helm install kube-prometheus-stack \
prometheus-community/kube-prometheus-stack \
-f monitoring-values.yaml \
--namespace monitoring \
--create-namespace \
--version 57.2.0
Dashboards Grafana essentiels
Quatre dashboards couvrent l'essentiel des besoins d'observabilité LLM. Voici les PromQL clés pour les panels les plus importants.
Dashboard 1 : LLM Performance Overview
// Panel : TTFT P95 en temps réel (Stat panel)
{
"title": "Time to First Token P95",
"type": "stat",
"targets": [{
"expr": "histogram_quantile(0.95, sum(rate(vllm:time_to_first_token_seconds_bucket[5m])) by (le)) * 1000",
"legendFormat": "TTFT P95 (ms)"
}],
"thresholds": {
"steps": [
{"value": 0, "color": "green"},
{"value": 300, "color": "yellow"},
{"value": 500, "color": "red"}
]
}
}
// Panel : Débit tokens/seconde (Time series)
{
"title": "Débit de génération (tokens/s)",
"targets": [{
"expr": "sum(rate(vllm:generation_tokens_total[1m]))",
"legendFormat": "Tokens/s générés"
}, {
"expr": "sum(rate(vllm:prompt_tokens_total[1m]))",
"legendFormat": "Tokens/s (prompts)"
}]
}
// Panel : Queue depth — requêtes en attente
{
"title": "Queue LLM (requêtes en attente)",
"targets": [{
"expr": "sum(vllm:num_requests_waiting)",
"legendFormat": "En attente"
}, {
"expr": "sum(vllm:num_requests_running)",
"legendFormat": "En cours"
}],
"alert": {
"conditions": [{
"evaluator": {"type": "gt", "params": [50]},
"query": {"params": ["A", "1m", "now"]}
}]
}
}
Dashboard 2 : GPU Health (DCGM)
// Panel : VRAM par GPU (Bar gauge)
{
"title": "VRAM utilisée par GPU",
"targets": [{
"expr": "DCGM_FI_DEV_FB_USED{pod=~\"vllm.*\"} / 1024",
"legendFormat": "{{ gpu }} - {{ pod }}"
}],
"unit": "decgbytes"
}
// Panel : Température GPU (Gauge)
{
"title": "Température GPU (°C)",
"targets": [{
"expr": "DCGM_FI_DEV_GPU_TEMP",
"legendFormat": "GPU {{ gpu }}"
}],
"thresholds": {"steps": [
{"value": 0, "color": "green"},
{"value": 75, "color": "yellow"},
{"value": 83, "color": "red"}
]}
}
// Panel : Power consumption (Time series)
{
"title": "Consommation électrique GPU",
"targets": [{
"expr": "sum(DCGM_FI_DEV_POWER_USAGE)",
"legendFormat": "Total (W)"
}],
"unit": "watt"
}
Dashboard 3 : Business Metrics
// Panel : Coût par département (Table)
{
"title": "Coût IA par département (mois en cours)",
"targets": [{
"expr": "sum by (department) (increase(llm_token_cost_eur_total[30d]))",
"legendFormat": "{{ department }}",
"instant": true
}],
"transformations": [{"id": "sortBy", "options": {"fields": [{"displayName": "Value", "desc": true}]}}]
}
// Panel : Satisfaction utilisateur moyenne
{
"title": "Score satisfaction moyen (1-5)",
"targets": [{
"expr": "sum(rate(llm_user_satisfaction_score_sum[24h])) / sum(rate(llm_user_satisfaction_score_count[24h]))",
"legendFormat": "Score moyen"
}]
}
Alertes critiques de production
# llm-alerting-rules.yaml
apiVersion: monitoring.coreos.com/v1
kind: PrometheusRule
metadata:
name: llm-production-alerts
namespace: monitoring
spec:
groups:
- name: llm.slo
interval: 30s
rules:
# SLO : TTFT P95 > 500ms pendant 3 minutes
- alert: LLMHighTTFT
expr: |
histogram_quantile(0.95,
sum(rate(vllm:time_to_first_token_seconds_bucket[5m])) by (le)
) > 0.5
for: 3m
labels:
severity: warning
team: llm-ops
annotations:
summary: "TTFT P95 > 500ms — dégradation performance LLM"
description: "TTFT P95 actuel : {{ $value | humanizeDuration }}"
runbook: "https://wiki.interne/runbooks/llm-high-latency"
# SLO : Taux d'erreur > 1%
- alert: LLMHighErrorRate
expr: |
rate(vllm:request_success_total{finished_reason="error"}[5m]) /
rate(vllm:request_success_total[5m]) > 0.01
for: 2m
labels:
severity: critical
annotations:
summary: "Taux d'erreur LLM > 1%"
description: "Taux actuel : {{ $value | humanizePercentage }}"
# GPU : VRAM critique > 95%
- alert: GPUVRAMCritical
expr: |
(DCGM_FI_DEV_FB_USED / DCGM_FI_DEV_FB_TOTAL) > 0.95
for: 1m
labels:
severity: critical
annotations:
summary: "VRAM GPU > 95% — OOM imminent sur {{ $labels.instance }}"
description: "GPU {{ $labels.gpu }} : {{ $value | humanizePercentage }} VRAM utilisée"
runbook: "https://wiki.interne/runbooks/gpu-oom"
# GPU : Erreur matérielle XID
- alert: GPUXIDError
expr: DCGM_FI_DEV_XID_ERRORS > 0
for: 0m
labels:
severity: critical
page: "true"
annotations:
summary: "ERREUR MATÉRIELLE GPU — XID {{ $value }} sur {{ $labels.instance }}"
description: "Intervention hardware requise immédiatement"
# Queue trop longue > 100 requêtes en attente
- alert: LLMQueueOverload
expr: sum(vllm:num_requests_waiting) > 100
for: 2m
labels:
severity: warning
annotations:
summary: "Queue LLM surchargée : {{ $value }} requêtes en attente"
description: "Considérer le scale-up horizontal ou le rate limiting renforcé"
# KV Cache presque plein
- alert: LLMKVCacheFull
expr: vllm:gpu_cache_usage_perc > 90
for: 5m
labels:
severity: warning
annotations:
summary: "KV Cache GPU > 90% — risque de dégradation"
# Pod vLLM redémarre fréquemment
- alert: LLMPodCrashLooping
expr: |
increase(kube_pod_container_status_restarts_total{
namespace="llm-production", container="vllm"
}[15m]) > 2
for: 0m
labels:
severity: critical
annotations:
summary: "Pod vLLM en crash loop — {{ $labels.pod }}"
# GPU surchauffé
- alert: GPUHighTemperature
expr: DCGM_FI_DEV_GPU_TEMP > 83
for: 3m
labels:
severity: warning
annotations:
summary: "GPU {{ $labels.gpu }} à {{ $value }}°C — risque de throttling"
Distributed tracing : OpenTelemetry + Jaeger
Le distributed tracing est indispensable pour comprendre la chaîne complète d'une requête LLM : Kong Gateway → LangChain → vLLM → Milvus vectorDB. Sans tracing, il est impossible de localiser où une requête lente perd du temps.
# otel_llm_middleware.py — Instrumentation OpenTelemetry
from opentelemetry import trace
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.instrumentation.httpx import HTTPXClientInstrumentor
# Configuration OpenTelemetry → Jaeger
tracer_provider = TracerProvider()
span_processor = BatchSpanProcessor(
OTLPSpanExporter(endpoint="http://jaeger-collector:4317")
)
tracer_provider.add_span_processor(span_processor)
trace.set_tracer_provider(tracer_provider)
# Auto-instrumentation HTTPX (appels vers vLLM)
HTTPXClientInstrumentor().instrument()
tracer = trace.get_tracer("llm-orchestrator")
async def rag_pipeline_with_tracing(query: str, user_id: str):
"""Pipeline RAG complète avec spans de tracing"""
with tracer.start_as_current_span("rag-pipeline") as root_span:
root_span.set_attribute("user.id", user_id)
root_span.set_attribute("query.length", len(query))
# Span : Embedding de la question
with tracer.start_as_current_span("embed-query") as embed_span:
embedding = await embed_text(query)
embed_span.set_attribute("embedding.dims", len(embedding))
# Span : Recherche vectorielle
with tracer.start_as_current_span("vector-search") as search_span:
docs = await milvus_search(embedding, top_k=5)
search_span.set_attribute("results.count", len(docs))
search_span.set_attribute("vector.db", "milvus")
# Span : Génération LLM
with tracer.start_as_current_span("llm-generate") as llm_span:
context = "\n".join([d.content for d in docs])
response = await call_vllm(query, context)
llm_span.set_attribute("llm.model", "elodie-32b")
llm_span.set_attribute("llm.tokens.prompt", response.usage.prompt_tokens)
llm_span.set_attribute("llm.tokens.completion", response.usage.completion_tokens)
return response
Logs structurés et corrélation trace_id
# structured_logger.py — Logging JSON avec corrélation OpenTelemetry
import json
import logging
from datetime import datetime
from opentelemetry import trace
class LLMStructuredLogger:
def __init__(self, service_name: str):
self.service = service_name
self.logger = logging.getLogger(service_name)
def log_request(self, level: str, event: str, **kwargs):
# Récupérer le trace_id OpenTelemetry courant
current_span = trace.get_current_span()
ctx = current_span.get_span_context()
log_entry = {
"timestamp": datetime.utcnow().isoformat() + "Z",
"level": level.upper(),
"service": self.service,
"event": event,
"trace_id": format(ctx.trace_id, '032x') if ctx.is_valid else None,
"span_id": format(ctx.span_id, '016x') if ctx.is_valid else None,
**kwargs
}
print(json.dumps(log_entry, ensure_ascii=False))
# Usage
logger = LLMStructuredLogger("vllm-orchestrator")
# Log complet d'une requête LLM
logger.log_request(
"info", "llm_request_completed",
user_id="jean.dupont@company.fr",
department="finance",
model="elodie-32b",
prompt_tokens=312,
completion_tokens=847,
latency_ms=1832,
queue_wait_ms=45,
ttft_ms=187,
finish_reason="stop"
)
# Output:
# {"timestamp": "2026-02-25T14:32:17Z", "level": "INFO",
# "trace_id": "4bf92f3577b34da6a3ce929d0e0e4736",
# "user_id": "jean.dupont@company.fr", "latency_ms": 1832, ...}
Incident response playbook LLM
#!/bin/bash
# llm-incident-response.sh — Playbook automatisé
# Déclenché par AlertManager webhook
ALERT_NAME="$1"
SEVERITY="$2"
case "$ALERT_NAME" in
"GPUVRAMCritical")
echo "[$(date -u)] INCIDENT: GPU VRAM critique"
# 1. Snapshot de l'état actuel
kubectl top pods -n llm-production > /tmp/incident-pods-$(date +%s).txt
kubectl describe nodes > /tmp/incident-nodes-$(date +%s).txt
# 2. Réduire la queue immédiatement
kubectl patch deployment vllm-elodie-32b -n llm-production \
-p '{"spec":{"template":{"spec":{"containers":[{"name":"vllm","args":["--max-num-seqs","64"]}]}}}}'
# 3. Alerter PagerDuty si pas déjà fait
curl -X POST https://events.pagerduty.com/v2/enqueue \
-H 'Content-Type: application/json' \
-d "{\"routing_key\":\"${PD_KEY}\",\"event_action\":\"trigger\",\
\"payload\":{\"summary\":\"GPU VRAM critique\",\"severity\":\"critical\"}}"
;;
"LLMPodCrashLooping")
echo "[$(date -u)] INCIDENT: Pod vLLM crash loop"
# 1. Capturer les logs du pod mourant
POD=$(kubectl get pods -n llm-production -l app=vllm \
--field-selector=status.phase!=Running -o name | head -1)
kubectl logs -n llm-production "$POD" --previous \
> /tmp/crashloop-logs-$(date +%s).txt 2>&1
# 2. Vérifier les erreurs CUDA
if grep -q "CUDA out of memory" /tmp/crashloop-logs-*.txt; then
echo "OOM CUDA détecté — réduction gpu-memory-utilization"
kubectl set env deployment/vllm-elodie-32b -n llm-production \
VLLM_GPU_MEMORY_UTILIZATION=0.80
fi
;;
esac
Ce qu'il faut retenir
- Les 4 SLIs essentiels d'un LLM en production : TTFT P95, taux d'erreur, GPU VRAM utilization, queue depth — monitorer en permanence avec des alertes sur seuils.
- DCGM Exporter en DaemonSet est obligatoire pour la visibilité GPU : VRAM, température, XID errors et consommation électrique.
- Le distributed tracing OpenTelemetry est la seule façon de savoir où une requête lente perd du temps (embedding, search vectorielle, génération).
- La corrélation trace_id dans tous les logs JSON permet de reconstruire précisément le parcours d'une requête problématique.
- Un playbook incident automatisé (AlertManager webhook → script) réduit le MTTR de 60 à 15 minutes en moyenne.
Stack d'observabilité LLM complète
Nos équipes déploient et configurent votre monitoring LLM complet — Prometheus, Grafana, DCGM, OpenTelemetry, alertes PagerDuty — avec des dashboards calibrés sur vos SLOs métier réels.
Déployer mon monitoring LLM →FAQ
Quelle est la différence entre TTFT et latence E2E ?
Le TTFT (Time To First Token) mesure le délai entre l'envoi de la requête et la réception du premier token de réponse — c'est la latence perçue par l'utilisateur dans une interface streaming. La latence E2E est le temps total jusqu'à la fin de la génération. Pour une UX satisfaisante, optimisez d'abord le TTFT (préférez un TTFT P95 < 300ms) : un utilisateur peut attendre que la réponse se génère progressivement, mais pas que l'interface reste blanche pendant 3 secondes.
Comment détecter les hallucinations automatiquement ?
La détection automatique des hallucinations passe par trois approches : (1) self-consistency : poser la même question plusieurs fois et mesurer la variance des réponses ; (2) RAG grounding score : vérifier que la réponse peut être ancrée dans les documents sources récupérés ; (3) modèle de détection dédié (ex. SelfCheckGPT). En pratique, implémentez le RAG grounding score dans votre pipeline LangChain et exposez la métrique llm_hallucination_risk_score à Prometheus pour alerter sur les dérives.
À quelle fréquence scraper les métriques vLLM ?
Configurez scrape_interval: 10s pour les métriques vLLM (TTFT, queue, KV cache) — elles évoluent rapidement et une résolution de 15-30s masque les pics. Pour DCGM (GPU metrics), 15s est suffisant. Pour les métriques business (coût, satisfaction), 30-60s est adapté. Attention : un scrape_interval trop court sur un cluster Prometheus sans SSD peut créer de la pression I/O. Utilisez le remote_write vers Thanos ou Mimir pour la rétention longue durée.