Skip to content

Project 3 — Voice Agent (SPEC)

Your third portfolio piece. Builds in projects/03-voice-agent/. Time: 3-5 weeks at 8-12h/week.


Goal

Build a voice agent that solves a real, vertical-specific problem and is demoable on a public URL.

End state :

  • Public web demo URL (or phone number)
  • Loom video of a full conversation
  • README with metrics : latency, cost, call success rate
  • Article on how you built it

🧠 Le modèle mental : un voice agent est un pipeline temps-réel, pas un chatbot

Un chatbot texte est une boucle requête/réponse : l'utilisateur poste un message, tu réponds quand tu veux, la latence se cache derrière un spinner. Un voice agent supprime le spinner. La conversation parlée a une horloge implacable : si l'humain s'arrête de parler et que rien ne revient en ~500-800 ms, il croit que la ligne est coupée, il répète, il coupe l'agent. La qualité perçue d'un voice agent est dominée par la latence de tour de parole, pas par l'intelligence du LLM.

Le pipeline canonique (Pattern B, "cascading") enchaîne quatre composants en série, par tour :

Micro → [VAD] → [STT] → [LLM] → [TTS] → Haut-parleur
        détecte  audio→  texte→  texte→
        la fin   texte   texte   audio
        de parole
  • VAD (Voice Activity Detection) — décide quand l'utilisateur a fini de parler. C'est le déclencheur du tour. Trop agressif → tu coupes l'utilisateur en plein milieu d'une phrase ; trop mou → tu ajoutes 500 ms de latence morte à chaque tour.
  • STT (Speech-to-Text) — transcrit l'audio en texte. En streaming, les transcriptions partielles arrivent en continu ; tu attends le final transcript (stabilisé) avant d'appeler le LLM.
  • LLM — raisonne, appelle des tools, génère la réponse textuelle. C'est là que vit ta logique métier.
  • TTS (Text-to-Speech) — synthétise la voix. En streaming, tu commences à parler dès les premiers mots du LLM, sans attendre la phrase complète.

Le piège du débutant : raisonner sur ces composants comme une chaîne séquentielle où chaque étape attend la précédente. Un staff engineer raisonne en pipeline qui se chevauche (overlapping) : le TTS du début de la réponse commence à jouer pendant que le LLM génère encore la fin. C'est ce chevauchement qui fait passer la latence ressentie de 1500 ms à 600 ms.

Pattern A (speech-to-speech) vs Pattern B (cascading) — le choix architectural fondateur

AxePattern A — Speech-to-speech (modèle realtime unique)Pattern B — Cascading (STT + LLM + TTS séparés)
LatenceLa plus basse (un seul hop, ~300-500 ms)Plus haute mais maîtrisable (~600-800 ms)
CoûtÉlevé, opaque (facturé à la seconde audio)Plus bas, chaque composant optimisable séparément
ContrôleFaible — la "pensée" est dans une boîte noireÉlevé — tu vois le texte à chaque étape, tu loggues tout
Tool callingSupporté mais moins mûrMûr, tu contrôles la boucle d'outils
ObservabilitéDifficile (pas de transcript intermédiaire fiable)Excellente — transcript + tool calls + timing par étape
Choix de voix / langueLimité au provider du modèle realtimeLibre (n'importe quel TTS, n'importe quelle langue)
Débogage"La voix a halluciné" — bonne chanceTu sais exactement quel composant a fauté

La décision en une phrase : pour un portfolio B2B vertical, prends Pattern B. Tu sacrifies ~200 ms de latence contre une observabilité totale, un coût 2-3× plus bas, et la capacité de débugger en production. La latence "wow" du Pattern A n'a aucune valeur si tu ne peux ni en expliquer le coût ni en tracer les bugs en entretien.


Acceptance criteria

Voice quality

  • [ ] Total response latency < 800ms (user stops talking → agent starts talking), mesurée au p95, pas à la moyenne
  • [ ] Natural turn-taking (handles interruptions, pauses)
  • [ ] Voice quality clearly intelligible (16+ kHz audio)
  • [ ] FR support if your vertical targets FR

