ITSkillsCenter
Intelligence Artificielle

Déployer un agent vocal en production : Kubernetes et LiveKit Cloud

9 دقائق للقراءة

📍 Guide principal de la série : Agents vocaux IA en 2026 : architecture, modèles, latence.

Un agent vocal qui tourne sur la machine du développeur n’est pas un agent en production. Le passage à l’échelle ajoute trois exigences que le code initial ignore : auto-scaling sur les pics de sessions concurrentes, observabilité de bout en bout (latence, qualité, coût), et stratégie de déploiement zero-downtime sur les sessions vocales longues. Ce tutoriel construit un déploiement complet sur Kubernetes auto-hébergé, puis montre l’alternative LiveKit Cloud, et instrumente l’ensemble avec OpenTelemetry et un alerting sur la latence end-to-end.

Prérequis

  • Worker LiveKit Agents fonctionnel en local (voir les tutoriels précédents)
  • Docker et un registre privé (GHCR, Docker Hub, registre cloud)
  • Pour Kubernetes : kubectl, Helm 3.x, un cluster K8s 1.29+ avec Ingress et cert-manager
  • Pour LiveKit Cloud : un projet créé et un plan payant pour l’auto-scaling
  • Connaissance basique de Kubernetes et OpenTelemetry
  • Temps estimé : 2-3 heures

Étape 1 — Arbitrer entre LiveKit Cloud et Kubernetes self-hosted

LiveKit Cloud offre l’auto-scaling, le placement géographique des sessions et l’observabilité native sans aucun code ops. Le tarif officiel LiveKit Cloud est de 0,01 USD par minute de session active plus le coût des modèles utilisés. Le self-hosted Kubernetes coûte moins en flux mais demande 3 à 4 semaines d’ingénierie pour atteindre la parité fonctionnelle (TURN server, autoscaling, observabilité, déploiement progressif).

Critère LiveKit Cloud Kubernetes self-hosted
Time-to-production 1-2 jours 3-4 semaines
Coût par minute 0,01 USD + LLM 0,002-0,004 USD + LLM
Placement géographique Auto, par région Manuel (clusters multi-régions)
Observabilité Dashboards inclus À construire (Grafana, OpenTelemetry)
Déploiement zero-downtime Natif (graceful drain) À implémenter
Conformité données EU Région EU disponible Maîtrise totale

La règle pratique : démarrer sur LiveKit Cloud, migrer vers Kubernetes uniquement quand le coût par minute devient un facteur déterminant ou que la conformité exige un contrôle complet de l’infrastructure. Les deux étapes suivantes couvrent les deux chemins.

Étape 2 — Containeriser le worker agent

Quel que soit le destination, on commence par un conteneur Docker reproductible. La structure minimale tient en un Dockerfile et un fichier requirements.txt figé.

# requirements.txt
livekit-agents[openai,silero,turn-detector]~=1.5
python-dotenv
opentelemetry-api
opentelemetry-sdk
opentelemetry-instrumentation-asyncio
# Dockerfile
FROM python:3.12-slim
WORKDIR /app

