Skip to content

Voice Agent Latency — Tenir le budget sub-1s (et parfois sub-700ms)

TL;DR — Un voicebot qui dépasse 1.2 s de latence perçue perd 30 % de complétion. Le budget se découpe : STT final (200 ms après silence) + LLM TTFT (300-500 ms) + TTS first chunk (75-200 ms) = 575-900 ms. Pour descendre sous 700 ms (drive-thru, urgences) : prompt caching Anthropic (-85 % TTFT), speculative TTS (parler avant la fin de la génération), parallel RAG + LLM, Haiku 4.5 routing sur intents simples, edge deployment (région eu-west-3 Paris ou OVH Roubaix), prefetch d'amorces TTS pré-rendues. Instrumentation OpenTelemetry obligatoire. TJM optim voicebot existant : 1 400-1 500 €/j sur 4-8 semaines de tuning ciblé.


🧠 Mental model

Timeline d'une réponse vocale réussie (target 700 ms perçus) :

T+0 ms     ─ user finit de parler (last audio sample)
T+0-200    ─ silence detection (endpoint VAD attend 200 ms ferme)
T+200      ─ STT envoie is_final=true (transcript prêt)
T+205      ─ orchestrator déclenche LLM avec prompt cache hit
T+450      ─ LLM TTFT (premier token "Bonjour")
T+460      ─ buffering jusqu'au point ou 12 mots
T+580      ─ première phrase complète, TTS WebSocket envoie chunk 1
T+655      ─ TTS first audio chunk arrive client (TTFB 75ms)
T+700      ─ ▶ AUDIO COMMENCE À JOUER
T+1500     ─ TTS chunk 2, 3, 4… en streaming continu
T+3200     ─ fin de la réponse, attente prochaine entrée user

      ════ perçu = 700 ms (excellent) ════

Analogie — Un voicebot c'est un comédien d'improvisation : entre la fin de la question et le premier mot de sa réponse, il faut moins d'une seconde. Pendant qu'il commence à dire « Bonjour Madame Dupont… » il prépare déjà mentalement la suite, qu'il prononcera pendant que l'orchestre (TTS) joue les premières notes. Si chaque musicien attend que le précédent ait fini sa phrase, c'est mort.

Trois leviers principaux

                  STT          LLM           TTS
  ─────────────────────────────────────────────────────
  Modèle plus rapide    │  Nova-3       Haiku 4.5    Flash v2.5
  Cache / réutilisation │  partial       prompt-cache  amorces
  Parallélisme          │  multi-track   tools-parallel chunks
  Edge / région         │  same region   eu-west-3     eu-paris
  Skip / shortcut       │  VAD pre-roll  router        cache

Comment un staff engineer raisonne sur le budget

1. La latence perçue n'est pas la latence end-to-end. Ce qui compte, c'est le délai entre « l'utilisateur a fini de parler » et « le premier sample audio joue ». Tout ce qui se passe après le début de l'audio (génération LLM restante, TTS des phrases 2-N, tool calls) est masqué par la lecture. D'où la règle d'or : commencer à parler le plus tôt possible, finir de penser pendant qu'on parle. Un voicebot à 1 800 ms end-to-end mais 250 ms perçus bat un voicebot à 900 ms end-to-end mais 900 ms perçus.

2. Le budget est additif et impitoyable — il faut un P95, pas une moyenne. Optimiser la moyenne est un piège : c'est le P95/P99 qui crée l'abandon (le client qui raccroche est toujours sur la queue de distribution). Chaque maillon doit être tenu au P95, et les budgets s'additionnent :

MaillonLevier dominantBudget P95 cibleQui le casse
Endpointing (VAD)seuil de silence adaptatif200-400 msseuil figé, parole hésitante
STT finalmodèle + région co-localisée240-290 mscross-region, modèle lourd
LLM TTFTprompt cache + Haiku routing95-180 ms (cache hit)cache miss, system volatile
TTS TTFBFlash v2.5 + optimizeStreamingLatency75-145 mscold WebSocket, qualité max
Réseau / orchestrationpooling HTTP/2, même VPC8-30 mshandshake TLS répété

3. Le thinking est un coût de latence, pas gratuit. adaptive thinking (thinking: {type: "adaptive"}) améliore la qualité du raisonnement mais rallonge le TTFT : le modèle réfléchit avant le premier token visible. Sur un drive-thru sub-700 ms, on désactive le thinking sur les intents simples (le routing Haiku ne pense pas) et on le réserve aux tours complexes (triage médical, litige) où 1,5-5 s sont acceptables. Sur ces tours, output_config.effort (low/medium/high) règle finement le compromis profondeur ↔ latence. Ne jamais mettre thinking enabled par défaut sur un chemin latence-critique.

4. Tier de modèle ≠ tier de qualité partout. Le réflexe « toujours Sonnet pour la qualité » coûte ~2× le TTFT de Haiku. Le bon design : router par intent. 70-85 % des tours (saluer, prendre une commande, confirmer) sont triviaux → Haiku 4.5. Le reste (ambiguïté, escalade, raisonnement) → Sonnet 4.6. Le routing lui-même doit être moins cher que ce qu'il économise : un classifieur Haiku à 80 ms qui évite 200 ms de Sonnet inutile est rentable ; un classifieur Sonnet à 380 ms ne l'est pas. Pour les tours de bout en bout les plus durs (litige multi-tours, triage médical), claude-opus-4-8 (flagship, 5 $ / 25 $ par M tok à 1M de contexte) reste réservé au back-office asynchrone, jamais sur le chemin vocal chaud — son TTFT le disqualifie pour le temps réel.

5. La latence est un budget d'erreur, pas seulement de vitesse. Chaque optim agressive achète des millisecondes contre du risque : le speculative TTS gagne ~400 ms mais peut produire un cringe (amorce qui contredit la réponse), optimizeStreamingLatency: 4 gagne ~40 ms de TTFB mais dégrade la prosodie, un endpointing serré gagne 200 ms mais coupe la parole hésitante. Un staff engineer ne raisonne pas « plus vite = mieux » mais « combien de risque j'achète par milliseconde, et sur quel percentile ça se paie ». La bonne question en revue de design : « quel est le pire échec de cette optim, à quelle fréquence, et est-il réversible dans le tour ? »


