Skip to content

Modul 26 — AI in Rust

AI-Praxis mit Rust: Tensoren & Inferenz (candle), Tokenizer & Text als Typen, eine kleine Inference-Pipeline, RAG mit Sum Types und Verifikation von unsafe-geführten Seams mit Miri und Kani.

131

Warum Rust für AI?

Lektion 131 von 5

Die Architektur-Wahl: Warum Rust statt Python?

In der Welt der Künstlichen Intelligenz dominiert Python. Das liegt nicht an der Sprache selbst, sondern an dem riesigen Ökosystem aus C++-Bindings. Wenn wir uns in Modul 25 mit der Migration von C++ zu Rust beschäftigt haben, sahen wir, dass Rust die Sicherheitsgarantien von C++ übernimmt, ohne die Performance zu opfern. Für AI-Anwendungen bedeutet das: Wir können die Rechenlast auf die GPU auslagern, aber die Steuerungslogik (das "Orchestriering") in einer Sprache schreiben, die deterministisch ist.

Ein kritischer Punkt in der Produktion ist die Latenz. Python nutzt einen Garbage Collector (GC), was zu unvorhersehbaren "Stop-the-World"-Pausen führen kann. In einer Echtzeit-Inference-Pipeline, in der Millisekunden über die User Experience entscheiden, sind solche Pausen inakzeptabel. Rust bietet Performance ohne GC, was eine konsistente Latenz ermöglicht. Wir tauschen die Laufzeit-Überprüfung des GC gegen die Compile-Zeit-Prüfung des Borrow Checkers ein.

Deterministische Latenz

Im Gegensatz zu GC-basierten Sprachen gibt es in Rust keine unvorhersehbaren Pausen zur Speicherbereinigung. Dies ist essenziell für AI-Modelle in der Edge-Computing-Umgebung oder bei High-Frequency-Inference.

Tensoren und Typensicherheit

AI-Modelle arbeiten mit Tensoren – mehrdimensionalen Arrays. In Python sind diese oft dynamisch typisiert, was erst zur Laufzeit zu ShapeMismatch-Fehlern führt. In Rust versuchen wir, so viel wie möglich in das Typsystem zu heben. Bibliotheken wie candle implementieren Tensoren so, dass Dimensionen und Datentypen (dtype) klar definiert sind.

Ein Tensor in Rust ist nicht einfach nur ein Blob aus Daten, sondern ein Objekt, das seine Form (Shape) und seinen Typ (z. B. f32) kennt. Während wir in Python oft raten müssen, ob eine Operation ein Float oder ein Int zurückgibt, zwingt uns Rust dazu, die Typen explizit zu handhaben oder sie via Generics wie Tensor<f32> zu definieren.

// Beispielhafte Nutzung eines Tensor-Konzepts (inspiriert von candle)
// Wir simulieren hier die Erstellung eines 2D-Tensors für Embeddings
use candle_core::{Tensor, Device, DType};

fn main() -> candle_core::Result<()> {
    // Definiere das Device (CPU oder CUDA)
    let device = Device::Cpu;

    // Erstelle einen Tensor mit 2 Zeilen und 3 Spalten (f32)
    // Die Daten sind in einem flachen Slice organisiert
    let data: &[f32] = &[1.0, 2.0, 3.0, 4.0, 5.0, 6.0];
    let tensor = Tensor::from_slice(data, (2, 3), &device)?;

    println!("Tensor Shape: {:?}", tensor.shape());
    println!("DType: {:?}", tensor.dtype());

    // Eine einfache Matrix-Multiplikation würde hier typsicher erfolgen
    // Fehler bei inkompatiblen Dimensionen werden als Result zurückgegeben
    Ok(())
}

Das Rust-AI-Ökosystem

Wir müssen das Rad nicht neu erfinden. Es gibt bereits eine Reihe von Frameworks, die Rusts Stärken für AI nutzen:

  • candle: Ein minimalistisches ML-Framework von Hugging Face, das auf Effizienz und einfache Deployment-Binaries setzt.
  • burn: Ein flexibles Deep-Learning-Framework, das eine starke Typisierung über verschiedene Backends hinweg bietet.
  • ort: Die Rust-Binding-Schicht für ONNX Runtime, ideal für das Deployment von Modellen, die in PyTorch oder TensorFlow trainiert wurden.
  • tokenizers: Die extrem schnelle Implementierung von Tokenisierungs-Algorithmen (ebenfalls Hugging Face).
  • rig: Ein Framework für die Erstellung von LLM-Agenten mit Fokus auf Typensicherheit.
  • mistral.rs: Eine native Rust-Implementierung zur Ausführung von Mistral-Modellen.

