AI Inference Engineering: detaillierte Erklärung mit Beispielen
1. Überblick
Wenn Sie die API eines Chatbots aufrufen und auf die Antwort warten, die Wort für Wort auf dem Bildschirm erscheint, beobachten Sie die Inference eines Large Language Model (LLM). Dieser Artikel erklärt, wie Inference sich von Training unterscheidet, warum es enorme Ressourcen verbraucht, und welche Techniken Ingenieure einsetzen, um es schneller und günstiger zu machen: KV Cache, Continuous Batching, Quantization, Speculative Decoding. Nach dem Lesen werden Sie die Zahlen TTFT/TPOT in Benchmarks verstehen und Java-Code schreiben können, um die Performance eines echten Inference-Endpunkts zu messen.
Hinweis: Dieser Artikel geht nicht tief in die Interna von Transformern (Attention, Feed-Forward…) ein. Der Fokus liegt auf der Infrastruktur-Perspektive – wie ein Request durch das Inference-System „fließt”.
2. Was ist Inference?
Training ist der Prozess, bei dem ein Modell Gewichte aus Daten lernt – kostet Tausende GPUs über viele Wochen, passiert aber nur einmal. Inference ist, wenn das Modell bereits trainiert ist – Sie geben einen Prompt ein und erhalten eine Ausgabe – dies geschieht bei jeder Request, mit Millionen von Requests täglich in Produktion. Daher ist Training zwar teurer in Bezug auf einmalige Kosten, aber Inference bestimmt die langfristigen Betriebskosten und die Benutzer-Latenz.
Ein wichtiger Unterschied: Im Training sieht das Modell den ganzen Satz auf einmal zur Verlustberechnung. Bei der Inference von Textgenerierung muss das Modell Token für Token generieren, wobei jedes Token vom vorherigen abhängt – dies heißt autoregressives Decoding.
2.1. Zwei Phasen: Prefill und Decode
Jede Inference-Request durchläuft 2 Phasen:
- Prefill: Das Modell liest den gesamten Eingabe-Prompt auf einmal und berechnet Attention für alle Input-Token. Diese Phase nutzt die GPU gut, weil sie parallelisiert ist (compute-bound).
- Decode: Das Modell generiert Output-Token nacheinander, ein Token pro Schritt, basierend auf dem gesamten bisherigen Kontext. Diese Phase läuft sequenziell – jeder Schritt berechnet nur 1 neues Token (memory-bound – Engpass ist die Speicherbandbreite, nicht die Rechenpower).
Eine einfache Java-Illustration einer Decode-Schleife (simuliert, nicht echtes Modell) zeigt die sequenzielle Natur:
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); // Neues Token wird Input für nächsten Schritt
generated.add(nextToken);
}
return generated;
}
private String predictNextToken(List<String> context) {
// Simulation: echtes Modell führt Forward Pass durch ganzen Transformer
return "token_" + context.size();
}
}
Bei jedem Aufruf von predictNextToken muss das Modell den gesamten bisherigen Kontext lesen, um Attention zu berechnen – ohne Optimierung wächst der Aufwand mit der Satzlänge. Dies ist der Grund für die KV Cache in Teil 4.
3. Inference-Performance messen: TTFT und TPOT
Die zwei wichtigsten Metriken beim Benchmarking eines LLM:
- TTFT (Time To First Token): Zeit vom Absenden der Request bis zum Empfang des ersten Tokens – spiegelt die Latenz der Prefill-Phase wider.
- TPOT (Time Per Output Token): Durchschnittliche Zeit zum Generieren jedes weiteren Tokens in der Decode-Phase.
public record InferenceMetrics(Duration ttft, Duration avgTpot, int totalTokens) {
public Duration totalLatency() {
return ttft.plus(avgTpot.multipliedBy(totalTokens - 1));
}
}
Die Methode totalLatency() zeigt die Formel: Gesamtzeit ≈ TTFT (1x) + TPOT × (verbleibende Token). Bei einer 200-Token-Antwort kann schon TPOT von 10ms/Token zu 2 zusätzlichen Sekunden führen – deshalb wird TPOT in Serving-Benchmarks kritischer beobachtet als TTFT.
Reales Beispiel (7B-Modell, Batch-Größe 1, GPU A100):
TTFT ≈ 80ms
TPOT ≈ 15ms/Token
200 Token → 80ms + 15ms × 199 ≈ 3,07s
4. KV Cache: Vermeidung von Neuberechnungen
Ohne Cache müsste jeder Decode-Schritt Attention für die gesamte Token-Sequenz von Anfang an neu berechnen – Komplexität wächst quadratisch mit der Länge. KV Cache speichert Key/Value-Vektoren bereits verarbeiteter Token, sodass der nächste Schritt nur noch für das neue Token rechnet und den alten Cache wiederverwenden kann.
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 wird einmal für jedes neu generierte Token aufgerufen; allKeysUpTo erlaubt dem nächsten Decode-Schritt, alle bisherigen Keys ohne erneuten Forward Pass vom Anfang zu nutzen. Das ist der Grund, warum lange Requests (großer Kontext) sehr schnell VRAM aufbrauchen – der Cache wächst mit batch_size × sequence_length × num_layers × hidden_size.
Hinweis: KV Cache ist der Hauptgrund, warum lange Context-Fenster (32K, 128K Token) enorme GPU-RAM benötigen, nicht die Berechnung selbst. Deshalb entstanden Techniken wie Paged Attention (in vLLM) – Cache-Management wie virtueller Speicher, um Fragmentierung zu vermeiden.
5. Continuous Batching: Requests zusammenfassen für höheren Durchsatz
GPU-Verarbeitung in Batches ist effizienter als einzelne Requests. Aber jede Request erzeugt unterschiedlich viele Tokens – Request A endet bei Token 50, Request B läuft bis Token 300. Static Batching (warte bis ganzer Batch fertig ist) verschwendet GPU-Zeit, weil wir auf die langsamste Request warten. Continuous Batching löst dies: Sobald eine Request im Batch fertig ist, wird ihr Platz sofort mit einer neuen Request aus der Warteschlange gefüllt.
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()); // Neue Request sofort einfügen
} else {
req.decodeNextToken();
}
}
}
}
onStep() läuft bei jedem Decode-Schritt: Wenn ein Slot frei ist oder die Request eben fertig wurde, wird sofort die nächste Request aus waiting genommen. So ist die GPU immer mit vollen Batches ausgelastet – das ist der Kern-Mechanismus in vLLM, TGI (Text Generation Inference) und TensorRT-LLM.
6. Quantization: Genauigkeit gegen Geschwindigkeit tauschen
Ursprüngliche Modelle speichern Gewichte meist in FP16 oder BF16 (16-bit). Quantization reduziert die Bitanzahl (INT8, INT4), um VRAM zu sparen und Durchsatz zu erhöhen, auf Kosten leicht reduzierter Genauigkeit.
7B-Parameter-Modell, Speichergröße-Vergleich:
- FP16: ~14 GB VRAM
- INT8: ~7 GB VRAM
- INT4: ~3,5 GB VRAM
Beim Serving mit vLLM deklarieren Sie einfach die Konfiguration:
model: meta-llama/Llama-3-8B
quantization: awq
dtype: float16
gpu_memory_utilization: 0.9
quantization: awq nutzt AWQ-Methode (Activation-aware Weight Quantization) – eine INT4-Technik, die wichtigere Gewichte besser behält als naive Quantization.
Hinweis: Zu aggressive Quantization (INT4 und niedriger) kann zu mehr Halluzinationen oder schlechterer Reasoning führen. Immer nach Quantization die Output-Qualität neu testen (nicht nur Geschwindigkeit), besonders bei Aufgaben wie Code-Generierung oder Rechnung, wo Genauigkeit zählt.
7. Speculative Decoding: Vorausahnung zur Token-Einsparung
Sequenzielles Decoding ist langsam, weil jeder Schritt nur 1 Token erzeugt. Speculative Decoding nutzt ein kleines, schnelles Modell (“Draft Model”), um mehrere Token vorauszusagen, dann verifiziert das große Modell (“Target Model”) diese Token in einem Forward Pass statt mehreren.
Draft-Modell sagt voraus: "Der" "Himmel" "ist" "blau"
Target-Modell prüft alle 4 Token in 1 Forward Pass:
- Wenn 3/4 Token mit Target-Verteilung passen → behalte 3, verwerfe Token 4, decode weiter.
- Wenn von Anfang falsch → nur 1 Forward Pass gekostet wie normales Decoding, kein Verlust.
Diese Technik verringert nicht die Output-Qualität (weil immer durch Target-Modell verifiziert), sondern nur die Anzahl sequenzieller Forward Passes – am effektivsten, wenn Draft-Modell oft korrekt rät.
8. In der Praxis: TTFT/TPOT von einem LLM-Serving-Endpoint messen
Angenommen, Sie haben einen vLLM-Server mit OpenAI-kompatibler API unter http://localhost:8000. Fügen Sie WebClient-Abhängigkeit hinzu:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux</artifactId>
</dependency>
Schreiben Sie einen Client, um TTFT mit Stream-Response zu messen:
@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 wird aufgerufen, wenn jeder neue Chunk (Token) ankommt – beim ersten Aufruf wird firstTokenTime gespeichert, das ist die TTFT. Nach Stream-Ende (blockLast) rechnen wir TPOT aus: Gesamtzeit minus TTFT, geteilt durch verbleibende Token.
8.1. Test mit JUnit 5 und AssertJ
@SpringBootTest
class LlmBenchmarkClientTest {
@Autowired
LlmBenchmarkClient client;
@Test
void shouldMeetLatencySla() {
InferenceMetrics metrics = client.measure("Erkläre KV Cache in 2 Sätzen.");
assertThat(metrics.ttft()).isLessThan(Duration.ofMillis(500));
assertThat(metrics.avgTpot()).isLessThan(Duration.ofMillis(50));
assertThat(metrics.totalTokens()).isGreaterThan(0);
}
}
Dieser Test prüft nicht den Inhalt der Antwort, nur die Latenz-SLA – das ist der Kern von „Inference Engineering”: schnelle Antworten garantieren, nicht Model-Qualität bewerten (das ist eine andere Disziplin).
Hinweis: Dieser Test braucht einen echten laufenden LLM-Server (oder MockWebServer zum Mocken). In CI sollte man solche Tests als Integration Tests separieren, nicht mit Unit Tests laufen lassen, weil sie externe Infrastruktur benötigen.
9. Häufige Fehler und wann man keine Inference Engine selbst bauen sollte
- Batching/KV Cache von Grund auf neu schreiben: Wenn Sie nicht ML-Infrastruktur-Forschung betreiben, bauen Sie nicht selbst nach – Sie werden Monate brauchen, um die Optimierungen von vLLM, TGI oder TensorRT-LLM zu erreichen.
- Benchmark nur nach Durchsatz, nicht Tail Latency messen: Hoher Durchsatz aber schlechter p99-Latenz ruin die Benutzer-Erfahrung. Immer p50/p95/p99 messen, nicht nur Durchschnitt.
- Quantize ohne Output-Qualität neu zu testen: Wie in Teil 6 erwähnt, Bitanzahl-Reduktion hat immer Konsequenzen – nur Geschwindigkeit messen ohne Qualität ist ein häufiger Fehler.
- Static Batching für variable Production-Traffic: Wenn Requests unterschiedliche Output-Längen haben, verschwendet Static Batching viel GPU – Continuous Batching sollte Standard sein.
- Prefill vs. Decode-Workload nicht trennen: Manche Production-Systeme betreiben Prefill- und Decode-Server separat (disaggregated serving) – wenn Ihr Traffic ein klares Pattern hat (z.B. RAG mit langen Prompts, kurzen Outputs), lohnt sich diese Architektur.
10. Fazit
Inference Engineering ist eine Optimierungsaufgabe zwischen Latenz, Durchsatz und GPU-Kosten – nicht ein Modell-Training-Problem. Mit Prefill/Decode, KV Cache, Continuous Batching, Quantization und Speculative Decoding verstehen Sie Benchmarks, wählen die richtige Engine (vLLM, TGI…), und messen Ihr System mit den richtigen Metriken (TTFT, TPOT) statt nur „es läuft schnell/langsam” zu sagen. Der nächste Schritt ist, quantization oder gpu_memory_utilization in Ihrer Config zu ändern und diese Metriken auf Ihrem echten Workload zu beobachten.