Vier Kräne eines Hamburger Containerterminals am gegenüberliegenden Kai, jenseits ruhigen Wassers im Morgendunst

KI-Anwendungen in Produktion skalieren

Erkenntnisse zum Aufbau nichtdeterministischer Systeme in Produktion und was Engineering-Teams besser lassen sollten.

Die meisten Teams wissen, wie man deterministische Software ausliefert. Sie schreiben eine Funktion, Sie schreiben einen Test, der Test besteht oder eben nicht, und Sie schlafen ruhig. Der Vertrag zwischen Ihnen und Ihrem Code ist klar: gleicher Input, gleicher Output, jedes Mal.

Angewandte KI bricht diesen Vertrag.

Sobald Sie ein LLM, ein Embedding-Modell oder irgendeine probabilistische Komponente in einen Produktionspfad einbauen, haben Sie ein System, das auf dieselbe Frage am Dienstag eine andere Antwort geben kann als am Mittwoch. Das ist kein Bug, den man wegpatcht. Das ist das Fundament. Und doch wurden die meisten Engineering-Praktiken – CI, Code Review, Monitoring, On-Call-Playbooks – für eine Welt gebaut, in der genau das nicht passiert.

Ich erlebe immer wieder, dass Engineering-Teams genau das unterschätzen, wenn sie von einer funktionierenden Demo zu etwas übergehen, auf das sich ein zahlender Kunde verlässt. Im Folgenden die Muster, die sich bei HUBBLR bewährt haben – in unseren eigenen Produkten wie in der KI-Arbeit für unsere Kunden.

Das Modell ist nicht das System

Der erste Fehler ist, „das Modell“ mit „dem Produkt“ gleichzusetzen. Das Modell ist eine Komponente. Das Produkt ist alles drumherum: die Prompt-Konstruktion, die Retrieval-Schicht, die Validierung, die Fallback-Pfade, der Human-in-the-Loop, die Observability, die Kostenkontrolle und die Stelle, an der der Output tatsächlich in einem Workflow landet.

Wenn in der Produktion etwas schiefgeht, liegt es fast nie am Modell – sondern am Gerüst um das Modell herum. Ein schwächeres Modell mit einer durchdachten Pipeline schlägt ein naiv angebundenes Frontier-Modell, jedes Mal.

Wenn Sie also ein KI-Feature planen, planen Sie zuerst das Gerüst. Das Modell lässt sich später am leichtesten austauschen.

Evals sind Ihre neue Test-Suite

Unit-Tests setzen Determinismus voraus. Evals nicht.

Ein Eval ist ein Datensatz repräsentativer Inputs, gepaart mit erwarteten Outputs, Bewertungskriterien oder beidem. Sie lassen Ihre Pipeline dagegen laufen, sobald sich etwas ändert – ein neuer Prompt, eine neue Modellversion, ein neuer Retrieval-Schritt – und beobachten die aggregierten Scores. Kein Pass/Fail. Verteilung.

Das klingt selbstverständlich, bis Sie versuchen, es in eine CI-Pipeline einzubauen. Dann müssen Sie echte Fragen beantworten: Wo liegt die Regressionsschwelle? Wer verantwortet das Eval-Set? Wie verhindern Sie, dass es veraltet? Wie verhindern Sie, dass es nur die Demo abbildet, die Ihr Vertrieb so mag? Ein Eval-Set, das nur den Happy Path enthält, ist schlimmer als gar keins, weil es Ihnen falsche Sicherheit gibt.

Teams, die das richtig machen, behandeln ihre Eval-Suite als lebendes Artefakt, das Engineering und die Fachexperten gemeinsam verantworten. Neue Fehlerbilder aus der Produktion werden wieder aufgenommen. Alte werden nicht gelöscht, nur weil sie peinlich sind.

Ein minimales Muster, mit dem wir Evals in die CI einbinden, sieht so aus:

# tests/evals/test_classifier_eval.py
import json
import pytest
from pathlib import Path
from app.pipeline import classify_product

EVAL_SET = json.loads(Path("evals/products.json").read_text())
REGRESSION_THRESHOLD = 0.92  # don't merge below this

@pytest.mark.eval
@pytest.mark.parametrize("case", EVAL_SET, ids=lambda c: c["id"])
def test_classification_case(case, eval_recorder):
    result = classify_product(case["input"])
    correct = result.category == case["expected_category"]
    eval_recorder.record(
        case_id=case["id"],
        passed=correct,
        predicted=result.category,
        expected=case["expected_category"],
        confidence=result.confidence,
    )