Diese Tools ermöglichen es uns, komplette AI-Pipelines zu bauen, die als eine einzige, statisch gelinkte Binary ausgeliefert werden können – ohne dass der Endnutzer eine Python-Umgebung installieren muss.

Wo bleibt das unsafe?

Ein häufiges Missverständnis ist, dass AI-Frameworks in Rust komplett "safe" seien. Das stimmt nicht. Um maximale Performance aus der GPU zu holen, müssen wir auf CUDA-Kernel oder cuBLAS zugreifen. Diese APIs sind C-basiert und damit naturgemäß unsafe.

Hier greift die Lektion aus Modul 25, Lektion 4: unsafe ist kein Werkzeug, um den Borrow Checker zu umgehen, sondern ein "gated seam" (eine kontrollierte Nahtstelle). In einem Framework wie candle ist der unsafe-Code hochgradig isoliert in den Hardware-Backends. Als Anwender der Library interagierst du mit einer sicheren API, während die "gefährliche" Arbeit im Inneren durch strikte Invarianten geschützt wird.

// Konzeptuelle Darstellung eines Hardware-Wrappers
struct GpuBuffer {
    ptr: *mut f32,
    size: usize,
}

impl GpuBuffer {
    // Die gefährliche Operation wird in eine sichere Methode gekapselt
    pub fn write_data(&mut self, data: &[f32]) {
        assert_eq!(data.len(), self.size);
        unsafe {
            // Nur hier wird der rohe Zeiger genutzt (Foreign Function Interface)
            std::ptr::copy_nonoverlapping(data.as_ptr(), self.ptr, self.size);
        }
    }
}

Wann ist Rust die richtige Wahl für AI?

Rust ist nicht immer die erste Wahl. Für das explorative Experimentieren, Data Science und das Training von Modellen bleibt Python aufgrund der Iterationsgeschwindigkeit überlegen. Rust ist jedoch die richtige Wahl, wenn folgende Bedingungen zutreffen:

  1. Latenz-Sensitivität: Wenn die Antwortzeit in Millisekunden stabil bleiben muss (kein GC-Jitter).
  2. Standalone-Binaries: Wenn das Modell als einzelne Datei ohne Abhängigkeiten (pip install ...) verteilt werden soll.
  3. Determinismus: Wenn Speicherverbrauch und Ausführungszeit präzise kontrolliert werden müssen.
  4. Integration: Wenn die AI-Funktionalität in eine bestehende Rust-Infrastruktur (z. B. einen High-Performance-Webserver) integriert wird.
// Vergleich: Ein einfacher Inference-Wrapper
struct ModelInference {
    weights: Vec<f32>, // Vereinfacht
    threshold: f32,
}

impl ModelInference {
    fn predict(&self, input: &[f32]) -> bool {
        // In Rust ist dieser Pfad deterministisch und extrem schnell
        let score: f32 = input.iter()
            .zip(&self.weights)
            .map(|(i, w)| i * w)
            .sum();
        
        score > self.threshold
    }
}

✅ Wichtige Erkenntnisse

0/4

Haken setzen, um deinen Lernfortschritt zu markieren:

132

Tokenizer & Text als Typen

Lektion 132 von 5

Die Brücke zwischen Text und Tensor

In der Welt der LLMs ist Text für das Modell unsichtbar. Ein Transformer-Modell operiert nicht auf Buchstaben oder Wörtern, sondern auf Vektoren, die wiederum auf ganzzahligen Indizes basieren. Der Prozess, Text in diese Zahlen zu überführen, ist das Tokenizing. In Rust bedeutet dies nicht nur den Aufruf einer Bibliothek, sondern die bewusste Entscheidung über die Typisierung dieser Datenströme.

Viele Anfänger begehen den Fehler, den gesamten Pipeline-Fluss als String oder Vec<u32> zu modellieren. Das führt schnell zum sogenannten "String-String-Sumpf", in dem man nicht mehr weiß, ob eine Variable ein roher Prompt, ein tokenisierter String oder ein bereits dekodiertes Ergebnis ist. In diesem Deep-Dive betrachten wir, wie wir Rusts Typsystem nutzen, um diese Zustände strikt zu trennen.

Unicode, Bytes und die Falle aus Modul 25

