Wie LLMs funktionieren: Token, Context Window und Sampling

1. Übersicht

Wenn Sie eine API eines Large Language Model (LLM) aufrufen, werden Sie auf Parameter wie max_tokens, temperature, top_p und Fehler wie „context length exceeded” stoßen. Diese Parameter sind leicht zu verstehen, wenn Sie wissen, wie das Modell Text generiert.

Dieser Artikel erklärt drei grundlegende Konzepte: Token, Context Window und Sampling. Zu jedem Konzept gibt es ein ausführbares Java-21-Codebeispiel. Nach dem Lesen können Sie Token vor dem API-Aufruf zählen, den Chat-Verlauf für das Context Window zuschneiden und wissen, wie Sie temperature oder top_p für jede Aufgabe richtig wählen.

2. Kernschleife: Vorhersage des nächsten Tokens

Ein LLM schreibt die gesamte Antwort nicht auf einmal. Es läuft eine einfache Schleife:

  1. Empfangen einer Eingangsfolge von Tokens.
  2. Berechnung eines Logit (Rohpunktzahl) für jedes Token im Vokabular, typischerweise Zehntausende bis über Hundertausende von Tokens.
  3. Umwandlung des Logits in eine Wahrscheinlichkeit und Auswahl eines Tokens. Dieser Auswahlschritt wird Sampling genannt.
  4. Das gerade ausgewählte Token an die Eingabe anfügen und zu Schritt 1 zurückkehren.

Die Schleife stoppt, wenn das Modell ein Stoptoken generiert oder das Limit max_tokens erreicht. Aus dieser Schleife ergeben sich drei Konsequenzen:

  • Das Modell behandelt Text als Token, nicht als Zeichen oder Wörter.
  • Alles, das das Modell in einem Aufruf „erinnert”, muss in der Eingabe sein. Diese Eingabe ist durch das Context Window begrenzt.
  • Die Antwort ist das Ergebnis mehrfacher kontrollierter Zufallsauswahl. Daher kann dieselbe Anfrage unterschiedliche Ergebnisse liefern.

3. Token

3.1. Was ist ein Token?

Ein Tokenizer zerlegt Text in kleine Stücke und ordnet jedes Stück einer ganzen Zahl zu. Die meisten aktuellen Modelle verwenden eine Variante von Byte Pair Encoding (BPE). Zeichenketten, die häufig in den Trainingsdaten vorkommen, werden zu einem Token zusammengefasst. Seltene Zeichenketten werden in kleinere Tokens aufgeteilt, manchmal bis auf Byte-Ebene.

Deshalb verursacht ein häufiges englisches Wort wie hello normalerweise nur ein Token. Im Gegensatz dazu benötigt Vietnamesisch mit Diakritika normalerweise mehr Tokens für die gleiche Menge an Inhalt, da Kombinationen wie „ượ” oder „ững” in den Trainingsdaten des Tokenizers selten vorkommen.

3.2. Token mit JTokkit zählen

Um Tokens in Java zu zählen, können Sie JTokkit verwenden. Diese Bibliothek implementiert OpenAIs BPE-Encodings wie cl100k_base und o200k_base:

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

Der folgende Code vergleicht die Anzahl der Tokens in einem englischen und vietnamesischen Satz:

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 Zeichen%n",
                vietnamese, viTokens, vietnamese.length());
    }
}

Erklärung:

  • getEncoding(EncodingType.CL100K_BASE) ruft das Vokabular ab, das frühe GPT-4-Modelle verwenden.
  • encode(...) gibt eine Liste von Token-IDs zurück. countTokens(...) gibt nur die Anzahl zurück und ist daher schneller, wenn Sie die IDs nicht benötigen.
  • Mit "Hello world" ist das Ergebnis 2 Tokens: Hello und world. Das Leerzeichen wird in das Token dahinter eingebunden.

Die Ausgabe sieht etwa so aus:

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

Führen Sie dies selbst aus und Sie werden sehen, dass der vietnamesische Satz deutlich mehr Tokens benötigt als die Anzahl seiner Wörter. Dies ist ein wichtiger Punkt bei der Kostenprognose, da Anbieter nach Tokens abrechnen.

Hinweis: Jede Modellreihe verwendet einen eigenen Tokenizer. Claude, Gemini, Llama und GPT teilen nicht die gleichen Tokens auf. JTokkit ist nur bei OpenAIs Encoding korrekt. Für andere Modelle sollten Sie den Tokenizer oder einen Token-Zähl-Endpunkt des Anbieters verwenden, oder betrachten Sie JTokkit-Zahlen als Schätzung mit einem Sicherheitspuffer.

4. Context Window

4.1. Was enthält das Context Window?

Das Context Window ist die maximale Anzahl von Tokens, die das Modell in einem Aufruf verarbeiten kann. Diese Zahl berücksichtigt sowohl Eingabe als auch Ausgabe:

System-Prompt + Chat-Verlauf + angehängte Dokumente + Frage + Antwort ≤ Context Window

