Skip to content

Project 3 — Voice Agent

Ton troisième portfolio piece. Un voice agent vertical, démontrable sur une URL publique. Spec complète (modèle mental, budgets, failure modes, exercices détaillés) : ../../04-voice-agents/03-build-voice-agent.md

Ce README est le guide d'exécution du projet : il assume que tu as lu la spec, et il te donne les décisions d'architecture, le code Claude prêt à tourner, les invariants de prod, et la checklist de livraison. La spec explique le pourquoi ; ce fichier cadre le comment et le standard de qualité attendu.


🧠 Le modèle mental en une phrase

Un voice agent n'est pas un chatbot avec une voix collée dessus : c'est un pipeline temps-réel qui se chevauche, dominé par la latence de tour de parole (p95), pas par l'intelligence du LLM. Tout le reste — choix de modèle, prompt caching, parallélisation des tools, barge-in — découle de cette seule contrainte : si l'humain s'arrête de parler et que rien ne revient en ~500–800 ms, il croit que la ligne a coupé.

Micro → [VAD] → [STT] → [LLM] → [TTS] → Haut-parleur
        fin de   audio→  texte→  texte→
        parole   texte   texte   audio

        ▲ overlapping : le TTS du début de réponse joue PENDANT que le LLM génère encore la fin

Le réflexe staff : ne raisonne jamais sur ces composants comme une chaîne séquentielle où chaque étape attend la précédente. Raisonne en pipeline qui se chevauche — c'est le chevauchement qui fait passer la latence ressentie de 1500 ms à 600 ms.


🏗️ Les trois décisions d'architecture (à trancher avant d'écrire une ligne)

1. Pattern A (speech-to-speech) vs Pattern B (cascading)

Tranche Pattern B. Tu sacrifies ~200 ms de latence contre : observabilité totale (transcript + timing + usage par étape), coût 2–3× plus bas, chaque composant optimisable séparément, et la capacité de débugger précisément quel composant a fauté. 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. Détail du tableau de tradeoff dans la spec.

2. Choix du LLM : Sonnet 4.6 pour le dialogue, Opus 4.8 en réserve asynchrone

Pour un tour de dialogue, le time-to-first-token (TTFT) domine la qualité ressentie, pas l'intelligence brute. Tu n'as pas besoin de la profondeur d'Opus pour pré-qualifier un dossier ou prendre un RDV — tu as besoin que le premier token sorte vite pour que le TTS démarre.

UsageModèleIDPrix (in/out par M tok)Pourquoi
Tours de dialogue (chemin critique)Claude Sonnet 4.6claude-sonnet-4-63 $ / 15 $TTFT bas, tool calling vertical suffisant, 3× moins cher qu'Opus en sortie
Résumé/analyse post-appel (asynchrone)Claude Opus 4.8claude-opus-4-85 $ / 25 $ (1M ctx)La latence n'a aucune importance hors-ligne ; la qualité du résumé compte
Sous-tâche de routage trivialClaude Haiku 4.5claude-haiku-4-51 $ / 5 $« urgence ou pas ? » n'a pas besoin de Sonnet

⚠️ Ne mets pas Opus sur le chemin critique du dialogue par réflexe « le meilleur modèle ». Sur la voix, le meilleur modèle est celui qui rend le premier token le plus vite tout en tenant le tool calling. C'est Sonnet 4.6. Garde Opus 4.8 pour le tour de résumé post-appel, où tu peux même streamer 64K+ tokens sans contrainte de latence.

3. Réutilise ton serveur MCP de Projet 2

Si ton MCP de Projet 2 cible la même verticale, branche-le ici comme couche de tools. Ton portfolio devient alors trois projets connectés — une histoire bien plus forte en entretien que trois démos isolées. Concrètement : ton voice_turn appelle les tools exposés par le MCP au lieu de réimplémenter la logique métier.


⚙️ Les réglages Claude qui changent tout pour la voix