Bevor wir uns an die tokenizers-Crate wagen, müssen wir eine fundamentale Lektion aus Modul 25 wiederholen: Die Differenz zwischen chars und bytes.

Ein Tokenizer arbeitet intern oft auf Byte-Ebene (Byte-Pair Encoding, BPE), um sicherzustellen, dass jedes beliebige Unicode-Zeichen darstellbar ist, ohne dass das Vokabular explodit. Wenn Sie jedoch die Länge eines Prompts validieren oder Texte für das UI zuschneiden, ist .len() in Rust gefährlich, da es die Byte-Länge zurückgibt. Ein einziges Emoji kann 4 Bytes belegen, zählt aber als ein Zeichen.

Unicode-Awareness in Tokenizern

Moderne Tokenizer wie BPE zerlegen Text oft in Byte-Sequenzen, um Out-of-Vocabulary (OOV) Probleme zu vermeiden. In Rust müssen wir konsequent zwischen &str (UTF-8 Bytes) und .chars() (Unicode Scalar Values) unterscheiden, um keine Zeichen mitten im Byte-Cluster zu zerschneiden.

fn main() {
    let text = "Rust AI 🦀";
    
    // GEFÄHRLICH: Gibt die Byte-Länge zurück (12)
    println!("Byte-Länge: {}", text.len()); 
    
    // KORREKT: Zählt die tatsächlichen Unicode-Zeichen (9)
    println!("Zeichen-Anzahl: {}", text.chars().count());
    
    // Warum das für Tokenizer wichtig ist: 
    // Ein Tokenizer könnte das Emoji 🦀 in mehrere Tokens zerlegen, 
    // aber die logische Zeichenanzahl bleibt für die Prompt-Validierung entscheidend.
}

Typisierung gegen den "String-Sumpf"

Um Logikfehler zu vermeiden, führen wir Newtypes ein. Anstatt u32 für eine Token-ID zu verwenden, definieren wir einen eigenen Typ. Das verhindert, dass wir versehentlich eine TokenId mit einer SequenceLength oder einem anderen u32-Wert addieren.

Ebenso trennen wir den Prompt (den Input des Nutzers) vom Vocab (der Map zwischen Text und ID).

use std::collections::HashMap;
use tokenizers::Tokenizer;

// Newtype für Token-IDs: Verhindert Verwechslung mit anderen u32
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
struct TokenId(u32);

// Wrapper für den Prompt, um ihn von dekodierten Strings zu trennen
struct Prompt(String);

// Repräsentation des Vokabulars
struct Vocab(HashMap<String, TokenId>);

fn main() -> Result<(), Box<dyn std::error::Error>> {
    // Tokenizer aus HuggingFace-Pretrained laden
    let tokenizer = Tokenizer::from_pretrained("bert-base-uncased", None)
        .map_err(|e| format!("Laden des Tokenizers fehlgeschlagen: {}", e))?;

    let prompt = Prompt("Hello Rust AI!".to_string());
    
    // Encoding gibt ein Result zurück, da Tokens fehlen könnten (obwohl BPE dies minimiert)
    let encoding = tokenizer.encode(prompt.0, true)
        .map_err(|e| format!("Encoding Error: {}", e))?;

    // Konvertierung der rohen IDs in unseren Newtype `Vec<TokenId>`
    let token_ids: Vec<TokenId> = encoding.get_ids()
        .iter()
        .map(|&id| TokenId(id))
        .collect();

    println!("Tokens: {:?}", token_ids);
    Ok(())
}

Fehlerbehandlung: Result statt Panic

In produktiven AI-Systemen darf ein unbekanntes Zeichen oder ein korrupter Input niemals zu einem panic! führen. Die tokenizers-Crate ist hier vorbildlich und gibt Result zurück.

Ein kritischer Punkt ist die Dekodierung. Wenn ein Modell Sequenzen generiert, die im Vokabular nicht mehr konsistent sind (z.B. durch Bit-Flips oder extrem schlechte Sampling-Parameter), muss die Dekodierung sicher scheitern. Wir nutzen hierfür Rusts ?-Operator, um Fehler an die oberste Ebene zu delegieren, anstatt mit .unwrap() zu arbeiten.

Newtype Pattern für AI-Pipelines

Indem wir struct TokenId(u32) statt u32 verwenden, zwingen wir uns dazu, explizit zu konvertieren. Dies macht den Code selbstdokumentierend: Eine Funktion, die Vec<TokenId> erwartet, kann niemals versehentlich mit einer Liste von VocabularyIndices aufgerufen werden, selbst wenn beide intern u32 sind.