✂️ Barge-in — la latence inverse qu'on oublie

Le budget sub-1s mesure « combien de temps avant que le bot parle ». Mais la latence ressentie comme la plus frustrante est l'inverse : combien de temps le bot met à se taire quand l'utilisateur l'interrompt. Un humain s'arrête en ~200 ms quand on lui coupe la parole ; un voicebot qui continue son monologue 1,5 s après un « non, attendez » est perçu comme sourd, peu importe son TTFT.

Le barge-in impose trois contraintes que les pipelines naïfs ratent :

Sous-budget barge-inCible P95Ce qui le casse
VAD détecte la voix user pendant le TTS< 150 msVAD désactivé pendant la lecture (half-duplex)
Annulation du stream TTS + LLM en cours< 100 mspas d'AbortController, on attend la fin du tour
Vidage du buffer audio déjà envoyé au client< 50 msjitter buffer côté client non purgeable
ts
// barge-in : annuler proprement le tour en cours dès que le VAD détecte la voix user
const ac = new AbortController();

const stream = await anthropic.messages.stream(
  { model: "claude-haiku-4-5", max_tokens: 120, system: [/* ... */], messages },
  { signal: ac.signal }, // le SDK propage l'abort → coupe la connexion HTTP, pas de tokens facturés en plus
);

vad.on("user_speech_start", () => {
  ac.abort();          // 1. stoppe la génération LLM (et donc le pipeline TTS en aval)
  ttsStream.cancel();  // 2. coupe le WebSocket TTS provider
  ws.send(FLUSH_AUDIO); // 3. signale au client de purger son jitter buffer
});

Piège full-duplex. En half-duplex (micro coupé pendant que le bot parle), le barge-in est impossible — vous ne pouvez pas entendre l'interruption. Le sub-700 ms en aller n'a aucune valeur si le retour coûte 1,5 s. Exigez un transport full-duplex (LiveKit, WebRTC) et un echo cancellation correct, sinon le « gain latence » est une illusion mesurée sur le mauvais axe.


🛠️ Code minimal

Prompt caching Anthropic (Sonnet 4.6 / Haiku 4.5)

ts
import Anthropic from "@anthropic-ai/sdk";

// Serveur de prod : un seul client réutilisé (connection pool + HTTP/2),
// retries SDK avec backoff exponentiel, timeout par appel agressif sur le chemin chaud.
const anthropic = new Anthropic({
  maxRetries: 2,          // 429 / 5xx / 529 retentés automatiquement avec backoff
  timeout: 8_000,         // 8 s hard : un voicebot ne doit JAMAIS pendre 60 s
});

const SYSTEM = `Tu es un agent de réservation pour le restaurant Le Septime à Paris.
[... 2 000 tokens de règles, menu, politique d'annulation ...]`;

const resp = await anthropic.messages.create({
  model: "claude-haiku-4-5",
  max_tokens: 250,
  system: [
    // ⚠️ Le système DOIT être byte-for-byte identique d'un appel à l'autre.
    // Aucune interpolation volatile ici (date, user_id, timestamp) → sinon préfixe
    // invalidé et cache_read = 0. Le contexte volatile va APRÈS, dans messages.
    { type: "text", text: SYSTEM, cache_control: { type: "ephemeral" } },
  ],
  messages: [{ role: "user", content: "Une table pour 4 vendredi 20h ?" }],
});

// 2e appel dans les 5 min (TTL ephemeral) → cache hit → TTFT -85 %, coût input ÷10.
console.log(resp.usage.cache_read_input_tokens); // > 0 = ça marche
console.log(resp.usage.input_tokens);            // = portion NON cachée (à logger pour le coût)

⚠️ Piège du minimum cachable (différent par modèle). Le préfixe ne se met en cache que s'il dépasse un seuil : 2 048 tokens sur Sonnet 4.6, mais 4 096 tokens sur Haiku 4.5. Un system prompt de 3 000 tokens cache silencieusement sur Sonnet et ne cache pas du tout sur Haiku (cache_creation_input_tokens: 0, aucune erreur). Sur un router Haiku, gonflez le préfixe (menu structuré, exemples) au-delà de 4 096 tokens ou n'attendez aucun gain.

Gestion d'erreurs typée (jamais de string-matching sur le message). Un voicebot doit dégrader, pas planter :

ts
import Anthropic from "@anthropic-ai/sdk";

try {
  const resp = await anthropic.messages.create({ /* ... */ });
} catch (err) {
  if (err instanceof Anthropic.RateLimitError) {
    // 429 : router vers une amorce pré-rendue + retry async, ne pas bloquer l'audio
  } else if (err instanceof Anthropic.OverloadedError) {
    // 529 : fallback modèle (Haiku au lieu de Sonnet) ou phrase d'attente TTS
  } else if (err instanceof Anthropic.APITimeoutError) {
    // dépassement du timeout 8 s : couper et jouer « un instant s'il vous plaît »
  } else if (err instanceof Anthropic.APIError) {
    // tout le reste : log structuré (request_id) + amorce de secours
  }
}

Speculative TTS (commencer à parler avant la fin de la génération)

ts
// speculative-tts.ts
const ASSURED_PHRASES = [
  "Bien sûr,", "Très bien,", "Un instant,", "Bonjour,",
  "D'accord,", "Je vérifie,", "Je note,",
];

async function speculativePipeline(userText: string, tts: TTSStream) {
  // 1. micro-LLM ultra-rapide pour deviner si on peut amorcer
  const intent = await classifyIntent(userText); // Haiku 4.5, 80ms
  const starter = pickStarter(intent);           // "Bien sûr," ou "Je vérifie,"

  // 2. lance TTS de l'amorce IMMÉDIATEMENT (avant LLM principal)
  const starterPromise = tts.speak(starter); // T+0

  // 3. lance le LLM principal en parallèle
  const llmStream = await anthropic.messages.stream({
    model: "claude-sonnet-4-6",
    max_tokens: 250,
    system: [{ type: "text", text: SYSTEM, cache_control: { type: "ephemeral" } }],
    messages: [{ role: "user", content: userText }],
  });

  // 4. quand starter terminé, on bascule sur le stream LLM
  await starterPromise;
  for await (const sentence of sentenceStream(llmStream)) {
    await tts.speak(sentence);
  }
}