Deux réglages, non négociables :

  • Adaptive thinking (thinking: {type: "adaptive"}) — laisse Claude décider quand raisonner. Sur 4.6/4.7/4.8, c'est la seule forme d'extended thinking : l'ancien thinking: {type: "enabled", budget_tokens: N} est supprimé et renvoie un HTTP 400. Contrôle la profondeur via output_config: {effort: ...}, pas via un budget de tokens.
  • 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(...)) et pousser les tokens vers le TTS au fil de l'eau.

Le levier effort sur la voix : mets effort: "low" sur les tours de dialogue simples (le thinking ajoute de la latence morte avant le premier mot audible) et réserve "high" aux tours qui font un vrai raisonnement métier lourd. L'effort vit dans output_config, pas en top-level.

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 automatique 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], system: 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
            system=system,                    # cache_control posé sur le DERNIER bloc system
            tools=tools,
            messages=history,
        ) 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)                # usage pour le $/min RÉEL + le cache hit rate
        return final                          # Message complet (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, jamais 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 renvoyer Message et pas str : le tour doit pouvoir contenir un bloc 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 d'éventuels 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.

Prompt caching : ton system + tes tools sont identiques à chaque tour

Le préfixe tools → system est byte-identique d'un tour à l'autre dans un même appel. Mets un cache_control dessus : les tours 2..N lisent le cache à ~0.1× du prix d'entrée et réduisent le TTFT.

python
# Le préfixe stable, construit UNE fois par appel (pas par tour).
SYSTEM = [
    {
        "type": "text",
        "text": "Tu es l'assistant vocal du cabinet …",  # FROZEN : aucune date, aucun ID interpolé
        "cache_control": {"type": "ephemeral"},            # ttl 5 min par défaut, "1h" si appels espacés
    }
]
# Les tools rendent AVANT system → le breakpoint sur le dernier bloc system cache tools + system ensemble.

L'invariant à connaître : le caching est un match de préfixe — un seul octet qui change quelque part dans le préfixe invalide tout ce qui suit. Les tueurs silencieux classiques sur un voice agent :

Invalidateur silencieuxPourquoi ça casse le cacheFix
datetime.now() / horodatage dans le system promptLe préfixe change à chaque tourInjecte la date dans un message user, pas dans system
call_id / session_id interpolé dans systemPréfixe unique par appel → zéro partagePasse l'ID en contenu de message, après le dernier breakpoint
Liste de tools réordonnée (set, dict non trié)Les tools rendent en position 0 → invalide toutSérialise les tools dans un ordre déterministe (tri par name)
Le tour 2 reconstruit system/tools différemmentFork (résumé, sous-agent) qui ne réutilise pas le préfixe exactCopie system/tools/model verbatim, append le contenu du fork à la fin

Prouve-le, ne le suppose pas : vérifie final.usage.cache_read_input_tokens > 0 sur le tour 2. S'il est à zéro à travers des tours au préfixe identique, un invalidateur est à l'œuvre — diffe les octets rendus de deux requêtes pour le trouver. Note aussi que le minimum cacheable dépend du modèle : 2048 tokens sur Sonnet 4.6, 4096 sur Opus 4.8 — un préfixe plus court ne cache pas (silencieusement, cache_creation_input_tokens: 0).

Pré-chauffe le cache à l'ouverture de la room

Le premier tour d'un appel froid paie le coût d'écriture du cache (~1.25× sur le préfixe). Pour effacer cette latence sur le premier vrai tour — celui que l'utilisateur attend en direct — envoie une requête max_tokens: 0 à l'ouverture de la room :

python
# Pré-warm : prefill le cache au breakpoint, retour immédiat (content: [], 0 token de sortie facturé).
await client.messages.create(
    model="claude-sonnet-4-6",
    max_tokens=0,
    system=SYSTEM,                       # cache_control sur le dernier bloc system
    messages=[{"role": "user", "content": "warmup"}],
)

max_tokens: 0 est rejeté avec stream: true, thinking: {type: "enabled"}, output_config.format, ou dans un batch. Pose le cache_control sur le dernier bloc partagé avec la vraie requête (le system), pas sur le message placeholder. Le TTL s'applique : si tes appels sont espacés de plus de 5 min, re-warme ou passe en ttl: "1h".