Functionality

  • [ ] At least 3 tool calls during conversation (look up data, perform action)
  • [ ] State preserved across turns (multi-turn dialogue)
  • [ ] Graceful degradation (handles "I don't know", asks clarifying questions)
  • [ ] Termination condition (agent knows when to wrap up)

Engineering

  • [ ] Reconnection logic (if WebRTC drops)
  • [ ] Error handling (LLM down, tool fails, API limits)
  • [ ] Logging : full transcript + tool calls + latency per turn
  • [ ] Cost tracking : $/min for each conversation
  • [ ] Tests : at least mock-based unit tests for the agent logic

Distribution

  • [ ] Public demo URL (web app where anyone can try)
  • [ ] Loom video : full conversation showing the value
  • [ ] README with architecture, metrics, tradeoffs
  • [ ] Article : "Building a voice agent for [vertical] in 4 weeks"
  • [ ] LinkedIn post

Use case ideas by vertical

Voice agent: Pre-qualification of legal cases. Caller describes situation → agent asks clarifying questions → categorizes case → routes to right lawyer or books consultation.

Finance / Compta

Voice agent: Cabinet comptable phone assistant. Clients call → agent looks up their file, answers basic questions (what's my TVA balance, when's my deadline), books call with comptable if complex.

RH / Recrutement (Loxira angle)

Voice agent: Candidate pre-screening. Recruiter sends URL → candidate calls → agent screens (motivation, salary, availability, key skills) → scores + summary to recruiter.

E-commerce

Voice agent: Customer support tier 1. Track order, initiate refund, answer FAQ, escalate to human when needed.

Médical (PoC only, no real patients)

Voice agent: Appointment booking with light triage. Patient calls → agent asks symptoms → suggests urgency → books with right doc.


ComponentToolWhy
TransportLiveKit CloudFree tier, production-ready
STTDeepgram Nova-3Cheap, fast, FR support
LLMClaude Sonnet 4.6 (claude-sonnet-4-6)Best quality/cost, time-to-first-token bas
TTSElevenLabs Flash v2.5Top voice quality, FR good
AgentLiveKit Agents (Python) OR customDefault to LiveKit Agents
BackendFastAPI or ExpressServe agent + tools
DBPostgres (you have it on k3s)State + transcripts
MCP integrationProject 2 MCP server reused!DRY across portfolio
FrontendNext.js + LiveKit React componentsWeb demo

Pro tip: If your Project 2 MCP server is for the same vertical, reuse it here. Then your portfolio = 3 connected projects, much stronger story.

Pourquoi Claude Sonnet 4.6 pour la voix (et pas Opus)

Pour un voice agent, le time-to-first-token (TTFT) domine la qualité ressentie, pas l'intelligence brute. Tu n'as pas besoin de la profondeur de raisonnement d'Opus 4.8 pour pré-qualifier un dossier ou prendre un RDV — tu as besoin que le premier token sorte vite pour que le TTS démarre. Sonnet 4.6 (claude-sonnet-4-6, 3 $/15 $ par M tokens) est le sweet spot : assez intelligent pour le tool calling vertical, assez rapide pour tenir le budget de latence, 3× moins cher qu'Opus en sortie. Garde Opus 4.8 (claude-opus-4-8) en réserve pour un éventuel tour de "résumé post-appel" asynchrone, où la latence n'a aucune importance et où la qualité compte.

Deux réglages Claude qui changent tout pour la voix :

  • Adaptive thinking (thinking: {type: "adaptive"}) — laisse Claude décider quand raisonner. Mais pour les tours conversationnels simples, vise un effort bas pour ne pas exploser le TTFT ; le thinking ajoute de la latence morte avant le premier mot audible. Règle via output_config: {effort: "low"} sur les tours de dialogue, "high" seulement si un tour fait un raisonnement métier lourd.
  • Streaming obligatoire — un voice agent qui appelle Claude en non-streaming attend la réponse complète avant de parler. Tu dois streamer (client.messages.stream(...) / .stream() en TS) et pousser les tokens vers le TTS au fil de l'eau.
python
# Tour de dialogue voix : AsyncAnthropic + streaming + effort bas pour minimiser le TTFT
from anthropic import (
    AsyncAnthropic,
    APIStatusError,
    APITimeoutError,
    RateLimitError,
    OverloadedError,
)
from anthropic.types import Message

