Skip to content

Latency Optimization — Passer de 3.2s à 600ms sans baisser la qualité

TL;DR La latence LLM tue la conversion. Sur un voicebot drive, > 700 ms de TTFT et tu perds 30% des transactions. Sur un chat e-commerce, > 1.5s "vide" et le user ferme l'onglet. Les 7 leviers 2026 : (1) streaming (TTFT < 400 ms perçu), (2) prompt caching (le cache cut aussi la latence input de ~50%), (3) tiering modèle — claude-haiku-4-5 (1$/5$ par MTok) pour les fast paths, escalade vers claude-sonnet-4-6 (3$/15$) ou claude-opus-4-8 (5$/25$ à 1M ctx) seulement si nécessaire, (4) parallel tool calling (3 outils en parallèle au lieu de séquentiel), (5) region routing (EU pour clients FR — pas de round-trip US), (6) edge inference (Cloudflare Workers AI / Vercel AI SDK pour des modèles légers), (7) batching d'embeddings côté serveur. Un cas type FR : chat e-commerce DTC passe de 3.2s → 600 ms en combinant ces leviers. Le tiering modèle est le seul qui réduit latence ET coût à la fois — d'où sa place centrale. Côté freelance, vendre une latency mission "sub-second guarantee" est ferme à 25-40 k€ avec success-fee si seuil atteint.


🧠 Mental model

              REQUEST


        ┌────────────────┐
        │ Network (FR-EU)│  20-80 ms
        │ DNS+TCP+TLS    │
        └────────────────┘


        ┌────────────────┐
        │ Server logic   │  10-100 ms
        │ auth, validate │
        └────────────────┘


        ┌────────────────┐
        │ Retrieval (RAG)│  80-400 ms  ← parallelize embeds, cache
        └────────────────┘


        ┌────────────────┐
        │ Prompt assembly│  5-50 ms
        └────────────────┘


        ┌────────────────┐  TTFT (time to first token)
        │ LLM start ─────┼─────────► first chunk streamed
        │ 200-1500 ms    │
        └────────────────┘


        ┌────────────────┐  TTLT (time to last token)
        │ LLM stream     │  ~50-100 ms / 100 tokens
        │ chunks chunks  │
        └────────────────┘


        ┌────────────────┐
        │ Post-process   │  10-50 ms (PII, guardrails)
        └────────────────┘


              RESPONSE

Analogie : optimiser la latence, c'est l'art du "perçu vite". Le user ne se soucie pas du total_time, il se soucie de quand quelque chose commence à apparaître (TTFT) et du débit de l'apparition (tokens/sec). Comme un restaurant : ce qui compte n'est pas "quand l'addition arrive" mais "quand le pain arrive sur la table". Streaming = pain. Cache = cuisine pré-préparée. Haiku = pizza four à bois (rapide). Opus = soufflé (long mais magistral).

Deux métriques à séparer absolument : TTFT (perçu instantané) et TTLT (perçu fluide). Optimiser le mauvais te fait travailler dans le vent.


🛠️ Code minimal

Streaming Anthropic + prompt caching + parallel tool calling NestJS.

ts
// libs/llm/streaming.service.ts
import Anthropic from "@anthropic-ai/sdk";
import { Response } from "express";

// Un seul client process-wide → réutilise le pool HTTP keep-alive (cf. pitfalls).
// max_retries=2 (défaut SDK) backoff exponentiel sur 429/5xx ; timeout par appel
// pour ne JAMAIS laisser une requête lente bloquer la connexion SSE indéfiniment.
const anthropic = new Anthropic({
  maxRetries: 2,
  timeout: 30_000, // 30 s — au-delà on coupe et on dégrade côté UX
});

export async function streamAnswer(
  res: Response,
  opts: { question: string; tenantId: string; system: string; tools: Anthropic.Tool[] }
) {
  res.setHeader("Content-Type", "text/event-stream");
  res.setHeader("Cache-Control", "no-cache");
  res.setHeader("Connection", "keep-alive");
  res.setHeader("X-Accel-Buffering", "no"); // Nginx : ne bufferise pas le SSE

  // cache_control = breakpoint préfixe. Render order : tools → system → messages.
  // UN SEUL breakpoint sur le DERNIER bloc stable (ici le dernier outil) cache
  // tools + system d'un coup. En mettre un par outil gaspille les 4 breakpoints
  // dispo et n'apporte rien (cf. shared prompt-caching : prefix match).
  const tools = opts.tools.map((t, i) =>
    i === opts.tools.length - 1
      ? { ...t, cache_control: { type: "ephemeral" as const } }
      : t,
  );

  try {
    const stream = anthropic.messages.stream({
      model: "claude-haiku-4-5", // fast tier par défaut (cheap : 1$ / 5$ par MTok)
      max_tokens: 600,
      system: [{ type: "text", text: opts.system }],
      tools,
      messages: [{ role: "user", content: opts.question }],
    });

    for await (const event of stream) {
      if (event.type === "content_block_delta" && event.delta.type === "text_delta") {
        res.write(`data: ${JSON.stringify({ text: event.delta.text })}\n\n`);
      }
    }
    const final = await stream.finalMessage();
    // Loggue usage pour le coût ET le hit-rate cache (cache_read_input_tokens).
    res.write(`data: ${JSON.stringify({ done: true, usage: final.usage })}\n\n`);
  } catch (err) {
    // Exceptions typées du SDK — jamais de string-match sur le message.
    if (err instanceof Anthropic.RateLimitError) {
      res.write(`data: ${JSON.stringify({ error: "rate_limited", retryable: true })}\n\n`);
    } else if (err instanceof Anthropic.APITimeoutError) {
      res.write(`data: ${JSON.stringify({ error: "timeout", retryable: true })}\n\n`);
    } else if (err instanceof Anthropic.APIError) {
      res.write(`data: ${JSON.stringify({ error: "api_error", status: err.status })}\n\n`);
    } else {
      throw err;
    }
  } finally {
    res.end();
  }
}

