Skip to content

Modul 4: AI-Anwendungen mit Go

AI-Praxis mit Go: LLM-Clients & Structured Output, Embeddings & RAG, Function-Calling & Agent-Loops, nebenläufige AI-Pipelines und Evals zur Absicherung.

Fortschritt0/5 Lektionen
16

LLM-Clients & Structured Output

Lektion 16 von 5

Die API als Vertrag: Structured Output

Wenn wir LLMs in produktive Go-Anwendungen integrieren, ist die größte Herausforderung nicht das Senden der Anfrage, sondern die Verlässlichkeit der Antwort. Ein LLM gibt im Kern Text zurück. Für eine Software ist "ungefilterter Text" jedoch riskant, da er unvorhersehbar ist. Wir behandeln die Kommunikation mit einer OpenAI-kompatiblen API daher als strikten Datenvertrag.

Anstatt auf schwere SDKs zu setzen, nutzen wir in Go die Standardbibliothek. Das macht den Code transparent und wartungsarm. Der Kern besteht darin, Structs zu definieren, die exakt das JSON-Schema der API widerspiegeln. Durch json:"..."-Tags steuern wir die Serialisierung und stellen sicher, dass Go-Typen (wie float64 für Temperaturen oder int für Token-Limits) korrekt übertragen werden.

JSON-Mapping als Prompt-Vertrag

In Go definieren wir die API-Schnittstelle über Structs. Das JSON-Schema dient hierbei als "Vertrag": Wir fordern vom Modell (oft via System-Prompt), die Antwort in einem bestimmten Format zu liefern, und mappen dieses direkt in Go-Typen, um Typensicherheit in der Applikation zu gewährleisten.

Hier ist die Grundstruktur für eine Chat-Kompletion ohne externe Abhängigkeiten:

package main

import (
	"bytes"
	"encoding/json"
	"fmt"
	"net/http"
)

// ChatMessage repräsentiert eine einzelne Nachricht im Chat-Verlauf
type ChatMessage struct {
	Role    string `json:"role"`    // "system", "user", "assistant"
	Content string `json:"content"`
}

// ChatRequest ist der Body für die /v1/chat/completions API
type ChatRequest struct {
	Model    string        `json:"model"`
	Messages []ChatMessage `json:"messages"`
	MaxTokens int           `json:"max_tokens,omitempty"`
	Temp      float64       `json:"temperature"`
}

// ChatResponse mappt die Antwort der API
type ChatResponse struct {
	Choices []struct {
		Message ChatMessage `json:"message"`
		Finish  string      `json:"finish_reason"`
	} `json:"choices"`
}

Implementierung des HTTP-Clients

Um eine Antwort zu erhalten, müssen wir einen POST-Request an den API-Endpunkt senden. Wichtig ist hier die korrekte Handhabung des Content-Type und die effiziente Dekodierung der Antwort mittels json.NewDecoder. Letzterer ist performanter als json.Unmarshal, da er direkt vom Netzwerk-Stream liest, ohne den gesamten Body erst in einem Byte-Slice zwischenzuspeichern.

Ein kritischer Punkt bei der Integration ist das Management des Kontextfensters. Jede Nachricht im Messages-Slice verbraucht Token. Wenn der Verlauf zu lang wird, schneidet die API die ältesten Nachrichten ab oder gibt einen Fehler zurück. In Go steuern wir dies über MaxTokens und eine manuelle Verwaltung des Slices.

func getCompletion(apiKey, model, prompt string) (string, error) {
	url := "https://api.openai.com/v1/chat/completions"

	reqBody := ChatRequest{
		Model: model,
		Messages: []ChatMessage{
			{Role: "system", Content: "Du bist ein präziser Go-Experte."},
			{Role: "user", Content: prompt},
		},
		MaxTokens: 500,
		Temp:      0.7,
	}

	jsonData, _ := json.Marshal(reqBody)
	req, _ := http.NewRequest("POST", url, bytes.NewBuffer(jsonData))
	req.Header.Set("Content-Type", "application/json")
	req.Header.Set("Authorization", "Bearer "+apiKey)

	client := &http.Client{}
	resp, err := client.Do(req)
	if err != nil {
		return "", err
	}
	defer resp.Body.Close()

	if resp.StatusCode != http.StatusOK {
		return "", fmt.Errorf("API Error: %s", resp.Status)
	}

	var chatResp ChatResponse
	if err := json.NewDecoder(resp.Body).Decode(&chatResp); err != nil {
		return "", err
	}

	if len(chatResp.Choices) == 0 {
		return "", fmt.Errorf("no choices returned")
	}

	return chatResp.Choices[0].Message.Content, nil
}