Parallel RAG + LLM

ts
// avoid sequential: RAG → LLM
// prefer: speculative RAG started in parallel with intent classification

async function parallelRagAndLLM(userText: string) {
  // déclencher en parallèle
  const ragP = retrieveRelevantDocs(userText);     // 180ms typique
  const intentP = classifyIntent(userText);       // 80ms

  const [docs, intent] = await Promise.all([ragP, intentP]);

  // si l'intent ne nécessite pas RAG (ex: salutation), on jette les docs
  const useRag = ["lookup", "factual", "policy"].includes(intent);
  const context = useRag ? formatDocs(docs) : "";

  return anthropic.messages.stream({
    model: "claude-sonnet-4-6",
    system: [
      { type: "text", text: BASE_SYSTEM, cache_control: { type: "ephemeral" } },
      { type: "text", text: context }, // ne pas cacher (volatile)
    ],
    messages: [{ role: "user", content: userText }],
  });
}

Slot extraction structurée (sans re-prompt JSON)

Pour extraire des champs typés d'un tour (item commandé, quantité, créneau), n'imposez jamais « réponds en JSON » dans le prompt : ça coûte des tokens, casse parfois, et oblige un parse défensif. Utilisez le parsing natif du SDK (client.messages.parse() avec un schéma Zod/Pydantic, ou output_config.format) — le modèle est contraint au schéma côté serveur, le résultat arrive déjà typé.

ts
import Anthropic from "@anthropic-ai/sdk";
import { z } from "zod";

const OrderSlot = z.object({
  item_id: z.string(),
  qty: z.number().int().positive(),
  options: z.array(z.string()),
});

// extraction de slot sur le chemin chaud : Haiku, max_tokens serré, schéma contraint
const parsed = await anthropic.messages.parse({
  model: "claude-haiku-4-5",
  max_tokens: 80,
  system: [{ type: "text", text: SYS, cache_control: { type: "ephemeral" } }],
  messages: [{ role: "user", content: "deux cheeseburgers sans oignon" }],
  response_format: OrderSlot, // contraint au schéma → pas de JSON malformé à gérer
});

parsed.item_id; // typé, validé, prêt pour le POS — aucun JSON.parse fragile

Le slot structuré n'est pas sur le chemin de la réponse vocale (il ne produit pas de phrase à dire), donc sa latence est masquée par l'audio en cours : lancez-le en parallèle (Promise.all) du streaming TTS de la phrase de confirmation.

Sentence-end detection sur LLM stream

ts
const SENTENCE_BOUNDARY = /([.!?])\s/;
const MIN_LEN = 14;

export async function* sentenceStream(llmStream: AsyncIterable<any>) {
  let buf = "";
  for await (const event of llmStream) {
    if (event.type !== "content_block_delta") continue;
    const delta = (event.delta as any).text;
    if (!delta) continue;
    buf += delta;
    let m: RegExpMatchArray | null;
    while ((m = buf.match(SENTENCE_BOUNDARY))) {
      const end = m.index! + m[0].length;
      if (end >= MIN_LEN) {
        yield buf.slice(0, end).trim();
        buf = buf.slice(end);
      } else break;
    }
  }
  if (buf.trim()) yield buf.trim();
}

🎬 Cas d'usage concrets

1. Restaurant — voicebot de réservation, abandon sub-1s

Contexte — Chaîne de bistros (240 restaurants FR), gère 18 000 appels/jour. Étude interne : 31 % des appelants raccrochent si l'IVR met plus de 1.5 s à répondre. Veut un voicebot rivalisant avec un humain pour la réservation.

Stack & latence cible

  • STT : Deepgram Nova-3 (latence p95 240 ms)
  • LLM : Haiku 4.5 avec prompt cache, system = 1 800 tokens (politique réservation chaîne + menu)
  • TTS : ElevenLabs Flash v2.5 (TTFB 75 ms)
  • Routing : 70 % des appels traités en sub-800 ms perçus
  • 30 % complexes (groupe > 12, allergies multiples) → escalade humaine en gardant l'audio jusqu'ici

Optimisations clés

  • Amorces pré-rendues TTS : « Bonjour, restaurant Le Petit Marché, j'écoute. » (cache 0 ms TTFB)
  • Speculative TTS : « Très bien, je vérifie nos disponibilités… » lance avant que le LLM ait fini
  • Cache OVH eu-west-3 (Paris), latence backend < 8 ms

Résultats — 78 % des réservations complétées 100 % IA. Abandon < 12 %. Coût marginal par réservation : 0,18 €.

2. Urgences médicales — triage téléphonique

Contexte — Plateforme régionale d'aide médicale (SAMU partenaire pilote), filtre les appels non-vitaux pour orienter vers conseil médical / pharmacie de garde / médecin de garde / urgences.

