← Engineering Notes

Note 02 · Inference · Deployment

Llama 3.1 70B auf einer L40S — was wir gemessen haben.

Eine einzige NVIDIA L40S (48 GB), Llama 3.1 70B Instruct, vLLM als Serving-Layer. Wir haben Quantisierungs-Varianten, Batch-Größen und Prompt-Längen gegeneinander vermessen. Diese Note dokumentiert die realen Latenz- und Throughput-Werte aus einem produktiven Customer-Support-Agenten.

~22 min Mai 2026 Aus einem Live-Deployment
TL;DR

Llama 3.1 70B in Q4_K_M passt mit 4k Kontext komfortabel auf eine L40S. Time-to-First-Token: P50 ~480 ms, P95 ~780 ms. Die Decode-Rate liegt bei ~15–20 Token/s pro Stream — eine 200-Token-Antwort ist damit nach ~11–13 s komplett, dank Token-Streaming sieht der Nutzer die ersten Wörter aber praktisch sofort. Bei batch_size=4 und 8k Kontext steigt die P95-TTFT Richtung 1.6 s und die Decode-Rate pro Stream sinkt spürbar. Q8 liefert ~6% bessere Faithfulness gegen 35% mehr VRAM und höhere Latenz — für die meisten Mittelstands-Use-Cases nicht das Geld wert. FP16 ist auf einer einzelnen L40S nicht praktikabel: 4-bit-Quantisierung ist hier kein Kompromiss, sondern die korrekte Wahl.

1 · Hardware und Modell

Die Konfiguration, über die wir reden:

  • GPU — NVIDIA L40S, 48 GB GDDR6 ECC, 350 W TDP. Eine Karte, keine NVLink-Verbindung.
  • Host — AMD EPYC 9354P, 128 GB RAM, NVMe local. Kein Netzwerk-Storage im Inferenz-Pfad.
  • Modell — meta-llama/Llama-3.1-70B-Instruct, quantisiert nach Q4_K_M (~40 GB on disk, ~38 GB in VRAM).
  • Stack — vLLM 0.6.x, CUDA 12.4, Python 3.11. Kein TensorRT-LLM, kein Triton — vLLM ist für unsere Workloads schlicht genug und schnell genug.

Use-Case: ein Customer-Support-Agent mit RAG-Kontext (Top-5 Chunks, je ~400 Token), durchschnittliche Antwortlänge 180–240 Token. Die Anforderung: Time-to-First-Token P95 ≤ 1 s — die Antwort wird per Token-Streaming ausgeliefert, entscheidend ist also, wie schnell die ersten Wörter erscheinen, nicht wann das letzte Token fertig ist.

2 · Quantisierung — was wir verglichen haben

Wir haben drei Quantisierungs-Stufen gegen unser Ground-Truth-Set (siehe Note 01) laufen lassen. Identische Prompts, identische Decoding-Parameter (temperature=0.2, top-p=0.9), 200 Token max.

Variante VRAM TTFT P50 / P95 Faithfulness
Q4_K_M~38 GB~480 / ~780 ms0.891
Q8_0~52 GB ⚠
FP16~140 GB ⚠

Q8 und FP16 sprengen die einzelne L40S — Q8 fast (würde paged offloading erfordern), FP16 deutlich. Wir haben Q8 separat auf einem Zwei-GPU-Setup (2× L40S, Tensor-Parallelism) gemessen: 0.945 Faithfulness, TTFT bei ~590/950 ms. Das sind +6% Faithfulness gegen ungefähr verdoppelte Hardware-Kosten. Für ein internes Wissens-System überzeugend, für einen Kunden-Chatbot meist nicht.

3 · Batching — wo der Knick liegt

vLLMs Continuous-Batching ist beeindruckend, aber bei großem Kontext (Top-5 RAG-Chunks plus System-Prompt) verschiebt sich das Optimum schnell. Wir haben batch_size 1, 4, 8 gegen 4k und 8k Kontext gemessen — die Werte sind gerundete Bereiche aus mehreren Läufen, keine Punktmessungen:

Kontext Batch TTFT P95 Decode pro Stream
4k1~0.8 s~18–20 Token/s
4k4~1.1 s~15–17 Token/s
4k8~1.5 s~12–15 Token/s
8k1~1.3 s~17–19 Token/s
8k4~1.6 s~13–16 Token/s
8k8~3 s ⚠~10–12 Token/s

Zur Einordnung: Bei ~15–20 Token/s pro Stream ist eine 200-Token-Antwort nach ~11–13 s vollständig ausgeliefert. Weil wir per Token-Streaming ausliefern, ist das für Nutzer unkritisch — die ersten Wörter erscheinen nach der TTFT, also unter einer Sekunde. Für unseren Use-Case (TTFT P95 ≤ 1 s, ~3–4 parallele Streams in der Spitze) ist 4k Kontext mit batch_size=4 der sweet spot. Bei 8k-Kontext-Anfragen routen wir bewusst auf einen separaten Pool mit niedrigerem Batch, um die TTFT nicht zu sprengen.

4 · vLLM-Konfiguration, die uns Schmerzen erspart hat

# vllm-server.yaml (Auszug)

model: /models/llama-3.1-70b-q4km
quantization: gguf
dtype: auto

# Kontext und KV-Cache
max-model-len: 8192
kv-cache-dtype: fp8        # spart ~30% VRAM, Qualitätsverlust nicht messbar
enable-prefix-caching: true   # für RAG-Workloads kritisch

# Throughput
max-num-seqs: 8
max-num-batched-tokens: 32768
gpu-memory-utilization: 0.92  # nicht 0.95 — lässt Headroom für CUDA graphs

# Stabilität
swap-space: 4  # GB
disable-log-stats: false

# Networking
host: 0.0.0.0
port: 8000
api-key: "${VLLM_API_KEY}"

Drei Einstellungen, die wir teuer gelernt haben:

  • enable-prefix-caching: true — bei RAG-Workloads ändert sich der System-Prompt und oft sogar große Teile des Kontexts pro Request nicht. Prefix-Caching halbiert die Time-to-First-Token. Ein-Zeile-Änderung mit gewaltigem Impact.
  • kv-cache-dtype: fp8 — ein Drittel weniger VRAM, kein messbarer Qualitätsverlust im Eval-Set. Erlaubt uns batch_size=8 statt 6.
  • gpu-memory-utilization: 0.92 — der vLLM-Default 0.9 ist konservativ, 0.95 hat bei uns OOM-Crashes ausgelöst, sobald CUDA-Graphs aktiv wurden. 0.92 ist der Wert, der robust läuft.

5 · Wann eine zweite GPU lohnt

Wir empfehlen einem Kunden eine zweite L40S in genau drei Szenarien:

  • Q8-Quantisierung ist für den Use-Case nötig (regulatorisch oder messbar bessere Faithfulness > 5%).
  • Nachhaltig mehr als ~6–8 parallele Streams, nicht nur in Peaks.
  • Active-Active-HA-Anforderung (zwei Server in zwei Brandabschnitten).

In allen anderen Fällen reicht eine einzelne L40S — und der Kunde spart sich rund 9.000 € Hardware plus die Komplexität von Tensor-Parallelism. Wir bauen lieber ein zweites L40S-System für Failover, als zwei zu einer logischen Einheit zu binden.

6 · Was nicht in den TL;DR passt

Drei Dinge, die wir bei einem nächsten Deployment anders machen würden:

  • Wir würden früher mit AWQ-Quantisierung experimentieren. Q4_K_M ist gut, aber AWQ liefert in unseren ersten Tests vergleichbare Latenz mit messbar besserer Faithfulness. Die Produktionsstabilität müssen wir noch verifizieren.
  • Wir würden System-Prompt-Caching aggressiver nutzen. Im aktuellen Deployment ändert sich der System-Prompt monatlich — das ist Verschwendung.
  • Wir würden Speculative Decoding evaluieren. vLLM unterstützt es inzwischen stabil, wir haben es schlicht noch nicht getestet.