def test_aggregate_accuracy(eval_recorder):
    # Runs after the parametrized cases above.
    accuracy = eval_recorder.pass_rate()
    assert accuracy >= REGRESSION_THRESHOLD, (
        f"Eval accuracy {accuracy:.2%} below threshold "
        f"{REGRESSION_THRESHOLD:.2%}. Inspect regressions before merging."
    )
# tests/evals/test_classifier_eval.py
import json
import pytest
from pathlib import Path
from app.pipeline import classify_product

EVAL_SET = json.loads(Path("evals/products.json").read_text())
REGRESSION_THRESHOLD = 0.92  # don't merge below this

@pytest.mark.eval
@pytest.mark.parametrize("case", EVAL_SET, ids=lambda c: c["id"])
def test_classification_case(case, eval_recorder):
    result = classify_product(case["input"])
    correct = result.category == case["expected_category"]
    eval_recorder.record(
        case_id=case["id"],
        passed=correct,
        predicted=result.category,
        expected=case["expected_category"],
        confidence=result.confidence,
    )

def test_aggregate_accuracy(eval_recorder):
    # Runs after the parametrized cases above.
    accuracy = eval_recorder.pass_rate()
    assert accuracy >= REGRESSION_THRESHOLD, (
        f"Eval accuracy {accuracy:.2%} below threshold "
        f"{REGRESSION_THRESHOLD:.2%}. Inspect regressions before merging."
    )
# tests/evals/test_classifier_eval.py
import json
import pytest
from pathlib import Path
from app.pipeline import classify_product

EVAL_SET = json.loads(Path("evals/products.json").read_text())
REGRESSION_THRESHOLD = 0.92  # don't merge below this

@pytest.mark.eval
@pytest.mark.parametrize("case", EVAL_SET, ids=lambda c: c["id"])
def test_classification_case(case, eval_recorder):
    result = classify_product(case["input"])
    correct = result.category == case["expected_category"]
    eval_recorder.record(
        case_id=case["id"],
        passed=correct,
        predicted=result.category,
        expected=case["expected_category"],
        confidence=result.confidence,
    )

def test_aggregate_accuracy(eval_recorder):
    # Runs after the parametrized cases above.
    accuracy = eval_recorder.pass_rate()
    assert accuracy >= REGRESSION_THRESHOLD, (
        f"Eval accuracy {accuracy:.2%} below threshold "
        f"{REGRESSION_THRESHOLD:.2%}. Inspect regressions before merging."
    )

Es geht nicht um das Framework – es geht um die Form. Ergebnisse pro Fall, gespeichert für die spätere Analyse, eine aggregierte Schwelle, die Merges absichert, und eine Struktur, die sich um Latenz, Kosten oder LLM-as-Judge-Scores erweitern lässt, ohne das Harness neu zu schreiben.

Logging ist keine Observability

In einem deterministischen System sagt Ihnen eine Logzeile, was passiert ist. In einem nicht-deterministischen System sagt Ihnen eine Logzeile, wie ein einzelner Wurf der Würfel ausgesehen hat. Das ist nicht nichts – aber es reicht nicht.

Stattdessen brauchen Sie drei Dinge:

Den vollständigen Trace der Anfrage, einschließlich des exakt gesendeten Prompts, der exakt empfangenen Antwort, des abgerufenen Kontexts, der Tool-Calls und des für den Nutzer sichtbaren Outputs. Keine Zusammenfassungen, sondern die rohen Payloads. Speicher ist billig; eine Halluzination sechs Wochen später ohne den ursprünglichen Prompt zu debuggen, ist teuer.

Aggregierte Metriken, die zeigen, wie sich die Verteilung des Verhaltens über die Zeit entwickelt. Durchschnittliche Latenz, p95-Latenz, Token-Verbrauch, Kosten pro Anfrage, Ablehnungsrate, Erfolgsquote der Tool-Calls. Diese Werte verschieben sich, bevor irgendetwas sichtbar kaputtgeht.

Feedback-Schleifen von den Nutzern. Daumen hoch oder runter, ein „Erneut versuchen“, eine manuelle Korrektur des KI-Outputs – all das sind Signale, und die meisten Teams werfen sie weg.

Wenn Sie das exakte Verhalten einer einzelnen Produktionsanfrage nicht aus Ihrem Observability-Stack rekonstruieren können, fliegen Sie blind.

Planen Sie für Teilausfälle, nicht für „das Modell lag falsch“

Deterministische Systeme scheitern laut: Ein Service ist down, eine Datenbank ist nicht erreichbar, eine Exception wird geworfen. Nicht-deterministische Systeme scheitern leise: Die Antwort ist plausibel, flüssig formuliert und unbemerkt falsch.