🔁 La boucle de tour sous contrainte temps-réel (le cœur du projet)

C'est l'endroit où un voice agent diverge le plus d'un chatbot, 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. Dans un chatbot, ce round-trip d'outil se cache derrière un spinner. En voix, c'est du silence audible — et le silence est interprété comme une panne.

python
import asyncio, random

FILLERS = ["laissez-moi vérifier ça…", "un instant, je regarde…"]
MAX_TOOL_ROUNDS = 4  # borne dure : une hallucination "je rappelle le même tool" ne doit pas boucler à l'infini

async def voice_turn(history, tools, system, tool_impls) -> None:
    for _ in range(MAX_TOOL_ROUNDS):              # boucle d'outils BORNÉE
        msg = await llm_turn(history, tools, system)   # 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 → 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

    # Borne atteinte → on ne facture pas un appel sans fin, on escalade proprement.
    speak("je n'arrive pas à finaliser ça tout de suite, je vous transfère à un conseiller.")
    await escalate_to_human(history)

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

  1. asyncio.gather sur les tools indépendants. 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 est une optimisation de latence directe, pas une coquetterie.
  2. Le filler joué pendant le round-trip (au moment où tu lances gather, pas après). Sans lui, l'utilisateur entend 300–800 ms de rien. Le filler transforme un silence anxiogène en interaction naturelle, gratuitement en latence ressentie.
  3. return_exceptions=True + la borne de boucle. Un tool qui timeout ne doit pas tuer le tour ; et la boucle doit être bornée (MAX_TOOL_ROUNDS), sinon une hallucination 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 : tout ce qui parle ou raisonne doit être annulable. Trois choses doivent être interruptibles simultanément :

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 (économise des tokens de sortie)
    # on repart sur le nouveau transcript ; l'ancien tour est abandonné

Le piège : on pense à couper le TTS (1) mais on oublie la file audio déjà bufferisée (2). Si le TTS a synthétisé 2 phrases 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 est cassé.


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

Failure modeSymptômeMitigation
Barge-inL'utilisateur coupe ; l'agent parle par-dessusCoupe TTS + flush file audio + close stream LLM à la détection de parole entrante
Hallucination STTBruit/accent → transcript garbage → réponse à côtéSeuil de confiance STT ; sous le seuil, demande de répéter au lieu d'agir sur du bruit
Endpointing prématuréLe VAD coupe l'utilisateur en pleine pause de réflexionVAD sémantique + délai de fin de tour adaptatif
Latence en cascadeUn composant lent (TTS surchargé) fait dérailler le budgetTimeout par composant + filler joué pendant l'attente
WebRTC dropLe réseau lâche en plein appelReconnection : ré-établis la room, restaure l'état depuis le transcript persisté, reprends
Tool failureL'API métier (MCP, Postgres) timeout« je n'arrive pas à accéder à votre dossier, je peux vous rappeler ? » — jamais un silence ni un stacktrace audible
Boucle de clarificationL'agent redemande la même chose en boucleCompteur de tentatives ; après N échecs, escalade humaine ou terminaison propre
Coût qui dérapeUn appel qui ne se termine jamais (ligne ouverte)Timeout d'inactivité + condition de terminaison explicite
Refusal Claudestop_reason: "refusal" → ton code lit content[0] et crasheVérifie stop_reason avant de lire content ; dégrade vers un filler/escalade

Le principe transversal : dans le doute, un voice agent dégrade vers une phrase de remplissage ou une escalade humaine — jamais vers le silence ou une erreur audible. Le silence = panne perçue ; un stacktrace audible = produit cassé.


📊 Observabilité : ce que tu logges par tour (sinon tu ne peux rien défendre)

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
}

Transcript complet + timing par étape + usage Claude = ton tableau de bord ET ta preuve. C'est 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. Logge des histogrammes par étape, pas des moyennes (cf. la première question d'entretien).


📁 Structure de dossier suggérée