// Beispiel für eine typsichere Funktion
fn process_tokens(tokens: &[TokenId]) {
    println!("Verarbeite {} Tokens", tokens.len());
}

fn main() {
    let raw_ids = vec![101, 2023, 102];
    
    // process_tokens(&raw_ids); // FEHLER: Erwartet &[TokenId], nicht &[u32]
    
    let safe_ids: Vec<TokenId> = raw_ids.into_iter().map(TokenId).collect();
    process_tokens(&safe_ids); // OK
}

✅ Wichtige Erkenntnisse

0/3

Haken setzen, um deinen Lernfortschritt zu markieren:

133

Eine kleine Inference-Pipeline

Lektion 133 von 5

Die Anatomie einer Inference-Pipeline

In der Theorie klingt Inference simpel: Daten hinein, Modell anwenden, Vorhersage heraus. In der Rust-Praxis ist die Inference-Pipeline jedoch der Ort, an dem die meisten Laufzeitfehler auftreten, meist in Form von Panic durch inkompatible Tensor-Dimensionen oder Typ-Mismatches. Eine robuste Pipeline muss nicht nur das Modell ausführen, sondern die Datenströme präzise steuern, Dimensionen transformieren und die Latenz im Auge behalten.

Wenn wir Frameworks wie candle oder ort (ONNX Runtime) nutzen, verlassen wir die Welt der stark typisierten Rust-Strukturen und betreten die Welt der dynamischen Tensor-Shapes. Hier übernimmt der Entwickler die Verantwortung für die Validierung der Datenformate, bevor sie in den (oft in C++ oder CUDA geschriebenen) Backend-Kern fließen.

Tensor-Dimensionen & Squeezing

Ein Tensor hat oft Dimensionen, die für das Modell notwendig, für das Ergebnis aber redundant sind (z. B. eine Batch-Dimension von 1). "Squeezing" entfernt diese Dimensionen der Größe 1, um von einem Shape wie [1, 1, 768] zu einem flachen Vektor [768] zu gelangen.

Dimensionen und Typ-Sicherheit

Ein klassischer Fehler in AI-Pipelines ist die Verwechslung von f32 und f64. Während die meisten Deep-Learning-Modelle auf f32 (Single Precision) oder sogar f16/bf16 optimiert sind, neigt Rust in mathematischen Kontexten oft zu f64. Ein falscher Typ führt entweder zu einem Kompilierfehler (wenn die Library Generics wie Tensor<T> nutzt) oder zu einem Laufzeitabsturz, wenn das Backend ein striktes Format erwartet.

Besonders kritisch ist die Überprüfung der Shapes. Ein Modell erwartet beispielsweise einen Input-Tensor der Form [batch_size, sequence_length, hidden_dim]. Wenn Sie einen einzelnen Satz verarbeiten, ist die batch_size gleich 1. Viele Modelle geben jedoch Ergebnisse in derselben Form zurück. Um das Ergebnis als einfachen Vektor für eine Softmax-Operation oder einen Vergleich zu nutzen, müssen wir die redundanten Dimensionen entfernen.

// Beispiel für eine schematische Inference-Pipeline
// Wir simulieren hier die Logik, wie sie in candle oder ort vorkommt.

use std::time::Instant;

#[derive(Debug)]
struct InferenceResult {
    prediction: Vec<f32>,
    latency: std::time::Duration,
}

struct SimpleModel {
    weights: Vec<f32>, // Vereinfacht: ein flacher Tensor
    input_dim: usize,
    output_dim: usize,
}

impl SimpleModel {
    // Simuliert eine Vorwärtsrechnung (Forward Pass)
    fn forward(&self, input: &[f32]) -> Result<Vec<f32>, String> {
        if input.len() != self.input_dim {
            return Err(format!("Shape mismatch: Expected {}, got {}", self.input_dim, input.len()));
        }

        // Simuliere eine Matrix-Multiplikation: [1, input_dim] * [input_dim, output_dim]
        let mut output = vec![0.0; self.output_dim];
        for j in 0..self.output_dim {
            for i in 0..self.input_dim {
                output[j] += input[i] * self.weights[i * self.output_dim + j];
            }
        }
        Ok(output)
    }
}