Das heißt: „Ist es fehlgeschlagen?“ ist keine binäre Frage mehr, die Ihr Code selbst beantworten kann. Einige der wichtigsten Produktionsmuster, die wir einsetzen:

Strukturierte Outputs mit Schema-Validierung. Wenn das Modell JSON zurückgibt, validieren Sie es. Schlägt die Validierung fehl, haben Sie einen sauberen Retry-Pfad. Besteht sie, sind die Werte aber Unsinn, greift die nächste Schicht.

Konfidenzbasiertes Routing. Nicht jede Entscheidung braucht volle Autonomie. Ordnen Sie Anfragen nach ihrer Tragweite ein. Geringe Tragweite: Das Modell handelt selbst. Mittlere Tragweite: Das Modell handelt, ein Mensch gibt frei. Hohe Tragweite: Das Modell entwirft, entscheidet aber nie.

from enum import Enum
from dataclasses import dataclass

class Action(Enum):
    AUTO_APPLY = "auto_apply"        # model acts, no human in the loop
    QUEUE_FOR_REVIEW = "review"      # human approves before commit
    DRAFT_ONLY = "draft"             # human decides, model only suggests

@dataclass
class RoutingDecision:
    action: Action
    reason: str

def route(prediction, *, stakes: str) -> RoutingDecision:
    # High stakes always require a human, regardless of confidence.
    if stakes == "high":
        return RoutingDecision(Action.DRAFT_ONLY, "high-stakes path")

    if prediction.confidence >= 0.90 and stakes == "low":
        return RoutingDecision(Action.AUTO_APPLY, "high confidence, low stakes")

    if prediction.confidence >= 0.70:
        return RoutingDecision(Action.QUEUE_FOR_REVIEW, "medium confidence")

    return RoutingDecision(Action.DRAFT_ONLY, "low confidence")
from enum import Enum
from dataclasses import dataclass

class Action(Enum):
    AUTO_APPLY = "auto_apply"        # model acts, no human in the loop
    QUEUE_FOR_REVIEW = "review"      # human approves before commit
    DRAFT_ONLY = "draft"             # human decides, model only suggests

@dataclass
class RoutingDecision:
    action: Action
    reason: str

def route(prediction, *, stakes: str) -> RoutingDecision:
    # High stakes always require a human, regardless of confidence.
    if stakes == "high":
        return RoutingDecision(Action.DRAFT_ONLY, "high-stakes path")

    if prediction.confidence >= 0.90 and stakes == "low":
        return RoutingDecision(Action.AUTO_APPLY, "high confidence, low stakes")

    if prediction.confidence >= 0.70:
        return RoutingDecision(Action.QUEUE_FOR_REVIEW, "medium confidence")

    return RoutingDecision(Action.DRAFT_ONLY, "low confidence")
from enum import Enum
from dataclasses import dataclass

class Action(Enum):
    AUTO_APPLY = "auto_apply"        # model acts, no human in the loop
    QUEUE_FOR_REVIEW = "review"      # human approves before commit
    DRAFT_ONLY = "draft"             # human decides, model only suggests

@dataclass
class RoutingDecision:
    action: Action
    reason: str

def route(prediction, *, stakes: str) -> RoutingDecision:
    # High stakes always require a human, regardless of confidence.
    if stakes == "high":
        return RoutingDecision(Action.DRAFT_ONLY, "high-stakes path")

    if prediction.confidence >= 0.90 and stakes == "low":
        return RoutingDecision(Action.AUTO_APPLY, "high confidence, low stakes")

    if prediction.confidence >= 0.70:
        return RoutingDecision(Action.QUEUE_FOR_REVIEW, "medium confidence")

    return RoutingDecision(Action.DRAFT_ONLY, "low confidence")

Zwei Punkte sind hier wichtig. Erstens: „Konfidenz“ ist das Signal, dem Sie tatsächlich vertrauen – Logprobs, ein kalibrierter Classifier, ein LLM-as-Judge-Score, Übereinstimmung über mehrere Samples hinweg. Der Score ist nur so nützlich wie seine Kalibrierung, also messen Sie sie. Zweitens: Die Schwellenwerte sind Produktentscheidungen, keine Engineering-Entscheidungen. Sie gehören in die Konfiguration und werden von denjenigen geprüft, die die geschäftlichen Folgen einer Fehlentscheidung verantworten.

Graceful Degradation. Wenn das Modell langsam oder teuer ist oder die Antwort verweigert: Was ist der Fallback? Ein gecachtes Ergebnis? Ein einfacheres Modell? Eskalation an einen Menschen? „Sorry, bitte erneut versuchen“? Das zu gestalten ist Produktarbeit, nicht nur Infrastrukturarbeit.