Note senior : si la cible n'est pas juste "TTFT bas" mais "coût bas", claude-haiku-4-5 (1 $ / 5 $ par MTok) est ~5× moins cher en sortie que claude-sonnet-4-6 (3 $ / 15 $) et ~5× moins cher que claude-opus-4-8 (5 $ / 25 $ à 1M de contexte). Le tiering modèle est le levier latence ET coût : Haiku pour le fast path, escalade vers Sonnet/Opus uniquement sur les requêtes qui le justifient (raisonnement multi-step, outils chaînés). Opus 4.8 reste réservé aux tâches agentiques longues — pas un chat e-commerce sub-second.

ts
// libs/llm/parallel-tools.service.ts
// Quand Claude renvoie plusieurs tool_use dans un même tour, on les exec en parallèle.

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

export async function execToolsParallel(
  toolUses: Anthropic.ToolUseBlock[],
  registry: Record<string, (input: any) => Promise<any>>,
) {
  return Promise.all(
    toolUses.map(async tu => ({
      type: "tool_result" as const,
      tool_use_id: tu.id,
      content: JSON.stringify(await registry[tu.name](tu.input)),
    })),
  );
}
ts
// usage
const tools = [
  { name: "get_product", description: "...", input_schema: {...} },
  { name: "get_stock", description: "...", input_schema: {...} },
  { name: "get_promo", description: "...", input_schema: {...} },
];

const res = await anthropic.messages.create({...});
if (res.stop_reason === "tool_use") {
  const toolBlocks = res.content.filter(b => b.type === "tool_use") as Anthropic.ToolUseBlock[];
  const results = await execToolsParallel(toolBlocks, REGISTRY);
  // Claude est trained pour émettre plusieurs tool_use en parallèle quand il sait
  // qu'ils sont indépendants. Tu économises N-1 round-trips si N tools.
}

🎬 Cas d'usage concrets

Cas 1 — Voicebot drive (sub-700ms target)

Le client : franchise de drive de fast-food FR, voicebot prend la commande au micro. Contrainte UX : TTFT < 700 ms sinon le client trouve ça artificiel et raccroche / repart.

Mesures initiales :

  • ASR (Deepgram FR) : 200 ms after end-of-utterance.
  • LLM (Sonnet US East) : 1200 ms TTFT.
  • TTS (ElevenLabs Turbo, streaming) : 250 ms TTFT.
  • Total perçu : ~1.65s. Inadmissible.

Optim :

  1. Routing France : Anthropic Bedrock région eu-west-3 (Paris). TTFT → 850 ms.
  2. Haiku pour 90% des requêtes (commande standard). TTFT Haiku eu → 320 ms.
  3. Prompt caching : system (menu, prix, règles) cached → -40% TTFT additionnel → 200 ms.
  4. Streaming end-to-end : LLM stream → TTS stream dès le premier token de phrase complète (détection ponctuation .,?!).
  5. Pré-warm : pour les 50 questions ultra-fréquentes ("juste un menu maxi best of"), réponse pré-générée servie en < 50 ms.

Résultat : TTFT perçu (utilisateur entend le bot) < 500 ms dans 95% des cas. Mission 38 k€ avec success-fee de 8 k€ au passage du seuil. Drives clients gagnent +6% de conversion → ROI mission < 2 mois pour le franchiseur.

Cas 2 — Chatbot e-commerce sub-300ms time-to-stream

Le client : DTC FR, chat dans le tunnel de paiement. KPI : TTFT < 300 ms pour ne pas tuer la conv. Le user tape "j'ai un code promo VIP15", attend, clique ailleurs si rien n'apparaît.

Levier principal : edge inference via Cloudflare Workers AI sur Llama 3.3 70B pour les questions "préfixe rapide" (commande, retour, promo). Inference < 200 ms TTFT depuis Paris edge.