fn main() {
    // Modell-Initialisierung
    let model = SimpleModel {
        weights: vec![0.1; 128 * 10], // 128 input -> 10 output
        input_dim: 128,
        output_dim: 10,
    };

    // Input-Daten: Wir simulieren einen Tensor-Shape [1, 128]
    // In einer echten Pipeline käme hier ein Tensor-Objekt mit dtype f32
    let input_data = vec![0.5f32; 128];

    // Messung der Latenz
    let start = Instant::now();
    let result = model.forward(&input_data).expect("Inference failed");
    let duration = start.elapsed();

    println!("Inference erfolgreich. Shape: [1, {}]", result.len());
    println!("Latenz: {:?}", duration);
}

Performance-Messung und Batching

In einer Produktionsumgebung ist die reine Korrektheit nicht ausreichend; die Latenz pro Request ist die entscheidende Metrik. Wir nutzen std::time::Instant, um die Zeit zwischen dem Start des Forward-Pass und dem Erhalt des Ergebnis-Tensors zu messen.

Ein wichtiger Hebel zur Performance-Steigerung ist das Batching. Anstatt 10 einzelne Requests mit dem Shape [1, seq, hidden] zu senden, ist es effizienter, diese zu einem einzigen Tensor mit dem Shape [10, seq, hidden] zu bündeln. Dies nutzt die SIMD-Einheiten der CPU oder die parallelen CUDA-Kerne der GPU deutlich besser aus, da der Overhead für den Modellaufruf nur einmal anfällt.

Hier sehen wir die Auswirkung von Batch-Größen auf die Latenz:

fn benchmark_batch_size(model: &SimpleModel, batch_size: usize) {
    // Simuliere einen Batch-Tensor [batch_size, input_dim]
    let batch_data = vec![0.5f32; batch_size * model.input_dim];
    
    let start = Instant::now();
    
    // Simuliere Batch-Processing
    for chunk in batch_data.chunks_exact(model.input_dim) {
        let _ = model.forward(chunk).unwrap();
    }
    
    let duration = start.elapsed();
    println!("Batch-Größe {}: Gesamtzeit {:?}, pro Item {:?}", 
              batch_size, duration, duration / batch_size as u32);
}

Fehlerquellen in der Pipeline

Die größte Gefahr in Rust-AI-Pipelines ist die "stille" Fehlpropagation in C-Bindings. Wenn eine Bibliothek wie ort einen Tensor mit falschem dtype erhält, kann es passieren, dass die Bibliothek nicht sauber in ein Rust-Result zurückkehrt, sondern ein Signal auslöst, das zum Absturz führt.

Achten Sie daher immer auf folgende Checkliste:

  1. Dtype-Validierung: Ist der Input wirklich f32? Ein f64-Array wird nicht implizit konvertiert.
  2. Shape-Check: Entspricht die Dimension [batch, seq, dim] exakt den Erwartungen des Modells?
  3. Squeeze/Unsqueeze: Wurden Batch-Dimensionen für die Post-Processing-Logik entfernt?
  4. Memory Alignment: Bei sehr großen Tensoren kann die Speicherallokation zum Flaschenhals werden; hier helfen Pre-allocated Buffers.
// Beispiel für eine Validierungs-Funktion vor der Inference
fn validate_tensor_shape(shape: &[usize], expected: &[usize]) -> Result<(), String> {
    if shape != expected {
        return Err(format!("Invalid shape. Expected {:?}, found {:?}", expected, shape));
    }
    Ok(())
}

// Anwendung in der Pipeline
// let input_shape = [1, 128];
// validate_tensor_shape(&input_shape, &[1, 128])?;

✅ Wichtige Erkenntnisse

0/4

Haken setzen, um deinen Lernfortschritt zu markieren:

134

RAG mit Sum Types

Lektion 134 von 5

Typensicheres RAG: Von Strings zu Sum Types

Retrieval Augmented Generation (RAG) wird oft als simpler Prozess aus "Daten suchen und an LLM senden" missverstanden. In einer produktiven Rust-Umgebung ist dieser Prozess jedoch eine Kette von Transformationen, bei der die Typensicherheit entscheidend ist, um Laufzeitfehler bei der Vektorsuche oder dem Chunking zu vermeiden. Anstatt Daten als lose String-Sammlungen zu behandeln, nutzen wir in diesem Deep-Dive das Sum-Type-Pattern aus Modul 25, um verschiedene Retrieval-Strategien explizit und exhaustiv zu modellieren.

