Comment fonctionne une LLM : tokens, context window et sampling

1. Vue d’ensemble

Quand vous appelez l’API d’un Large Language Model (LLM), vous rencontrerez des paramètres comme max_tokens, temperature, top_p et des erreurs du type « context length exceeded ». Ces paramètres deviennent faciles à comprendre quand vous maîtrisez la façon dont le modèle génère du texte.

Cet article explique trois concepts fondamentaux : les tokens, la context window et le sampling. Pour chaque concept, vous disposerez d’un morceau de code Java 21 fonctionnel. Après la lecture, vous pourrez compter les tokens avant d’appeler l’API, découper l’historique du chat pour qu’il tienne dans la context window, et savoir choisir temperature ou top_p approprié à chaque problème.

2. La boucle centrale : prédire le token suivant

Une LLM ne rédige pas une réponse complète en une seule fois. Elle exécute une boucle simple :

  1. Reçoit une séquence de tokens d’entrée.
  2. Calcule un logit (score brut) pour chaque token du vocabulaire, généralement entre quelques dizaines de milliers et plus d’une centaine de milliers de tokens.
  3. Convertit les logits en probabilités, puis sélectionne un token. Cette étape de sélection s’appelle le sampling.
  4. Ajoute le token sélectionné à l’entrée et revient à l’étape 1.

La boucle s’arrête quand le modèle génère un token de fin ou atteint la limite de max_tokens. De cette boucle découlent trois conséquences :

  • Le modèle voit le texte sous forme de tokens, pas de caractères ou de mots.
  • Tout ce que le modèle « se souvient » au cours d’un appel doit se trouver dans l’entrée. Cette entrée est limitée par la context window.
  • La réponse est le résultat de multiples sélections aléatoires contrôlées. Ainsi, le même prompt peut produire des résultats différents.

3. Token

3.1. Qu’est-ce qu’un token ?

Le tokenizer découpe le texte en petits morceaux, puis mappe chaque morceau à un nombre entier. La plupart des modèles actuels utilisent une variante du Byte Pair Encoding (BPE). Les séquences de caractères qui apparaissent fréquemment dans les données d’entraînement sont fusionnées en un seul token. Les séquences rares sont divisées en plusieurs tokens plus petits, parfois au niveau de chaque octet.

Ainsi, un mot anglais courant comme hello ne coûte généralement qu’un seul token. En revanche, le vietnamien avec ses diacritiques coûte généralement plus de tokens pour la même quantité de contenu, car des combinaisons comme « ượ » ou « ững » apparaissent rarement dans les données d’entraînement du tokenizer.

3.2. Compter les tokens avec JTokkit

Pour compter les tokens en Java, vous pouvez utiliser JTokkit. Cette bibliothèque implémente les encodages BPE d’OpenAI comme cl100k_base et o200k_base :

<dependency>
    <groupId>com.knuddels</groupId>
    <artifactId>jtokkit</artifactId>
    <version>1.1.0</version>
</dependency>

Ce code compare le nombre de tokens d’une phrase en anglais et d’une phrase en vietnamien :

import com.knuddels.jtokkit.Encodings;
import com.knuddels.jtokkit.api.Encoding;
import com.knuddels.jtokkit.api.EncodingType;
import com.knuddels.jtokkit.api.IntArrayList;

public class TokenCounterDemo {

    public static void main(String[] args) {
        Encoding encoding = Encodings.newDefaultEncodingRegistry()
                .getEncoding(EncodingType.CL100K_BASE);

        String english = "Hello world";
        String vietnamese = "Đơn hàng của bạn đã được giao thành công";

        IntArrayList ids = encoding.encode(english);
        System.out.println(english + " -> " + ids.size() + " tokens: " + ids);

        int viTokens = encoding.countTokens(vietnamese);
        System.out.printf("%s -> %d tokens, %d ký tự%n",
                vietnamese, viTokens, vietnamese.length());
    }
}