SSE-Streaming für Echtzeit-Antworten

Für eine gute User Experience (UX) ist es oft unerwünscht, dass der Nutzer Sekunden warten muss, bis die gesamte Antwort generiert wurde. Hier kommt Server-Sent Events (SSE) ins Spiel. Die API sendet die Antwort in kleinen Chunks, beginnend mit data: { ... }.

In Go implementieren wir dies mit einem bufio.Scanner, der den Response-Body Zeile für Zeile liest. Wir müssen die Präfixe data: entfernen und das verbleibende JSON-Fragment parsen. Wenn wir diese Antwort wiederum an einen Browser weiterleiten, müssen wir den http.ResponseWriter als http.Flusher casten, um die Daten sofort zu senden, anstatt sie im Buffer zu halten.

import (
	"bufio"
	"strings"
)

// StreamCompletion liest eine streaming Antwort der API
func StreamCompletion(resp *http.Response) {
	scanner := bufio.NewScanner(resp.Body)
	for scanner.Scan() {
		line := scanner.Text()
		if line == "" {
			continue
		}
		
		// SSE-Format: "data: { ... }"
		if strings.HasPrefix(line, "data: ") {
			data := strings.TrimPrefix(line, "data: ")
			if data == "[DONE]" {
				break
			}
			
			// Hier würde man das Fragment in ein Struct dekodieren
			// und z.B. via fmt.Print oder WebSocket an den Client senden
			fmt.Println("Chunk empfangen:", data)
		}
	}
}

Ein ehrlicher Hinweis zur Praxis: Streaming ist komplexer zu implementieren, da man Fehlerbehandlungen (wie Verbindungsabbrüche mitten im Stream) separat managen muss. Für einfache Backend-Tasks reicht der klassische Request-Response-Zyklus völlig aus.

✅ Wichtige Erkenntnisse