Ein robuster RAG-Workflow definiert jeden Schritt – vom Chunk über das Embedding bis zum finalen Score – als eigenen Typ. Dies verhindert, dass wir versehentlich ein Embedding einer anderen Dimension verwenden oder einen BM25-Score mit einer Kosinus-Ähnlichkeit verwechseln.

Sum Types für Retrieval-Strategien

Durch die Modellierung des Retrievers als enum erzwingt Rust via match, dass jede mögliche Suchstrategie (z. B. Vektorsuche vs. Keyword-Suche) explizit gehandhabt wird. Dies macht die Pipeline erweiterbar und verhindert "Silent Failures" bei der Strategiewahl.

Modellierung der Datenpipeline

Wir beginnen mit der Definition unserer Grundbausteine. Ein Chunk ist mehr als nur Text; er enthält Metadaten für die Rückverfolgbarkeit. Das Embedding ist ein Wrapper um Vec<f32>, um Typenverwechslungen mit anderen numerischen Vektoren zu vermeiden.

#[derive(Debug, Clone)]
struct Chunk {
    text: String,
    metadata: std::collections::HashMap<String, String>,
}

#[derive(Debug, Clone)]
struct Embedding(Vec<f32>);

impl Embedding {
    // Berechnet die Kosinus-Ähnlichkeit zwischen zwei Vektoren
    fn cosine_similarity(&self, other: &Self) -> f32 {
        let dot_product: f32 = self.0.iter().zip(&other.0).map(|(a, b)| a * b).sum();
        let norm_a: f32 = self.0.iter().map(|x| x * x).sum::<f32>().sqrt();
        let norm_b: f32 = other.0.iter().map(|x| x * x).sum::<f32>().sqrt();
        dot_product / (norm_a * norm_b)
    }
}

/// Die Sum-Type-Modellierung unserer Retrieval-Methoden
enum Retriever {
    VectorStore { index_id: String },
    BM25 { corpus_id: String },
    Hybrid { vector_weight: f32, bm25_weight: f32 },
}

struct ScoredChunk {
    chunk: Chunk,
    score: f32,
}

Hier sehen wir die Anwendung des Sum-Type-Patterns: Der Retriever ist nicht bloß ein Konfigurations-String, sondern ein Typ, der die notwendigen Parameter für die jeweilige Strategie bündelt.

Die Pipeline: Von der Query zum Top-K

Die eigentliche RAG-Logik ist eine Kette von getypten Schritten. Wir vermeiden "String-Typing", indem wir den Fluss strikt definieren: Query $\rightarrow$ Embedding $\rightarrow$ Vec<ScoredChunk> $\rightarrow$ TopK.

async fn retrieve(
    query: &str, 
    retriever: &Retriever, 
    embedder: &impl Embedder
) -> Result<Vec<ScoredChunk>, RagError> {
    match retriever {
        Retriever::VectorStore { index_id } => {
            let query_vec = embedder.embed(query).await?;
            // In echt: Aufruf an Vector-DB (z.B. Qdrant/Milvus)
            println!("Suche in Vektor-Index {}", index_id);
            Ok(vec![]) // Platzhalter für Ergebnisse
        }
        Retriever::BM25 { corpus_id } => {
            println!("Keyword-Suche in Corpus {}", corpus_id);
            Ok(vec![]) // Platzhalter für Ergebnisse
        }
        Retriever::Hybrid { vector_weight, bm25_weight } => {
            // Kombination beider Welten
            println!("Hybrid-Suche mit Gewichten {}/{}", vector_weight, bm25_weight);
            Ok(vec![])
        }
    }
}

// Hilfs-Trait für die Embedding-Logik
#[async_trait::async_trait]
trait Embedder {
    async fn embed(&self, text: &str) -> Result<Embedding, RagError>;
}

#[derive(Debug)]
enum RagError {
    EmbeddingFailed,
    RetrievalError(String),
}

Durch den match-Block wird die Pipeline exhaustiv. Wenn wir später eine neue Strategie wie GraphRetrieval hinzufügen, wird der Rust-Compiler uns an jeder Stelle im Code darauf hinweisen, wo die Logik ergänzt werden muss.

Async Streaming und Cancellation via Drop

Wenn die retrieved Chunks an das LLM gesendet werden, wollen wir die Antwort streamen, um die Time-to-First-Token (TTFT) zu minimieren. Hier nutzen wir tokio::sync::mpsc. Ein entscheidender Vorteil von Rust ist die automatische Cancellation: Wenn der Consumer den Receiver droppt (z. B. weil der User den Request abbricht), wird der Sender im Producer-Task beim nächsten send() einen Fehler zurückgeben, was uns erlaubt, den teuren LLM-Call sofort zu beenden.