Explication :

  • getEncoding(EncodingType.CL100K_BASE) récupère la table de vocabulaire utilisée par les premiers modèles GPT-4.
  • encode(...) retourne une liste d’identifiants de tokens. countTokens(...) retourne uniquement le nombre, ce qui est plus rapide quand vous n’avez pas besoin des identifiants.
  • Avec "Hello world", le résultat est 2 tokens : Hello et world. L’espace est fusionné dans le token suivant.

Le résultat ressemble à :

Hello world -> 2 tokens: [9906, 1917]
Đơn hàng của bạn đã được giao thành công -> ... tokens, 40 ký tự

Exécutez-le vous-même pour voir que la phrase en vietnamien consomme bien plus de tokens par rapport au nombre de mots. C’est un point à prendre en compte lors de l’estimation des coûts, car les fournisseurs facturent au token.

Remarque : Chaque famille de modèles utilise son propre tokenizer. Claude, Gemini, Llama et GPT ne divisent pas les tokens de la même façon. JTokkit n’est précis que pour l’encodage d’OpenAI. Avec d’autres modèles, utilisez le tokenizer ou l’endpoint de comptage de tokens du fournisseur, ou considérez les chiffres de JTokkit comme une estimation avec une marge de sécurité.

4. Context window

4.1. Que contient une context window ?

La context window est le nombre maximum de tokens que le modèle peut traiter lors d’un seul appel. Ce nombre compte à la fois l’entrée et la sortie :

system prompt + historique de conversation + documents attachés + question + réponse ≤ context window

Il y a trois idées fausses courantes :

  • « Le modèle se souvient de la conversation précédente. » Faux. L’API ne maintient pas d’état entre les appels. L’application de chat doit renvoyer l’historique complet à chaque fois, de sorte que chaque appel ultérieur consomme plus de tokens que le précédent.
  • « Plus longue est la context, mieux c’est. » Pas vraiment. Le coût et la latence augmentent avec le nombre de tokens d’entrée. Le modèle a aussi tendance à oublier les informations situées au milieu d’une très longue context, phénomène souvent appelé « lost in the middle ».
  • « Faire tenir l’entrée dans la context window est suffisant. » Ce n’est pas suffisant. Vous devez aussi réserver de la place pour la sortie. Si l’entrée occupe presque tout, la réponse sera tronquée.

4.2. Découper l’historique du chat pour tenir dans le budget de tokens

La stratégie simple et courante est : toujours conserver le system prompt, puis prendre les messages du plus récent au plus ancien jusqu’à épuiser le budget.

import com.knuddels.jtokkit.api.Encoding;

import java.util.ArrayDeque;
import java.util.Deque;
import java.util.List;

public record ChatMessage(String role, String content) {}

public class ContextWindowManager {

    // Chaque message consomme quelques tokens supplémentaires pour le rôle et les séparateurs
    private static final int PER_MESSAGE_OVERHEAD = 4;

    private final Encoding encoding;
    private final int contextWindow;
    private final int reservedForOutput;

    public ContextWindowManager(Encoding encoding, int contextWindow, int reservedForOutput) {
        this.encoding = encoding;
        this.contextWindow = contextWindow;
        this.reservedForOutput = reservedForOutput;
    }

    public int countTokens(ChatMessage message) {
        return encoding.countTokens(message.content()) + PER_MESSAGE_OVERHEAD;
    }

    public List<ChatMessage> fit(ChatMessage systemPrompt, List<ChatMessage> history) {
        int budget = contextWindow - reservedForOutput - countTokens(systemPrompt);
        if (budget < 0) {
            throw new IllegalStateException("Le system prompt est trop long par rapport à la context window");
        }

        Deque<ChatMessage> kept = new ArrayDeque<>();
        for (int i = history.size() - 1; i >= 0; i--) {
            int cost = countTokens(history.get(i));
            if (cost > budget) {
                break;
            }
            kept.addFirst(history.get(i));
            budget -= cost;
        }
        kept.addFirst(systemPrompt);
        return List.copyOf(kept);
    }
}

