On réduit la latence LLM en mesurant TTFT et TPOT, puis en attaquant le prompt, la mémoire, le batching et le décodage. Le gain vient rarement d’un seul réglage. C’est une série de petits choix propres, testés sur votre workflow réel.
Où part vraiment la latence ?
La latence part surtout à deux endroits : le prefill et le decode. Quand un client me dit “mon API LLM est lente”, je regarde d’abord ça, pas le modèle au hasard, pas le réseau au hasard.
Le prefill, c’est le moment où le modèle lit tout votre prompt. Il ingère les instructions, l’historique, les documents RAG, les variables métier. Cette phase est plutôt limitée par le calcul. Plus votre prompt est long, plus elle coûte cher.
Le decode, c’est la suite : les tokens sortent un par un. Un token, c’est un morceau de texte, parfois un mot, parfois juste une partie de mot. Cette phase dépend beaucoup de la bande passante mémoire, parce que le modèle doit relire énormément de poids à chaque token généré.
Entre nous, on le sait bien, faire appel à un consultant en automatisation intelligente et en agent IA, c’est souvent le raccourci le plus malin. On en parle ?
Deux métriques donnent vite la vérité :
- TTFT, Time To First Token, c’est le temps avant le premier mot visible.
- TPOT, Time Per Output Token, c’est le rythme de génération après ce premier token.
Le streaming aide côté ressenti. L’utilisateur voit quelque chose arriver plus tôt, donc l’attente paraît moins longue. Mais si le TPOT est mauvais, le texte va continuer à sortir lentement. Le streaming masque le démarrage, il ne transforme pas un moteur lent en moteur rapide.
import os
import time
from openai import OpenAI
# Variables d'environnement attendues :
# OPENAI_API_KEY=xxx
# OPENAI_BASE_URL=http://localhost:8000/v1
# MODEL=meta-llama/Llama-3.1-8B-Instruct
client = OpenAI(
api_key=os.getenv("OPENAI_API_KEY", "dummy-key"),
base_url=os.getenv("OPENAI_BASE_URL", "http://localhost:8000/v1"),
)
model = os.getenv("MODEL", "meta-llama/Llama-3.1-8B-Instruct")
messages = [
{"role": "system", "content": "Tu réponds clairement et brièvement."},
{"role": "user", "content": "Explique la latence LLM en 5 phrases."},
]
start = time.perf_counter()
first_token_time = None
output_text = ""
stream = client.chat.completions.create(
model=model,
messages=messages,
stream=True,
temperature=0.2,
max_tokens=300,
)
for chunk in stream:
delta = chunk.choices[0].delta.content
if delta:
now = time.perf_counter()
if first_token_time is None:
first_token_time = now
output_text += delta
print(delta, end="", flush=True)
end = time.perf_counter()
# Comptage simple des tokens générés.
# Pour une mesure précise, utilisez le tokenizer du modèle en production.
generated_tokens = len(output_text.split())
ttft = (first_token_time - start) if first_token_time else None
total_time = end - start
tpot = ((end - first_token_time) / max(generated_tokens, 1)) if first_token_time else None
print("\n\n--- Mesures ---")
print(f"TTFT: {ttft:.3f} s" if ttft else "TTFT: Aucun token reçu")
print(f"TPOT: {tpot:.3f} s/token" if tpot else "TPOT: Non calculable")
print(f"Tokens générés approximatifs: {generated_tokens}")
print(f"Temps total: {total_time:.3f} s")
Je fais souvent tourner ce genre de script avant de toucher à l’infra. Ça évite de confondre un prompt trop lourd avec un serveur qui décode lentement.
| Symptôme | Métrique à regarder | Cause probable |
| Rien ne s’affiche pendant longtemps | TTFT | Prompt trop long, prefill coûteux, file d’attente serveur |
| Le texte démarre vite puis avance lentement | TPOT | Decode lent, bande passante mémoire limitée, modèle trop gros |
| Tout est lent du début à la fin | TTFT et TPOT | Saturation GPU, batching mal réglé, endpoint sous-dimensionné |
Faut-il raccourcir le prompt ?
Oui, le prompt est souvent le premier levier, surtout pour réduire le TTFT, le Time To First Token, c’est-à-dire le délai avant que le modèle commence à répondre. Chaque token d’entrée coûte du calcul pendant le prefill, la phase où le modèle lit tout votre prompt avant de générer le premier mot. Donc oui, un prompt trop long se paie cash.
Je vois souvent le même problème chez des clients : un prompt système énorme, tout l’historique de conversation collé derrière, puis un RAG qui ramène 12 passages alors que 3 suffisent. Le RAG, c’est la recherche augmentée par vos documents métier. Pratique, mais si vous envoyez trop de contexte, vous ralentissez l’inférence.
Les actions les plus rentables sont simples :
- Limiter l’historique aux derniers messages vraiment utiles.
- Résumer les anciens échanges au lieu de les renvoyer en entier.
- Dédupliquer les passages RAG, surtout quand plusieurs chunks disent la même chose.
- Réduire top_k, le nombre de chunks récupérés, par exemple passer de 10 à 4.
- Filtrer les chunks peu pertinents avec un score minimum.
- Mettre en cache les préfixes stables avec du prefix caching quand beaucoup de requêtes partagent le même début.
- Séparer les instructions fixes des données variables, pour réutiliser ce qui ne change pas.
import tiktoken
# Compte les tokens avec le tokenizer d'un modèle OpenAI récent
enc = tiktoken.encoding_for_model("gpt-4o-mini")
def count_tokens(text):
# Retourne le nombre de tokens d'un texte
return len(enc.encode(text or ""))
def trim_history(messages, max_tokens=1200):
# Garde le système, un résumé éventuel, puis les derniers messages utiles
system = [m for m in messages if m["role"] == "system"]
summary = [m for m in messages if m["role"] == "summary"][-1:]
chat = [m for m in messages if m["role"] in ("user", "assistant")]
kept = []
total = sum(count_tokens(m["content"]) for m in system + summary)
# On remonte depuis la fin, car les derniers échanges sont souvent les plus utiles
for msg in reversed(chat):
size = count_tokens(msg["content"])
if total + size > max_tokens:
break
kept.insert(0, msg)
total += size
return system + summary + kept
messages = [
{"role": "system", "content": "Tu es un assistant support interne. Réponds court, factuel, avec les sources."},
{"role": "summary", "content": "Le client cherche à automatiser le traitement des demandes RH."},
{"role": "user", "content": "Comment gérer les demandes de congés ?"},
{"role": "assistant", "content": "Il faut vérifier le solde, la règle manager, puis créer la demande."},
{"role": "user", "content": "Et si le solde est insuffisant ?"}
]
short_messages = trim_history(messages, max_tokens=300)
print(sum(count_tokens(m["content"]) for m in short_messages))
Une structure de prompt métier plus saine ressemble à ça :
[Instructions fixes]
Tu es l'assistant support RH. Réponds en 5 lignes maximum. Cite uniquement les sources fournies.
[Contexte résumé]
Le salarié demande une règle sur les congés.
[Documents utiles]
Source 1 : ...
Source 2 : ...
[Question]
Et si le solde est insuffisant ?
| Version | Tokens d’entrée | Impact attendu sur TTFT | Risque fonctionnel |
| Avant | 8 000 | Lent, prefill lourd | Faible oubli, mais bruit élevé |
| Après | 2 500 | Plus rapide, souvent très visible | Risque modéré si le filtrage est mauvais |
Comment réduire la mémoire utilisée ?
Réduire la mémoire aide surtout la phase de décodage, c’est-à-dire le moment où le LLM génère les tokens un par un. À chaque token, le modèle doit relire ses poids et gérer le KV cache. Le KV cache, c’est une mémoire intermédiaire qui garde les clés et valeurs d’attention déjà calculées. Ça évite de recalculer tout le contexte à chaque nouveau token. Très pratique. Mais dès que le contexte grossit, ou que vous servez plusieurs requêtes en batch, ça mange vite beaucoup de VRAM.
Les leviers fiables sont assez clairs. Je commence souvent par limiter la longueur de contexte, parce que c’est simple et ça évite les prompts “fourre-tout”. Ensuite je regarde la quantification INT8 ou INT4, si la qualité tient. INT8 ou INT4 veut dire qu’on stocke les poids avec moins de précision numérique, donc moins de mémoire. Mais attention, sur certaines tâches métier fines, extraction juridique, scoring risque, raisonnement long, ça peut dégrader. Il faut mesurer qualité et latence ensemble, pas juste regarder les tokens par seconde.
Avec vLLM, PagedAttention aide beaucoup à gérer le KV cache plus intelligemment. C’est un peu comme de la pagination mémoire pour les séquences. Quand les kernels FlashAttention sont disponibles, ils accélèrent aussi l’attention et réduisent la pression mémoire.
# Lancement simple avec vLLM
# max-model-len limite le contexte et réduit le KV cache
# gpu-memory-utilization évite de saturer la VRAM
vllm serve mistralai/Mistral-7B-Instruct-v0.3 \
--dtype float16 \
--max-model-len 4096 \
--gpu-memory-utilization 0.85
# Lancement avec quantification, si le modèle et vLLM la supportent
# Exemple avec AWQ, souvent utilisé pour de l'INT4
vllm serve TheBloke/Mistral-7B-Instruct-v0.2-AWQ \
--quantization awq \
--dtype float16 \
--max-model-len 4096 \
--gpu-memory-utilization 0.90
Je fais toujours un mini-test métier avant de valider. Pas besoin d’un labo complet au début. Quelques prompts représentatifs suffisent pour repérer une grosse régression.
import requests, time
prompts = [
"Résume ce ticket support en 3 points et donne la priorité.",
"Extrait le SIREN, le montant et la date de cette facture.",
"Classe ce message client : réclamation, question ou demande commerciale."
]
def call(model_url, prompt):
start = time.time()
r = requests.post(model_url, json={
"model": "model",
"messages": [{"role": "user", "content": prompt}],
"temperature": 0
})
latency = time.time() - start
return r.json()["choices"][0]["message"]["content"], latency
for prompt in prompts:
base, t1 = call("http://localhost:8000/v1/chat/completions", prompt)
quant, t2 = call("http://localhost:8001/v1/chat/completions", prompt)
print("\nPrompt:", prompt)
print("Base:", round(t1, 2), base[:200])
print("Quant:", round(t2, 2), quant[:200])
| Levier | Gain attendu | Point de vigilance |
| Quantification INT8 / INT4 | Moins de VRAM, décodage souvent plus rapide | Peut dégrader la qualité, surtout en raisonnement fin |
| KV cache optimisé | Meilleure tenue en contexte long et batch | Dépend fortement du moteur d’inférence |
| Réduction du contexte | Gain immédiat mémoire et latence | Risque de retirer une info utile |
| Modèle plus petit spécialisé | Souvent le meilleur ratio coût, vitesse, qualité | Demande un bon cadrage métier |
Le serveur peut-il aller plus vite ?
Oui, le serveur peut aller plus vite. Mais seulement si je traite le serving comme un vrai système de production, pas comme un simple appel modèle posé derrière une API.
Le point clé, c’est souvent le continuous batching. Au lieu de traiter les requêtes une par une, le serveur regroupe dynamiquement plusieurs générations en cours pour mieux remplir le GPU. Une requête arrive, elle rejoint un batch vivant, le GPU travaille sur plusieurs séquences à la fois, et on évite les trous d’air.
Il faut séparer trois notions. La latence individuelle, c’est le temps vu par un utilisateur. Le débit global, c’est le nombre de tokens ou de requêtes traités par seconde. La concurrence, c’est le nombre de requêtes en même temps. J’ai déjà vu des équipes avec un GPU à 35% d’utilisation et pourtant une latence mauvaise. Pourquoi ? File d’attente trop longue, batch mal réglé, générations trop longues, ou max_tokens laissé à 2048 “au cas où”. Le GPU n’est pas magique. S’il attend, s’il batch trop tard, ou s’il doit générer des romans, l’utilisateur attend aussi.
Des serveurs comme vLLM, Hugging Face Text Generation Inference et TensorRT-LLM existent justement pour ça. Ils optimisent la mémoire, le batching, le streaming, le cache d’attention, c’est-à-dire la mémoire interne utilisée par le modèle pour ne pas recalculer tout le contexte à chaque token. Ce n’est pas un comparatif vendeur. C’est juste le bon niveau d’outillage quand le LLM devient un composant métier sérieux.
import asyncio, time, statistics, httpx
URL = "http://localhost:8000/v1/chat/completions"
N = 50
CONCURRENCY = 10
payload = {
"model": "mon-modele",
"stream": True,
"max_tokens": 200,
"messages": [{"role": "user", "content": "Résume ce dossier client en 5 lignes."}]
}
async def call(client, sem):
async with sem:
start = time.perf_counter()
ttft = None
error = False
try:
async with client.stream("POST", URL, json=payload, timeout=30) as r:
if r.status_code >= 400:
error = True
async for _ in r.aiter_lines():
if ttft is None:
ttft = time.perf_counter() - start
total = time.perf_counter() - start
except Exception:
error = True
total = time.perf_counter() - start
return total, ttft, error
async def main():
sem = asyncio.Semaphore(CONCURRENCY)
async with httpx.AsyncClient() as client:
results = await asyncio.gather(*(call(client, sem) for _ in range(N)))
lat = [r[0] for r in results]
ttfts = [r[1] for r in results if r[1] is not None]
errors = sum(1 for r in results if r[2])
print("p50 latency:", statistics.median(lat))
print("p95 latency:", sorted(lat)[int(0.95 * len(lat)) - 1])
print("p50 TTFT:", statistics.median(ttfts) if ttfts else None)
print("p95 TTFT:", sorted(ttfts)[int(0.95 * len(ttfts)) - 1] if ttfts else None)
print("error rate:", errors / N)
asyncio.run(main())
Mes réglages de base sont simples. Je limite max_tokens, je fixe des timeouts, je streame la réponse pour réduire le temps avant le premier token, je surveille la queue, et je dimensionne sur le p95, pas sur la moyenne. La moyenne ment souvent. Le p95 montre ce que vivent les vrais utilisateurs quand ça charge.
| Paramètre serveur | Effet positif | Piège à éviter |
| Max_tokens | Réduit le temps de génération | Laisser une valeur énorme par défaut |
| Continuous batching | Améliore l’utilisation GPU | Créer trop d’attente pour remplir les batchs |
| Timeouts | Évite les requêtes zombies | Couper trop court les longues tâches utiles |
| Streaming | Réduit le TTFT perçu | Confondre premier token rapide et réponse totale rapide |
| Queue monitoring | Détecte la saturation réelle | Regarder seulement l’usage GPU |
| Dimensionnement p95 | Protège l’expérience utilisateur | Optimiser uniquement la moyenne |
Peut-on accélérer le décodage ?
C’est le levier le plus délicat, parce que le décodage reste séquentiel. Un token dépend du précédent. Le modèle écrit “mot” après “mot”, même si techniquement on parle de tokens, donc des morceaux de texte. C’est là que le TPOT se joue beaucoup, le Time Per Output Token, autrement dit le temps nécessaire pour générer chaque token de sortie.
Le speculative decoding est souvent la piste la plus intéressante. L’idée est simple : un petit modèle brouillon propose plusieurs tokens d’avance, puis le grand modèle les valide ou les rejette. Si les propositions sont bonnes, on gagne du temps. Les travaux connus sur le sujet montrent bien l’intérêt de cette approche, mais je reste prudent avec les promesses. Les gains réels dépendent du modèle, du matériel, de la longueur des réponses et même du style de prompt. Chez un client, on avait un vrai gain sur des réponses standardisées, beaucoup moins sur des analyses longues et ouvertes.
# Grand modèle cible : celui qui valide la réponse finale.
export TARGET_MODEL="meta-llama/Llama-3.1-70B-Instruct"
# Petit modèle brouillon : à adapter selon votre stack et votre qualité attendue.
export DRAFT_MODEL="meta-llama/Llama-3.2-1B-Instruct"
# Nombre de tokens proposés à l'avance.
# Plus haut peut accélérer, mais peut aussi augmenter les rejets.
export SPEC_TOKENS=4
vllm serve "$TARGET_MODEL" \
--tensor-parallel-size 4 \
--max-model-len 8192 \
--speculative-config '{"model":"'"$DRAFT_MODEL"'","num_speculative_tokens":'"$SPEC_TOKENS"'}'
Il y a aussi des leviers plus simples, souvent sous-estimés. Je limite la verbosité demandée au modèle. Je mets des stop sequences, c’est-à-dire des marqueurs qui arrêtent la génération dès qu’on a ce qu’il faut. Quand j’attends une sortie structurée, j’utilise un JSON schema ou une grammaire. Ça évite les paragraphes inutiles, les phrases de politesse, les “bien sûr voici…”. Et pour les requêtes simples, je route vers un modèle plus petit. Pas besoin d’un 70B pour classer un email en “urgent” ou “non urgent”.
from openai import OpenAI
import json
client = OpenAI()
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=[
{
"role": "system",
"content": "Réponds uniquement en JSON valide, sans texte autour. Garde la réponse courte."
},
{
"role": "user",
"content": "Classe ce message client : 'Je n'arrive plus à me connecter depuis ce matin.'"
}
],
max_tokens=80,
temperature=0
)
data = json.loads(response.choices[0].message.content)
print(data)
| Levier | Impact TPOT | Risque qualité | Complexité |
| Speculative decoding | Élevé si le brouillon prédit bien | Moyen | Élevée |
| Contraintes JSON schema ou grammaire | Moyen | Faible à moyen | Moyenne |
| Stop sequences | Moyen | Faible si bien testé | Faible |
| Routage vers petit modèle | Élevé sur requêtes simples | Moyen | Moyenne |
| Réduction de la verbosité | Moyen | Faible | Faible |
Qu’est-ce que vous gagnez au final ?
Au final, je regarde la latence LLM comme une chaîne. Si le TTFT est mauvais, je commence par le prompt, le prefill, le cache et le serving. Si le TPOT est lent, je regarde plutôt mémoire, KV cache, quantification, batching et décodage. Le vrai piège, c’est d’optimiser au hasard parce qu’un benchmark vu ailleurs promet un miracle. Je préfère mesurer sur vos prompts, vos volumes, vos contraintes qualité. C’est moins spectaculaire, mais ça marche. Le bénéfice pour vous est simple : des workflows IA plus rapides, plus stables, et souvent moins chers à faire tourner.
FAQ
- Qu’est-ce que la latence LLM ?
La latence LLM, c’est le délai entre votre requête et la réponse du modèle. Je la découpe surtout en deux mesures : le TTFT, donc le temps avant le premier token, et le TPOT, donc le temps moyen pour générer chaque token ensuite. - Pourquoi mon LLM met du temps à répondre ?
Les causes les plus fréquentes sont un prompt trop long, un contexte RAG trop large, un modèle trop gros, un serveur mal configuré, un batch mal dimensionné ou une génération trop longue. Sans mesure TTFT et TPOT, on devine. Et deviner coûte cher. - Quelle est la différence entre prefill et decode ?
Le prefill correspond au moment où le modèle lit votre prompt. Il dépend beaucoup du calcul. Le decode correspond à la génération token par token. Il dépend beaucoup de la mémoire et du KV cache. Les deux ne s’optimisent pas exactement pareil. - La quantification réduit-elle toujours la latence ?
Elle peut réduire l’empreinte mémoire et accélérer certains cas, surtout avec des modèles lourds. Mais elle peut aussi dégrader la qualité ou ne pas donner le gain attendu selon le matériel et le moteur d’inférence. Je teste toujours latence et qualité ensemble. - Quel est le premier levier à tester en production ?
Je commence presque toujours par mesurer proprement TTFT, TPOT, tokens d’entrée, tokens de sortie et p95. Ensuite je réduis le prompt et la sortie demandée. C’est souvent le levier le plus simple, le moins risqué, et celui qui révèle les vrais problèmes.
A propos de l’auteur
Je suis Franck Scandolera, expert et formateur en tracking avancé server-side, Analytics Engineering, automatisation No/Low Code avec n8n, intégration de l’IA en entreprise et SEO/GEO. Avec mon agence webAnalyste et l’organisme Formations Analytics, j’accompagne des équipes chez Logis Hôtel, Yelloh Village, BazarChic, la Fédération Française de Football, Texdecor et d’autres. Si vous voulez mettre vos workflows IA, data ou automatisation au propre, je peux vous aider. Contactez-moi.
⭐ Data Analyst, Analytics Engineer et expert dans l’automatisation IA ⭐
Ref clients : Logis Hôtel, Yelloh Village, BazarChic, Fédération Football Français, Texdecor…
Mon terrain de jeu :
Data Analyst & Analytics engineering : tracking propre RGPD, entrepôt de données (GTM server, BigQuery…), modèles (dbt/Dataform), dashboards décisionnels (Looker, SQL, Python).
Automatisation IA des taches Data, Marketing, RH, compta etc : conception de workflows intelligents robustes (n8n, Make, App Script, scraping) connectés aux API de vos outils et LLM (OpenAI, Mistral, Claude…).
Engineering IA pour créer des applications et agent IA sur mesure : intégration de LLM (OpenAI, Mistral…), RAG, assistants métier, génération de documents complexes, APIs, backends Node.js/Python.