use tokio::sync::mpsc;

struct LLMResponse {
    token: String,
    is_final: bool,
}

async fn stream_llm_answer(
    context: Vec<Chunk>, 
    tx: mpsc::Sender<LLMResponse>
) {
    let tokens = vec!["Die", "Antwort", "laut", "Dokument", "ist..."];
    
    for token in tokens {
        // Wenn der Receiver gedroppt wurde, schlägt send() fehl.
        // Das ist unser Signal für die Cancellation.
        if tx.send(LLMResponse {
            token: token.to_string(),
            is_final: false,
        }).await.is_err() {
            println!("Consumer abgebrochen. Beende LLM-Streaming.");
            return;
        }
        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
    }
    let _ = tx.send(LLMResponse { token: "".into(), is_final: true }).await;
}

#[tokio::main]
async fn main() {
    let (tx, mut rx) = mpsc::channel(32);
    let context = vec![]; // Gefüllte Chunks aus retrieve()

    tokio::spawn(async move {
        stream_llm_answer(context, tx).await;
    });

    // Simuliere teilweises Lesen und dann Abbruch (Drop des Receivers)
    if let Some(res) = rx.recv().await {
        println!("Token empfangen: {}", res.token);
    }
    drop(rx); // Hier triggern wir die Cancellation im Producer-Task
}

✅ Wichtige Erkenntnisse

0/3

Haken setzen, um deinen Lernfortschritt zu markieren:

135

Safe Seams & Verifikation

Lektion 135 von 5

Die letzte Meile: Von "läuft" zu "bewiesen"

Wir haben in diesem Modul komplexe AI-Architekturen in Rust implementiert. Doch in der Welt der High-Performance-AI, wo wir oft an der Grenze zwischen Rust und C++/CUDA-Bibliotheken operieren, reicht ein erfolgreicher Compiler-Durchlauf nicht aus. Wenn wir unsafe nutzen, um GPU-Buffer direkt zu manipulieren oder FFI-Calls an Tensor-Runtimes zu senden, übernehmen wir die Verantwortung für die Speicher- und Thread-Sicherheit selbst.

Ein "Safe Seam" (eine sichere Nahtstelle) ist das Muster, bei dem wir einen winzigen, hochgradig kontrollierten unsafe-Block umschließen und eine strikte, sichere API nach außen geben. Das Ziel ist nicht, unsafe zu vermeiden, sondern es so zu isolieren, dass die Invarianten beweisbar bleiben.

Die benannte Invariante

Ein unsafe-Block ohne Dokumentation der Voraussetzungen ist eine Zeitbombe. Eine benannte Invariante ist ein expliziter Kommentar, der genau definiert, welche Bedingungen (z. B. Alignment, Lebensdauer, Thread-Sicherheit) vom Aufrufer garantiert werden müssen, damit der Block nicht zu Undefined Behavior (UB) führt.

Safe Gates und Verifikationstools

Ein klassisches Problem in AI-Pipelines ist der Zugriff auf GPU-Speicher über Raw-Pointer. Wenn wir eine C-Bibliothek nutzen, die uns einen Pointer auf einen Tensor zurückgibt, müssen wir sicherstellen, dass dieser Pointer nicht länger lebt als der zugrundeliegende Kontext.

Hier kommt die Verifikation ins Spiel. Während der Standard-Compiler uns bei Safe-Rust schützt, benötigen wir für die "Seams" spezialisierte Werkzeuge:

  1. Miri: Der Miri-Interpreter (cargo miri) führt den Rust-Code in einer virtuellen Maschine aus und erkennt UB, wie z.B. Out-of-bounds-Zugriffe oder Datenrennen, die im normalen Release-Build unbemerkt bleiben würden.
  2. Kani: Für kritische Pfade in der RAG-Pipeline (z. B. Token-Parsing oder Chunking-Logik) nutzen wir Model Checking mit cargo kani. Im Gegensatz zu Tests, die einzelne Inputs prüfen, beweist Kani mathematisch, dass eine Funktion für alle möglichen Inputs innerhalb einer Range nicht panikt.
/// Ein Safe-Gate für einen hypothetischen CUDA-Tensor-Zugriff.
struct GpuTensor {
    ptr: *mut f32,
    len: usize,
}