# Serveur → async + retries SDK typés (backoff exponentiel sur 429/5xx/529).
# timeout court : un voice agent ne peut pas attendre 60 s, on échoue vite et on dégrade.
client = AsyncAnthropic(max_retries=2, timeout=10.0)

async def llm_turn(history: list[dict], tools: list[dict]) -> Message:
    """Un tour de dialogue. Stream les tokens vers le TTS au fil de l'eau,
    renvoie le Message final (pour persister tool_use + usage)."""
    try:
        async with client.messages.stream(
            model="claude-sonnet-4-6",
            max_tokens=1024,
            thinking={"type": "adaptive"},
            output_config={"effort": "low"},  # dialogue simple : minimise le TTFT
            tools=tools,
            messages=history,
            # cache_control sur le préfixe stable (system + tools) → tours 2..N en cache,
            # placé au niveau du dernier bloc system, pas ici (voir §latence #5).
        ) as stream:
            async for text in stream.text_stream:
                push_to_tts(text)          # on parle DÈS le premier token, pas à la fin
            final = await stream.get_final_message()
        log_usage(final.usage)             # logge usage pour le $/min réel
        return final                       # on renvoie le Message (history.append, tool loop)
    except (RateLimitError, OverloadedError, APITimeoutError):
        # 429 / 529 / timeout → réponse de secours, on ne plante pas l'appel en cours
        return await fallback_response()
    except APIStatusError as e:
        # exceptions SDK typées, pas de string-matching sur le message d'erreur
        log.error("llm_turn failed", status=e.status_code, type=e.type)
        return await fallback_response()