Fallback : si la question est plus complexe (multi-step, raisonnement), fallback Anthropic Sonnet (~600 ms TTFT) avec un message intermédiaire "Je vérifie ça pour vous..." streamé immédiatement (< 80 ms) pour combler le gap UX.

Résultat : TTFT P50 = 180 ms, P95 = 580 ms, P99 = 1.1 s. Mission 27 k€.

Cas 3 — Agent banque mobile (UX patience faible)

Le client : néobanque FR. Chatbot in-app pour conseil simple ("où est mon RIB", "limite carte", "suspendre carte"). Sur mobile, les users sont impatients : > 1.5s TTFT = abandon.

Solution :

  1. Région EU : Mistral Large 2 hébergé sur Scaleway IA (Paris). TTFT 400 ms.
  2. Prompt caching : KYC context + règles de conformité = 8 000 tokens cached.
  3. Parallel tool calling : 3 outils (get_balance, get_last_tx, get_card_status) en parallèle quand l'intent est "synthèse compte". Avant 1.8s, après 600 ms.
  4. Pre-fetch sur ouverture chat : dès que l'utilisateur ouvre le chat, on lance un appel "cache warmup" qui pré-remplit le cache Mistral (system prompt) → 1er message instantané.

Mission 32 k€ avec mesure objective avant/après en docs.

Cas 4 — RAG immobilier (recherche < 1s)

Le client : SaaS immobilier, recherche conversationnelle ("appartement 3 pièces Bordeaux centre < 400k"). KPI : résultat < 1 s.

Mesures avant : TTFT 2.4 s (retrieval qdrant 600 ms + rerank cohere 500 ms + LLM 1.3 s).

Optim :

  • Embedder batching (les 3 sous-requêtes de query rewriting en 1 appel) : -200 ms.
  • Rerank côté serveur GPU partagé (BAAI bge-reranker-v2-m3 self-host) → -300 ms vs Cohere distant.
  • LLM Haiku pour la reformulation + Sonnet en parallèle pour la synthèse (race contre le temps : Haiku gagne quasi toujours, on prend sa réponse, on annule Sonnet).
  • Prompt cache sur les filtres métier.

Résultat : P50 720 ms, P95 1.4 s. Mission 24 k€.


🛠️ Exemple end-to-end — Chat e-commerce 3.2s → 600ms

Contexte : DTC mode FR, chatbot AI sur la fiche produit ("est-ce que ça taille petit ?", "quel délai de livraison Paris ?"). Stack actuelle : GPT-4o US, retrieval Qdrant cloud (US), pas de streaming, pas de cache. TTFT moyen 3.2 s. Conversion chatbot 4.1%. Le client veut TTFT < 800 ms et conv > 6%.

1) Mesure baseline (Langfuse + Web Vitals)

ts
// libs/observability/latency-tracker.ts
export class LatencyTracker {
  start = performance.now();
  marks: Record<string, number> = {};
  mark(name: string) {
    this.marks[name] = performance.now() - this.start;
  }
  report(trace: LangfuseTraceClient) {
    trace.update({ metadata: { latency_marks: this.marks } });
  }
}

Tu instrumentes les étapes : req_received, embed_done, retrieve_done, rerank_done, llm_first_token, llm_last_token, sent_to_client. Tu obtiens (sur 1 semaine, 10k traces) :

req_received → embed_done       : 180 ms (cold), 25 ms (warm)
embed_done → retrieve_done      : 320 ms
retrieve_done → rerank_done     : 480 ms (Cohere US)
rerank_done → llm_first_token   : 1850 ms (GPT-4o US, no cache)
llm_first_token → llm_last_token: 600 ms
post-process                    : 90 ms
TOTAL TTFT (until first token)  : 2.83s   ← problem
TOTAL TTLT                      : 3.52s

Goulots : LLM TTFT (1.85 s) > rerank (480 ms) > retrieve (320 ms).

2) Plan d'attaque

  1. Routage Anthropic eu (Paris) + Haiku par défaut.
  2. Prompt caching sur system + tools.
  3. Streaming SSE bout en bout.
  4. Rerank self-hosted (bge-reranker) GPU EU.
  5. Embed batching côté serveur.
  6. Pre-warm cache sur chargement de page produit.
  7. Parallel tools (3 outils possibles : get_product, get_stock, get_promo).

3) NestJS — handler streaming

ts
// apps/chat-ecom/src/chat/chat.controller.ts
import { Controller, Get, Query, Res } from "@nestjs/common";
import { Response } from "express";
import { ChatService } from "./chat.service";
import { LatencyTracker } from "@app/observability";

@Controller("chat")
export class ChatController {
  constructor(private chat: ChatService) {}