Kosten sind ein Feature

Rechenleistung ist nicht mehr kostenlos, nicht einmal annähernd, und anders als die meisten Cloud-Ausgaben skalieren KI-Kosten linear mit der Nutzung. Ein Feature, das einen Nutzer für drei Cent pro Aufruf begeistert, begeistert tausend Nutzer für dreißig Dollar pro Stunde, den ganzen Tag.

Das gehört vom ersten Tag an in Ihre Engineering-Reviews. Nicht als Nebenthema, sondern als zentrale Designvorgabe, auf einer Stufe mit Latenz und Genauigkeit. Einige Fragen, die Sie stellen sollten, bevor ein Feature live geht:

  • Was kostet ein erfolgreiches Ergebnis (nicht ein Aufruf)?

  • Was kostet ein fehlgeschlagenes Ergebnis, inklusive Retries?

  • Wohin fließen die teuersten Tokens, und ist das nötig?

  • Kann ein kleineres Modell 80 % der Arbeit erledigen und nur bei Bedarf eskalieren?

Wir haben Teams gesehen, die ihre Inferenzkosten um eine Größenordnung gesenkt haben – nicht durch einen Anbieterwechsel, sondern indem sie ihre Prompts gestrafft, konsequent gecacht und ehrlich hinterfragt haben, welche Schritte wirklich ein Frontier-Modell brauchen.

Menschen sind Teil der Architektur

Die zuverlässigsten KI-Systeme in Produktion sind derzeit nicht vollständig autonom. Sie haben Menschen im Loop – mal unsichtbar, mal offen –, weil die Kosten eines Fehlers schwerer wiegen als die Kosten der Langsamkeit.

Das ist kein provisorisches Gerüst, das man entfernt, sobald das Modell besser wird. Es ist eine Designentscheidung. Sobald Sie das akzeptieren, wird vieles einfacher: Sie können früher ausliefern, Sie können anhand dessen iterieren, was Menschen tatsächlich korrigieren, und Sie bauen als Nebeneffekt des normalen Betriebs einen gelabelten Datensatz für die nächste Eval-Runde auf.

Die Engineering-Arbeit besteht darin, die Aufgabe des Menschen ergonomisch zu machen. Eine Review-Queue mit dem richtigen Kontext, dem richtigen Diff und den richtigen Tastenkürzeln ist eine echte Produktoberfläche, kein Nebengedanke.

Was Engineering-Teams daraus mitnehmen sollten

Ein paar Dinge, die sich leicht sagen und schwer verinnerlichen lassen:

Das Deployment ist nicht die Ziellinie, sondern der Start der Messung. Bei deterministischer Software ist die Engineering-Arbeit mit dem Ausliefern eines Features größtenteils erledigt. Bei angewandter KI sehen Sie erst beim Ausliefern, wie sich das System im großen Maßstab tatsächlich verhält – mit echten Inputs und echten Nutzern, die Dinge tun, mit denen Sie nicht gerechnet haben.

Versionierung wird wichtiger, nicht unwichtiger. Prompts, Eval-Sets, Modellversionen, Retrieval-Indizes – all das muss mit derselben Sorgfalt versioniert werden wie Ihr Code. Eine stille Prompt-Änderung ist die neue stille Migration.

Recruiting und Teamzuschnitt müssen sich weiterentwickeln. Reine Backend-Engineers tun sich ohne Erfahrung mit Evals und Prompt Engineering schwer. Reine ML-Forscher tun sich mit Zuverlässigkeit in der Produktion schwer. Teams, die gut ausliefern, haben meist Leute, die beide Kontexte im Blick behalten können.

Skepsis ist eine Senior-Kompetenz. Das Wertvollste, was ein Senior Engineer in ein KI-Projekt einbringen kann, ist der Reflex, bei jedem beeindruckend aussehenden Output zu fragen: „Ja, aber wie oft sieht es so aus, und wie sieht es aus, wenn es falsch ist?“

Angewandte KI in Produktion ist ein anderer Job, als deterministische Software zu bauen. Wenn Sie so tun, als wäre es derselbe – oder als würde das Modell „einfach besser werden“ –, liefern Sie am Ende etwas aus, das in der Demo funktioniert und Sie beim Kunden blamiert.

Behandeln Sie den Nicht-Determinismus als Designproblem, nicht als Hindernis. Teams, die das tun, sind diejenigen, deren KI-Features aufhören, eine Demo zu sein, und anfangen, ein Produkt zu sein.

No heading elements found. Showing placeholder content.

Kontakt

Kunden-Login

Sprache

Deutsch