impl GpuTensor {
    /// Liest einen Wert an Index `i`.
    /// 
    /// # Safety
    /// Invariante: `ptr` muss auf einen gültigen, vom GPU-Treiber 
    /// zugewiesenen Speicherbereich zeigen, der mindestens `len` 
    /// Elemente umfasst und nicht gleichzeitig von anderen 
    /// Threads schreibend verändert wird.
    pub unsafe fn read_value(&self, i: usize) -> f32 {
        // Wir kapseln den unsafe-Zugriff in einer safe-Methode,
        // prüfen aber die Bounds explizit, um Panics statt UB zu provozieren.
        if i >= self.len {
            panic!("Index out of bounds in GpuTensor");
        }
        
        // SAFETY: Die Invariante garantiert die Gültigkeit des Pointers.
        // Der Bounds-Check verhindert den illegalen Speicherzugriff.
        *self.ptr.add(i)
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_gpu_tensor_bounds() {
        let data = vec![1.0, 2.0, 3.0];
        let tensor = GpuTensor {
            ptr: data.as_ptr() as *mut f32,
            len: data.len(),
        };

        // Testet die Invariante: Der Safe-Gate muss bei Index-Verletzung paniken,
        // anstatt UB durch einen illegalen Pointer-Offset zu erzeugen.
        let result = std::panic::catch_unwind(|| {
            unsafe { tensor.read_value(10) }
        });
        assert!(result.is_err(), "Sollte bei Index-Überlauf paniken");
    }
}

Deterministische Evals und die "Ship-of-Theseus"-Strategie

In der AI-Entwicklung ist "Zufall" der Feind der Verifikation. Wenn wir die Qualität unserer RAG-Pipeline evaluieren, dürfen unsere Goldenen Datensätze nicht auf stochastischen Samples basieren. Wir nutzen Table-Driven Testing mit festgeschriebenen Seeds für alle Zufallskomponenten. Jeder Testlauf muss bit-identisch reproduzierbar sein, sonst wissen wir nicht, ob ein Performance-Gewinn durch Code-Optimierung oder durch einen "glücklichen" Seed entstand.

Wenn wir bestehende C++/CUDA-Basen in Rust migrieren, wenden wir das Ship-of-Theseus-Prinzip an: Wir ersetzen nicht das ganze System auf einmal, sondern Komponente für Komponente. Dabei nutzen wir die 7-Punkte-Checkliste aus Modul 25 als Qualitätsfilter für jede neue Rust-Komponente:

  1. Sum Types: Werden Fehler als enum statt als Integer-Codes modelliert?
  2. Ownership: Sind die Lebensdauern der Tensor-Buffer klar definiert?
  3. Result statt Panic: Werden recoverable Fehler sauber nach oben gereicht?
  4. chars-count: Wird bei Text-Preprocessing korrekt zwischen Bytes und Chars unterschieden?
  5. Tests: Gibt es für jede unsafe-Nahtstelle einen entsprechenden Test?
  6. Unsafe begründet: Ist jeder unsafe-Block mit einer benannten Invariante kommentiert?
  7. Idiomatisch: Nutzt der Code Iterators und Option statt manueller C-style Loops?
/// Beispiel für eine deterministische Evaluation
struct EvalCase {
    input: &'static str,
    expected_output: &'static str,
}

fn evaluate_pipeline(cases: &[EvalCase], seed: u64) -> f32 {
    // ponytail: Wir nutzen einen festen Seed, um den Zufall in der 
    // Sampling-Strategie des LLM-Adapters zu eliminieren.
    let mut rng = deterministic_rng(seed); 
    let mut passed = 0;

    for case in cases {
        let output = run_rag_pipeline(case.input, &mut rng);
        if output == case.expected_output {
            passed += 1;
        }
    }
    passed as f32 / cases.len() as f32
}

fn main() {
    let golden_dataset = [
        EvalCase { input: "Was ist Rust?", expected_output: "Eine Systemsprache." },
        EvalCase { input: "Was ist Miri?", expected_output: "Ein UB-Detektor." },
    ];

    // Festgelegter Seed garantiert, dass die Eval-Metrik stabil bleibt.
    let score = evaluate_pipeline(&golden_dataset, 42);
    println!("Accuracy: {:.2}%", score * 100.0);
}

✅ Wichtige Erkenntnisse

0/4

Haken setzen, um deinen Lernfortschritt zu markieren:

This site uses essential cookies for Stripe payments. No tracking cookies.