Explication :

  • reservedForOutput est la part réservée à la réponse. Cette valeur doit correspondre au max_tokens que vous envoyez à l’API.
  • La boucle remonte depuis la fin de la liste, car les messages les plus récents sont généralement les plus importants pour la question actuelle.
  • Dès qu’un message ne tient plus, arrêtez complètement avec break, ne l’ignorez pas pour en récupérer un plus ancien. Si vous l’ignorez, la conversation sera interrompue au milieu et le modèle risquera de mal comprendre le contexte.
  • addFirst maintient l’ordre chronologique dans le résultat renvoyé.

En pratique, vous pouvez combiner deux techniques supplémentaires : résumer les anciens messages en un passage court, ou utiliser RAG pour ne fournir que les passages de documents pertinents pour la question au lieu de l’intégralité du document.

5. Sampling

5.1. Des logits aux probabilités : softmax et temperature

Supposons que le modèle doit compléter la phrase « La capitale du Vietnam est… » et retourne quatre candidats avec les logits suivants : Hà Nội = 4.0, Huế = 2.5, Đà Nẵng = 2.0, Sài Gòn = 1.0.

Pour convertir les logits en probabilités, le modèle utilise la fonction softmax. Avant de calculer le softmax, les logits sont divisés par la temperature (T) :

  • T < 1 rend la distribution plus « pointue ». Les tokens avec des scores élevés sont plus faciles à sélectionner.
  • T > 1 rend la distribution plus « plate ». Les tokens avec des scores faibles ont plus de chances.

Voici une classe qui implémente softmax, temperature, top-k, top-p et la fonction de sélection de token :

import java.util.*;

public record Candidate(String token, double logit) {}

public class TokenSampler {

    private final Random random;

    public TokenSampler(long seed) {
        this.random = new Random(seed);
    }

    public static Map<String, Double> softmax(List<Candidate> candidates, double temperature) {
        if (temperature <= 0) {
            throw new IllegalArgumentException("temperature doit être > 0, utilisez greedy() pour T = 0");
        }
        double max = candidates.stream().mapToDouble(Candidate::logit).max().orElseThrow();
        Map<String, Double> exps = new LinkedHashMap<>();
        double sum = 0;
        for (Candidate c : candidates) {
            double e = Math.exp((c.logit() - max) / temperature);
            exps.put(c.token(), e);
            sum += e;
        }
        Map<String, Double> probs = new LinkedHashMap<>();
        for (var entry : exps.entrySet()) {
            probs.put(entry.getKey(), entry.getValue() / sum);
        }
        return sortDesc(probs);
    }

    public static Map<String, Double> topK(Map<String, Double> probs, int k) {
        Map<String, Double> kept = new LinkedHashMap<>();
        sortDesc(probs).entrySet().stream().limit(k)
                .forEach(e -> kept.put(e.getKey(), e.getValue()));
        return normalize(kept);
    }

    public static Map<String, Double> topP(Map<String, Double> probs, double p) {
        Map<String, Double> kept = new LinkedHashMap<>();
        double cumulative = 0;
        for (var entry : sortDesc(probs).entrySet()) {
            kept.put(entry.getKey(), entry.getValue());
            cumulative += entry.getValue();
            if (cumulative >= p) {
                break;
            }
        }
        return normalize(kept);
    }

    public static String greedy(List<Candidate> candidates) {
        return candidates.stream()
                .max(Comparator.comparingDouble(Candidate::logit))
                .orElseThrow().token();
    }

    public String sample(Map<String, Double> probs) {
        double r = random.nextDouble();
        double cumulative = 0;
        String last = null;
        for (var entry : probs.entrySet()) {
            cumulative += entry.getValue();
            last = entry.getKey();
            if (r < cumulative) {
                return last;
            }
        }
        return last; // protéger contre l'erreur d'arrondi
    }