03-voice-agent/
├── README.md
├── pyproject.toml
├── .env.example
├── backend/
│   ├── agent.py             ← point d'entrée LiveKit Agents (boucle de tour, barge-in)
│   ├── llm.py               ← llm_turn() : AsyncAnthropic + streaming + caching
│   ├── tools.py             ← tools / client MCP (réutilise Projet 2)
│   ├── prompts.py           ← SYSTEM frozen + définitions de tools (ordre déterministe)
│   ├── observability.py     ← log par tour : timing_ms, usage, cost_estimate
│   └── chaos.py             ← injecteur de pannes (exercice 4)
├── frontend/                ← démo web Next.js
│   ├── package.json
│   └── app/ + components/VoiceClient.tsx  ← composants React LiveKit
├── deploy/
│   ├── Dockerfile
│   └── k3s/
└── tests/                   ← tests mock-based de la logique d'agent

✅ Definition of Done

Voice quality

  • [ ] Latence totale < 800 ms au p95 (pas la moyenne), histogrammes par étape
  • [ ] Turn-taking naturel + barge-in fonctionnel (test physique passé)
  • [ ] Audio ≥ 16 kHz intelligible ; support FR si la verticale le demande

Functionality

  • [ ] ≥ 3 tool calls pendant une conversation (réutilise le MCP de Projet 2)
  • [ ] État préservé multi-tours ; clarification gracieuse ; condition de terminaison

Engineering

  • [ ] Reconnection logic (WebRTC drop → restore depuis transcript)
  • [ ] Error handling : LLM down (429/529/timeout), tool fail, refusal Claude
  • [ ] Logging : transcript + tool calls + timing_ms + usage par tour
  • [ ] Cost tracking : $/appel terminé ventilé par poste
  • [ ] Prompt caching prouvé (cache_read_input_tokens > 0)
  • [ ] Tests mock-based de la logique d'agent

Distribution

  • [ ] URL de démo publique (web app)
  • [ ] Loom de 90 s d'une conversation complète
  • [ ] README avec architecture, métriques, tradeoffs
  • [ ] Article Medium + post LinkedIn
  • [ ] Repo GitHub dédié, pinné