Es gibt drei häufige Missverständnisse:

  • „Das Modell merkt sich das vorherige Gespräch.“ Falsch. Die API behält keinen Zustand zwischen Aufrufen. Eine Chat-Anwendung muss den gesamten Verlauf bei jedem Aufruf erneut senden, daher kostet jeder spätere Aufruf mehr Tokens als der vorherige.
  • „Je länger der Context, desto besser.“ Nicht unbedingt. Kosten und Latenz erhöhen sich mit der Anzahl der Eingabe-Tokens. Das Modell neigt auch dazu, Informationen in der Mitte eines sehr langen Context zu übersehen, ein Phänomen, das oft als „Lost in the Middle” bezeichnet wird.
  • „Wenn die Eingabe in das Context Window passt, reicht es aus.“ Noch nicht genug. Sie müssen auch Platz für die Ausgabe reservieren. Wenn die Eingabe fast alles füllt, wird die Antwort abgeschnitten.

4.2. Chat-Verlauf für das Token-Budget zuschneiden

Eine einfache und weit verbreitete Strategie ist: Immer das System-Prompt beibehalten, dann Nachrichten vom neuesten zum ältesten übernehmen, bis das Budget aufgebraucht ist.

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 {

    // Jede Nachricht verursacht zusätzliche Token für Rolle und Trennzeichen
    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("System-Prompt ist zu lang für das 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);
    }
}

Erklärung:

  • reservedForOutput ist der für die Antwort reservierte Platz. Dieser Wert sollte mit dem max_tokens übereinstimmen, das Sie an die API senden.
  • Die Schleife geht von hinten durch die Liste, da neuere Nachrichten normalerweise wichtiger für die aktuelle Frage sind.
  • Wenn eine Nachricht nicht mehr passt, wird mit break vollständig gestoppt, nicht übersprungen. Wenn übersprungen, wird das Gespräch in der Mitte unterbrochen und das Modell versteht den Kontext leicht falsch.
  • addFirst bewahrt die korrekte zeitliche Reihenfolge im Ergebnis.

In der Praxis können Sie zwei weitere Techniken kombinieren: Zusammenfassung älterer Nachrichten in einen kurzen Absatz oder Verwendung von RAG, um nur Dokumentabschnitte einzubeziehen, die für die Frage relevant sind, statt des gesamten Dokuments.

5. Sampling

5.1. Von Logit zu Wahrscheinlichkeit: Softmax und Temperature

Angenommen, das Modell muss den Satz „Die Hauptstadt von Vietnam ist…” vervollständigen und gibt vier Kandidaten mit Logits zurück: Hanoi = 4.0, Hue = 2.5, Da Nang = 2.0, Ho-Chi-Minh-Stadt = 1.0.

Um Logits in Wahrscheinlichkeiten umzuwandeln, verwendet das Modell die Softmax-Funktion. Vor der Softmax-Berechnung wird das Logit durch Temperature (T) dividiert:

  • T < 1 macht die Verteilung „spitzer”. Token mit hohen Punkten werden wahrscheinlicher gewählt.
  • T > 1 macht die Verteilung „flacher”. Token mit niedrigen Punkten bekommen mehr Chancen.

Die nächste Schicht implementiert Softmax, Temperature, Top-k, Top-p und die Token-Auswahlfunction:

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 muss > 0 sein, verwenden Sie greedy() für 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; // falls Rundungsfehler vorkommen
    }

    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;
    }
}

Erklärung der Hauptteile:

  • In softmax wird jedes Logit vor dem Aufruf von Math.exp vom Maximum subtrahiert. Das mathematische Ergebnis ändert sich nicht, aber Überläufe bei großen Logits werden vermieden.
  • topK und topP normalisieren die Wahrscheinlichkeiten nach dem Entfernen von Kandidaten neu, damit die Summe 1 ist.
  • sample wählt eine Zufallszahl im Bereich [0, 1) und addiert die Wahrscheinlichkeiten. Der Kandidat, dessen kumulative Summe die Zahl überschreitet, wird gewählt.

Versuchen Sie, Wahrscheinlichkeiten bei drei Temperaturebenen auszudrucken:

var candidates = List.of(
        new Candidate("Hanoi", 4.0), new Candidate("Hue", 2.5),
        new Candidate("Da Nang", 2.0), new Candidate("Ho-Chi-Minh-Stadt", 1.0));

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

Ergebnis (gerundet):

T=0.5 {Hanoi=0.934, Hue=0.047, Da Nang=0.017, Ho-Chi-Minh-Stadt=0.002}
T=1.0 {Hanoi=0.710, Hue=0.158, Da Nang=0.096, Ho-Chi-Minh-Stadt=0.035}
T=2.0 {Hanoi=0.485, Hue=0.229, Da Nang=0.178, Ho-Chi-Minh-Stadt=0.108}

Bei T = 2.0 wird das Modell ungefähr 1 von 9 Mal „Ho-Chi-Minh-Stadt” antworten. Für eine Faktenfrage ist das eine falsche Antwort. Dieses Beispiel zeigt, warum hohe Temperaturwerte zu Halluzinationen führen können.

