AI Inference Engineering: explication détaillée avec exemples
1. Aperçu général
Quand vous appelez l’API d’un chatbot et attendez que la réponse s’affiche progressivement à l’écran, vous assistez au processus d’inference d’un Large Language Model (LLM). Cet article explique en quoi l’inference diffère du training, pourquoi elle consomme des ressources énormes, et quelles techniques les ingénieurs utilisent pour la rendre plus rapide et moins chère : KV cache, continuous batching, quantization, speculative decoding. Après la lecture, vous comprendrez les chiffres TTFT/TPOT dans les benchmarks, et vous saurez écrire du code Java pour mesurer les performances d’un vrai endpoint d’inference.
Note : Cet article n’approfondit pas les mécanismes internes du transformer (attention, feed-forward…). L’accent est mis sur la perspective d’ingénierie d’infrastructure — comment une requête « s’exécute » dans le système d’inference.
2. Qu’est-ce que l’inference ?
Le training est le processus d’apprentissage des poids (weights) à partir des données — coûteux en milliers de GPU sur plusieurs semaines, mais effectué une seule fois. L’inference, c’est quand le model est déjà entraîné, vous donnez un prompt en entrée et recevez un output — cela se produit à chaque requête, avec des millions de requêtes par jour en production. Par conséquent, bien que le training soit « plus cher » au total une fois, c’est l’inference qui détermine le coût de fonctionnement à long terme et l’expérience utilisateur (latence).
La différence cruciale : pendant le training, le model voit la phrase complète pour calculer la loss à la fois. Pendant l’inference de génération de texte, le model doit générer un token à la fois, et chaque token dépend du précédent — on appelle cela le autoregressive decoding.
2.1. Deux phases : Prefill et Decode
Une requête d’inference traverse toujours 2 phases :
- Prefill : le model lit tout le prompt d’entrée d’un coup, calcule l’attention pour tous les tokens d’entrée. Cette phase exploite bien le GPU car elle traite en parallèle (compute-bound).
- Decode : le model génère les tokens de sortie l’un après l’autre, un token à la fois, basé sur tout le contexte précédent. Cette phase s’exécute séquentiellement, chaque étape ne calcule que 1 nouveau token (memory-bound — goulot d’étranglement dans la lecture de la mémoire, pas dans le calcul).
Illustration avec une boucle decode simple en Java (simulation, pas un vrai model) pour voir clairement la nature séquentielle :
public class AutoregressiveDecoder {
public List<String> generate(List<String> prompt, int maxNewTokens) {
List<String> context = new ArrayList<>(prompt);
List<String> generated = new ArrayList<>();
for (int step = 0; step < maxNewTokens; step++) {
String nextToken = predictNextToken(context); // 1 forward pass
if (nextToken.equals("<EOS>")) break;
context.add(nextToken); // le nouveau token devient l'entrée de l'étape suivante
generated.add(nextToken);
}
return generated;
}
private String predictNextToken(List<String> context) {
// simulation : le vrai model exécute un forward pass dans le transformer complet
return "token_" + context.size();
}
}
À chaque appel de predictNextToken, le model doit relire tout le contexte existant pour calculer l’attention — si non optimisé, le coût augmente avec la longueur de la phrase. C’est ce qui a motivé l’apparition du KV cache à la section 4.
3. Mesurer les performances de l’inference : TTFT et TPOT
Deux métriques les plus importantes lors du benchmark d’un LLM en serving :
- TTFT (Time To First Token) : le temps depuis l’envoi de la requête jusqu’à la réception du premier token — reflète la latence de la phase de prefill.
- TPOT (Time Per Output Token) : le temps moyen pour générer chaque token suivant dans la phase de decode.
public record InferenceMetrics(Duration ttft, Duration avgTpot, int totalTokens) {
public Duration totalLatency() {
return ttft.plus(avgTpot.multipliedBy(totalTokens - 1));
}
}
La fonction totalLatency() montre la formule réelle : temps total ≈ TTFT (une fois) + TPOT × (nombre de tokens restants). Avec une réponse de 200 tokens, si TPOT est juste 10ms/token, cela ajoute 2 secondes au temps total — c’est pourquoi TPOT est scruté plus attentivement que TTFT dans les benchmarks de serving.
Exemple de chiffres réels (model 7B, batch size 1, GPU A100) :
TTFT ≈ 80ms
TPOT ≈ 15ms/token
200 tokens → 80ms + 15ms × 199 ≈ 3,07s
4. KV Cache : éviter de recalculer ce qui est déjà connu
Sans cache, chaque étape de decode devrait recalculer l’attention pour toute la séquence de tokens depuis le début — la complexité augmente avec le carré de la longueur. Le KV cache sauvegarde les vecteurs key/value des tokens déjà traités, pour que l’étape suivante ne calcule que pour le nouveau token, puis réutilise l’ancien cache.
public class KvCache {
private final Map<Integer, float[]> keyCache = new HashMap<>();
private final Map<Integer, float[]> valueCache = new HashMap<>();
public void store(int position, float[] key, float[] value) {
keyCache.put(position, key);
valueCache.put(position, value);
}
public List<float[]> allKeysUpTo(int position) {
return IntStream.rangeClosed(0, position)
.mapToObj(keyCache::get)
.toList();
}
}
store est appelé une fois pour chaque nouveau token généré ; allKeysUpTo permet à l’étape suivante de decode de récupérer toutes les clés déjà calculées sans avoir besoin de relancer un forward pass depuis le début. C’est pourquoi une requête longue (contexte long) consomme très vite la VRAM — ce cache est proportionnel à batch_size × sequence_length × num_layers × hidden_size.
Note : Le KV cache est la cause principale de la consommation énorme de RAM pour les contextes longs (32K, 128K tokens), pas le calcul en lui-même. C’est aussi la raison pour laquelle des techniques comme paged attention (utilisées dans vLLM) ont été développées — gérer le cache comme la mémoire virtuelle (pages) pour éviter la fragmentation.
5. Continuous batching : grouper les requêtes pour augmenter le throughput
Le GPU traite les batches plus efficacement que les requêtes individuelles. Mais chaque requête a une longueur de génération différente — la requête A se termine au token 50, la requête B continue jusqu’au token 300. Le static batching (attendre que tout le batch se termine avant d’accepter un nouveau batch) gaspille le GPU car il faut attendre la requête la plus lente. Le continuous batching résout cela : dès qu’une requête dans le batch se termine, son « emplacement » est remplacé par une nouvelle requête de la file d’attente, sans attendre le reste du batch.
public class ContinuousBatchScheduler {
private final int batchSlots;
private final Queue<InferenceRequest> waiting = new LinkedList<>();
private final List<InferenceRequest> active;
public ContinuousBatchScheduler(int batchSlots) {
this.batchSlots = batchSlots;
this.active = new ArrayList<>(Collections.nCopies(batchSlots, null));
}
public void onStep() {
for (int slot = 0; slot < batchSlots; slot++) {
InferenceRequest req = active.get(slot);
if (req == null || req.isFinished()) {
active.set(slot, waiting.poll()); // remplir immédiatement avec la nouvelle requête
} else {
req.decodeNextToken();
}
}
}
}
onStep() s’exécute à chaque étape de decode : si un emplacement est vide ou si une requête vient de se terminer, il récupère immédiatement la requête suivante de la file d’attente waiting. Grâce à cela, le GPU fonctionne toujours avec un batch complet, sans emplacement « vide » attendant une requête lente — c’est le mécanisme central de vLLM, TGI (Text Generation Inference) et TensorRT-LLM.
6. Quantization : échanger la précision pour la vitesse
Le model original stocke généralement les poids en FP16 ou BF16 (16 bits). La quantization réduit le nombre de bits stockés (INT8, INT4) pour réduire la VRAM et augmenter le throughput, en échange d’une légère perte de précision.
Model 7B, comparaison de la consommation mémoire :
- FP16: ~14 GB VRAM
- INT8: ~7 GB VRAM
- INT4: ~3,5 GB VRAM
Quand vous servez avec des moteurs comme vLLM, vous spécifiez simplement la configuration :
model: meta-llama/Llama-3-8B
quantization: awq
dtype: float16
gpu_memory_utilization: 0.9
quantization: awq spécifie la méthode AWQ (Activation-aware Weight Quantization) — une technique INT4 qui préserve mieux la précision qu’une quantization « naïve » en privilégiant les poids les plus importants.
Note : Une quantization trop agressive (INT4 ou moins) peut augmenter les hallucinations du model ou réduire sa capacité de raisonnement. Toujours re-tester la qualité de la sortie (pas seulement la vitesse) après quantization, notamment pour les tâches nécessitant une haute précision comme la génération de code ou les calculs.
7. Speculative decoding : prédire à l’avance pour économiser les étapes
Le decode séquentiel est lent car chaque étape ne génère qu’un token. Le speculative decoding utilise un model plus petit, plus rapide (« draft model ») pour prédire plusieurs tokens à l’avance, puis le model plus gros (« target model ») ne doit que vérifier (verify) ces tokens dans un seul forward pass au lieu de plusieurs.
Le draft model prédit : "Hà" "Nội" "là" "thủ" "đô"
Le target model vérifie les 5 tokens d'un coup dans 1 forward pass :
- Si 4/5 tokens correspondent à la distribution du grand model → garder 4 tokens, rejeter le 5e, redécoder à partir de là.
- Si c'est faux dès le début → ne coûte que 1 forward pass comme le decode normal, rien de perdu.
Cette technique ne réduit pas la qualité de la sortie (car elle vérifie toujours avec le target model), elle réduit juste le nombre de fois où il faut exécuter un forward pass séquentiel sur le grand model — c’est plus efficace quand le draft model prédit correctement.
8. Pratique : mesurer TTFT/TPOT depuis un vrai endpoint de LLM serving
Supposons que vous avez un serveur vLLM exécutant l’API compatible OpenAI sur http://localhost:8000. Ajoutez la dépendance pour WebClient :
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux</artifactId>
</dependency>
Écrivez un client pour mesurer le TTFT avec une réponse en streaming :
@Service
public class LlmBenchmarkClient {
private final WebClient webClient;
public LlmBenchmarkClient(WebClient.Builder builder) {
this.webClient = builder.baseUrl("http://localhost:8000").build();
}
public InferenceMetrics measure(String prompt) {
Instant start = Instant.now();
AtomicReference<Instant> firstTokenTime = new AtomicReference<>();
AtomicInteger tokenCount = new AtomicInteger();
webClient.post()
.uri("/v1/completions")
.bodyValue(Map.of("prompt", prompt, "stream", true, "max_tokens", 100))
.retrieve()
.bodyToFlux(String.class)
.doOnNext(chunk -> {
if (firstTokenTime.get() == null) {
firstTokenTime.set(Instant.now());
}
tokenCount.incrementAndGet();
})
.blockLast();
Duration ttft = Duration.between(start, firstTokenTime.get());
Duration total = Duration.between(start, Instant.now());
Duration avgTpot = tokenCount.get() > 1
? total.minus(ttft).dividedBy(tokenCount.get() - 1)
: Duration.ZERO;
return new InferenceMetrics(ttft, avgTpot, tokenCount.get());
}
}
doOnNext est appelé chaque fois qu’un nouveau chunk (token) arrive — le premier appel enregistre le moment du firstTokenTime qui correspond exactement au TTFT. Une fois que le stream se termine (blockLast), nous calculons le TPOT moyen en prenant le temps total moins le TTFT, divisé par le nombre de tokens restants.
8.1. Test avec JUnit 5 et AssertJ
@SpringBootTest
class LlmBenchmarkClientTest {
@Autowired
LlmBenchmarkClient client;
@Test
void shouldMeetLatencySla() {
InferenceMetrics metrics = client.measure("Explique le KV cache en 2 phrases.");
assertThat(metrics.ttft()).isLessThan(Duration.ofMillis(500));
assertThat(metrics.avgTpot()).isLessThan(Duration.ofMillis(50));
assertThat(metrics.totalTokens()).isGreaterThan(0);
}
}
Ce test ne vérifie pas le contenu de la réponse, seulement le SLA sur la latence — c’est vraiment l’essence de l’« inference engineering » : garantir que le système répond assez vite, pas évaluer la qualité du model (c’est un domaine différent, évaluation de la qualité).
Note : Ce test a besoin d’un vrai serveur LLM qui tourne (ou d’un mock WebClient avec
MockWebServer). En CI, vous devriez séparer ce type de test en tant que test d’intégration, pas l’exécuter avec les unit tests habituels car il dépend d’une infrastructure externe.
9. Erreurs courantes et quand ne pas construire votre propre moteur d’inference
- Réécrire le batching/KV cache de zéro : sauf si vous faites de la recherche en infrastructure ML approfondie, ne reconstruisez pas ce que vLLM, TGI, TensorRT-LLM ont déjà optimisé — vous allez perdre des mois juste pour atteindre la performance qu’ils ont déjà.
- Benchmark seulement le throughput, oublier la latence des queues (tail latency) : un throughput élevé mais un p99 latency horrible ruine l’expérience utilisateur. Mesurez toujours p50/p95/p99, pas juste la moyenne.
- Quantize sans re-tester la qualité : comme dit à la section 6, réduire les bits a toujours un coût — mesurer la vitesse en ignorant la qualité est une erreur courante.
- Utiliser le static batching pour un trafic réel variable : si vos requêtes ont des longueurs de sortie très différentes, le static batching gaspillera sérieusement le GPU — choisissez un moteur avec continuous batching dès le départ.
- Ne pas séparer les workloads prefill-heavy et decode-heavy : certains systèmes production séparent les nœuds de prefill et les nœuds de decode (disaggregated serving) pour optimiser les ressources — si votre trafic suit un pattern clair (par exemple RAG avec un prompt très long et une sortie courte), c’est une approche à considérer.
10. Conclusion
L’inference engineering est un problème d’optimisation entre la latence, le throughput et le coût du GPU — ce n’est pas une affaire d’entraîner le model. Maîtriser prefill/decode, KV cache, continuous batching, quantization et speculative decoding vous aide à comprendre les benchmarks, choisir le bon moteur de serving (vLLM, TGI…), et mesurer votre système avec les bonnes métriques (TTFT, TPOT) au lieu de juste dire « c’est rapide ou lent ». L’étape suivante consiste naturellement à essayer de changer la quantization ou la gpu_memory_utilization dans votre configuration réelle et d’observer comment ces métriques se comportent sur votre propre workload.