Pourquoi Message et pas str : le tour doit pouvoir contenir un tool_use (l'agent appelle lookup_tva), pas seulement du texte. Tu renvoies le Message complet pour (a) l'append à history à l'identique — y compris les blocs thinking que tu rejoues tels quels sur le même modèle —, (b) boucler sur les tool calls, (c) logger usage. Renvoyer une str casserait la boucle d'outils dès le premier tour qui appelle un tool.


Week-by-week plan

Week 1 — LiveKit setup + hello world

  • [ ] LiveKit Cloud account
  • [ ] LiveKit Agents Python quickstart
  • [ ] Local agent answers "hello"
  • [ ] Deploy backend to your k3s

Week 2 — Build agent logic

  • [ ] Define conversation flow (state machine if complex)
  • [ ] Implement 3+ tools (or connect to project 2's MCP server)
  • [ ] Multi-turn working
  • [ ] Edge cases : silence, gibberish, off-topic

Week 3 — Frontend + Deploy

  • [ ] Next.js web app with LiveKit React components
  • [ ] Mic permission flow, visual feedback
  • [ ] Deploy front (Vercel) + back (k3s)
  • [ ] Public URL working

Week 4 — Polish + metrics

  • [ ] Reconnection + error handling
  • [ ] Latency optimization (parallel STT/LLM, pre-fetch)
  • [ ] Logging dashboard
  • [ ] Test with 10+ real conversations, fix issues

Week 5 — Distribution

  • [ ] Loom video (90 sec)
  • [ ] README polish
  • [ ] Article on Medium
  • [ ] LinkedIn post
  • [ ] Submit to LiveKit showcase if possible

Latency budget (per turn, target < 800ms p95)

ComponentBudget
User stops talking (VAD)200ms
STT final transcript100-200ms
LLM time to first token200-400ms
TTS audio start100-150ms
Network50-100ms
TOTAL650-1050ms

⚠️ Le budget se mesure au p95, pas à la moyenne. Une moyenne de 600 ms avec un p95 à 1400 ms donne une conversation où un tour sur vingt fait croire à l'utilisateur que la ligne a coupé — et c'est ce tour-là qu'il retient. Logge chaque étape par tour (vad_ms, stt_ms, ttft_ms, tts_start_ms) et trace les histogrammes, pas les moyennes.

Les optimisations qui comptent (par ordre de gain)

  1. Stream-and-overlap le TTS — démarre le TTS dès le premier token du LLM, ne l'attends pas en entier. C'est le plus gros gain : ~300-500 ms économisés par tour. Coupe la réponse LLM aux frontières de phrase pour alimenter le TTS en chunks naturels.
  2. Co-localise les services — STT, LLM, TTS et ton backend dans la même région cloud. Chaque hop transatlantique coûte ~80-150 ms × 4 composants. Un voice agent dont le backend est en eu-west mais qui appelle un STT en us-east est mort-né.
  3. Endpointing intelligent (VAD) — n'attends pas un silence fixe de 700 ms pour déclarer la fin de tour. Utilise un VAD sémantique (le modèle prédit si la phrase est grammaticalement finie) pour couper le délai de fin de tour de moitié sur les phrases complètes.
  4. Pré-chauffe les connexions — garde les WebSockets STT/TTS ouverts, garde le cache de prompt Claude chaud. Le premier tour d'un appel froid paie le coût de connexion ; pré-warme à l'ouverture de la room.
  5. Prompt caching côté Claude — ton system prompt + tes définitions de tools sont identiques à chaque tour. Mets un cache_control sur le préfixe stable (system + tools) : les tours 2..N lisent le cache à ~0.1× du prix d'entrée et réduisent le TTFT. Vérifie via usage.cache_read_input_tokens ; s'il est à zéro, un invalidateur silencieux (timestamp dans le system prompt, ordre de tools non déterministe) casse le cache.
  6. Modèles plus petits quand c'est possible — un tour qui ne fait que router ("urgence ou pas ?") n'a pas besoin de la même profondeur qu'un tour de synthèse.

Cost budget (per minute)

Pattern B (STT-LLM-TTS), ordres de grandeur 2026 (vérifie toujours les tarifs courants des providers) :

  • STT : ~$0.0043/min (Deepgram streaming)
  • LLM : Claude Sonnet 4.6 à 3 $/15 $ par M tokens. Un tour conversationnel typique = ~500 tokens in (avec prompt caching, surtout des reads) + ~150 tokens out. Compte ~$0.02-0.06/min de conversation active selon la densité de tours et la part de cache. Logge resp.usage à chaque tour pour le chiffre réel, ne devine pas.
  • TTS : ~$0.10-0.30/min (ElevenLabs Flash, varie avec le plan)
  • LiveKit : free tier, sinon ~$0.05/min
  • Total : ~$0.20-0.45/min, dominé par le TTS et le transport, pas par le LLM

10-min conversation = ~$2-4.50. Raisonnable pour du B2B.

Comment un staff engineer raisonne sur le coût voix

Le réflexe débutant est d'optimiser le LLM. C'est le mauvais levier : sur un pipeline Pattern B avec Sonnet 4.6 et prompt caching, le LLM est rarement le poste dominant — le TTS et le transport le sont. Avant d'optimiser quoi que ce soit, instrumente le coût par poste sur de vrais appels, puis attaque le poste le plus gros.

Les leviers réels, par ordre d'impact :

  • TTS : choisis un modèle "flash/turbo" plutôt que "premium" pour les tours fonctionnels ; ne synthétise pas les silences ; coupe les réponses verbeuses (un prompt "réponds en une à deux phrases" économise des tokens TTS et du temps).
  • Tours évités : chaque tour coûte STT + LLM + TTS. Un agent qui pose trois questions là où une suffit triple le coût et la latence cumulée. La conception du dialogue est une optimisation de coût.
  • LLM : prompt caching sur le préfixe stable, effort: "low" sur les tours simples, max_tokens serré.
  • Le piège de la moyenne : ton coût/min réel dépend de la distribution de durée d'appel. Un agent de pré-qualification (appels courts, denses) et un agent de support (appels longs, ponctués de silences) ont des profils de coût opposés. Reporte un coût par appel terminé, pas par minute brute.

🔁 La boucle de tour : tool calling sous contrainte temps-réel

C'est l'endroit où un voice agent diverge le plus d'un chatbot texte, et où la plupart des PoC se cassent en démo. Le LLM ne renvoie pas toujours du texte : il peut renvoyer un tool_use (« je dois chercher le solde de TVA »). Dans un chatbot, tu exécutes le tool et tu reboucles, l'utilisateur attend derrière un spinner. En voix, ce round-trip d'outil est du silence audible — et le silence est interprété comme une panne (cf. failure modes).

Le réflexe staff : un tour de voix n'est pas « stream le texte → fini ». C'est une boucle où chaque itération peut être du texte (à streamer vers le TTS) ou un tool call (à exécuter, masquer derrière une phrase de remplissage, puis reboucler).

python
import asyncio

FILLERS = ["laissez-moi vérifier ça…", "un instant, je regarde…"]

async def voice_turn(history: list[dict], tools: list[dict], tool_impls: dict) -> None:
    while True:                                  # boucle d'outils, bornée plus bas
        msg = await llm_turn(history, tools)     # stream le texte vers le TTS au passage
        history.append({"role": "assistant", "content": msg.content})

        tool_calls = [b for b in msg.content if b.type == "tool_use"]
        if not tool_calls:
            return                               # tour fini, on rend la parole

        # On masque le silence du round-trip : on joue un filler PENDANT l'exécution.
        speak(random.choice(FILLERS))

        # Tools indépendants → on les exécute EN PARALLÈLE, pas en série.
        results = await asyncio.gather(
            *(run_tool(tc, tool_impls) for tc in tool_calls),
            return_exceptions=True,              # un tool qui plante ne tue pas les autres
        )
        history.append({"role": "user", "content": [
            tool_result_block(tc, r) for tc, r in zip(tool_calls, results)
        ])
        # on reboucle : le LLM voit les résultats et formule la réponse parlée

Trois décisions de senior cachées dans ces 20 lignes :

  1. asyncio.gather sur les tools indépendants. Si l'agent doit lire le dossier et vérifier l'échéance, ce sont deux appels DB indépendants — les sérialiser ajoute la latence de l'un à l'autre en plein silence audible. La parallélisation des tool calls est une optimisation de latence directe, pas une coquetterie.
  2. Le filler joué pendant le round-trip. Sans lui, l'utilisateur entend 300-800 ms de rien après avoir parlé. Le filler (« laissez-moi vérifier… ») transforme un silence anxiogène en une interaction naturelle. C'est gratuit en latence ressentie et ça achète tout le budget du tool.
  3. return_exceptions=True + la borne de boucle. Un tool qui timeout ne doit pas tuer le tour ; et la boucle d'outils doit être bornée (compteur de tours max, sinon une hallucination « j'appelle encore le même tool » te coince dans une boucle infinie en plein appel facturé).

Barge-in : l'invariant que personne n'implémente du premier coup

Le barge-in (l'utilisateur coupe l'agent) n'est pas un cas limite, c'est le comportement par défaut d'une vraie conversation. L'implémenter correctement impose un invariant dur sur l'architecture : tout ce qui parle ou raisonne doit être annulable. Concrètement, trois choses doivent être interruptibles simultanément à la détection de parole entrante :

python
async def on_user_speech_detected(state):
    state.tts.cancel()              # 1. coupe l'audio en cours de lecture
    state.audio_queue.flush()       # 2. vide la file des chunks déjà synthétisés
    if state.llm_stream:
        await state.llm_stream.close()  # 3. interrompt le stream LLM en cours (économise des tokens)
    # on repart sur le nouveau transcript, l'ancien tour est abandonné

Le piège : on pense souvent à couper le TTS (1) mais on oublie la file audio déjà bufferisée (2). Tu as streamé 4 phrases vers le TTS, le TTS en a synthétisé 2 d'avance — couper le « lecteur » sans vider la file laisse l'agent finir ces 2 phrases par-dessus l'utilisateur. Le test de validation est physique : coupe l'agent à voix haute en pleine phrase. S'il finit sa phrase, ton invariant d'annulabilité est cassé quelque part.


⚠️ Failure modes : ce qui casse un voice agent en production

Un voice agent a des modes de défaillance qu'un chatbot texte n'a pas. Les nommer et les gérer est exactement ce qui sépare un PoC d'un truc démontrable en entretien sans prier.

Failure modeSymptômeMitigation
Barge-in / interruptionL'utilisateur coupe l'agent ; l'agent continue de parler par-dessusDétecte la parole entrante pendant que le TTS joue → coupe le TTS immédiatement, vide la file audio, traite le nouvel input. Sans ça l'agent paraît sourd.
Hallucination STTBruit de fond / accent → transcript garbage → le LLM répond à côtéSeuil de confiance STT ; sous le seuil, l'agent demande de répéter au lieu d'agir sur du bruit
Endpointing prématuréLe VAD coupe l'utilisateur en plein milieu d'une pause de réflexionVAD sémantique + délai de fin de tour adaptatif ; ne déclenche pas le LLM sur une phrase manifestement incomplète
Latence en cascadeUn composant lent (TTS surchargé) fait dérailler tout le budgetTimeout par composant + réponse de remplissage ("laissez-moi vérifier...") jouée pendant qu'on attend
WebRTC dropLe réseau lâche en plein appelReconnection logic : ré-établis la room, restaure l'état conversationnel depuis le transcript persisté, reprends le tour
Tool failureL'API métier (le MCP de Projet 2, Postgres) timeoutRéponse gracieuse : "je n'arrive pas à accéder à votre dossier là, je peux vous rappeler ?" — jamais un silence ni un stacktrace audible
Boucle infinie de clarificationL'agent redemande la même chose en boucle car il ne comprend pasCompteur de tentatives ; après N échecs sur le même slot, escalade vers un humain ou termine proprement
Coût qui dérapeUn appel qui ne se termine jamais (utilisateur parti, ligne ouverte)Timeout d'inactivité + condition de terminaison explicite ; ferme la room après X s de silence

Le principe transversal : dans le doute, un voice agent doit dégrader vers une phrase de remplissage ou une escalade humaine, jamais vers le silence ou une erreur audible. Le silence est interprété comme une panne ; un stacktrace audible est interprété comme un produit cassé.


Observabilité : ce que tu dois logger par tour

Sans ces logs, tu ne peux ni défendre ta latence ni ton coût en entretien, ni débugger en prod. Persiste, par tour :

jsonc
{
  "call_id": "uuid",
  "turn": 4,
  "user_transcript": "je voudrais connaître mon solde de TVA",
  "stt_confidence": 0.94,
  "agent_response": "votre solde de TVA est de ...",
  "tool_calls": [{ "name": "lookup_tva", "input": {...}, "latency_ms": 120, "ok": true }],
  "timing_ms": { "vad": 180, "stt": 150, "ttft": 310, "tts_start": 110, "total": 750 },
  "llm_usage": { "input_tokens": 512, "cache_read_input_tokens": 400, "output_tokens": 140 },
  "cost_estimate_usd": 0.031
}

Le transcript complet + le timing par étape + l'usage Claude = ton tableau de bord et ta preuve. C'est aussi ce qui te permet de dire en entretien "mon p95 est à 740 ms, dont 310 de TTFT, et voici comment je l'ai réduit" — une phrase qui vaut dix slides.


🏋️ Exercices

Progressifs et exigeants. Chacun a un Objectif et un Indice/Solution. Le but n'est pas de "changer une constante" — c'est de construire, casser, puis défendre.

Exercice 1 — Instrumente le budget de latence par tour

Objectif : rendre mesurable le p95 de chaque étape (vad, stt, ttft, tts_start, total) sur de vrais appels, et afficher l'histogramme. Indice/Solution : pose un timestamp monotone (time.monotonic()) à chaque transition d'étape dans la boucle de tour, persiste le dict timing_ms en base, agrège en histogramme (pas en moyenne). Tu dois pouvoir répondre "où part le temps sur le tour le plus lent ?" sans deviner.

Exercice 2 — Implémente le barge-in (interruption)

Objectif : quand l'utilisateur parle pendant que l'agent parle, l'agent se tait dans les 150 ms et traite le nouvel input. Indice/Solution : écoute l'événement de parole entrante du VAD pendant que le TTS joue. À la détection : annule le stream TTS, vide la file audio (flush), interromps le tour LLM en cours (stream.close()), repars sur le nouveau transcript. Teste-le en coupant l'agent à voix haute — s'il finit sa phrase, c'est cassé.

Exercice 3 — Active et prouve le prompt caching Claude

Objectif : réduire le TTFT des tours 2..N en cachant le préfixe stable (system + tools), et prouver le cache hit. Indice/Solution : mets cache_control: {type: "ephemeral"} sur le dernier bloc system (les tools rendent avant system, donc ils sont cachés aussi). Vérifie usage.cache_read_input_tokens > 0 sur le tour 2. S'il est à zéro : tu as un invalidateur silencieux — un timestamp dans le system prompt, un ordre de tools non trié, un ID de session interpolé. Diffe les octets rendus de deux requêtes pour le trouver.

Exercice 4 — Casse-le puis répare-le : injecte des pannes

Objectif : prouver la dégradation gracieuse sous trois pannes : LLM 429, tool timeout, WebRTC drop. Indice/Solution : ajoute un flag de chaos qui (a) fait lever RateLimitError, (b) fait timeouter ton tool MCP, (c) coupe la connexion WebRTC à mi-appel. Pour chacune, l'agent doit produire une phrase audible de remplissage ou d'escalade, jamais un silence ni un stacktrace. Pour le drop WebRTC : restaure l'état conversationnel depuis le transcript persisté et reprends. Filme les trois — c'est ton meilleur clip de démo.

Exercice 5 — Défends le chiffre : coût réel par appel terminé

Objectif : produire un coût par appel terminé ventilé par poste (STT / LLM / TTS / transport) sur ≥ 20 vrais appels, et identifier le poste dominant. Indice/Solution : logge resp.usage Claude à chaque tour (pas une estimation), additionne les minutes STT/TTS facturables, ventile. Tu vas probablement découvrir que le LLM n'est pas le poste dominant. Écris une phrase défendable : "Le coût est dominé par le TTS à X %, et voici le levier que j'actionnerais d'abord." En entretien, ce chiffre vaut plus que toute la stack.

Exercice 6 — VAD sémantique vs silence fixe (le tour d'optimisation final)

Objectif : réduire le délai de fin de tour sans couper les utilisateurs qui font des pauses de réflexion. Indice/Solution : compare deux stratégies d'endpointing sur le même jeu d'enregistrements : (a) silence fixe de 700 ms, (b) endpointing sémantique qui prédit si la phrase est grammaticalement complète. Mesure deux métriques antagonistes : latence de fin de tour (à minimiser) et taux de coupure prématurée (à minimiser aussi). Trace la courbe de tradeoff. La défense attendue : "j'ai choisi ce point parce que..." avec les deux chiffres.

Exercice 7 — Parallélise la boucle d'outils et masque le silence

Objectif : un tour qui appelle 2 tools indépendants doit (a) les exécuter en parallèle, (b) ne jamais produire de silence audible, (c) être borné contre la boucle infinie. Indice/Solution : pars de la boucle voice_turn de la section « tool calling temps-réel ». Remplace une exécution séquentielle par asyncio.gather, joue un filler au moment où tu lances l'exécution (pas après), et ajoute un compteur max_tool_rounds qui termine proprement après N itérations. Mesure-le : instrumente tool_round_latency_ms pour la version série vs parallèle sur un tour qui touche deux tools — tu dois pouvoir montrer le gain chiffré. Le test de la boucle infinie : force le LLM (prompt adverse) à rappeler le même tool en boucle, et prouve que ton borneur escalade vers un humain au lieu de facturer un appel sans fin.

Exercice 8 — Prouve l'annulabilité totale (le test physique du barge-in)

Objectif : prouver que TTS en cours, file audio bufferisée et stream LLM sont tous les trois interruptibles au même instant — pas seulement le « lecteur ». Indice/Solution : instrumente un timestamp à chaque étape d'annulation (tts_cancel_ms, queue_flush_ms, llm_close_ms) relatif à la détection de parole entrante. Le piège à exposer : coupe l'agent quand le TTS a déjà synthétisé 2 phrases d'avance ; si tu ne flush() pas la file, il finit ces 2 phrases par-dessus l'utilisateur même avec le TTS « coupé ». La défense attendue en entretien : « mon barge-in tue les trois canaux en < 150 ms, voici le timestamp du flush de file, et voici le clip vidéo où je coupe l'agent en pleine phrase et il se tait net. »


🎤 En entretien

Q : Ton voice agent affiche 600 ms de latence moyenne. Pourquoi ça ne suffit pas à dire qu'il est rapide ? R : Parce que la voix se vit au p95, pas à la moyenne — un tour sur vingt à 1400 ms fait croire à l'utilisateur que la ligne a coupé, et c'est ce tour-là qu'il retient ; je reporte des histogrammes par étape, pas des moyennes.

Q : Pattern A (speech-to-speech) ou Pattern B (cascading) — lequel et pourquoi ? R : Pattern B pour un produit B2B, parce que je sacrifie ~200 ms de latence contre une observabilité totale (transcript + timing + usage par étape), un coût 2-3× plus bas, et la capacité de débugger précisément quel composant a fauté — la latence "wow" du A n'a aucune valeur si je ne peux ni en expliquer le coût ni en tracer les bugs.

Q : Où part l'argent dans un voice agent, et quel levier tu actionnes en premier ? R : Contre-intuitivement, pas le LLM — avec Sonnet 4.6 et prompt caching, le LLM est rarement dominant ; ce sont le TTS et le transport ; donc j'instrumente le coût par poste sur de vrais appels, et j'attaque le TTS (modèle flash, réponses courtes) et le nombre de tours avant de toucher le LLM.

Q : Comment tu gères le fait que l'utilisateur coupe l'agent en plein milieu d'une phrase ? R : Barge-in : je détecte la parole entrante via le VAD pendant que le TTS joue, je coupe le TTS et vide la file audio dans les ~150 ms, j'interromps le stream LLM en cours et je repars sur le nouvel input — sans ça l'agent paraît sourd et le produit est inutilisable.

Q : Que fait ton agent quand le LLM renvoie un 429 ou que ton tool métier timeout en plein appel ? R : Il dégrade gracieusement vers une phrase audible de remplissage ou d'escalade humaine — jamais un silence (interprété comme une panne) ni un stacktrace audible ; côté code, j'attrape les exceptions SDK typées (RateLimitError, OverloadedError, APITimeoutError, APIStatusError) avec un timeout court et max_retries SDK, et je sers une réponse de secours pré-écrite plutôt que d'attendre le retry complet.

Q : Le LLM renvoie un tool_use au milieu d'un tour parlé. Comment tu gères le round-trip d'outil sans casser la conversation ? R : Un tour de voix est une boucle, pas un stream linéaire : tant que le LLM renvoie des tool_use, j'exécute les tools indépendants en parallèle avec asyncio.gather, je joue une phrase de remplissage pendant l'exécution pour masquer le silence audible du round-trip, je rappend les tool_result à l'historique et je reboucle — le tout borné par un compteur de tours pour ne pas me faire piéger par une hallucination qui rappelle le même tool à l'infini en plein appel facturé.

Q : Pourquoi claude-sonnet-4-6 et pas Opus 4.8 ou Haiku pour le LLM d'un voice agent ? R : Le TTFT domine la qualité ressentie, donc je ne paie pas la profondeur d'Opus 4.8 pour pré-qualifier un dossier — Sonnet 4.6 est le sweet spot intelligence/latence/coût avec un tool calling fiable ; je réserve Opus 4.8 pour le résumé post-appel asynchrone où la latence n'existe pas, et je descendrais vers Haiku 4.5 seulement sur des tours purement routeurs (« urgence ou pas ? ») où l'intelligence ne sert à rien. Côté réglage : adaptive thinking avec effort: "low" sur les tours simples pour ne pas exploser le TTFT, jamais un budget de thinking fixe (retiré sur la famille 4.7/4.8).


What "done" looks like

  • Your URL works on phone + desktop
  • Anyone can have a 5-min conversation with it
  • Conversation ends gracefully
  • Interruptions (barge-in) are handled — l'agent se tait quand on le coupe
  • Failures degrade gracefully — jamais de silence ni de stacktrace audible
  • You can demo it live in an interview without praying — et tu peux défendre ton p95 et ton coût par appel avec des chiffres

→ Now : LinkedIn outreach with all 3 projects = first mission incoming.

Bibliothèque tech perso — Achref