🏋️ 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. (Plusieurs recoupent la spec ; ici ils sont cadrés pour l'exécution dans backend/.)

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 (numpy.percentile, pas mean). Tu dois pouvoir répondre « où part le temps sur le tour le plus lent ? » sans deviner.

Exercice 2 — Implémente le barge-in et prouve l'annulabilité totale

Objectif : quand l'utilisateur parle pendant que l'agent parle, l'agent se tait dans les 150 ms et traite le nouvel input — TTS, file audio bufferisée et stream LLM tous les trois coupés. Indice/Solution : à la détection de parole entrante, exécute les trois annulations de on_user_speech_detected. Instrumente tts_cancel_ms, queue_flush_ms, llm_close_ms relatifs à la détection. Le piège à exposer : coupe l'agent quand le TTS a 2 phrases d'avance — sans flush(), il les finit par-dessus l'utilisateur même avec le TTS « coupé ». Filme le test physique : tu coupes en pleine phrase, il se tait net.

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 : cache_control: {type: "ephemeral"} sur le dernier bloc system. Vérifie usage.cache_read_input_tokens > 0 sur le tour 2. S'il est à zéro : invalidateur silencieux — diffe les octets rendus de deux requêtes (cherche un timestamp dans system, un ordre de tools non trié, un call_id interpolé). Bonus : ajoute le pré-warm max_tokens: 0 à l'ouverture de la room et mesure le TTFT du premier tour avec vs sans.

Exercice 4 — Casse-le puis répare-le : injecte des pannes (chaos.py)

Objectif : prouver la dégradation gracieuse sous quatre pannes : LLM 429, tool timeout, WebRTC drop, refusal Claude. Indice/Solution : un flag de chaos qui (a) fait lever RateLimitError, (b) fait timeouter ton tool MCP, (c) coupe le WebRTC à mi-appel, (d) force un stop_reason: "refusal" (mock du Message). Pour chacune, l'agent produit une phrase audible de remplissage ou d'escalade, jamais un silence ni un stacktrace. Pour le refusal : vérifie que ton code branche sur stop_reason avant de lire content[0]. Pour le drop : restaure l'état depuis le transcript persisté et reprends. Filme les quatre — 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 final.usage Claude à chaque tour (pas une estimation), distingue input_tokens, cache_read_input_tokens (à ~0.1×) et output_tokens pour calculer le coût LLM réel, additionne les minutes STT/TTS facturables, ventile. Tu vas probablement découvrir que le LLM n'est pas le poste dominant (le TTS + le transport le sont). Écris une phrase défendable : « Le coût est dominé par le TTS à X %, et voici le levier que j'actionnerais d'abord. »

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 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 (↓) et taux de coupure prématurée (↓). Trace la courbe de tradeoff et défends ton point de fonctionnement 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 voice_turn. Remplace une exécution séquentielle par asyncio.gather, joue le filler au moment où tu lances l'exécution, ajoute MAX_TOOL_ROUNDS. Instrumente tool_round_latency_ms série vs parallèle sur un tour à deux tools — montre le gain chiffré. Test de la boucle infinie : force (prompt adverse) le LLM à rappeler le même tool en boucle, prouve que ton borneur escalade au lieu de facturer un appel sans fin.

Exercice 8 — Le tour de résumé post-appel avec Opus 4.8 (hors chemin critique)

Objectif : après raccrochage, générer un résumé structuré de l'appel (motif, slots remplis, action recommandée) avec un modèle haut de gamme, sans aucune contrainte de latence. Indice/Solution : appelle client.messages.stream(model="claude-opus-4-8", ...) sur le transcript complet persisté, avec output_config={"effort": "high"} et un schéma de sortie structuré (output_config.format avec un schéma Pydantic via client.messages.parse()) pour un résumé typé et parseable. Streame si max_tokens est élevé (les SDK timeoutent au-delà de ~16K en non-streaming). Défense attendue : « le chemin critique tourne sur Sonnet 4.6 pour le TTFT, et je bascule sur Opus 4.8 pour le résumé asynchrone où la qualité prime et la latence n'existe pas. » C'est exactement le genre de décision de routage de modèle qu'on attend d'un senior.


🎤 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 que la ligne a coupé, et c'est ce tour-là que l'utilisateur retient. Je reporte des histogrammes par étape (vad/stt/ttft/tts_start), pas des moyennes.

Q : Pattern A (speech-to-speech) ou Pattern B (cascading) — lequel et pourquoi ? R : Pattern B pour un produit B2B : je sacrifie ~200 ms contre une observabilité totale (transcript + timing + usage par étape), un coût 2–3× plus bas, et la capacité de débugger 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 : Pourquoi Sonnet 4.6 sur le chemin critique et pas Opus 4.8 « le meilleur modèle » ? R : Sur la voix, le TTFT domine la qualité ressentie, pas l'intelligence brute. Sonnet 4.6 (claude-sonnet-4-6) sort le premier token plus vite, tient le tool calling vertical, et coûte 3× moins en sortie. Je garde Opus 4.8 (claude-opus-4-8) pour le résumé post-appel asynchrone, où la latence n'existe pas et où la qualité compte. Routage de modèle par contrainte, pas par prestige.

Q : Comment tu réduis la latence et le coût côté Claude sans dégrader la qualité ? R : Prompt caching sur le préfixe stable (system + tools, byte-identique entre tours) → les tours 2..N lisent le cache à ~0.1× et le TTFT baisse ; je le prouve via cache_read_input_tokens. effort: "low" sur les tours simples via output_config (l'ancien budget_tokens est supprimé sur 4.6/4.8 et renvoie un 400 — j'utilise l'adaptive thinking). max_tokens serré, streaming obligatoire pour parler dès le premier token, et un pré-warm max_tokens: 0 à l'ouverture de la room pour effacer le coût de cache froid sur le premier tour.

Bibliothèque tech perso — Achref