Haken setzen, um deinen Lernfortschritt zu markieren:

    17

    Embeddings & RAG

    Lektion 17 von 5

    Wissen in Vektoren verwandeln

    Wenn wir LLMs mit eigenen Daten füttern wollen, stoßen wir schnell an das Kontext-Limit. Wir können nicht das gesamte Firmenhandbuch in jeden Prompt kopieren. Die Lösung ist RAG (Retrieval Augmented Generation): Wir suchen nur die relevanten Textstellen heraus und übergeben diese als Kontext.

    Das Herzstück hiervon sind Embeddings. Ein Embedding ist im Grunde nichts anderes als eine Liste von Zahlen ([]float64), die die semantische Bedeutung eines Textes repräsentiert. Texte mit ähnlicher Bedeutung landen im hochdimensionalen Raum nah beieinander.

    In Go nutzen wir meist externe APIs (wie OpenAI oder lokale Modelle via Ollama), um diese Vektoren zu erhalten. Da es sich um einen einfachen HTTP-Request handelt, genügt net/http.

    Was ist ein Embedding?

    Ein Embedding bildet Text auf einen Vektor in einem Vektorraum ab. Anstatt nach exakten Keywords zu suchen, vergleicht RAG die "Richtung" dieser Vektoren, um Konzepte zu finden, die thematisch verwandt sind, auch wenn sie unterschiedliche Wörter nutzen.

    package main
    
    import (
    	"bytes"
    	"encoding/json"
    	"fmt"
    	"net/http"
    )
    
    // EmbeddingResponse spiegelt die typische API-Antwort wider
    type EmbeddingResponse struct {
    	Data []struct {
    		Embedding []float64 `json:"embedding"`
    	} `json:"data"`
    }
    
    func getEmbedding(text string, apiKey string) ([]float64, error) {
    	url := "https://api.openai.com/v1/embeddings"
    	payload, _ := json.Marshal(map[string]interface{}{
    		"input": text,
    		"model": "text-embedding-3-small",
    	})
    
    	req, _ := http.NewRequest("POST", url, bytes.NewBuffer(payload))
    	req.Header.Set("Content-Type", "application/json")
    	req.Header.Set("Authorization", "Bearer "+apiKey)
    
    	resp, err := http.DefaultClient.Do(req)
    	if err != nil {
    		return nil, err
    	}
    	defer resp.Body.Close()
    
    	var res EmbeddingResponse
    	if err := json.NewDecoder(resp.Body).Decode(&res); err != nil {
    		return nil, err
    	}
    
    	if len(res.Data) == 0 {
    		return nil, fmt.Errorf("kein Embedding erhalten")
    	}
    	return res.Data[0].Embedding, nil
    }
    

    Ähnlichkeit messen und speichern

    Sobald wir Vektoren haben, müssen wir wissen, welcher Vektor einer Suchanfrage am nächsten an einem gespeicherten Dokument liegt. Der Standard hierfür ist die Kosinus-Ähnlichkeit. Sie misst den Winkel zwischen zwei Vektoren: Ein Wert von 1.0 bedeutet identische Richtung (maximale Ähnlichkeit), 0.0 bedeutet orthogonal (kein Zusammenhang).

    Für einen produktiven Einsatz nutzt man Vektor-Datenbanken wie Pinecone, Milvus oder pgvector. Für kleine Datensätze oder Prototypen reicht jedoch ein einfacher In-Memory-Store.

    import "math"
    
    // CosineSimilarity berechnet die Ähnlichkeit zwischen zwei Vektoren
    func CosineSimilarity(a, b []float64) float64 {
    	if len(a) != len(b) {
    		return 0
    	}
    	var dotProduct, normA, normB float64
    	for i := 0; i < len(a); i++ {
    		dotProduct += a[i] * b[i]
    		normA += a[i] * a[i]
    		normB += b[i] * b[i]
    	}
    	if normA == 0 || normB == 0 {
    		return 0
    	}
    	return dotProduct / (math.Sqrt(normA) * math.Sqrt(normB))
    }
    
    type Document struct {
    	Text     string
    	Vector   []float64
    }
    
    type VectorStore struct {
    	Docs []Document
    }
    
    func (s *VectorStore) Search(queryVec []float64, topK int) []Document {
    	// ponytail: lineare Suche O(n). Bei >10k Dokumenten auf HNSW oder FAISS umsteigen.
    	type scoreDoc struct {
    		doc   Document
    		score float64
    	}
    	results := make([]scoreDoc, 0, len(s.Docs))
    
    	for _, doc := range s.Docs {
    		results = append(results, scoreDoc{doc, CosineSimilarity(queryVec, doc.Vector)})
    	}
    
    	// Hier würde man normalerweise sortieren und die topK zurückgeben
    	// (Vereinfacht: wir geben die Liste zurück, in der Praxis sortiert nach score DESC)
    	return []Document{results[0].doc} 
    }
    

    Die RAG-Pipeline: Vom Text zur Antwort

    Ein LLM kann nicht mit einem 50-seitigen PDF umgehen. Wir müssen den Text in kleinere Stücke (Chunks) zerlegen. Eine einfache, aber ehrliche Heuristik ist das Splitten nach Absätzen oder einer festen Zeichenanzahl mit leichtem Überlapp, damit der Kontext an den Schnittstellen nicht verloren geht.

    Die komplette Pipeline sieht dann so aus:

    1. Ingestion: Dokument $\rightarrow$ Chunking $\rightarrow$ Embedding $\rightarrow$ VectorStore.
    2. Retrieval: Nutzerfrage $\rightarrow$ Embedding $\rightarrow$ Suche in VectorStore $\rightarrow$ Top-K Chunks.
    3. Generation: Prompt-Bau ("Nutze diesen Kontext: [Chunks]. Frage: [Nutzerfrage]") $\rightarrow$ LLM.
    func simpleChunker(text string, chunkSize int) []string {
    	var chunks []string
    	for len(text) > 0 {
    		end := chunkSize
    		if len(text) < chunkSize {
    			end = len(text)
    		}
    		chunks = append(chunks, text[:end])
    		// ponytail: einfacher Slice ohne Überlapp; für bessere Qualität 
    		// 10-20% des Chunks als Überlapp zum nächsten hinzufügen.
    		text = text[end:]
    	}
    	return chunks
    }
    
    func GenerateRAGPrompt(query string, context []Document) string {
    	var contextText string
    	for _, doc := range context {
    		contextText += "- " + doc.Text + "\n"
    	}
    	return fmt.Sprintf(
    		"Du bist ein hilfreicher Assistent. Nutze ausschließlich den folgenden Kontext, um die Frage zu beantworten.\n\nKontext:\n%s\n\nFrage: %s\nAntwort:",
    		contextText, query,
    	)
    }
    

    ✅ Wichtige Erkenntnisse

    Haken setzen, um deinen Lernfortschritt zu markieren:

      18

      Function-Calling & Agent-Loops

      Lektion 18 von 5

      Die Brücke zwischen Text und Aktion: Function Calling

      Bisher haben wir LLMs primär als Text-Generatoren genutzt. Das Problem: Ein Modell kann zwar erklären, wie man das Wetter in Berlin prüft, aber es kann nicht tatsächlich eine API aufrufen, um den aktuellen Wert zu ermitteln. Hier kommt Function Calling (oder Tool Use) ins Spiel.

      Wichtig ist das Verständnis: Das Modell führt den Go-Code nicht selbst aus. Es entscheidet lediglich auf Basis einer bereitgestellten Funktionsbeschreibung, dass eine Funktion aufgerufen werden soll und welche Parameter dafür nötig sind. Es gibt uns ein strukturiertes JSON zurück, und wir – die Entwickler – führen die Funktion in unserer Go-Runtime aus und senden das Ergebnis zurück an das Modell.

      Das JSON-Schema Prinzip

      Damit das Modell weiß, welche Tools verfügbar sind, übergeben wir eine Liste von Definitionen. Diese enthalten den Funktionsnamen, eine Beschreibung (die für das Modell als "Bedienungsanleitung" dient) und ein JSON-Schema der Parameter. Nur so weiß das Modell, dass get_weather ein location-Argument vom Typ string erwartet.

      // Beispiel für eine Tool-Definition, wie sie an die API gesendet wird
      type Tool struct {
      	Type     string `json:"type"` // Meistens "function"
      	Function struct {
      		Name        string `json:"name"`
      		Description string `json:"description"`
      		Parameters  struct {
      			Type       string                 `json:"type"` // "object"
      			Properties map[string]interface{} `json:"properties"`
      			Required   []string               `json:"required"`
      		} `json:"parameters"`
      	} `json:"function"`
      }
      
      func weatherTool() Tool {
      	return Tool{
      		Type: "function",
      		Function: struct {
      			Name        string `json:"name"`
      			Description string `json:"description"`
      			Parameters  struct {
      				Type       string                 `json:"type"`
      				Properties map[string]interface{} `json:"properties"`
      				Required   []string               `json:"required"`
      			} `json:"parameters"`
      		}{
      			Name:        "get_weather",
      			Description: "Ruft das aktuelle Wetter für eine Stadt ab",
      			Parameters: struct {
      				Type       string                 `json:"type"`
      				Properties map[string]interface{} `json:"properties"`
      				Required   []string               `json:"required"`
      			}{
      				Type: "object",
      				Properties: map[string]interface{}{
      					"location": map[string]string{"type": "string", "description": "Die Stadt, z.B. Berlin"},
      				},
      				Required: []string{"location"},
      			},
      		},
      	}
      }
      

      Der Agenten-Loop: Die Denkschleife

      Ein einfacher API-Aufruf ist eine lineare Interaktion. Ein Agent hingegen arbeitet in einer Schleife (Loop), bis das Ziel erreicht ist. Dieser Prozess folgt meist dem Muster: Planen $\rightarrow$ Handeln $\rightarrow$ Beobachten $\rightarrow$ Reflektieren.

      In Go implementieren wir dies über eine for-Schleife, die so lange läuft, bis das Modell eine finale Antwort generiert oder ein Sicherheitslimit erreicht ist. Wir müssen dabei die gesamte Historie (inklusive der Tool-Aufrufe und deren Ergebnisse) mitschicken, da das Modell kein Gedächtnis über die einzelnen Schleifendurchläufe hat.

      Typisierte Tool-Ergebnisse

      Da verschiedene Tools unterschiedliche Datentypen zurückgeben, nutzen wir in Go oft ein Interface oder ein Struct mit einem Value-Feld vom Typ any. Um die Ergebnisse sauber zu verarbeiten, empfiehlt sich ein Typ-Switch oder eine zentrale ToolResult-Struktur, die den Namen des aufgerufenen Tools mit dem Ergebnis verknüpft.

      type ToolResult struct {
      	ToolName string
      	Content  string // Das Ergebnis als String, da LLMs Text verarbeiten
      }
      
      // Vereinfachter Agent-Loop
      func RunAgent(ctx context.Context, userPrompt string) (string, error) {
      	messages := []Message{{Role: "user", Content: userPrompt}}
      	maxIterations := 8 // Guardrail gegen Endlosschleifen
      
      	for i := 0; i < maxIterations; i++ {
      		// 1. Modell aufrufen
      		resp, err := callLLM(ctx, messages)
      		if err != nil {
      			return "", err
      		}
      
      		// 2. Prüfen, ob das Modell ein Tool aufrufen möchte
      		if len(resp.ToolCalls) == 0 {
      			return resp.Content, nil // Finaler Text erreicht
      		}
      
      		// 3. Tool-Aufrufe ausführen
      		for _, tc := range resp.ToolCalls {
      			result := executeTool(tc.Name, tc.Arguments)
      			
      			// Ergebnis an die Historie hängen
      			messages = append(messages, Message{
      				Role:    "tool",
      				ToolID:  tc.ID,
      				Content: result,
      			})
      		}
      		// Historie um die Tool-Anforderung ergänzen, bevor der nächste Loop startet
      		messages = append(messages, resp.ToMessage())
      	}
      	return "", errors.New("max iterations reached: agent is looping")
      }
      
      func executeTool(name string, args string) string {
      	switch name {
      	case "get_weather":
      		// Hier würde die echte API-Logik stehen
      		return "Das Wetter in Berlin ist 18 Grad und sonnig."
      	default:
      		return "Tool nicht gefunden."
      	}
      }
      

      Guardrails: Wenn der Agent "halluziniert" oder loopte

      Agenten-Systeme sind inhärent instabil. Es gibt drei klassische Fehlerbilder, die wir in Go abfangen müssen:

      1. Die Endlosschleife: Das Modell ruft Tool A auf, bekommt ein Ergebnis, versteht es nicht und ruft Tool A erneut mit denselben Parametern auf. Hier ist ein harter maxIterations-Zähler (wie im Code oben) zwingend erforderlich.
      2. Der Context-Timeout: API-Aufrufe in einer Schleife können langsam sein. Wir nutzen context.WithTimeout, um den gesamten Agenten-Lauf zeitlich zu begrenzen.
      3. Parameter-Fehler: Das Modell könnte Parameter senden, die nicht dem Schema entsprechen (z.B. eine Zahl statt eines Strings). Wir müssen die Argumente validieren, bevor wir die eigentliche Go-Funktion ausführen.

      Ein ehrlicher Hinweis: Je komplexer die Tool-Kette, desto höher die Chance, dass das Modell "vom Weg abkommt". Es ist oft effizienter, den Agenten in kleine, spezialisierte Teil-Agenten zu zerlegen, anstatt einem einzigen Loop 20 verschiedene Tools zur Verfügung zu stellen.

      // Beispiel für eine robuste Ausführung mit Timeout
      func SafeAgentRun(prompt string) {
      	ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
      	defer cancel()
      
      	res, err := RunAgent(ctx, prompt)
      	if err != nil {
      		log.Printf("Agent failed: %v", err)
      		return
      	}
      	fmt.Println("Result:", res)
      }
      

      ✅ Wichtige Erkenntnisse

      Haken setzen, um deinen Lernfortschritt zu markieren:

        19

        Nebenläufige AI-Pipelines

        Lektion 19 von 5

        Paralleles Processing von AI-Requests

        In der Praxis stehen AI-Anwendungen oft vor dem Problem der Latenz. Wenn Sie 1.000 Dokumente in Vektoren (Embeddings) umwandeln müssen, um sie in einer Vektordatenbank zu speichern, dauert ein sequenzieller Durchlauf bei einer API-Antwortzeit von 200ms pro Request etwa 3,3 Minuten. Das ist ineffizient, da der Großteil der Zeit auf I/O (Warten auf die API) entfällt.

        Hier spielt Go seine Stärke aus: Goroutines erlauben uns ein "Fan-out"-Muster. Wir verteilen die Requests auf viele Worker, die parallel laufen. Damit wir dabei nicht die Kontrolle verlieren – etwa wenn ein Request einen fatalen Fehler auslöst oder das Programm beendet werden muss – nutzen wir die errgroup.Group aus dem Paket golang.org/x/sync/errgroup. Sie ist eine Erweiterung der sync.WaitGroup, die zusätzlich den ersten aufgetretenen Fehler zurückgibt und über einen context.Context alle anderen Goroutines signalisiert, ebenfalls abzubrechen.

        Fan-out & errgroup

        Beim Fan-out werden Aufgaben auf mehrere parallele Worker verteilt. errgroup koordiniert diese Worker: Sobald ein Worker einen Fehler liefert, wird der Kontext für alle anderen ungültig, was Ressourcen schont und schnelle Fehlerantworten ermöglicht.

        package main
        
        import (
        	"context"
        	"fmt"
        	"log"
        
        	"golang.org/x/sync/errgroup"
        )
        
        // Simuliert einen Embedding-API-Call
        func getEmbedding(ctx context.Context, text string) (string, error) {
        	// In echt: http.NewRequestWithContext(ctx, ...)
        	return fmt.Sprintf("vec(%s)", text), nil
        }
        
        func main() {
        	texts := []string{"Go ist super", "AI ist spannend", "Pipelines sind effizient"}
        	
        	// errgroup.WithContext erstellt einen Context, der gecancelt wird, 
        	// sobald eine Goroutine einen Fehler zurückgibt.
        	g, ctx := errgroup.WithContext(context.Background())
        	
        	results := make(chan string, len(texts))
        
        	for _, t := range texts {
        		text := t // Capture loop variable
        		g.Go(func() error {
        			emb, err := getEmbedding(ctx, text)
        			if err != nil {
        				return err
        			}
        			results <- emb
        			return nil
        		})
        	}
        
        	// Warten, bis alle fertig sind oder einer einen Fehler wirft
        	if err := g.Wait(); err != nil {
        		log.Fatalf("Pipeline fehlgeschlagen: %v", err)
        	}
        	close(results)
        
        	for res := range results {
        		fmt.Println("Ergebnis:", res)
        	}
        }
        

        Rate-Limiting und höfliche Parallelität

        Wer blind tausende Goroutines auf eine externe API wie OpenAI oder HuggingFace loslässt, wird sehr schnell mit einem HTTP 429 Too Many Requests begrüßt. Parallele Ausführung bedeutet nicht, dass man die API-Limits ignoriert. Wir müssen die "Geschwindigkeit" unserer Pipeline drosseln.

        Das Standard-Werkzeug hierfür ist der Token-Bucket-Algorithmus, implementiert in golang.org/x/time/rate. Ein rate.Limiter erlaubt uns, eine maximale Anzahl von Requests pro Sekunde (RPS) festzulegen. Bevor ein Worker den API-Call startet, muss er einen "Token" vom Limiter anfordern. Wenn keine Token verfügbar sind, blockiert der Worker, bis der Limiter wieder Kapazität freigibt. Dies geschieht synchron und respektiert den context.Context, sodass ein Timeout der gesamten Pipeline auch den Wait-Zustand am Limiter beendet.

        Token-Bucket Rate Limiting

        Stellen Sie sich einen Eimer vor, der stetig mit Token gefüllt wird. Jeder Request benötigt ein Token. Ist der Eimer leer, muss der Request warten. Das verhindert Lastspitzen und sorgt für einen gleichmäßigen Datenfluss (Smoothing).

        package main
        
        import (
        	"context"
        	"fmt"
        	"golang.org/x/time/rate"
        	"time"
        )
        
        func main() {
        	// Erlaube 5 Requests pro Sekunde, mit einem Burst von maximal 2
        	limiter := rate.NewLimiter(rate.Every(time.Second/5), 2)
        	ctx := context.Background()
        
        	for i := 1; i <= 10; i++ {
        		// Blockiert, bis ein Token verfügbar ist oder ctx gecancelt wird
        		if err := limiter.Wait(ctx); err != nil {
        			fmt.Println("Limiter Fehler:", err)
        			return
        		}
        		fmt.Printf("Request %d gesendet um %s\n", i, time.Now().Format("15:04:05.000"))
        	}
        }
        

        Resilienz: Retries, Backoff und Race-Conditions

        Trotz Rate-Limiting können Netzwerkschwankungen oder temporäre Serverüberlastungen (HTTP 5xx) auftreten. Eine robuste Pipeline implementiert daher "Retries". Ein einfacher Loop reicht hier nicht aus; man nutzt meist einen "Exponential Backoff". Dabei wird die Wartezeit zwischen den Versuchen exponentiell erhöht (z. B. 1s, 2s, 4s...), um dem Server Zeit zur Erholung zu geben.

        Ein kritischer Punkt bei nebenläufigen Pipelines ist der Zugriff auf gemeinsame Ressourcen. Wenn Worker Ergebnisse in eine Slice schreiben, anstatt einen Channel zu nutzen, riskieren Sie "Data Races". In Go ist es essenziell, den Race-Detector zu verwenden. Starten Sie Ihre Tests immer mit go test -race ./.... Der Detector analysiert zur Laufzeit, ob zwei Goroutines gleichzeitig auf denselben Speicherbereich zugreifen, wobei mindestens einer der Zugriffe ein Schreibzugriff ist.

        // Beispiel für einen simplen Retry-Mechanismus
        func callAPIWithRetry(ctx context.Context, text string) (string, error) {
        	backoff := time.Second
        	for i := 0; i < 3; i++ { // Max 3 Versuche
        		res, err := getEmbedding(ctx, text)
        		if err == nil {
        			return res, nil
        		}
        		
        		// Nur bei temporären Fehlern (z.B. 429 oder 503) warten
        		fmt.Printf("Versuch %d fehlgeschlagen, warte %v...\n", i+1, backoff)
        		
        		select {
        		case <-time.After(backoff):
        			backoff *= 2 // Exponentieller Anstieg
        		case <-ctx.Done():
        			return "", ctx.Err()
        		}
        	}
        	return "", fmt.Errorf("maximal Anzahl an Retries erreicht")
        }
        

        ✅ Wichtige Erkenntnisse

        Haken setzen, um deinen Lernfortschritt zu markieren:

          20

          Eval & Absicherung

          Lektion 20 von 5

          Die letzte Meile: Eval & Absicherung

          Wir haben in den letzten Lektionen gelernt, wie man LLMs integriert, Streams handhabt und Agenten baut. Doch wer schon einmal einen Prompt "optimiert" hat, kennt das Problem: Man löst einen Bug an einer Stelle, nur um festzustellen, dass drei andere Antworten, die vorher perfekt waren, plötzlich instabil werden. In der Softwareentwicklung nennen wir das eine Regression. Bei LLMs ist das Risiko extrem hoch, da die Ausgaben stochastisch sind.

          Die Lösung ist nicht "mehr Hoffen", sondern ein systematischer Evaluierungs-Prozess (Eval). Wir behandeln Prompts nicht als Textdateien, sondern als Code, der durch automatisierte Tests laufen muss. Go ist hierfür prädestiniert: Seine Stärke bei Table-Driven Tests lässt sich eins zu eins auf LLM-Evals übertragen.

          Table-Driven Evals & Goldene Datensätze

          Ein "goldener Datensatz" ist eine kuratierte Liste von Eingaben und den dazugehörigen, idealen Ausgaben. Anstatt manuell im Chat-Interface zu prüfen, ob die neue Version des Prompts funktioniert, schreiben wir einen Test, der hunderte Beispiele in Sekunden durchläuft.

          Wir definieren eine Struktur für unsere Testfälle, die sowohl den Input als auch die erwarteten Kriterien (z. B. enthaltene Keywords oder ein bestimmtes JSON-Format) speichert.

          Golden Dataset Eval

          Ein fester Satz von Paarungen (Input $\rightarrow$ Expected Output), die als Benchmark dienen. Jede Prompt-Änderung triggert einen Durchlauf über diesen Satz, um sicherzustellen, dass die Qualität nicht sinkt.

          type EvalCase struct {
          	Name     string
          	Input    string
          	Expected string // Oder eine Validierungsfunktion
          }
          
          func TestPromptRegression(t *testing.T) {
          	goldenSet := []EvalCase{
          		{
          			Name:     "Einfache Extraktion",
          			Input:    "Ich wohne in Berlin und bin 30 Jahre alt.",
          			Expected: `{"city": "Berlin", "age": 30}`,
          		},
          		{
          			Name:     "Komplexer Kontext",
          			Input:    "Mein Standort ist Hamburg, das Alter wurde nicht genannt.",
          			Expected: `{"city": "Hamburg", "age": null}`,
          		},
          	}
          
          	for _, tc := range goldenSet {
          		t.Run(tc.Name, func(t *testing.T) {
          			got, err := callLLM(tc.Input) // Hilfsfunktion für API-Call
          			if err != nil {
          				t.Fatalf("API Fehler: %v", err)
          			}
          			if got != tc.Expected {
          				t.Errorf("Regression! \nInput: %s\nWill: %s\nGot:  %s", tc.Input, tc.Expected, got)
          			}
          		})
          	}
          }
          

          Wenn go test nun rot wird, wissen wir sofort: Der neue Prompt ist schlechter als der alte, egal wie "überzeugend" die ersten drei manuellen Tests im Playground wirkten.

          Guardrails: Validierung gegen Halluzinationen

          Ein LLM kann behaupten, JSON zu liefern, aber dann einen erklärenden Satz davor setzen oder Felder erfinden. In einer Produktionsumgebung führt das zu Abstürzen. Wir nutzen Go's striktes Typsystem als "Guardrail".

          Anstatt den Output einfach als String zu übernehmen, zwingen wir ihn durch einen json.Unmarshal in ein definiertes Struct. Wir prüfen dabei explizit, ob die Antwort vollständig ist.

          Schema-Enforcement

          Die Praxis, LLM-Outputs sofort gegen ein striktes Datenschema zu validieren, bevor sie an die Business-Logik weitergegeben werden. Unmarshal-Fehler werden als "Halluzination" gewertet und lösen einen Retry oder einen Fehler aus.

          type UserProfile struct {
          	City string `json:"city"`
          	Age  *int   `json:"age"` // Pointer für optional/null
          }
          
          func validateOutput(raw string) (*UserProfile, error) {
          	var profile UserProfile
          	// Wir nutzen einen Decoder, um unbekannte Felder ggf. zu erkennen
          	decoder := json.NewDecoder(strings.NewReader(raw))
          	decoder.DisallowUnknownFields() 
          
          	if err := decoder.Decode(&profile); err != nil {
          		return nil, fmt.Errorf("Schema-Verletzung: %w", err)
          	}
          
          	// Zusätzliche Logik-Prüfung (z.B. Alter darf nicht negativ sein)
          	if profile.Age != nil && *profile.Age < 0 {
          		return nil, errors.New("unplausibles Alter in LLM-Antwort")
          	}
          
          	return &profile, nil
          }
          

          Für besonders kritische Parser können wir zudem go test -fuzz einsetzen. Fuzzing generiert semi-zufällige, oft extrem hässliche Inputs, um zu sehen, ob unser Parsing-Code (der die LLM-Antwort verarbeitet) in Panics läuft oder Sicherheitslücken aufweist.

          Budgets: Kosten und Latenz im Griff

          Ein "besserer" Prompt, der 2000 Tokens mehr verbraucht und die Antwortzeit von 1s auf 5s erhöht, ist in der Produktion oft ein schlechterer Prompt. Wir integrieren die Messung von Token-Counts und Zeit direkt in unsere Test-Suite.

          Wir definieren Budgets pro Request. Wenn ein Testfall die Latenz-Grenze überschreitet, gilt der Test als fehlgeschlagen, auch wenn die Antwort inhaltlich korrekt ist.

          func TestPerformanceBudget(t *testing.T) {
          	start := time.Now()
          	resp, err := callLLM("Analysiere diesen langen Text...")
          	duration := time.Since(start)
          
          	if duration > 3*time.Second {
          		t.Errorf("Latenz-Budget überschritten: %v > 3s", duration)
          	}
          
          	tokenCount := countTokens(resp) // z.B. via tiktoken-go
          	if tokenCount > 500 {
          		t.Errorf("Token-Budget überschritten: %d > 500", tokenCount)
          	}
          }
          

          Modul-Zusammenfassung & Wochen-Check

          Damit schließen wir Modul 4 ab. Wir haben den Weg von einfachen API-Calls über strukturierte Prompt-Strategien bis hin zur Absicherung in Produktion zurückgelegt.

          Die wichtigste Erkenntnis: AI-Entwicklung ist kein "Schreiben von Text", sondern ein iterativer Engineering-Prozess. Die go test Schleife ist Ihr wichtigstes Werkzeug. Wenn Sie eine Änderung am Prompt vornehmen, ohne einen entsprechenden Testfall im goldenen Datensatz zu haben, bauen Sie technische Schulden auf.

          Wochen-Check: Haben Sie folgende Punkte gemeistert?

          1. Können Sie einen Prompt so gestalten, dass er konsistent valides JSON liefert?
          2. Haben Sie einen Test-Case geschrieben, der eine LLM-Antwort gegen ein Go-Struct validiert?
          3. Wissen Sie, wie Sie Token-Verbräuche messen, um Kosten vorherzusagen?
          4. Verstehen Sie, warum manuelle Tests im Browser für die Qualitätssicherung nicht ausreichen?

          ✅ Wichtige Erkenntnisse

          Haken setzen, um deinen Lernfortschritt zu markieren:

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