  @Get("stream")
  async stream(
    @Query("q") q: string,
    @Query("session") sessionId: string,
    @Res() res: Response,
  ) {
    const tracker = new LatencyTracker();
    res.setHeader("Content-Type", "text/event-stream");
    res.setHeader("Cache-Control", "no-cache");
    res.setHeader("Connection", "keep-alive");
    res.setHeader("X-Accel-Buffering", "no"); // Nginx no buffer

    // 1. retrieve + rerank in parallel with embed
    tracker.mark("start");
    const [embed, productCtx] = await Promise.all([
      this.chat.embed(q),
      this.chat.warmFromSession(sessionId), // pre-loaded product
    ]);
    tracker.mark("embed_done");

    const docs = await this.chat.retrieveAndRerank(embed, q);
    tracker.mark("retrieve_done");

    // 2. start streaming immediately
    const stream = this.chat.streamLLM({ q, docs, productCtx });
    let firstToken = true;
    for await (const chunk of stream) {
      if (firstToken) {
        tracker.mark("first_token");
        firstToken = false;
      }
      res.write(`data: ${JSON.stringify({ t: chunk })}\n\n`);
    }
    tracker.mark("done");
    res.write(`data: ${JSON.stringify({ done: true, latency: tracker.marks })}\n\n`);
    res.end();
  }
}

4) Service LLM streaming avec parallel tools

ts
// apps/chat-ecom/src/chat/chat.service.ts
import { Injectable } from "@nestjs/common";
import Anthropic from "@anthropic-ai/sdk";

const SYSTEM = `... 4500 tokens of brand voice + product knowledge ...`;
const TOOLS = [
  { name: "get_stock", description: "Returns stock for sku.", input_schema: {...} },
  { name: "get_delivery_eta", description: "Delivery ETA by postcode.", input_schema: {...} },
  { name: "get_size_guide", description: "Sizing recommendation.", input_schema: {...} },
];

@Injectable()
export class ChatService {
  private anthropic = new Anthropic({
    baseURL: process.env.ANTHROPIC_EU_URL, // bedrock eu-west-3 endpoint
  });

  async *streamLLM(input: { q: string; docs: Doc[]; productCtx: ProductCtx }) {
    const messages: Anthropic.MessageParam[] = [
      { role: "user", content: this.assembleUser(input) },
    ];

    let stop = false;
    while (!stop) {
      const stream = this.anthropic.messages.stream({
        model: "claude-haiku-4-5",
        max_tokens: 500,
        system: [
          { type: "text", text: SYSTEM, cache_control: { type: "ephemeral" } },
        ],
        // Un seul breakpoint sur le dernier outil (cache tools+system d'un bloc).
        tools: TOOLS.map((t, i) =>
          i === TOOLS.length - 1
            ? { ...t, cache_control: { type: "ephemeral" as const } }
            : t,
        ),
        messages,
      });

      for await (const event of stream) {
        if (event.type === "content_block_delta" && event.delta.type === "text_delta") {
          yield event.delta.text;
        }
      }
      const final = await stream.finalMessage();

      if (final.stop_reason === "tool_use") {
        const toolUses = final.content.filter(b => b.type === "tool_use") as Anthropic.ToolUseBlock[];
        const results = await Promise.all(
          toolUses.map(async tu => ({
            type: "tool_result" as const,
            tool_use_id: tu.id,
            content: JSON.stringify(await this.exec(tu.name, tu.input)),
          })),
        );
        messages.push({ role: "assistant", content: final.content });
        messages.push({ role: "user", content: results });
      } else {
        stop = true;
      }
    }
  }
}

5) Frontend SSE consumer

ts
// apps/web/src/chat-widget.ts
async function ask(q: string, sessionId: string) {
  const res = await fetch(`/api/chat/stream?q=${encodeURIComponent(q)}&session=${sessionId}`);
  const reader = res.body!.getReader();
  const decoder = new TextDecoder();
  let buf = "";
  while (true) {
    const { done, value } = await reader.read();
    if (done) break;
    buf += decoder.decode(value);
    for (const line of buf.split("\n\n")) {
      if (line.startsWith("data: ")) {
        const data = JSON.parse(line.slice(6));
        if (data.t) ui.appendToken(data.t);
        if (data.done) ui.markDone(data.latency);
      }
    }
    buf = buf.split("\n\n").slice(-1)[0];
  }
}

6) Pre-warm cache on page load

ts
// apps/web/src/product-page.ts
// dès que la page produit charge, on ping /api/chat/warm avec le SKU
// → backend lance un appel Claude avec system + tools (cache write 25% premium)
// → premier vrai message coûtera 0.1x sur input
fetch(`/api/chat/warm?sku=${sku}`, { method: "POST" });

7) Résultat après 3 semaines

req_received → embed_done       : 25 ms (warm)
embed_done → retrieve_done      : 140 ms (eu Qdrant)
retrieve_done → rerank_done     : 80 ms (self-host bge GPU)
rerank_done → first_token       : 320 ms (Haiku eu + cache hit)
TTFT total                      : 565 ms   ← target met
TTLT                            : 1.2 s