Stack & criticité

  • STT : WhisperX self-host GPU L4 (HDS) — latence sacrifiée à la souveraineté
  • LLM : Sonnet 4.6 avec adaptive thinking (thinking: {type: "adaptive"}) + output_config.effort: "high" pour le raisonnement médical (l'ancien budget_tokens est retiré sur la famille 4.6+ → HTTP 400)
  • TTS : Azure Neural FR (voix « Henri » officielle service public, ToS hospitalières OK)
  • Latence cible : 1.5 s sur dialogue normal, 5 s acceptable sur question complexe (« je sens une douleur thoracique qui irradie dans le bras gauche depuis 20 minutes »)
  • Si symptômes vitaux détectés (douleur thoracique + sueur + > 50 ans) → transfert SAMU prioritaire en < 2 s

Optimisations

  • Tool calls parallèles : check_symptoms + lookup_pharmacy_garde + geoloc en parallèle
  • Modèle binaire « urgence vitale ? » en arrière-plan sur chaque tour de parole (latence cachée)
  • Audit log signé chiffré + chaîne de blocs interne (audit médical)

Résultats — 62 % des appels résolus sans médecin (orientation pharmacie / conseil simple). 0 cas de sous-triage critique sur 8 mois pilote.

3. Helpdesk IT — auto-résolution avant escalade

Contexte — DSI groupe industriel (24 000 collaborateurs FR + Belgique + Maroc), service desk niveau-1 saturé. 35 % des tickets sont en réalité résolubles par un FAQ guidé (reset mot de passe, déblocage compte AD, VPN qui ne se connecte pas).

Stack & latence

  • Voicebot téléphonique (SIP) + bot Teams en parallèle (même backend)
  • LLM : Haiku 4.5 routing → Sonnet 4.6 si problème non-FAQ
  • RAG sur 4 200 articles KB internes (Confluence + ServiceNow KB)
  • Latence p50 : 850 ms, p95 1.4 s
  • Authentification SSO automatique via certificat client (pas de question identité, gain 8 secondes)

Optimisations

  • Prompt cache 18 000 tokens (toutes les procédures niveau-1 communes)
  • Pre-warm de la connexion ServiceNow API au début de l'appel
  • Tools parallèles : check_ad_status, check_vpn_logs, last_password_change

Résultats — 41 % d'auto-résolution. -2.1 ETP service desk. ROI projet 7 mois.


🛠️ Exemple end-to-end

Cas — Voicebot drive-thru pour chaîne de fast-food (McDo / Burger King / KFC), target sub-700 ms p95, instrumenté OpenTelemetry.

ts
// drive-thru.ts — voicebot fast-food, target sub-700ms p95
import Anthropic from "@anthropic-ai/sdk";
import { ElevenLabsClient } from "@elevenlabs/sdk";
import { trace, context, SpanStatusCode } from "@opentelemetry/api";
import { WebSocket } from "ws";
import { LRUCache } from "lru-cache";

const tracer = trace.getTracer("drive-thru-voicebot");
const anthropic = new Anthropic();
const el = new ElevenLabsClient({ apiKey: process.env.ELEVENLABS_KEY! });

// ─── Audio amorces pré-rendues en cache RAM ───
const PRERENDERED = new LRUCache<string, Buffer>({ max: 200 });
await preloadAmorces([
  "Bienvenue chez Burger House, je prends votre commande.",
  "Très bien,",
  "Un instant s'il vous plaît,",
  "Et avec ceci ?",
  "Sur place ou à emporter ?",
  "Voulez-vous le menu avec frites et boisson ?",
  "Ce sera tout ?",
  "Avancez à la prochaine borne, votre commande arrive.",
]);

async function preloadAmorces(texts: string[]) {
  for (const t of texts) {
    const audio = await synthesizeFull(t);
    PRERENDERED.set(t, audio);
  }
}

// ─── Cache prompt système (5 min TTL Anthropic) ───
const SYS = `Tu es l'agent vocal du drive-thru Burger House.
RÈGLES:
- Phrases ultra courtes (< 12 mots).
- Demande UNE chose à la fois (taille, sauce, boisson).
- Si client hésite > 4 secondes, propose le menu populaire.
- Détecte le total et annonce le prix à la fin.
- Ne propose JAMAIS de produits non disponibles ce jour (vérifie via tool).
MENU 2026: [...1900 tokens menu structuré...]
PROMOS DU JOUR: [...300 tokens promos rotatives...]`;

// ─── Métriques OTel ───
interface TurnMetrics {
  stt_final_at: number;
  llm_start_at: number;
  llm_first_token_at?: number;
  tts_first_chunk_at?: number;
  audio_play_start_at?: number;
}

async function handleTurn(userText: string, ws: WebSocket, sessionId: string) {
  const span = tracer.startSpan("drive-thru.turn", {
    attributes: { "session.id": sessionId, "user.text": userText },
  });

  const m: TurnMetrics = {
    stt_final_at: Date.now(),
    llm_start_at: 0,
  };

  try {
    // ── 1. Routing : intent ultra-rapide (Haiku 4.5, 80ms typique) ──
    const intent = await classifyIntent(userText);
    span.setAttribute("intent", intent);

    // ── 2. Amorce speculative SI applicable ──
    let starterPromise: Promise<void> = Promise.resolve();
    const starterKey = pickStarter(intent);
    if (starterKey && PRERENDERED.has(starterKey)) {
      const audio = PRERENDERED.get(starterKey)!;
      starterPromise = (async () => {
        const t0 = Date.now();
        ws.send(audio, { binary: true });
        if (!m.audio_play_start_at) m.audio_play_start_at = t0;
      })();
    }

    // ── 3. LLM principal en parallèle de l'amorce ──
    m.llm_start_at = Date.now();
    const stream = await anthropic.messages.stream({
      model: "claude-haiku-4-5",
      max_tokens: 120,
      system: [
        { type: "text", text: SYS, cache_control: { type: "ephemeral" } },
      ],
      messages: await buildConversation(sessionId, userText),
      tools: [
        {
          name: "check_availability",
          input_schema: {
            type: "object",
            properties: { item_id: { type: "string" } },
            required: ["item_id"],
          },
        },
        {
          name: "add_to_order",
          input_schema: {
            type: "object",
            properties: {
              item_id: { type: "string" },
              qty: { type: "number" },
              options: { type: "array", items: { type: "string" } },
            },
            required: ["item_id", "qty"],
          },
        },
        {
          name: "finalize_order",
          input_schema: { type: "object", properties: {}, required: [] },
        },
      ],
    });

    // ── 4. Sentence-level TTS streaming ──
    let buf = "";
    const SENT = /([.!?])\s/;
    for await (const ev of stream) {
      if (ev.type !== "content_block_delta") continue;
      if (!m.llm_first_token_at) m.llm_first_token_at = Date.now();
      const delta: any = ev.delta;
      if (delta.type !== "text_delta") continue;
      buf += delta.text;
      let mt: RegExpMatchArray | null;
      while ((mt = buf.match(SENT))) {
        const end = mt.index! + mt[0].length;
        if (end >= 12) {
          const sentence = buf.slice(0, end).trim();
          buf = buf.slice(end);
          await starterPromise; // s'assurer que l'amorce a fini
          await streamSentenceTTS(sentence, ws, m);
        } else break;
      }
    }
    if (buf.trim()) await streamSentenceTTS(buf.trim(), ws, m);

    // ── 5. Tools (en arrière-plan, l'audio peut continuer) ──
    const final = await stream.finalMessage();
    for (const block of final.content) {
      if (block.type === "tool_use") await runTool(sessionId, block);
    }

    // ── 6. Métriques ──
    const perceived = (m.audio_play_start_at ?? Date.now()) - m.stt_final_at;
    span.setAttributes({
      "metric.llm_ttft_ms": (m.llm_first_token_at! - m.llm_start_at),
      "metric.tts_ttfb_ms": (m.tts_first_chunk_at! - m.llm_first_token_at!),
      "metric.perceived_ms": perceived,
      "cache.input_tokens": final.usage.cache_read_input_tokens ?? 0,
    });
    span.setStatus({ code: SpanStatusCode.OK });
  } catch (err: any) {
    span.recordException(err);
    span.setStatus({ code: SpanStatusCode.ERROR, message: err.message });
    throw err;
  } finally {
    span.end();
  }
}

async function streamSentenceTTS(text: string, ws: WebSocket, m: TurnMetrics) {
  const audioStream = await el.textToSpeech.convertAsStream("brand-voice-2026", {
    text,
    modelId: "eleven_flash_v2_5",
    languageCode: "fr",
    outputFormat: "pcm_16000",
    optimizeStreamingLatency: 4, // max latency optim, accepte légère baisse qualité
  });
  let first = true;
  for await (const chunk of audioStream) {
    if (first) {
      first = false;
      if (!m.tts_first_chunk_at) m.tts_first_chunk_at = Date.now();
      if (!m.audio_play_start_at) m.audio_play_start_at = Date.now();
    }
    ws.send(chunk, { binary: true });
  }
}

async function classifyIntent(text: string): Promise<string> {
  const resp = await anthropic.messages.create({
    model: "claude-haiku-4-5",
    max_tokens: 8,
    system: "Classifie en UN MOT: ORDER, MODIFY, REMOVE, ASK_PRICE, HESITATE, FINALIZE, GREET, OTHER",
    messages: [{ role: "user", content: text }],
  });
  return ((resp.content[0] as any).text || "OTHER").trim();
}

function pickStarter(intent: string): string | null {
  switch (intent) {
    case "ORDER": return "Très bien,";
    case "ASK_PRICE": return "Un instant s'il vous plaît,";
    case "GREET": return "Bienvenue chez Burger House, je prends votre commande.";
    case "FINALIZE": return "Avancez à la prochaine borne, votre commande arrive.";
    default: return null;
  }
}

async function synthesizeFull(text: string): Promise<Buffer> {
  const chunks: Buffer[] = [];
  const stream = await el.textToSpeech.convertAsStream("brand-voice-2026", {
    text, modelId: "eleven_flash_v2_5", outputFormat: "pcm_16000",
  });
  for await (const c of stream) chunks.push(c);
  return Buffer.concat(chunks);
}

async function buildConversation(sessionId: string, userText: string) {
  // retourne historique compacté + tour courant
  return [{ role: "user" as const, content: userText }];
}

async function runTool(sessionId: string, block: any) {
  // ServiceNow / POS / order DB
}

Mesures observées (1 200 commandes drive-thru testées)

Métriquep50p95p99
STT silence → final220 ms290 ms410 ms
LLM TTFT (Haiku 4.5 + cache hit)95 ms180 ms320 ms
TTS TTFB (Flash v2.5)80 ms145 ms280 ms
Amorce speculative perçue0 ms0 ms8 ms
Perçu user (sans amorce)410 ms610 ms950 ms
Perçu user (avec amorce)0 ms0 ms8 ms

Avec speculative TTS sur intents simples (~70 % des tours), latence perçue moyenne 180 ms.


🎯 Patterns courants

  • Prompt cache layered — système figé (cache_control ephemeral) puis contexte volatile sans cache. Économie 85 % TTFT et coût input ÷10.
  • Speculative TTS sur intent classification 80 ms — démarrer une amorce safe en parallèle pour cacher la latence LLM.
  • Sentence-level chunking + TTS pipeline en queue — chaque phrase TTS commence dès que la précédente est en train de jouer.
  • Edge / région co-located — STT + LLM + TTS + base de données dans la même région (eu-west-3 ou eu-paris) : -50-120 ms vs cross-region.
  • Pre-warm tout — WebSocket TTS ouvert dès le décroché, connexion DB pool prête, modèle STT chauffé sur audio silencieux.
  • Two-tier model routing — Haiku 4.5 sur 70-85 % des intents, Sonnet 4.6 seulement quand complexité requise (détectée par confidence Haiku).
  • Adaptive endpointing — silence 400 ms après question fermée, 1 200 ms après question ouverte ; mesurer par stat sur 7 derniers jours par client.
  • Cache d'amorces pré-rendues — top-20 phrases d'amorce TTS gardées en RAM, 0 ms TTFB.
  • Parallel tool calls — si Anthropic API supporte plusieurs tools en un tour, ne JAMAIS les chaîner séquentiellement.
  • Async logging — jamais flush vers Datadog / Sentry sur le chemin chaud, toujours queue + worker.

🔄 Versions & écosystème 2026

ComposantRecommandation 2026Latence gain
Anthropic prompt cache (ephemeral)indispensableTTFT -75 à -88 %
Anthropic priority tier (entreprise)activé pour voicebot-15-25 % p95
Haiku 4.5 vs Sonnet 4.6router intelligently-50 % latence sur intents simples
Region Anthropic EU (Vertex AI Paris)beta 2026-60 ms vs US
Deepgram FR Paris regionGA mars 2025-80 ms vs Iowa
ElevenLabs region eu-west-1 DublinGA 2024-45 ms
Cartesia edge eu-parisGA déc 2025-70 ms
LiveKit Cloud FRGA 2025-40 ms
Self-host model + warmpoolvolume > 500h/moisflat latency

Modèles Anthropic latency-tier (mai 2026)

  • Haiku 4.5 standard : TTFT p50 280 ms, p95 480 ms
  • Haiku 4.5 priority tier : p50 180 ms, p95 320 ms
  • Sonnet 4.6 standard : p50 380 ms, p95 650 ms
  • Sonnet 4.6 priority tier : p50 270 ms, p95 460 ms
  • Avec cache hit : -75 % sur p50, -85 % sur p95

⚠️ Pitfalls

  1. Endpointing tuné une fois — vous avez choisi 700 ms en POC, jamais retouché. Réalité : seniors hésitent 1 200 ms, jeunes 400 ms. Mesurer par segment.
  2. TTFT en sandbox vs prod — votre dev environment a 30 ms RTT, la prod 90 ms. Test toujours dans des conditions réseau réelles.
  3. Streaming bloquant sur tools — si vous attendez tool result AVANT de TTS la phrase d'accroche, c'est mort. Le LLM doit pouvoir « parler pendant qu'il pense ».
  4. Prompt cache invalidé inutilement — la moindre virgule changée dans le system invalide le cache. Veillez à ce que le system soit BYTE-FOR-BYTE identique.
  5. Region mismatch — STT Frankfurt + LLM Iowa + TTS Tokyo = 350 ms juste de routing. Toujours co-locate.
  6. Pas de circuit breaker — quand Deepgram a 500 ms de latence anormale, vous patientez. Avec circuit breaker → fallback AssemblyAI en 50 ms.
  7. HTTPS handshake répété — chaque appel ouvre une nouvelle connexion = 200 ms TLS. Connection pooling + HTTP/2 multiplexing obligatoires.
  8. Sentence boundary mal détecté — « M. Dupont, 39 ans » casse votre regex . → TTS chunké en plein milieu d'une abréviation.
  9. Speculative TTS quand intent incertain — démarrer « Très bien, » sur un « Non en fait je voulais… » crée un cringe. Speculative seulement si intent confidence > 0.85.
  10. Pas d'instrumentation OTel — vous optimisez à l'aveugle. Le premier livrable d'une mission « optim latence » est l'instrumentation.

💰 Pricing / ROI client

Coût supplémentaire d'optim latence

OptimCoût marginalGain latence p95
Prompt cache (Anthropic)-75 % input tokens (économie)-85 % TTFT
Priority tier Anthropic+25 % output-25 % p95
Edge region eu-parisgratuit (provider)-60 ms
Pre-warm pool LLMquelques req/min en idle-180 ms cold start
Amorces pré-rendues TTSquelques caractères0 ms TTFB

Mission freelance type — « réduire la latence de votre voicebot de 1.4s à 700ms »

  • Audit + instrumentation OTel : 12-18 k€ (2-3 semaines)
  • Optim concrètes : 24-40 k€ (4-6 semaines)
  • TJM 1 400-1 500 €/j (senior latency engineer)
  • ROI client : abandon -20 % → +30 k appels traités/mois × marge évitée

ROI drive-thru

  • 240 restaurants × 800 commandes/jour × abandon 6 % → 11 520 abandons/jour évités par sub-700 ms
  • Ticket moyen 12 € × 11 520 × 30 % récupérables = 41 k€/jour
  • 15 M€/an de chiffre récupéré sur l'ensemble du parc.

🧪 Testing / Eval

ts
// latency-bench.ts — distribution réelle, pas juste moyenne
import { performance } from "node:perf_hooks";

interface Sample { perceived_ms: number; intent: string; cache_hit: boolean; }
const samples: Sample[] = [];

for (let i = 0; i < 500; i++) {
  const t0 = performance.now();
  const result = await runTurn(`Commande test ${i}: ${pickRandomQuery()}`);
  const perceived = performance.now() - t0;
  samples.push({
    perceived_ms: perceived,
    intent: result.intent,
    cache_hit: result.cache_read_tokens > 0,
  });
}

const sorted = samples.map(s => s.perceived_ms).sort((a, b) => a - b);
console.log(`p50: ${sorted[Math.floor(sorted.length*0.5)]}`);
console.log(`p95: ${sorted[Math.floor(sorted.length*0.95)]}`);
console.log(`p99: ${sorted[Math.floor(sorted.length*0.99)]}`);

Suite de tests latence

  • Bench cold start (1er appel après idle)
  • Bench warm pool (10e appel consécutif)
  • Bench avec cache hit vs miss
  • Bench avec tools (1, 2, 3 tools parallèles)
  • Bench dégradé réseau (jitter 50-150 ms simulé avec tc / clumsy)
  • Bench multi-tenants (10 sessions simultanées)

Métriques OTel à exposer

  • voicebot.turn.perceived_ms (histogram)
  • voicebot.llm.ttft_ms (histogram, label model, cache_hit)
  • voicebot.tts.ttfb_ms (histogram, label provider, voice)
  • voicebot.stt.final_lag_ms
  • voicebot.intent.confidence
  • voicebot.cache.hit_rate (counter)

🔁 Quand utiliser / éviter

Optim sub-700 ms justifiée

  • Drive-thru, kiosque self-order (perception immédiate)
  • Urgences (chaque seconde compte)
  • Trading vocal (B2B financier)
  • Visio temps réel avec assistant IA

Optim sub-1.2 s suffisante

  • Service client classique (banque, énergie, télco)
  • Helpdesk IT
  • Réservation restaurant / coiffeur

Optim non prioritaire (3-5 s acceptable)

  • Dictée médicale (le médecin attend la fin)
  • Coaching e-learning
  • Transcription d'audience
  • Génération de podcast

Éviter

  • Optimiser sans mesurer (commence par instrumenter OTel)
  • Sacrifier qualité TTS / WER STT pour 50 ms gagnés
  • Speculative TTS sur intents trop divers (cringe garanti)
  • Self-host pour économies si vous avez < 200 h/mois de volume
  • Optimiser l'aller (TTFT) en ignorant le retour (barge-in) — c'est mesurer un seul des deux axes
  • Half-duplex sur un cas où l'interruption compte (le barge-in y est physiquement impossible)

🎤 En entretien

Questions que ce sujet appelle en entretien senior/staff, avec la réponse courte attendue.

Q : « Votre voicebot fait 900 ms end-to-end, le client se plaint qu'il est lent. Que mesurez-vous d'abord ? » R : La latence perçue (fin de parole → premier sample audio), pas l'end-to-end. Si le perçu est élevé alors que l'end-to-end est correct, c'est qu'on attend la fin de la génération avant de parler — il faut du speculative TTS + sentence-level streaming. Et je mesure en P95, pas en moyenne : l'abandon vit sur la queue.

Q : « Pourquoi le prompt caching d'Anthropic réduit le TTFT et pas seulement le coût ? » R : Un cache hit évite de re-prefiller le préfixe système (tools + system rendus en premier) ; le modèle démarre la génération directement sur la portion volatile. -75 à -88 % de TTFT et coût input ÷10. Mais c'est un match de préfixe byte-for-byte : la moindre virgule ou un Date.now() dans le system invalide tout. Et il y a un minimum cachable par modèle (2 048 sur Sonnet 4.6, 4 096 sur Haiku 4.5) en dessous duquel ça ne cache pas, silencieusement.

Q : « Sonnet ou Haiku pour un voicebot ? » R : Les deux, via un router. Haiku 4.5 sur les 70-85 % d'intents simples (moitié du TTFT de Sonnet), Sonnet 4.6 uniquement quand la complexité le justifie, détectée par la confidence du classifieur. Le router doit coûter moins que ce qu'il économise — un classifieur Haiku 80 ms, pas un Sonnet 380 ms.

Q : « Comment gérez-vous un 429 ou un 529 d'Anthropic sur le chemin chaud ? » R : Jamais bloquer l'audio. Exceptions typées du SDK (RateLimitError, OverloadedError, APITimeoutError), timeout par appel agressif (~8 s), et fallback : amorce TTS pré-rendue (« un instant s'il vous plaît ») pendant le retry async, ou bascule de modèle (Sonnet → Haiku). maxRetries du SDK gère le backoff, mais le budget latence interdit d'attendre passivement.

Q : « Votre TTFT est excellent mais les utilisateurs trouvent le bot frustrant. Qu'est-ce qui peut clocher ? » R : Le barge-in. On a optimisé l'aller (fin de parole → audio) mais pas le retour : quand l'utilisateur interrompt, le bot met 1,5 s à se taire parce que le tour n'est pas annulable (AbortController sur le stream LLM + cancel du TTS + flush du jitter buffer client, le tout sub-300 ms). En half-duplex c'est pire : le micro est coupé pendant la lecture, l'interruption est physiquement inaudible. Le budget latence se mesure sur deux axes, pas un.


🏋️ Exercices

Progressifs et exigeants. L'objectif n'est pas « change une constante » mais « tiens le chiffre, casse-le, défends-le ».

Exercice 1 — Instrumenter avant d'optimiser

Objectif — Le premier livrable de toute mission « optim latence » est l'observabilité, pas l'optimisation. Instrumentez un tour de parole de bout en bout.

Implémentez un handleTurn qui émet un span OpenTelemetry avec 5 timestamps (stt_final, llm_start, llm_first_token, tts_first_chunk, audio_play_start) et dérive metric.perceived_ms, metric.llm_ttft_ms, metric.tts_ttfb_ms, plus cache.read_tokens depuis resp.usage. Exposez un histogramme voicebot.turn.perceived_ms avec les labels intent et cache_hit.

Indice/Solution — Voir le code end-to-end drive-thru.ts du guide. Le piège : audio_play_start_at doit être posé soit par l'amorce speculative (T+0), soit par le premier chunk TTS réel — celui qui arrive en premier. Sans ce if (!m.audio_play_start_at), vous mesurez la mauvaise chose.

Exercice 2 — Descendre un tour de 1,4 s à sub-700 ms perçus

Objectif — Prendre un pipeline naïf séquentiel (STT → RAG → LLM → TTS) et le ramener sous 700 ms P95 perçus sans toucher au choix des providers.

Partez d'un pipeline qui : (1) attend le RAG avant le LLM, (2) attend la fin de la génération LLM avant le premier TTS, (3) utilise Sonnet 4.6 sur tous les tours, (4) re-render le system prompt avec un timestamp à chaque appel. Corrigez les 4 dans l'ordre de gain décroissant et mesurez le delta P95 de chacun.

Indice/Solution — Ordre de gain : (4) figer le system → cache hit, -85 % TTFT (le timestamp tue le cache, c'est le plus gros) ; (2) sentence-level streaming + speculative TTS, masque le reste de la génération ; (1) RAG en parallèle du classifieur d'intent via Promise.all, jeter les docs si l'intent ne les requiert pas ; (3) router Haiku sur les intents simples. Défendez chaque chiffre avec une mesure avant/après, pas une estimation.

Exercice 3 — Speculative TTS sans cringe

Objectif — Le speculative TTS gagne ~400 ms perçus mais crée un « cringe » si l'amorce ne matche pas la réponse. Rendez-le sûr.

Implémentez pickStarter(intent, confidence) qui ne lance une amorce (« Très bien, », « Je vérifie, ») que si la confidence du classifieur dépasse un seuil, et qui choisit une amorce neutre compatible avec un refus comme avec une acceptation. Écrivez le cas d'échec : l'utilisateur dit « Une table pour 4 — non, en fait 6 » ; montrez que votre amorce ne se contredit pas avec la suite générée.

Indice/Solution — Seuil empirique ≥ 0,85. Amorces neutres uniquement (« Un instant, », « Je regarde, ») — jamais « Oui bien sûr, » qui se contredit avec un « malheureusement complet ». Au-dessus du seuil et intent ambigu → pas d'amorce, on absorbe les 400 ms. Mesurez le taux de cringe (amorce + contenu contradictoire) sur un jeu d'enregistrements réels, visez < 1 %.

Exercice 4 — Casser le prompt cache, puis le réparer

Objectif — Diagnostiquer un cache hit rate à 0 % en production, à l'aveugle.

On vous donne un service où cache_read_input_tokens est systématiquement à 0 malgré un cache_control posé. Trouvez et corrigez les trois invalidateurs silencieux plantés : (a) un \Menu du ${new Date().toISOString()}`interpolé dans le system, (b) unJSON.stringify(rules)` sans clés triées, (c) un system de 1 800 tokens routé sur Haiku 4.5.

Indice/Solution — (a) sortir la date du préfixe (la mettre dans messages, ou la supprimer) ; (b) sérialisation déterministe, trier les clés ; (c) le system fait 1 800 tokens < 4 096 → sous le minimum cachable de Haiku, il ne cachera jamais : gonfler le préfixe au-delà de 4 096 tokens ou router ce prompt sur Sonnet (min 2 048). Validez en diffant les bytes rendus de deux requêtes consécutives.

Exercice 5 — Production-grade : circuit breaker + dégradation

Objectif — Rendre le pipeline résilient à une dégradation provider sans casser le budget latence.

Ajoutez un circuit breaker autour de l'appel LLM : quand le TTFT P95 mesuré sur les 30 derniers tours dépasse 600 ms (provider en souffrance), basculez automatiquement Sonnet → Haiku, jouez une amorce pré-rendue pendant le basculement, et émettez une alerte async (queue + worker, jamais de flush Datadog sur le chemin chaud). Gérez OverloadedError (529) en bascule immédiate.

Indice/Solution — Circuit breaker avec fenêtre glissante de P95 (pas de moyenne). Trois états : closed (normal), half-open (mesure), open (fallback Haiku + amorce). Le retry du SDK (maxRetries) gère le transitoire ; le breaker gère le persistant. Le logging va dans une queue in-memory drainée par un worker — un await datadog.send() sur le tour de parole ajouterait 50-150 ms au chemin chaud.

Exercice 6 — Défendre le chiffre business

Objectif — Passer de « j'ai gagné 400 ms » à « j'ai récupéré 15 M€/an », et savoir le défendre.

À partir des hypothèses du drive-thru (240 restaurants, 800 commandes/jour, abandon -6 % grâce au sub-700 ms, ticket moyen 12 €, 30 % récupérables), construisez le modèle de ROI complet et identifiez les 3 hypothèses les plus fragiles qu'un CFO attaquera. Calculez le ROI dans le scénario pessimiste (abandon évité -2 % au lieu de -6 %).

Indice/Solution — Modèle nominal : 240 × 800 × 6 % × 12 € × 30 % × 365 ≈ 15 M€/an. Hypothèses fragiles : (1) la part récupérable de l'abandon (30 % est optimiste — beaucoup raccrochent pour d'autres raisons) ; (2) la causalité latence → abandon (corrélation ≠ causalité, exigez un A/B) ; (3) la généralisation des 6 % du pilote à tout le parc. Scénario pessimiste à -2 % : ≈ 5 M€/an — toujours largement positif vs une mission à 40 k€, ce qui est l'argument réel : même le pire cas finance le projet 100×.

Exercice 7 — Barge-in sub-300 ms (la latence inverse)

Objectif — Le bot a un TTFT excellent mais met 1,5 s à se taire quand on l'interrompt. Rendez l'interruption aussi rapide que la réponse.

Implémentez le chemin d'annulation complet : (1) un VAD qui détecte la voix user pendant que le TTS joue (full-duplex obligatoire), (2) un AbortController propagé au stream LLM ({ signal } du SDK) qui coupe la génération et la facturation, (3) le cancel() du WebSocket TTS, (4) un signal de flush au client pour purger son jitter buffer. Mesurez le délai « début de parole user → silence du bot » et tenez-le sous 300 ms P95. Puis cassez-le : passez le transport en half-duplex et montrez par la mesure que le barge-in devient impossible (le micro est muté pendant la lecture).

Indice/Solution — L'AbortController du SDK Anthropic coupe la connexion HTTP — aucun token supplémentaire facturé après l'abort. Le piège classique : le jitter buffer client garde 300-800 ms d'audio déjà reçu ; couper le LLM ne suffit pas, il faut un message de contrôle qui dit au client de vider sa file. Le faux confort du half-duplex : « ça simplifie l'echo cancellation » — oui, mais ça rend le barge-in physiquement impossible, donc c'est une régression UX déguisée en simplification. Défendez le choix full-duplex + AEC (acoustic echo cancellation) en mesurant les deux axes.

Exercice 8 — Slot extraction parallèle sans rallonger le tour

Objectif — Extraire des champs typés (item, quantité, options) d'un tour sans ajouter de latence au chemin vocal.

Partez d'une implémentation naïve qui : (a) demande au LLM principal de répondre en JSON via le prompt, puis JSON.parse défensif, et (b) attend ce résultat avant de jouer la confirmation vocale. Refactorez vers : un messages.parse() (schéma Zod) lancé en parallèle du streaming TTS de la phrase de confirmation, et prouvez par la mesure que la latence perçue est inchangée alors que l'extraction est maintenant typée et validée côté serveur.

Indice/Solution — Le slot structuré ne produit pas de phrase à dire → sa latence est masquée par l'audio en cours, à condition de ne pas l'await avant le TTS. Lancez les deux dans un Promise.all : [ttsConfirm(sentence, ws), anthropic.messages.parse({ response_format: OrderSlot, ... })]. Le « réponds en JSON » dans le prompt est un double anti-pattern : il coûte des tokens de sortie (donc du TTFT/latence) et il peut produire du JSON malformé qu'il faut parser défensivement. Le schéma contraint côté serveur élimine les deux. Bench : perceived_ms avant/après identique, taux de parse-error de N % → 0.


🔗 Liens

  • Anthropic prompt caching : docs.anthropic.com/en/docs/build-with-claude/prompt-caching
  • Anthropic priority tier : docs.anthropic.com/en/docs/about-claude/pricing#priority-tier
  • OpenTelemetry Node : opentelemetry.io/docs/languages/js/
  • LiveKit metrics dashboard : docs.livekit.io/agents/build/observability
  • Deepgram latency benchmarks : deepgram.com/learn/latency-deepgram-real-time
  • ElevenLabs optimize_streaming_latency : elevenlabs.io/docs/api-reference/text-to-speech/convert-as-stream
  • Cartesia edge regions : docs.cartesia.ai
  • Paper « Speculative Decoding for LLMs » : arxiv.org/abs/2211.17192
  • ITU-T G.114 (one-way latency targets) : itu.int/rec/T-REC-G.114
  • Drive-thru study on customer abandonment : restaurantbusinessonline.com/operations/drive-thru-times

Bibliothèque tech perso — Achref