Temperature = 0 wird normalerweise als Greedy Decoding verstanden: Immer das Token mit dem höchsten Logit wählen, entsprechend der greedy()-Funktion.

5.2. Top-k und Top-p

Beide Techniken schneiden den „langen Schwanz” von Tokens mit niedriger Wahrscheinlichkeit ab, bevor sie wählen:

  • Top-k: Behalten Sie nur die k Tokens mit den höchsten Wahrscheinlichkeiten. Mit k = 2 bleiben nur Hanoi und Hue.
  • Top-p (Nucleus Sampling): Behalten Sie die kleinste Menge von Tokens, deren kumulative Wahrscheinlichkeit ≥ p ist. Mit T = 1.0 und p = 0.9 ist die kumulative Summe 0.710, dann 0.868, dann 0.964. Top-p behält also 3 Tokens und entfernt Ho-Chi-Minh-Stadt.

Top-p ist flexibler als Top-k. Wenn das Modell sehr sicher ist, behält Top-p nur wenige Tokens. Wenn das Modell unsicher ist, behält es mehr Tokens. Top-k behält immer genau k Tokens, unabhängig davon, wie die Verteilung aussieht.

Hinweis: Viele Anbieter empfehlen, nur einen der Parameter temperature oder top_p anzupassen, nicht beide gleichzeitig. Der Grund ist, dass ihre Auswirkungen sich überlagern und das Ergebnis sehr schwer vorherzusagen ist.

5.3. Sampling-Verhalten testen

Dependency für Tests. Mit Spring Boot 3.x ist dies bereits in spring-boot-starter-test enthalten:

<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("Hanoi", 4.0), new Candidate("Hue", 2.5),
            new Candidate("Da Nang", 2.0), new Candidate("Ho-Chi-Minh-Stadt", 1.0));

    @Test
    void lowerTemperatureMakesTopTokenMoreLikely() {
        double cold = TokenSampler.softmax(candidates, 0.5).get("Hanoi");
        double hot = TokenSampler.softmax(candidates, 2.0).get("Hanoi");

        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("Hanoi", "Hue", "Da Nang");
        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("Hanoi");
    }
}

Der letzte Test macht einen wichtigen Punkt: Derselbe Seed ergibt die gleiche Ergebnisfolge. Einige APIs haben einen seed-Parameter, aber die meisten versprechen nur „Bester Aufwand”. Parallele Berechnungen auf GPUs und das Batching mehrerer Anfragen können die Logits leicht verschieben. Daher sollten Sie selbst bei temperature = 0 nicht schreiben, dass Tests exakt jedes Wort in echten LLM-Antworten abgleichen.

6. Parameter in der Praxis wählen

Aufgabe Empfehlung
Datenextraktion, Klassifikation, JSON-Generierung temperature 0–0.2
Fragebeantwortung, Code-Generierung temperature 0.2–0.5
Kreatives Schreiben, Brainstorming temperature 0.7–1.0 oder top_p 0.9–0.95

Häufig gemachte Fehler:

  • max_tokens zu niedrig setzen: JSON wird in der Mitte abgeschnitten und lässt sich nicht analysieren. Überprüfen Sie das Abbruchgrundfeld in der Antwort (normalerweise finish_reason oder stop_reason), bevor Sie das Ergebnis verarbeiten.
  • Tokens vor dem Senden nicht zählen: Anfrage wird abgelehnt, wenn der Chat-Verlauf mit der Zeit länger wird.
  • Hohe Temperatur für genauigkeitskritische Aufgaben verwenden: Das Modell „erfindet” nicht existente Fakten.
  • Temperature = 0 als Garantie für Korrektheit betrachten: Greedy wählt nur das Token mit der höchsten Wahrscheinlichkeit. Wenn das Modell von Anfang an falsch versteht, wird es konsistent falsch sein.

7. Fazit

Der Artikel hat drei grundlegende LLM-Konzepte behandelt. Token ist die Einheit, die das Modell liest, und auch die Abrechnungseinheit. Vietnamesisch benötigt normalerweise mehr Tokens als Englisch für den gleichen Inhalt. Context Window begrenzt die Gesamtzahl der Tokens von Eingabe und Ausgabe in einem Aufruf, also müssen Sie Token zählen und den Verlauf proaktiv kürzen. Sampling bestimmt, wie das nächste Token aus der Wahrscheinlichkeitsverteilung ausgewählt wird. temperature ändert die „Spitzheit” der Verteilung, während Top-k und Top-p den Schwanzbereich niedriger Wahrscheinlichkeit abschneiden.

Wenn Sie die Schleife „nächstes Token vorhersagen, Token wählen, an Eingabe anhängen” verstanden haben, können Sie die meisten Verhaltensweisen von LLMs erklären, denen Sie beim Arbeiten mit APIs begegnen, von abgeschnittenen Antworten bis zu unterschiedlichen Ergebnissen zwischen Aufrufen.

llmaijavatokenizationsampling