Conversion chatbot : 4.1% → 7.3%. CSAT proxy +0.6 pt. Mission 34 k€ + success-fee 6 k€ au seuil < 700 ms TTFT.


🎯 Patterns courants

  • TTFT vs TTLT : optimise TTFT en priorité (perçu). TTLT n'importe que pour les réponses longues.
  • Streaming SSE : standard 2026. WebSocket overkill pour du chat unilatéral.
  • Premier "filler chunk" : envoyer un "…" ou "Je vérifie" immédiatement (< 50 ms) tant que le LLM n'a rien produit. Le user perçoit "alive".
  • Parallel tool calling : Claude/GPT savent émettre plusieurs tool_use simultanés. Toujours les Promise.all. Souvent -50% sur le tour 2.
  • Race the models : lancer Haiku + Sonnet en parallèle, prendre le premier qui finit avec une réponse jugée "acceptable" par un guard rapide. Coûte plus, mais latence garantie.
  • Region routing : Anthropic Bedrock eu-west-3, Azure OpenAI francecentral, Mistral Scaleway. Pour clients FR, -300 à -500 ms RTT vs US.
  • Edge inference : Cloudflare Workers AI, Vercel AI SDK on-edge. Pour fast paths (Llama 3.3, Gemini Flash).
  • Pre-warm cache : appel "warmup" déclenché par interaction utilisateur AVANT la vraie requête (ouverture du chat, hover bouton).
  • HTTP/2 keep-alive : connexion persistante client → API → LLM provider. -50 à -100 ms par requête.
  • Batch embeddings : si tu fais 5 embeds dans la même requête, batche-les en un seul call API.
  • Async tail : si une étape post-LLM (audit log, eval async, push Langfuse) prend 100 ms, fais-la en setImmediate après res.end().
  • Truncate context fast : limite l'historique à 4 derniers tours → moins d'input → TTFT plus rapide (chaque 1k tokens input = +50 ms typique).

🔄 Versions & écosystème 2026

  • Anthropic Bedrock EU : eu-west-3 (Paris), eu-central-1 (Francfort). Sonnet et Haiku dispo.
  • Azure OpenAI France Central : GPT-4o, GPT-4o-mini, GPT-5-mini hébergés FR.
  • Mistral via Scaleway IA / OVHcloud : souverain FR, Large 2 et Small. Latence très basse FR.
  • Cloudflare Workers AI : Llama 3.3 70B, Llama 3.3 8B en edge. Idéal pour fast paths.
  • Groq : ultra-low latency (TTFT < 100 ms) sur Llama / Mixtral, hébergement US principalement.
  • Cerebras Inference : encore plus rapide que Groq sur certains modèles, US.
  • Together AI : self-host-like vitesse, multi-modèles.
  • Vercel AI SDK : abstraction streaming front-end facile.
  • NestJS / Fastify : prefer Fastify si tu vises le top latence backend.
  • HTTP/3 (QUIC) : Cloudflare + clients modernes, -30 à -80 ms vs HTTP/2 sur mobile.

⚠️ Pitfalls

  1. Nginx bufferise SSE : ajoute X-Accel-Buffering: no et désactive proxy_buffering.
  2. Cloudflare bufferise SSE : configure cache: no-store + Cache-Control: no-transform.
  3. Streaming + JSON structuré : le client doit parser des fragments JSON incomplets. Soit tu streams du texte, soit tu utilises un protocole "events" (delta operations).
  4. Latence du classifier : un classifier en GPT-4o pour décider du modèle ajoute 800 ms → annule le bénéfice du tiering. Use Haiku ou prompt cache.
  5. Mesurer la mauvaise métrique : tu mesures total_time au lieu de TTFT, tu ne vois pas que ton streaming marche.
  6. Region routing US par défaut : sans rien faire, ton SDK Anthropic appelle US-East. Toujours configurer la base URL EU.
  7. Cold start serverless : Lambda froide = +500 ms. Use provisioned concurrency ou container apps.
  8. Trop d'await en série : await embed; await retrieve; await rerank;Promise.all.
  9. Prompt cache write coûte 25% premium : si ton hit rate est <30%, tu paies plus en coût ET tu n'as pas le gain latence. Eval hit rate.
  10. TTS qui attend la fin du LLM : sur voicebot, le TTS doit consommer le LLM en stream et émettre dès la première phrase complète.

💰 Pricing / ROI client

Mission types :

  • Audit latence (mesures + plan) : 6-10 k€ (1 semaine).
  • Mission "sub-second guarantee" (refactor + déploiement EU + streaming + cache) : 25-40 k€ + success-fee 5-10 k€ au seuil.
  • Maintenance latence (regression watch, alerting) : 1500-2000€/j 1j/mois.

ROI client : conversion chatbot +30% à +80% sur les cas e-commerce / drive. Pour un client à 500 k€ CA/mois sur tunnel chat → +50 k€ CA mensuel = mission payée en 2 semaines.


