Solution française • Hébergement souverain • Conformité européenne Blog IA souveraine

Observabilité LLM en production — Prometheus, Grafana et alertes métier

Un LLM en production sans observabilité est une boîte noire. Vous ne savez pas si la latence monte, si la VRAM sature, si les utilisateurs abandonnent leurs sessions ou si le modèle commence à halluciner. Cette architecture d'observabilité complète couvre les métriques GPU (DCGM), les métriques applicatives vLLM, les indicateurs business, et les alertes critiques avec des seuils de production réels.

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étriqueTypeDescriptionSLO cible
vllm:time_to_first_token_secondsHistogramTemps avant le premier token généré (TTFT)P95 < 500ms
vllm:time_per_output_token_secondsHistogramTemps entre deux tokens consécutifsP95 < 50ms
vllm:e2e_request_latency_secondsHistogramLatence totale de la requêteP95 < 30s
vllm:num_requests_runningGaugeRequêtes en cours d'inférence< max_num_seqs
vllm:num_requests_waitingGaugeRequêtes en attente dans la queue< 50
vllm:gpu_cache_usage_percGaugeUtilisation du KV cache GPU (%)< 90%
vllm:prompt_tokens_totalCounterTotal tokens d'entrée traités
vllm:generation_tokens_totalCounterTotal tokens générés
vllm:request_success_totalCounterRequêtes réussiesError rate < 0.1%
<200msTTFT P95 cible production
<0.1%Taux d'erreur acceptable
<90%KV cache utilisation max
4SLIs essentiels à monitorer

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.