    private static Map<String, Double> normalize(Map<String, Double> probs) {
        double sum = probs.values().stream().mapToDouble(Double::doubleValue).sum();
        Map<String, Double> result = new LinkedHashMap<>();
        probs.forEach((token, prob) -> result.put(token, prob / sum));
        return result;
    }

    private static Map<String, Double> sortDesc(Map<String, Double> probs) {
        Map<String, Double> sorted = new LinkedHashMap<>();
        probs.entrySet().stream()
                .sorted(Map.Entry.<String, Double>comparingByValue().reversed())
                .forEach(e -> sorted.put(e.getKey(), e.getValue()));
        return sorted;
    }
}

Explication des parties principales :

  • Dans softmax, chaque logit est soustrait de la valeur maximale avant d’appeler Math.exp. Le résultat mathématique ne change pas, mais cela évite un débordement numérique quand les logits sont grands.
  • topK et topP renormalisent tous deux les probabilités pour que la somme soit 1 après avoir supprimé des candidats.
  • sample prend un nombre aléatoire dans l’intervalle [0, 1), puis accumule les probabilités. Le candidat dont la somme cumulée dépasse ce nombre sera sélectionné.

Essayons d’afficher les probabilités avec trois niveaux de temperature :

var candidates = List.of(
        new Candidate("Hà Nội", 4.0), new Candidate("Huế", 2.5),
        new Candidate("Đà Nẵng", 2.0), new Candidate("Sài Gòn", 1.0));

for (double t : new double[]{0.5, 1.0, 2.0}) {
    System.out.println("T=" + t + " " + TokenSampler.softmax(candidates, t));
}

Résultat (arrondi) :

T=0.5 {Hà Nội=0.934, Huế=0.047, Đà Nẵng=0.017, Sài Gòn=0.002}
T=1.0 {Hà Nội=0.710, Huế=0.158, Đà Nẵng=0.096, Sài Gòn=0.035}
T=2.0 {Hà Nội=0.485, Huế=0.229, Đà Nẵng=0.178, Sài Gòn=0.108}

À T = 2.0, environ 1 fois sur 9, le modèle répondra « Sài Gòn ». Pour une question factuelle, c’est une réponse incorrecte. Cet exemple montre pourquoi une temperature élevée conduit facilement à des « hallucinations ».

Temperature = 0 est généralement compris comme le greedy decoding : toujours sélectionner le token avec le logit le plus élevé, correspondant à la fonction greedy().

5.2. Top-k et top-p

Ces deux techniques éliminent la « queue longue » des tokens à basse probabilité avant la sélection :

  • Top-k : ne conserver que les k tokens avec les probabilités les plus élevées. Avec k = 2, il ne reste que Hà Nội et Huế.
  • Top-p (nucleus sampling) : conserver le plus petit ensemble de tokens dont la somme des probabilités ≥ p. Avec T = 1.0 et p = 0.9, la somme cumulée est 0.710, puis 0.868, puis 0.964. Donc top-p garde 3 tokens et élimine Sài Gòn.

Top-p est plus flexible que top-k. Quand le modèle est très confiant, top-p ne garde que quelques tokens. Quand le modèle hésite, il garde plus de tokens. Top-k garde toujours exactement k tokens, peu importe l’apparence de la distribution.

Remarque : De nombreux fournisseurs recommandent de n’ajuster que l’un des deux paramètres temperature ou top_p, ne pas les deux à la fois. La raison est que leurs effets se chevauchent et qu’il est très difficile de prédire le résultat.

5.3. Tester le comportement du sampling

Dépendance pour les tests, elle est déjà incluse dans spring-boot-starter-test avec Spring Boot 3.x :

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    <scope>test</scope>
</dependency>
import org.junit.jupiter.api.Test;

import java.util.List;
import java.util.stream.IntStream;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.within;

class TokenSamplerTest {

    private final List<Candidate> candidates = List.of(
            new Candidate("Hà Nội", 4.0), new Candidate("Huế", 2.5),
            new Candidate("Đà Nẵng", 2.0), new Candidate("Sài Gòn", 1.0));