🧪 Testing / Eval

  • Synthetic load test : k6 ou Artillery, 100 RPS pendant 10 min, mesure TTFT p50/p95/p99.
  • Real User Monitoring (RUM) : SDK web qui logge time_to_first_byte, time_to_first_token, time_to_done par session.
  • Regression alerts : Grafana alert si TTFT p95 > 800 ms 10 min.
  • Geographic test : mesurer depuis Lyon, Marseille, Lille, Bordeaux (CDN/route).
  • Cold path test : simuler un cache miss (clear redis) → vérifier que la latence reste sous SLA dégradée.

🔁 Quand utiliser / éviter

Utiliser :

  • Voicebot, livechat, agent in-app mobile.
  • Domaines avec haute pression conversionnelle (e-commerce, banque, immo).
  • KPI explicitement latence-sensible.

Use minimal :

  • Workflow batch (ingest, summarize nightly).
  • Email assistant, slack bot async.
  • Tool interne pour devs (les devs tolèrent 3 s).

🧩 Bonus — Patterns latence avancés FR

A. Speculative decoding (modèle helper)

Un petit modèle (Haiku) génère une "ébauche" tokens-par-tokens ; un grand modèle (Opus) valide en batch. Si validé → on garde, sinon → on reprend. Réduit TTLT de 30-60% sur les tâches répétitives (résumés, classifications).

En 2026, c'est natif dans Together AI et certains endpoints. Tu n'as pas à l'implémenter côté open-source.

A-bis. Adaptive thinking = le piège latence #1 sur Opus 4.8 / Sonnet 4.6

Sur les modèles Anthropic 4.6+, le thinking est adaptatif (thinking: {type: "adaptive"}) et non plus un budget de tokens fixe. C'est puissant pour la qualité, mais catastrophique pour le TTFT si tu le laisses tourner sur un fast path : le modèle peut "penser" plusieurs secondes avant le premier token visible. Pire, sur Opus 4.8/4.7 le display par défaut est "omitted" → côté user tu vois une longue pause vide avant le stream.