RUN apt-get update \
    && apt-get install -y --no-install-recommends ffmpeg libsndfile1 \
    && rm -rf /var/lib/apt/lists/*

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

# Pré-télécharger Silero VAD et le turn-detector
RUN python -c "from livekit.plugins import silero; silero.VAD.load()"
RUN python -c "from livekit.plugins.turn_detector.multilingual import MultilingualModel; MultilingualModel()"

COPY worker.py .
ENV PYTHONUNBUFFERED=1
CMD ["python", "worker.py", "start"]

Trois optimisations à noter. Le pré-téléchargement des modèles dans l’image évite que chaque pod télécharge 200 Mo au démarrage, ce qui réduit le temps de démarrage à froid de 30 à 5 secondes. PYTHONUNBUFFERED=1 est indispensable pour que les logs sortent en temps réel dans kubectl logs. La commande python worker.py start (et non dev) lance le worker en mode production avec gestion des signaux SIGTERM pour le graceful shutdown.

Étape 3 — Déployer sur LiveKit Cloud

LiveKit Cloud propose deux modes pour héberger ses agents : Agent Cloud (pour les workers managés directement par LiveKit) et l’auto-hébergement classique avec connexion au backend Cloud. Le premier est le plus rapide à mettre en place.

# livekit.toml — généré par "lk agent create"
[agent]
name    = "voice-aida"
project = "votre-projet-livekit"
image   = "ghcr.io/votre-org/voice-aida:1.0.0"

[agent.env]
OPENAI_API_KEY     = { secret = "openai-key" }
ELEVENLABS_API_KEY = { secret = "elevenlabs-key" }

[agent.scaling]
min_replicas = 1
max_replicas = 50
concurrent_jobs_per_replica = 8
lk agent deploy   # lit livekit.toml dans le répertoire courant

Le CLI lk est l’outil officiel LiveKit, installable depuis livekit.io/cli. Le déploiement push l’image, configure les secrets et déclare le worker auprès du backend Cloud. L’auto-scaling se base sur le nombre de jobs concurrents : avec concurrent_jobs_per_replica = 8, un pod supporte 8 sessions vocales simultanées avant qu’un nouveau pod soit créé. Le placement géographique est automatique : LiveKit choisit la région la plus proche du client WebRTC pour minimiser la latence.

Étape 4 — Déployer sur Kubernetes (alternative self-hosted)

Pour le self-hosted, on déploie le worker comme un Deployment classique. Pas de service exposé en HTTP : le worker se connecte sortant au backend LiveKit (Cloud ou self-hosted), il n’a pas besoin d’Ingress.

# k8s/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: voice-agent
spec:
  replicas: 2
  selector:
    matchLabels:
      app: voice-agent
  template:
    metadata:
      labels:
        app: voice-agent
    spec:
      terminationGracePeriodSeconds: 600  # session vocale moyenne
      containers:
      - name: agent
        image: ghcr.io/votre-org/voice-aida:1.0.0
        env:
        - name: LIVEKIT_URL
          valueFrom:
            secretKeyRef:
              name: lk-secrets
              key: url
        - name: LIVEKIT_API_KEY
          valueFrom:
            secretKeyRef:
              name: lk-secrets
              key: api_key
        - name: LIVEKIT_API_SECRET
          valueFrom:
            secretKeyRef:
              name: lk-secrets
              key: api_secret
        - name: OPENAI_API_KEY
          valueFrom:
            secretKeyRef:
              name: openai-secret
              key: api_key
        resources:
          requests:
            cpu:    "500m"
            memory: "1Gi"
          limits:
            cpu:    "2000m"
            memory: "3Gi"

Deux points cruciaux. terminationGracePeriodSeconds: 600 donne 10 minutes au worker pour terminer les sessions en cours quand le pod est terminé (rolling update, scale-down). Sans cette valeur, K8s tue le pod après 30 secondes par défaut, ce qui coupe les conversations en cours. Les resources dépendent du modèle utilisé : un agent en pipeline OpenAI consomme peu (CPU 0,3-0,7, RAM 1 Gi par session) ; un agent avec faster-whisper local exige du GPU et des limites bien plus larges.

Étape 5 — Auto-scaling horizontal sur les sessions actives

Le HPA (Horizontal Pod Autoscaler) classique sur CPU ne convient pas aux agents vocaux : la charge CPU varie peu, ce qui compte est le nombre de sessions en cours. On expose donc une métrique custom active_sessions et on scale dessus.

from prometheus_client import Gauge, start_http_server

active_sessions = Gauge("voice_agent_active_sessions",
                        "Nombre de sessions vocales actives")
start_http_server(9100)

@session.on("session_started")
def _start(ev): active_sessions.inc()
@session.on("session_ended")
def _end(ev):   active_sessions.dec()
# k8s/hpa.yaml
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata: { name: voice-agent }
spec:
  scaleTargetRef: { apiVersion: apps/v1, kind: Deployment, name: voice-agent }
  minReplicas: 2
  maxReplicas: 50
  metrics:
  - type: Pods
    pods:
      metric: { name: voice_agent_active_sessions }
      target: { type: AverageValue, averageValue: "6" }
  behavior:
    scaleDown:
      stabilizationWindowSeconds: 600   # patient sur le scale-down

Avec averageValue: "6", K8s vise 6 sessions actives par pod. Au-delà, il scale up. La fenêtre de stabilisation à 10 minutes sur le scale-down évite les remontées en yo-yo : on laisse les sessions vocales se terminer naturellement plutôt que de retirer brutalement un pod. Le metric-server doit être complété par Prometheus Adapter pour exposer les métriques custom au HPA — c’est le seul prérequis cluster-side de cette configuration.

Étape 6 — Tracing OpenTelemetry de bout en bout

Une session vocale touche cinq composants minimum (transport, STT, LLM, TTS, base RAG). Pour diagnostiquer où la latence se passe, le tracing distribué est obligatoire. OpenTelemetry instrumente Python avec quelques lignes et propage les trace_id à travers les appels API.

from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter

trace.set_tracer_provider(TracerProvider())
trace.get_tracer_provider().add_span_processor(
    BatchSpanProcessor(OTLPSpanExporter(endpoint="otel-collector:4317", insecure=True))
)
tracer = trace.get_tracer("voice-agent")

@session.on("user_speech_committed")
def _on_user(ev):
    span = tracer.start_span("turn", attributes={
        "user.transcript": ev.transcript[:200],
    })
    session._span = span

@session.on("agent_speech_committed")
def _on_agent(ev):
    if hasattr(session, "_span"):
        session._span.set_attribute("agent.response", ev.transcript[:200])
        session._span.end()

Le collector OpenTelemetry tourne en sidecar ou en DaemonSet K8s et reçoit les spans en gRPC sur le port 4317. Le backend de stockage (Tempo, Jaeger, Honeycomb) reçoit ensuite les traces et permet de zoomer sur un tour précis pour voir où les millisecondes se sont consumées. Les attributs user.transcript et agent.response sont précieux pour retrouver une trace à partir d’un cas litigieux remonté par le métier.

Étape 7 — Logs structurés et corrélation par session

Les logs texte ne se cherchent pas à grande échelle. On émet du JSON structuré avec un session_id et un turn_id sur chaque ligne, ce qui rend trivial le filtrage par session dans Loki, Datadog ou Cloudwatch.

import structlog

structlog.configure(
    processors=[
        structlog.processors.add_log_level,
        structlog.processors.TimeStamper(fmt="iso"),
        structlog.processors.JSONRenderer(),
    ]
)
logger = structlog.get_logger()

async def entrypoint(ctx):
    log = logger.bind(session_id=ctx.room.name)
    log.info("session_started", region=ctx.room.metadata)
    ...
    log.info("turn_complete", latency_ms=830, eou_score=0.78)

Le pattern bind de structlog ajoute automatiquement session_id à chaque log de la session, sans avoir à le passer en argument partout. Avec un index Grafana Loki sur le champ session_id, on retrouve toute la timeline d’un appel litigieux en une requête, croisée avec les traces OpenTelemetry du même trace_id.

Étape 8 — Alerting sur la latence end-to-end

L’indicateur opérationnel principal est la latence end-to-end (du speech_stopped côté utilisateur au premier audio sortant de l’agent). On l’expose en histogramme Prometheus et on alerte sur un percentile 95 qui dérive.

from prometheus_client import Histogram

latency_hist = Histogram(
    "voice_agent_e2e_latency_ms",
    "Latence end-to-end par tour, en ms",
    buckets=[200, 400, 600, 800, 1000, 1500, 2000, 3000],
)

@session.on("agent_first_audio")
def _first(ev):
    if hasattr(session, "_t0"):
        latency_hist.observe((time.perf_counter() - session._t0) * 1000)
# prometheus-rule.yaml
groups:
- name: voice-agent
  rules:
  - alert: VoiceAgentLatencyP95High
    expr: |
      histogram_quantile(0.95,
        sum(rate(voice_agent_e2e_latency_ms_bucket[5m])) by (le)
      ) > 1100
    for: 10m
    labels: { severity: warning }
    annotations:
      summary: "Latence p95 > 1100 ms depuis 10 min"

Le seuil de 1 100 ms sur le p95 correspond au moment où la conversation devient perceptiblement lente pour 5 % des tours — c’est l’indicateur avancé d’une dégradation produit. L’alerte se branche sur Alertmanager + un canal d’astreinte. En complément, surveiller le percentile 99 sur le coût par tour : un coup de pouce inattendu signale presque toujours un cache désactivé ou un contexte conversationnel qui ne se trim plus.

Erreurs fréquentes

Symptôme Cause Solution
Pods tués au milieu d’appels terminationGracePeriodSeconds trop court Mettre à 600 s minimum
HPA qui ne scale jamais Prometheus Adapter pas installé Helm chart prometheus-adapter
Latence qui dérive en pic LLM trop chargé côté provider Failover sur région secondaire
Conteneur qui démarre lentement Modèles téléchargés au boot Pré-télécharger dans le Dockerfile
Coût d’inférence non visible Pas d’instrumentation token usage Histogramme Prometheus par modèle
Sessions limitées par worker Pas de pool, asyncio bloquant Vérifier concurrent_jobs_per_replica

Sur un angle proche

Sponsoriser ce contenu

Cet emplacement est à vous

Position premium en fin d'article — c'est l'instant où les lecteurs sont le plus engagés. Réservez cet espace pour votre marque, votre formation ou votre offre.

Recevoir nos tarifs
Publicité