    @Test
    void lowerTemperatureMakesTopTokenMoreLikely() {
        double cold = TokenSampler.softmax(candidates, 0.5).get("Hà Nội");
        double hot = TokenSampler.softmax(candidates, 2.0).get("Hà Nội");

        assertThat(cold).isCloseTo(0.934, within(0.001));
        assertThat(cold).isGreaterThan(hot);
    }

    @Test
    void topPKeepsSmallestNucleus() {
        var nucleus = TokenSampler.topP(TokenSampler.softmax(candidates, 1.0), 0.9);

        assertThat(nucleus).containsOnlyKeys("Hà Nội", "Huế", "Đà Nẵng");
        assertThat(nucleus.values().stream().mapToDouble(Double::doubleValue).sum())
                .isCloseTo(1.0, within(1e-9));
    }

    @Test
    void sameSeedGivesSameSequence() {
        var probs = TokenSampler.softmax(candidates, 1.0);
        var first = new TokenSampler(42L);
        var second = new TokenSampler(42L);

        List<String> a = IntStream.range(0, 20).mapToObj(i -> first.sample(probs)).toList();
        List<String> b = IntStream.range(0, 20).mapToObj(i -> second.sample(probs)).toList();

        assertThat(a).isEqualTo(b);
    }

    @Test
    void greedyAlwaysPicksHighestLogit() {
        assertThat(TokenSampler.greedy(candidates)).isEqualTo("Hà Nội");
    }
}

Le dernier test soulève un point important : avec le même seed, on obtient la même séquence de résultats. Certaines API ont un paramètre seed, mais la plupart ne garantissent qu’un effort maximal (best effort) pour la reproductibilité. Le calcul parallèle sur GPU et le regroupement de plusieurs requêtes dans un même batch peuvent faire dévier légèrement les logits. Par conséquent, même avec temperature = 0, vous ne devriez pas écrire de tests comparant exactement chaque caractère de la réponse réelle d’une LLM.

6. Choisir les paramètres en pratique

Cas d’usage Suggestion
Extraction de données, classification, génération de JSON temperature 0–0.2
Réponse à des questions, génération de code temperature 0.2–0.5
Écriture créative, brainstorming temperature 0.7–1.0 ou top_p 0.9–0.95

Quelques erreurs courantes :

  • Définir max_tokens trop bas : le JSON est tronqué au milieu et ne peut pas être analysé. Vérifiez le champ de raison d’arrêt dans la réponse (généralement finish_reason ou stop_reason) avant de traiter le résultat.
  • Ne pas compter les tokens avant d’envoyer : la requête est rejetée quand l’historique du chat s’allonge avec le temps.
  • Utiliser une temperature élevée pour une tâche nécessitant de la précision : le modèle « invente » des faits inexistants.
  • Considérer temperature = 0 comme garantissant l’exactitude : greedy ne sélectionne que le token avec la plus haute probabilité. Si le modèle fait une mauvaise interprétation dès le début, il sera constamment faux.

7. Conclusion

Cet article a parcouru trois concepts fondamentaux des LLM. Les tokens sont l’unité que le modèle lit et aussi l’unité de facturation. Le vietnamien consomme généralement plus de tokens que l’anglais pour le même contenu. La context window limite le nombre total de tokens de l’entrée et de la sortie lors d’un appel, vous devez donc compter et découper activement l’historique. Le sampling détermine comment sélectionner le token suivant à partir de la distribution de probabilité. La temperature change le « pointage » de la distribution, tandis que top-k et top-p éliminent la queue de faible probabilité.

Une fois que vous comprenez la boucle « prédire le token, sélectionner le token, ajouter à l’entrée », vous pouvez expliquer la plupart des comportements des LLM que vous rencontrez en travaillant avec l’API, des réponses tronquées aux résultats différents entre les appels.

llmaijavatokenizationsampling