Règles de staff engineer :

  • Fast path latence-sensible (chat e-commerce, voicebot) : thinking désactivé sur Haiku/Sonnet, ou tout simplement ne pas envoyer le champ. Haiku 4.5 ne prend de toute façon pas de budget de pensée.
  • Si tu veux du raisonnement ET du perçu rapide : thinking: {type: "adaptive", display: "summarized"} pour streamer un résumé de raisonnement (l'utilisateur voit "ça travaille") au lieu d'une pause morte.
  • Le levier de profondeur, c'est output_config.effort (low | medium | high | max), pas un budget de tokens. Pour un fast path, effort: "low" réduit drastiquement les tokens de raisonnement → TTLT plus bas.
ts
// Fast path : pas de thinking, effort minimal → TTFT optimal
const stream = anthropic.messages.stream({
  model: "claude-haiku-4-5",
  max_tokens: 500,
  output_config: { effort: "low" },
  system: [{ type: "text", text: SYSTEM }],
  messages,
});

// Path "réflexion mais perçu vivant" : résumé streamé au lieu d'une pause vide
const deep = anthropic.messages.stream({
  model: "claude-sonnet-4-6",
  max_tokens: 4000,
  thinking: { type: "adaptive", display: "summarized" },
  output_config: { effort: "medium" },
  system: [{ type: "text", text: SYSTEM }],
  messages,
});

⚠️ thinking: {type: "enabled", budget_tokens: N} renvoie HTTP 400 sur Opus 4.8/4.7. L'ancienne syntaxe "budget" est supprimée. Si tu vois ça dans une base legacy, c'est une régression à corriger.

B. Prefill output (technique "started-with")

Si tu sais que la réponse commence souvent par un préfixe ("D'après l'article L."), tu peux prefill la réponse côté Anthropic via messages: [..., { role: "assistant", content: "D'après l'article L." }]. Économie de 50-100 ms TTFT perçu (le user voit le préfixe immédiatement).

C. UI patterns qui masquent la latence

Même avec TTFT 600 ms, le user perçoit "rapide" si :

  • Skeleton + typing dots : animés dès qu'on envoie la requête (perceived 0 ms).
  • Phrases d'attente : "Je vérifie votre demande..." streamé immédiatement avant le vrai contenu LLM.
  • Streaming progressif fluide : ralentir artificiellement le stream si trop rapide (paradoxal mais améliore confort de lecture).

D. Parallèle retrieval + LLM warmup

Avant d'avoir la réponse de l'embed + retrieve (300 ms), tu peux commencer un appel LLM avec un context placeholder + system prompt → le LLM réserve sa capacité (warmup). Une fois le retrieve fini, tu re-invoke avec le context final. Coût supplémentaire faible, gain TTFT 100-200 ms.

E. CDN edge cache pour les réponses ultra-statiques

Les questions "horaires d'ouverture", "politique retour" ne changent jamais. Cache leur réponse JSON sur Cloudflare avec un Cache-Tag invalidable. TTFT < 30 ms perçu côté user.

ts
// /api/static-faq?q=horaires
res.setHeader("Cache-Control", "public, s-maxage=3600, stale-while-revalidate=86400");
res.setHeader("Cache-Tag", "faq,horaires");

F. Mesure latence côté user (RUM)

Le client React/Vue logge en local :

  • t_request_sent
  • t_first_byte (TTFB)
  • t_first_token (premier data: {...} SSE)
  • t_first_word (premier mot visible — utile car selection peut filtrer "…")
  • t_last_token

Push tous les 60s vers /api/rum qui agrège par session/feature/region.

ts
window.addEventListener("beforeunload", () => {
  navigator.sendBeacon("/api/rum", JSON.stringify(rumBuffer));
});

G. Pré-fetch suivant l'intention

Si l'user tape "code prom..." dans le chat, dès qu'il dépasse 4 chars on lance un prefetch en arrière-plan avec un guess "code promo". Si l'user envoie réellement cette question dans la 1 seconde, on a déjà la réponse. Sinon, on cancel et on jette.

ts
const debounced = debounce(prefetch, 200);
input.addEventListener("input", e => debounced(e.target.value));

Économie perçue : TTFT quasi 0 ms pour 30-40% des questions courtes.

H. Latence budget par étape (SLO matriciel)

Décompose ton budget total en sous-budgets par étape. Si TTFT cible = 700 ms, alors :

ÉtapeBudgetSi dépassé
Network ingress60 msInvestiguer CDN / DNS
Auth + validate30 msCache JWT, pas de DB lookup
Embed50 msCache embedding ou batch
Retrieve120 msIndex optim, region
Rerank120 msSelf-host GPU
Prompt assembly20 msTrim, no JSON.stringify huge
LLM first token280 msCache, region, modèle
Filler send20 msFirst chunk SSE
Total700 ms

À chaque PR, mesurer ces 8 marks. Si une étape dépasse son budget, c'est un blocker.

I. P99 vs P95 — attention au tail

P95 = 95% des users ok, 5% souffrent. Sur 100k req/jour = 5000 users qui ont une mauvaise expérience. Optimise aussi P99, surtout sur les flagships. Souvent dû à :

  • Cold start serverless.
  • Cache miss + provider lent à ce moment.
  • Garbage collect côté Node (--max-old-space-size).
  • Connection pool épuisé.

J. Région-pinning explicite côté client

Pour ne pas que ton CDN te route mal, ajoute le hint régional :

ts
fetch("https://api.acme.fr/chat", {
  headers: {
    "x-acme-region-hint": "fr-par",
  },
});

Et côté backend, log la région servie. Si tu vois "lyon → fra" anormalement souvent, problème CDN à corriger.


🏋️ Exercices

Demandants et progressifs. Chaque exercice suppose le précédent fait. Objectif : sortir avec un chiffre défendable, pas juste "ça marche".

Exo 1 — Instrumenter avant d'optimiser (le socle)

Objectif : sans toucher au modèle, produire une décomposition TTFT par étape (embed → retrieve → rerank → first_token → done) sur 1 000 requêtes réelles, et identifier le goulot dominant avec une métrique P50/P95/P99.

Indice/Solution : reprends LatencyTracker. Pousse chaque mark dans Langfuse (ou un simple histogramme Prometheus histogram_quantile). Le piège : mesurer total_time au lieu du time-to-first-token. Sors un tableau "étape → P50 / P95" et entoure la ligne qui domine le P95. Tant que tu n'as pas ce tableau, toute optim est de la spéculation.

Exo 2 — Streaming SSE bout-en-bout qui ne se fait pas bufferiser

Objectif : passer un endpoint NestJS de réponse bloquante à du SSE streamé, et prouver côté navigateur que le premier token arrive < 400 ms via PerformanceObserver.

Indice/Solution : Content-Type: text/event-stream, X-Accel-Buffering: no, désactive proxy_buffering Nginx ET Cache-Control: no-transform si Cloudflare est devant. Mesure côté client t_first_token (premier data: SSE non vide). Si ton P95 ne bouge pas après le passage en streaming, c'est qu'un proxy bufferise — c'est ça le vrai test.

Exo 3 — Prompt caching : défendre le hit-rate

Objectif : mettre un cache_control sur le préfixe stable (system + tools), puis prouver via usage.cache_read_input_tokens que le hit-rate dépasse 70 % sur trafic continu. Si non, expliquer pourquoi.

Indice/Solution : le cache est un prefix match — un seul octet volatile en tête invalide tout l'aval. Cherche les invalidateurs silencieux : Date.now() dans le system, JSON d'outils non trié, set d'outils qui varie par tenant. Vérifie l'ordre de rendu tools → system → messages. Bonus : implémente le pre-warm (max_tokens: 0 au boot) et mesure le TTFT de la 1ʳᵉ vraie requête avant/après. Défends : "à quel hit-rate le cache write premium de 1,25× devient rentable ?" (réponse : dès 2 requêtes en TTL 5 min).

Exo 4 — Parallel tool calling + race-the-models

Objectif : pour un intent "synthèse compte" (3 outils indépendants), exécuter les tool_use en Promise.all au lieu de séquentiel, puis ajouter un race Haiku-vs-Sonnet sur la reformulation. Mesurer le gain TTFT du tour 2 et le surcoût en tokens.

Indice/Solution : Promise.all sur les tool_result économise N-1 round-trips. Pour le race : Promise.race avec un guard rapide qui accepte la première réponse "acceptable", AbortController pour annuler le perdant. Le vrai exercice est de défendre le surcoût : tu paies 2 modèles pour 1 réponse — calcule le coût additionnel par requête (input Haiku + input Sonnet) et le seuil de TJM/conversion à partir duquel ça vaut le coup.

Exo 5 — Casse-le puis répare-le (le tail P99)

Objectif : sous load test k6 à 100 RPS, faire exploser le P99 (vide le cache Redis à froid, sature le pool de connexions), observer le tail, puis le ramener sous SLA dégradé.

Indice/Solution : un P99 à 1,1 s sur 100k req/jour = 1 000 users qui souffrent par jour. Causes classiques : cold start serverless (→ provisioned concurrency), pool de connexions épuisé (→ HTTP/2 keep-alive vers le provider), GC Node (--max-old-space-size), cache miss simultané à un provider lent. Construis un budget matriciel par étape (le tableau SLO) et fais échouer le CI si une étape dépasse son budget. Livrable : courbe P99 avant/après + la cause racine identifiée.

Exo 6 (boss) — Garantie "sub-second" production-grade

Objectif : packager le tout en une mission "sub-second guarantee" : routing EU + Haiku par défaut + cache + streaming + fallback. Définir un SLA contractuel (TTFT P95 < 700 ms), l'alerting Grafana, et le comportement de dégradation quand le SLA n'est pas tenable (provider lent).

Indice/Solution : c'est l'exercice "staff". Tu dois répondre à : que se passe-t-il quand Haiku eu est surchargé (529) ? → fallback région ou modèle, avec un filler chunk immédiat ("Je vérifie…") pour tenir le perçu. Quand le retrieval dépasse son budget ? → réponse partielle ou message d'attente. Le SLA doit avoir une clause de dégradation explicite, sinon tu signes un chèque en bois. Bonus freelance : structure le contrat avec success-fee au franchissement du seuil mesuré objectivement en docs (avant/après Langfuse).


🎤 En entretien

Q : "Le client se plaint que le chat est lent à 3,2 s. Par où tu commences ?" R : J'instrumente d'abord (décompo TTFT par étape sur du trafic réel) — on n'optimise pas à l'aveugle. Le goulot dominant est presque toujours le LLM TTFT ; je sépare TTFT (perçu) de TTLT (fluidité) avant de toucher quoi que ce soit.

Q : "TTFT vs TTLT, lequel optimiser en priorité et pourquoi ?" R : TTFT, parce que c'est ce que l'utilisateur perçoit comme "ça répond". Le streaming masque le TTLT : tant que les tokens coulent, le perçu reste bon. Optimiser le TTLT d'une réponse longue sans toucher au TTFT, c'est travailler dans le vent.

Q : "Pourquoi mon prompt cache ne hit jamais alors que j'ai mis cache_control ?" R : Le cache est un prefix match — un invalidateur silencieux en tête du préfixe (un Date.now() dans le system, un JSON d'outils non trié, un set d'outils qui varie par tenant) casse tout l'aval. Je le prouve avec usage.cache_read_input_tokens à zéro, puis je diffe les bytes du préfixe entre deux requêtes.

Q : "Tu vois thinking: {type: 'enabled', budget_tokens: 8000} dans le code. Réaction ?" R : C'est mort sur Opus 4.8/4.7 — ça renvoie un 400, la syntaxe budget est supprimée. On passe en thinking: {type: 'adaptive'} + output_config.effort. Et sur un fast path latence-sensible, je désactive carrément le thinking : sinon le modèle réfléchit plusieurs secondes avant le premier token et tue le TTFT.

Q : "Comment tu garantis un SLA latence sans signer un chèque en bois ?" R : Un SLA sans clause de dégradation explicite est intenable — quand le provider rame (529), il faut un fallback (région/modèle) et un filler chunk immédiat pour tenir le perçu. Je mesure sur le P95/P99, pas la moyenne, et le success-fee se déclenche sur un seuil objectif tracé en docs (Langfuse avant/après).


🔗 Liens

Bibliothèque tech perso — Achref