Im Inneren eines LLM: Reine Python-Inferenz für Qwen3-0.6B auf MLX
Schrittweise Implementierung der LLM-Infersion in etwa 200 Python-Sätzen mit MLX: Tokenisierer, RMSNorm, RoPE, gruppierte Abfragedaten-Attention, SwiGLU, KV-Cache und Sampling.
Quellen: How Modern LLMs Actually Generate Text: A Pure Python Walkthrough
Man gibt „Die Hauptstadt Frankreichs ist“ ein, und ein Modell beendet den Satz mit „Paris“ – in etwa 900 Millisekunden. Was geschieht in dieser kurzen Zeit? Welche Abfolge von Transformationen verwandelt fünf Wörter in eine Vorhersage?
Die beste Möglichkeit, das herauszufinden, besteht darin, den gesamten Inferenzprozess selbst zu implementieren, anstatt eine Bibliothek zu verwenden, die die Details versteckt. Die nachstehende Anleitung zeigt dies mit echten Qwen3-0.6B-Gewichten, die über MLX, Apples Array-Framework, in reines Python geladen werden. Das vollständige Skript umfasst etwa 200 Zeilen und läuft auf jedem Apple Silicon Mac. Die Einrichtungsanleitungen finden Sie am Ende.
Ein Überblick über den Prozess
Qwen3, GPT-4, Llama sowie ihre Varianten folgen alle demselben gemeinsamen Ablauf:
Text → Tokenize → Embed → [Transformer Block × 28] → LM Head → Logits → Sample → Next Token
↑ |
└-------------------------- KV Cache --------------------------┘
Der Text wird eingegeben, in Zahlen umgewandelt, durch 28 Transformator-Blöcke geleitet und als Wahrscheinlichkeitsverteilung über 151.936 mögliche nächste Token ausgegeben. Ein Token wird ausgewählt und hinzugefügt, anschließend beginnt der Kreislauf erneut.
Der Kreislauf besteht aus zwei Phasen:
- Vorbefüllung. Alle Eingabetoken werden auf einmal verarbeitet. Das Modell liest die Anweisung und erstellt seinen internen Zustand.
- Decodierung. Die Token werden nacheinander erzeugt. Ein neues Token muss sich nur auf die früheren Token konzentrieren, deren Schlüssel und Werte im KV-Cache gespeichert sind, sodass nichts erneut verarbeitet werden muss.
Die Vorbefüllung läuft parallel, während die Decodierung sequenziell abläuft. Der KV-Cache sorgt dafür, dass die Decodierung schnell erfolgt. Ohne ihn würde die Erzeugung des Tokens N bedeuten, dass die Aufmerksamkeit für alle vorherigen N-1 Token erneut berechnet werden müsste.
Nun zu jeder Phase nacheinander.
Schritt 1: Die Tokenisierung wandelt Text in Ganzzahlen um
Ein Modell sieht niemals Wörter, nur Ganzzahlen.
from tokenizers import Tokenizer
tok = Tokenizer.from_file("Qwen3-0.6B/tokenizer.json")
ids = tok.encode("The capital of France is").ids
# [785, 6722, 315, 9625, 374]
Jede ganze Zahl ist ein Index in ein Vokabular mit 151.936 Tokens. Der Tokenisierer verwendet die Byte-Pair-Kodierung, wodurch die Grenzen der Tokens nicht immer mit den Wortgrenzen übereinstimmen. „capital“ ist ein Token und „France“ ein weiteres, wobei der vorangestellte Leerzeichen zum Token gehört. Die Erstellung einer BPE von Grund auf ist hier nicht das Interessante. Der Tokenisierer ist eine Abfrage-Tabelle, und das Skript verwendet einfach die veröffentlichte Version.
Schritt 2: Embedding wandelt Ganzzahlen in Vektoren um
Die erste eigentliche Berechnung ordnet jedem Token-ID einen 1.024-dimensionalen Vektor zu.
x = W["model.embed_tokens.weight"][mx.array(token_ids)] # (seq, 1024)
Es handelt sich dabei lediglich um eine Tabellensuche: Die Einbettungsmatrix hat die Größe (151936, 1024) – eine Zeile pro Token und eine Spalte pro versteckter Dimension – und der Code greift darauf zu. Man kann sich die Matrix als das Wörterbuch des Modells vorstellen: Jedes Token besitzt einen gelernten 1.024-stelligen „Fingerabdruck“, der die Bedeutung kodiert. Die Fingerabdrücke für „Paris“ und „Frankreich“ weisen ähnliche Richtungen auf, während die Fingerabdrücke für „Paris“ und „Computer“ völlig unterschiedliche Richtungen aufweisen.
Schritt 3: RMSNorm
Vor der Aufmerksamkeitsberechnung werden die versteckten Zustände normalisiert. Moderne Modelle verwenden RMSNorm anstelle des älteren LayerNorm.
Im Überblick teilt die Formel jeden Vektor durch seinen Quadratwurzel-Mittelwert – also die Quadratwurzel aus dem Mittel der quadrierten Elemente plus einer kleinen Konstante – und multipliziert das Ergebnis anschließend mit einem pro Merkmal gelernten Gewicht.
def rms_norm(x, w):
return x * mx.rsqrt(mx.mean(x * x, axis=-1, keepdims=True) + EPS) * w
Jeder Vektor wird zunächst nach seiner eigenen Länge skaliert und anschließend durch in der Trainingsphase erlernte Gewichte pro Merkmal angepasst. Es erfolgt weder eine Subtraktion des Mittelwerts noch ein Bias, was es einfacher macht als LayerNorm, ohne an Qualität einzubüßen. EPS (1e-6) verhindert eine Division durch Null, und das im Training erlernte Gewicht w ermöglicht es dem Modell, jede der 1.024 Dimensionen unabhängig neu zu skalieren.
Schritt 4: RoPE integriert die Position in die Vektoren
Hier verlieren viele „LLM von Grund auf“-Artikel ihre Leser, doch es ist einfacher, als es scheint.
Das Problem. Die Aufmerksamkeit ist permutationsinvariant. Wenn die Eingabetoken verschoben werden, bleibt das Ergebnis unverändert, sodass das Modell nicht erkennen kann, welches Token zuerst kam.
Die alte Lösung. Fügen Sie einer Embedding-Wert für jedes Token eine Positionszahl hinzu. Das funktioniert, lässt sich aber nicht auf andere Fälle übertragen, und das Modell hat Schwierigkeiten, mit relativen Entfernungen umzugehen.
Die moderne Lösung. Drehen Sie die Query- und Key-Vektoren um einen Winkel, der von der Position des Tokens abhängt. Das ist das Rotary Position Embedding, kurz RoPE. Jedes Paar von Dimensionen wird als Punkt in einer Ebene betrachtet und um einen durch die Position des Tokens festgelegten Winkel gedreht.
def apply_rope(q, k, positions):
half = D_HEAD // 2 # 64
freqs = 1.0 / (THETA ** (mx.arange(half, dtype=mx.float32) / half))
angles = mx.array(positions, dtype=mx.float32)[:, None] * freqs[None, :]
cos = mx.cos(angles)[None, :, :]
sin = mx.sin(angles)[None, :, :]
def rotate(x):
x1 = x[..., :half]
x2 = x[..., half:]
return mx.concatenate([x1 * cos - x2 * sin, x1 * sin + x2 * cos], axis=-1)
return rotate(q), rotate(k)
Bilden Sie jedes Paar von Dimensionen als 2D-Fläche vor. Tokens an unterschiedlichen Positionen drehen sich um unterschiedliche Mengen, sodass, wenn die Aufmerksamkeit das Skalarprodukt aus Abfrage und Schlüssel berechnet, die Antwort davon abhängt, wie weit sie voneinander entfernt sind – und nicht nur davon, was sie enthalten. Hier sind drei Tokens an den Positionen 0, 1 und 2, jeweils als 2D-Pfeil dargestellt, der etwas weiter gedreht ist als der vorherige:
Position 0 (angle = 0°) Position 1 (angle = 24°) Position 2 (angle = 48°)
q q q
| / /
| / /
| / /
| / /
+------ x +------ x +------ x
Same content, 0° rotation Rotated 24° counterclockwise Rotated 48° counterclockwise
"I'm at the start" "I'm one step in" "I'm two steps in"
In der ursprünglichen Zeichnung ist der dritte Pfeil nicht weiter gedreht als der zweite, daher sollte das Diagramm als grobe Darstellung und nicht als genaue Abbildung betrachtet werden.
Attention vergleicht einen Abfragespeer mit einem Schlüsselspeer mithilfe eines Skalarprodukts. Pfeile mit demselben Winkel ergeben ein großes Produkt, während Pfeile mit sehr unterschiedlichen Winkeln ein kleines Produkt liefern. Somit spiegelt das Produkt die Entfernung zwischen zwei Tokens wider, ohne dass irgendwo eine explizite Positionszahl gespeichert wird. RoPE fügt die Position nicht als separates Signal hinzu. Die Rotation integriert die Position in jeden Vektor.
Qwen3 setzt THETA = 1.000.000 fest – eine hohe Basisfrequenz, die dem Modell ermöglicht, mit sehr langen Kontexten umzugehen, bis zu 262.000 Tokens ohne Erweiterungen.
Schritt 5: Attention mit gruppierten Abfrageschädeln
Das ist der Kernpunkt. Versteht man Attention, versteht man den Transformer.
Von MHA zu MQA zu GQA
Multi-Head Attention (MHA): 16 Q heads, 16 K heads, 16 V heads — expensive
Multi-Query Attention (MQA): 16 Q heads, 1 K head, 1 V head — too aggressive
Grouped Query Attention: 16 Q heads, 8 K heads, 8 V heads — sweet spot
Qwen3-0.6B verwendet GQA, verfügt über 16 Abfrageschädel und nur 8 Schlüssel-Wert-Schädel, sodass jeder Schlüssel-Wert-Schädel zwei Abfrageschädel bedient. Dadurch wird der KV-Cache halbiert, ohne dass die Qualität stark leidet.
Die gesamte Aufmerksamkeitsfunktion
def attention(x, layer_idx, cache, positions):
p = f"model.layers.{layer_idx}.self_attn"
# Project to Q, K, V
q = x @ W[f"{p}.q_proj.weight"].T # (seq, 2048) = 16 heads × 128
k = x @ W[f"{p}.k_proj.weight"].T # (seq, 1024) = 8 heads × 128
v = x @ W[f"{p}.v_proj.weight"].T # (seq, 1024) = 8 heads × 128
# Split into heads
q = q.reshape(-1, N_HEADS, D_HEAD).transpose(1, 0, 2) # (16, seq, 128)
k = k.reshape(-1, N_KV, D_HEAD).transpose(1, 0, 2) # (8, seq, 128)
v = v.reshape(-1, N_KV, D_HEAD).transpose(1, 0, 2) # (8, seq, 128)
# QK-Norm (Qwen3-specific: normalize each head before RoPE)
q = rms_norm(q, W[f"{p}.q_norm.weight"])
k = rms_norm(k, W[f"{p}.k_norm.weight"])
# Apply RoPE
q, k = apply_rope(q, k, positions)
# KV Cache: append new keys/values
if cache[layer_idx] is not None:
ck, cv = cache[layer_idx]
k = mx.concatenate([ck, k], axis=1)
v = mx.concatenate([cv, v], axis=1)
cache[layer_idx] = (k, v)
# GQA: expand 8 KV heads → 16 (each KV head shared by 2 Q heads)
k = mx.repeat(k, N_HEADS // N_KV, axis=0) # (16, total, 128)
v = mx.repeat(v, N_HEADS // N_KV, axis=0)
# Scaled dot-product attention
scale = D_HEAD ** -0.5
scores = (q @ k.transpose(0, 2, 1)) * scale # (16, seq, total)
# Causal mask (only during prefill)
if seq_len > 1:
mask = mx.triu(mx.full((seq_len, total), -1e9, dtype=scores.dtype), k=1)
scores = scores + mask
# Softmax + weighted sum of V
attn = mx.softmax(scores, axis=-1)
out = attn @ v
# Merge heads + output projection
out = out.transpose(1, 0, 2).reshape(-1, N_HEADS * D_HEAD)
return out @ W[f"{p}.o_proj.weight"].T
Einige Teile davon erfordern eine Erklärung.
QK-Norm. Qwen3 wendet RMSNorm auf die Q- und K-Vektoren jeder Einheit vor RoPE an – ein neueres Verfahren, das das Training stabilisiert. Seine Gewichte q_norm und k_norm bestehen aus 128 Zahlen, jeweils einer Skalierung pro Dimension innerhalb einer Einheit.
Der KV-Cache. Dies ist die wichtigste Optimierung bei der Inferenz. Während des Vorausfüllens werden für jeden Eingabetoken K- und V-Werte berechnet und gespeichert. Beim Dekodieren müssen nur für den neuen Token wieder K- und V-Werte berechnet werden; diese werden dem Cache hinzugefügt, und die Aufmerksamkeitsberechnung berücksichtigt dann die gesamte Historie. Ohne Cache müssten K- und V-Werte für jeden vorherigen Token erneut berechnet werden, was eine lineare Aufgabe in eine quadratische verwandeln würde.
Die kausale Maske. Während des Vorausfüllens darf das Token an Position 3 das Token an Position 4 nicht sehen, da dieses sich in seiner Zukunft befindet. Der Code fügt dazu -1e9 zum oberen Dreieck der Bewertungsmatrix hinzu, um dies durchzusetzen. Bei der Dekodierung, wenn die Sequenzlänge 1 beträgt, ist keine Maske erforderlich, da das einzige neue Token alles vor sich betrachten kann.
Die Formel. Attention berechnet den Softmax-Wert von (Q mal K transponiert, geteilt durch die Quadratwurzel der Kopfdimension), multipliziert das Ergebnis anschließend mit V. Das Skalarprodukt misst, wie gut jede Abfrage zu jeder Schlüsselgröße passt. Das Teilen durch die Quadratwurzel der Kopfgröße verhindert, dass die Bewertungen so groß werden, dass Softmax saturiert. Softmax wandelt die Bewertungen in Wahrscheinlichkeiten um, und die Multiplikation mit V ergibt ein gewichtetes Mittel der Wertvektoren.
Schritt 6: Die SwiGLU-Vorwärtschicht
Nach der Aufmerksamkeitsverarbeitung durchläuft jedes Token ein Feed-Forward-Netzwerk. Moderne Modelle verwenden SwiGLU anstelle der älteren, auf ReLU basierenden Version. Mit anderen Worten: Aus der Eingabe werden zwei Projektionen berechnet, eine wird durch SiLU geleitet und als Schalter verwendet, die beiden werden elementweise multipliziert, und eine letzte Projektion führt das Ergebnis wieder zurück.
def ffn(x, layer_idx):
p = f"model.layers.{layer_idx}.mlp"
gate = x @ W[f"{p}.gate_proj.weight"].T # (seq, 3072)
up = x @ W[f"{p}.up_proj.weight"].T # (seq, 3072)
silu = gate * mx.sigmoid(gate) # SiLU(x) = x · σ(x)
return (silu * up) @ W[f"{p}.down_proj.weight"].T # (seq, 1024)
Das Feed-Forward-Netzwerk ist der Ort, an dem das Modell den größten Teil seines Wissens speichert, was einer der Gründe dafür ist, dass große, dichte Modelle so leistungsstark erscheinen. Es erweitert den 1.024-dimensionalen Vektor auf 3.072 Dimensionen, wendet das SiLU-Tor an und projiziert ihn wieder auf 1.024 Dimensionen zurück. Das Tor lernt, welche Informationen durchgelassen und welche blockiert werden sollen – ähnlich wie ein Türsteher für Neuronen. Die Erweiterung von 1.024 auf 3.072 und wieder zurück gibt dem Modell Raum, Informationen zu speichern und zu verarbeiten, und gerade dieses Tor-Prinzip ist der Grund, warum moderne Feed-Forward-Netzwerke die mit ReLU-Netzwerken besser abschneiden.
Schritt 7: Der Transformer-Block
Ein Block kombiniert all das oben Genannte:
def transformer_block(x, layer_idx, cache, positions):
p = f"model.layers.{layer_idx}"
# Pre-norm attention + residual
h = rms_norm(x, W[f"{p}.input_layernorm.weight"])
x = x + attention(h, layer_idx, cache, positions)
# Pre-norm FFN + residual
h = rms_norm(x, W[f"{p}.post_attention_layernorm.weight"])
x = x + ffn(h, layer_idx)
return x
Die Sequenz wird normalisiert, anschließend wird der Residuanteil hinzugefügt, erneut normalisiert, dann wird vorwärtsgegeben und erneut der Residuanteil hinzugefügt.
Diese Residuzverbindungen (x = x + ...) sind von entscheidender Bedeutung. Sie ermöglichen es den Gradienten während des Trainings direkt durch das Netzwerk zu fließen und es ermöglichen es der Information bei der Inferenz, einzelne Blöcke zu überspringen. Ein 28-Schichten-Modell ohne sie wäre nahezu untrainierbar.
Qwen3-0.6B verwendet 28 solcher Blöcke, wobei jeder die Repräsentation weiter verfeinert. Beim 28. Block enthält der versteckte Zustand genügend Verständnis des Eingangs, um den nächsten Token vorherzusagen.
Schritt 8: LM-Head und Sampling
Nach dem letzten Block erfolgt eine weitere Normalisierung, anschließend eine Projektion auf die Größe des Wortschatzes:
x = rms_norm(x, W["model.norm.weight"])
logits = x @ W["lm_head.weight"].T # (seq, 151936)
Die Ausgabe umfasst 151.936 Werte, sogenannte Logits, einen für jeden möglichen Token. Ein höherer Wert bedeutet einen wahrscheinlicheren Token. Für „Die Hauptstadt Frankreichs ist“ sehen die fünf höchsten Wahrscheinlichkeiten nach Softmax wie folgt aus:
| Token | Probability |
|-----------|-------------|
| Paris | 0.6582 |
| located | 0.0274 |
| the | 0.0233 |
| ... | 0.0157 |
| ____ | 0.0136 |
„Paris“ gewinnt mit 65,8 % – eine zuversichtliche Prognose. Anschließend wandelt das Sampling die Wahrscheinlichkeiten in eine einzige Token-ID um:
def sample(logits, temperature=0.0, top_p=0.95):
if temperature == 0:
return int(mx.argmax(logits)) # Greedy: always pick the best
logits = logits / temperature
probs = mx.softmax(logits)
# Top-p: keep smallest set of tokens whose cumulative prob ≥ top_p
order = mx.argsort(-probs)
sorted_p = probs[order]
cumsum = mx.cumsum(sorted_p)
cutoff = int(mx.sum(cumsum < top_p)) + 1
sorted_p = sorted_p[:cutoff]
sorted_p = sorted_p / mx.sum(sorted_p)
order = order[:cutoff]
idx = mx.random.categorical(mx.log(sorted_p[None, :]))
return int(order[idx])
Temperatur steuert die Zufälligkeit. Bei 0 gewinnt immer das Token mit der höchsten Wahrscheinlichkeit (greedy). Bei 1,0 werden die Tokens in proportion zu ihren Wahrscheinlichkeiten ausgewählt, und bei 0,7 wird die Verteilung leicht zugunsten der führenden Tokens schärfer.
Top-p, auch Kernstichprobenziehung genannt, behält die kleinste Gruppe von Tokens bei, deren kumulative Wahrscheinlichkeit p erreicht – beispielsweise 0,95. Dadurch wird der lange Schwanz der unwahrscheinlichen Tokens reduziert, während gleichzeitig etwas Vielfalt erhalten bleibt.
Schritt 9: Der Generierungszyklus
Der vollständige Zyklus füllt zunächst einmal vor und decodiert anschließend:
def generate(prompt, max_tokens=100, temperature=0.0, top_p=0.95):
ids = tok.encode(prompt).ids
# Prefill: process all input tokens at once
cache = [None] * N_LAYERS
logits = forward(ids, cache, 0)
next_id = sample(logits[-1], temperature, top_p)
ids.append(next_id)
# Decode: generate one token at a time
for _ in range(max_tokens - 1):
if next_id == EOS_ID:
break
pos = len(ids) - 1
logits = forward([next_id], cache, pos)
next_id = sample(logits[-1], temperature, top_p)
ids.append(next_id)
return tok.decode(ids)
Das ist der gesamte Inferenzzyklus. Dienstengines wie vLLM, SGLang, TensorRT-LLM und mlx-lm sind im Grunde Optimierungen dieses Kerns, und der Kern passt in etwa 198 Zeilen.
Was das Skript weglässt
Das Skript umfasst den gesamten Inferenzprozess eines Standard-Transformers. Neuere Modelle fügen weitere Elemente hinzu:
- Hybride Aufmerksamkeit. Laut Angaben ersetzt Qwen3.5 75 Prozent seiner Aufmerksamkeitsschichten durch Gated DeltaNet, eine lineare Aufmerksamkeitsvariante mit einem festen rekurrenten Zustand anstelle der vollständigen Aufmerksamkeitsmatrix. Vollständige Aufmerksamkeit ist wie fotografisches Gedächtnis – perfekt, aber aufwändig. DeltaNet ähnelt komprimierten Notizen – effizient, aber mit geringfügigen Verlusten. Eine hybride Variante bietet effizientes Arbeiten mit langen Kontexten zusammen mit periodischem, perfektem Abrufen.
- Mehrfach-Token-Vorhersage. Einige Modelle prognostizieren bei jeder Vorwärtsberechnung mehrere Token und tauschen damit etwas Qualität gegen eine erhebliche Geschwindigkeitssteigerung ein.
- Quantisierung. Bei der Inferenz wird zur Ersparnis von Speicher und Zeit 4-Bit- oder 8-Bit-Gewichte verwendet. Dieses Skript behält die ursprünglichen bfloat16-Gewichte bei.
- Batching. Server verarbeiten mehrere Anfragen gleichzeitig und teilen die Berechnungen untereinander auf. Dieses Skript bearbeitet jeweils nur eine Anfrage nach der anderen.
Keine dieser Maßnahmen ändert den Kernalgorithmus. Sie bauen alle auf derselben Grundlage auf.
Warum sich die Mühe machen?
Wenn man weiß, wie die Inferenz funktioniert, wird man ein geschickterer Anwender. Eine Warnung des Servers wie „KV-Cache voll“ ist dann nicht mehr rätselhaft. Der Argumentationspunkt bezüglich des Speicherverbrauchs bei GQA lässt sich leicht erklären. Und ein Modell, das unkoordiniert und wiederholend arbeitet, weist darauf hin, dass Temperaturen und Top-p relevant sind.
Ein modernes LLM ist kein Wunder. Es besteht aus 28 Schichten mit Operationen wie Normalisierung, Aufmerksamkeitsberechnung und Feed-Forward, die wiederholt werden, wobei die Positionen durch Rotation berücksichtigt werden und das Wissen in gesteuerten linearen Schichten gespeichert ist. Die Mathematik entspricht dem Niveau eines Bachelorstudiums. Die Ingenieurskunst ist der Ort, an dem die Klugheit zum Tragen kommt, doch die Mathematik bildet die Grundlage. Es reichen etwa 200 Zeilen Python, echte Gewichte sowie echte Ausgaben aus.
Testen Sie es selbst
Alles oben Genannte lässt sich auf jedem Apple Silicon Mac (M1 oder neuer) ausführen.
1. Das Modell herunterladen
Holen Sie sich Qwen3-0.6B von Hugging Face (das Qwen/Qwen3-0.6B-Repository). Es sind nur vier Dateien erforderlich, daher können Sie die restlichen überspringen:
Qwen3-0.6B/
├── config.json
├── model.safetensors (1.5 GB — the actual weights)
├── tokenizer.json
└── tokenizer_config.json
Platzieren Sie den Modellordner neben llm_inference.py, da die Skript ./Qwen3-0.6B/ aus dem Verzeichnis auflöst, in dem es gestartet wird.
2. Erstellen Sie eine virtuelle Umgebung
# Create and activate a fresh venv
python3 -m venv llm_venv
source llm_venv/bin/activate
# Install the minimum packages - just two
pip install mlx tokenizers
Kein PyTorch, keine Transformers, kein Accelerate, kein Safetensors-Paket. MLX übernimmt die Array-Berechnungen sowie das Laden der Gewichte, und die Tokenizer-Bibliothek erledigt die BPE-Kodierung und -Decodierung.
3. Ausführen
python llm_inference.py
Erwarten Sie eine Ausgabe in dieser Art:
Model: Qwen3-0.6B (28 layers, 1024 dim, 16 Q heads, 8 KV heads)
Prompt: The capital of France is
------------------------------------------------------------
Output: The capital of France is Paris. The capital of Italy is
Rome. The capital of Spain is Madrid...
Time: 0.89s
Falls Sie den Fehler ModuleNotFoundError: No module named 'mlx' sehen, überprüfen Sie, ob die venv aktiviert ist (source llm_venv/bin/activate) und ob das Gerät Apple Silicon basiert. MLX läuft nicht auf Intel-Macs, Linux oder Windows.
Alles läuft auf einem Laptop, und die Antwort wird in weniger als einer Sekunde angezeigt.