Logging und Fehlerbehandlung, M0-Abschluss
Track KI · M0 Python produktiv, Baustein 08 und Abschluss-Check 09 · ca. 65 Min. plus Projektaufgabe (laut Quelle 2 bis 3 Std.)
Worum es geht
Ein LLM-Dienst fällt anders aus als eine normale Web-App. Das Modell antwortet langsam, liefert kaputtes JSON, wird vom Anbieter gedrosselt (Rate Limit) oder lehnt den Schlüssel ab. Du sitzt nicht daneben, wenn es passiert. Darum brauchst du zwei Dinge: Logs, die eine Maschine durchsuchen kann (strukturiert, als JSON, structured logging), und Fehlerklassen, an denen dein Code erkennt, ob ein zweiter Versuch Sinn hat (Wiederholbar, retryable) oder ob er nur Geld und Zeit kostet (Endgültig, fatal). Die Quelle sagt es so: print() sieht man nur, solange man selbst danebensteht, und eine Exception-Hierarchie unterscheidet Fehler, auf die der Aufrufer sinnvoll reagieren kann, von solchen, die nur eskalieren sollen.
Dies ist die letzte Lektion von M0. Am Ende steht die Projektaufgabe (der Abschluss-Check des Moduls). Die Logging-Details der Standardbibliothek (Handler, Propagation, configparser) stehen schon in python/18. Hier wendest du logging auf LLM-Anwendungen an, und die Exceptions-Grundlagen aus python/06 bekommen eine Architektur.
Von JS/TS her gedacht
| Idee | JS/TS | Python |
|---|---|---|
| Strukturierter Log | logger.info({ korrelationId }, "msg") (pino) |
logger.info("msg", extra={"korrelation_id": ...}) plus JSON-Formatter |
| Eigene Fehlerklasse | class RateLimitError extends LlmError {} |
class RateLimit(Wiederholbar): pass |
| Fehler nach Klasse behandeln | ein catch, dort if (e instanceof RateLimitError) |
mehrere except-Zweige, von oben nach unten geprüft |
| Ursache mitgeben | new Error("x", { cause: err }) |
raise X("x") from err |
| Fehler weiterreichen | throw e |
raise (ohne Argument, behält den Traceback) |
| Verschlucken | catch (e) {} |
except Exception: pass |
| Request-Kontext | AsyncLocalStorage |
contextvars.ContextVar |
Zwei Unterschiede merken: Python hat pro try mehrere except-Zweige und nimmt den ersten, der passt. Und raise X from e ist das Gegenstück zu cause: Es setzt das Attribut __cause__, das der Traceback als “direct cause” ausgibt.
Konzept
Schritt 1: Level statt print
Ein Logger hat ein Level. Jede Meldung hat auch eins, und nur Meldungen ab dem Level des Loggers kommen durch. Die Reihenfolge von unten nach oben: DEBUG, INFO, WARNING, ERROR, CRITICAL. Für eine LLM-App ein brauchbares Raster (allgemeine Praxis, nicht aus der Quelle):
| Level | Wofür | Beispiel |
|---|---|---|
DEBUG |
Details für die Fehlersuche, in Produktion aus | Prompt-Aufbau, Zwischenstände |
INFO |
normaler Ablauf | “Anfrage an Modell, 812 Tokens” |
WARNING |
etwas ist ungewöhnlich, es geht weiter | Retry, langsame Antwort |
ERROR |
diese Anfrage ist gescheitert | Retries aufgebraucht, JSON kaputt |
CRITICAL |
der Dienst selbst ist gefährdet | Schlüssel abgelehnt, nichts geht mehr |
Das Ziel StringIO ersetzt hier die Konsole. So kannst du die Ausgabe einsammeln und ansehen (im Browser gibt es keine Log-Datei):
Die Meldung wird mit Platzhaltern und Argumenten geschrieben ("%d Tokens", 812), nicht als f-String. Der Logger setzt den Text erst ein, wenn die Meldung wirklich durchkommt. lg.debug(f"...") würde den Text jedes Mal bauen, auch wenn DEBUG aus ist.
Zwei Regeln für LLM-Dienste (über die Quelle hinaus, allgemeines Fachwissen): Logge pro Aufruf Modellname, Tokenzahl und Dauer, damit du Kosten und Latenz später auswerten kannst. Logge nie API-Schlüssel und im Zweifel auch nicht den vollen Prompt, denn dort stehen oft personenbezogene Daten.
Schritt 2: JSON-Logs mit Korrelations-ID
Ein Satz wie Anfrage an Modell, 812 Tokens ist für Menschen lesbar, aber schwer zu durchsuchen. Ein JSON-Log schreibt pro Meldung eine Zeile mit festen Feldern. Ein Log-Werkzeug kann dann nach level == "ERROR" filtern.
Dazu kommt die Korrelations-ID (correlation id, auch request id): eine Kennung, die jede Log-Zeile einer Anfrage trägt. Läuft eine Anfrage durch fünf Funktionen und drei Modellaufrufe, findest du mit einer ID alle Zeilen.
Es sind drei kleine Bausteine. Jeder macht genau eine Sache:
ContextVar(Gegenstück zuAsyncLocalStorage): hält die ID der Anfrage, die gerade läuft. Du setzt sie einmal am Anfang.Filter: liest die ID aus derContextVarund hängt sie an jeden Log-Eintrag. So musst du sie nicht an jeden Aufruf von Hand schreiben.Formatter: baut aus dem Eintrag eine JSON-Zeile.
Das Beispiel setzt die drei zusammen. Der Ablauf pro Meldung: Logger nimmt lg.info(...) an, der Filter ergänzt die ID, der Formatter schreibt die Zeile.
Jede Zeile ist ein eigenes JSON-Objekt, und die ID steht in allen drei. Vier Stellen im Code sind neu:
record.getMessage()setzt die Platzhalter ein.record.msgwäre noch der Rohtext mit%d(Falle in Übung 2).getattr(record, "korrelation_id", None)liefertNone, wenn das Attribut fehlt.record.korrelation_idwürde dann einenAttributeErrorwerfen, mitten im Logging.exc_info=Truehängt die aktuell behandelte Exception an.record.exc_infoist ein Tupel:[0]ist die Klasse,[1]die Instanz.extra={"modell": "x"}ist der Weg für einzelne Zusatzfelder: jeder Schlüssel wird zum Attribut des Records (record.modell).
Eine Zeile pro Eintrag heißt auch: Der Formatter darf nie einen Zeilenumbruch hineinlassen. json.dumps schreibt Umbrüche als \n im Text, das passt.
Schritt 3: Eigene Exception-Hierarchie
Die Quelle zeigt eine Basisklasse und davon abgeleitete Fehler: RechnungsFehler und darunter WaehrungUngueltig. Eine Hierarchie ist ein Vokabular für Fehler. Für LLM-Dienste lohnt sich eine zweite Ebene, die genau die Frage “nochmal versuchen?” beantwortet:
Typische Zuordnung (über die Quelle hinaus, je nach Anbieter bitte prüfen):
| Situation | Klasse | Warum |
|---|---|---|
| Rate Limit (HTTP 429), Server überlastet (5xx), Timeout | Wiederholbar |
Zustand ist vorübergehend |
| ungültiger Schlüssel (401), Anfrage zu groß | Endgueltig |
gleiche Eingabe, gleiches Ergebnis |
| Modell liefert kein gültiges JSON | Abwägung, hier Endgueltig |
Ein Modell ist nicht deterministisch: ein zweiter Versuch kann klappen, kostet aber jedes Mal einen bezahlten Aufruf. Hier der Einfachheit halber endgültig. In der Praxis erlauben viele Teams ein bis zwei Wiederholungen (zum Beispiel mit Hinweis auf das Format). |
Jetzt sieht der Aufrufer nur noch die Klasse, nicht den Text. Die except-Zweige werden von oben nach unten geprüft, der erste passende gewinnt. Ein Zweig für eine Basisklasse fängt auch alle Unterklassen. Darum steht das Spezielle oben und das Allgemeine unten:
Der dritte Fall (die nackte Basisklasse) landet im letzten Zweig. Hättest du except LLMFehler an die erste Stelle gesetzt, würde er alles fangen, und die beiden Zweige darunter wären totes Gerüst. Python warnt dich dabei nicht.
Schritt 4: raise ... from und nie verschlucken
Wenn du einen niedrigen Fehler in einen eigenen übersetzt, willst du die Ursache behalten. Das macht raise Neu(...) from alt. Zum Vergleich in einer anderen Domäne, mit int() und einer Konfigurationseinstellung:
Ohne from e wirft Python trotzdem, aber __cause__ ist None. Die alte Exception hängt dann nur lose als __context__ daran, und der Traceback sagt “During handling of the above exception, another exception occurred”. Das klingt nach einem Folgefehler, nicht nach einer Übersetzung.
Der gefährlichste Fehler ist das Verschlucken:
0 sieht aus wie ein gültiges Limit. Niemand erfährt vom Fehler, der Dienst läuft mit einer falschen Einstellung weiter. In einer LLM-Anwendung ist das noch tückischer: Ein verschluckter Fehler wird zu einer leeren oder halb gefüllten Antwort, die der nächste Schritt für echt hält.
Zum Schluss ein Wiederholungs-Gerüst, das zeigt, wie die Hierarchie genutzt wird. Es fängt nur Wiederholbar. Ein Endgueltig fliegt sofort durch, ohne zweiten Versuch:
Der Schlüsselfehler kostete genau einen Aufruf. Genau dafür ist die Hierarchie da. Das Gerüst loggt nichts und wirft am Ende den letzten Fehler unverändert. In Übung 3 baust du eine bessere Version.
Falle
except Exception: pass(oderreturn {}oderreturn None). Der Fehler verschwindet, das Programm läuft mit falschen Daten weiter.raise Neu(...)ohnefrom. Die Ursache hängt nur als__context__an,__cause__bleibtNone.- Die Reihenfolge der
except-Zweige. Eine Basisklasse oben verdeckt alles darunter, ohne Warnung. - Log und raise in jeder Schicht. Derselbe Fehler steht dann mehrfach im Log, mit Traceback. Logge dort, wo der Fehler endgültig behandelt wird (meist an der Grenze: Route, Worker, Job), und lasse ihn dazwischen einfach durch.
- f-String im Logger (
logger.info(f"...")), der Text wird auch dann gebaut, wenn das Level die Meldung verwirft. - Geheimnisse im Log. API-Schlüssel, vollständige Prompts mit Kundendaten, Antworten mit personenbezogenen Daten (über die Quelle hinaus).
- Alles wiederholen. Wer auch
SchluesselUngueltigwiederholt, verbrennt Anfragen (und bei bezahlten APIs Geld) für einen Fehler, der nie von selbst weggeht.
Übungen
Übung 1: Welcher except-Zweig gewinnt? (leicht)
Ein Dienst, der Texte in Vektoren verwandelt, hat diese Fehlerklassen und diese Funktion:
class EmbeddingFehler(Exception): pass
class Wiederholbar(EmbeddingFehler): pass
class Endgueltig(EmbeddingFehler): pass
class Ueberlastet(Wiederholbar): pass
class Zeitlimit(Wiederholbar): pass
class TextZuLang(Endgueltig): pass
def reaktion(fehler):
try:
raise fehler
except Ueberlastet:
return "warten"
except EmbeddingFehler:
return "melden"
except Wiederholbar:
return "nochmal"
except Exception:
return "unbekannt"Sage voraus, was reaktion für diese vier Fehler zurückgibt, in dieser Reihenfolge: Ueberlastet(), Zeitlimit(), TextZuLang(), KeyError("x"). Trage ein Tupel mit vier Strings ein.
In welcher Reihenfolge prüft Python die except-Zweige, und welche Fehler fängt ein Zweig für eine Basisklasse mit? Schau dir an, wo Zeitlimit in der Klassenhierarchie steht.
antwort = ("warten", "melden", "melden", "unbekannt")
antwortÜbung 2: JSON-Formatter schreiben (mittel)
Schreibe die Methode format für JsonFormatter. Sie gibt pro Log-Eintrag einen String mit einem JSON-Objekt zurück, mit diesen Feldern:
"stufe": der Level-Name in Kleinbuchstaben ("info","error")"text": die fertige Meldung (Platzhalter eingesetzt)"modell": der Wert vonextra={"modell": ...}, falls nicht angegeben"unbekannt""fehler": der Klassenname der Exception, nur wenn eine Exception am Eintrag hängt"ursache": der Klassenname der Ursache (__cause__), nur wenn die Exception eine hat
Baue zuerst ein dict mit den festen Feldern und ergänze "fehler" und "ursache" nur in if-Zweigen. Für die Ursache brauchst du die Exception-Instanz (nicht nur ihre Klasse): welches Element von record.exc_info ist das? Und welches Attribut der Instanz hält die mit from gesetzte Ursache?
class JsonFormatter(logging.Formatter):
def format(self, record):
eintrag = {
"stufe": record.levelname.lower(),
"text": record.getMessage(),
"modell": getattr(record, "modell", "unbekannt"),
}
if record.exc_info:
exc = record.exc_info[1]
eintrag["fehler"] = type(exc).__name__
if exc.__cause__ is not None:
eintrag["ursache"] = type(exc.__cause__).__name__
return json.dumps(eintrag, ensure_ascii=False)
JsonFormatterÜbung 3: Retry mit Logging und raise ... from (mittel)
Schreibe rufe_mit_retry(fn, max_versuche, logger). Die Klassen stehen schon bereit: ModellFehler als Basis, Wiederholbar und Endgueltig darunter, dazu RetryErschoepft(ModellFehler). Die Regeln:
fn()wird höchstensmax_versucheMal aufgerufen. Bei Erfolg wird das Ergebnis zurückgegeben.- Ein
Wiederholbarwird gefangen. Pro fehlgeschlagenem Versuch gibt es genau einelogger.warning-Meldung, deren Text die Versuchsnummer nennt. Dann der nächste Versuch (ohne Wartezeit, das lassen wir hier weg). - Alles andere (zum Beispiel ein
Endgueltigoder einKeyError) wird nicht gefangen: kein zweiter Versuch, keine Warnung, derselbe Fehler kommt oben an. - Sind alle Versuche mit
Wiederholbargescheitert, wirdRetryErschoepft("nach N Versuchen aufgegeben")geworfen, mit dem letzten Fehler als Ursache (from).
Eine Schleife über die Versuche, darin try mit return fn(). Welche Klasse darf der except-Zweig nennen, damit Endgültiges durchfliegt? Die Variable aus except ... as x gibt es nur im Zweig, du musst sie dir für nach der Schleife merken.
def rufe_mit_retry(fn, max_versuche, logger):
letzter = None
for versuch in range(1, max_versuche + 1):
try:
return fn()
except Wiederholbar as fehler:
letzter = fehler
logger.warning("Versuch %d von %d fehlgeschlagen: %s", versuch, max_versuche, fehler)
raise RetryErschoepft(f"nach {max_versuche} Versuchen aufgegeben") from letzter
rufe_mit_retryÜbung 4: Fehler finden, der leise läuft (mittel)
Die Funktion parse_antwort soll die JSON-Antwort eines Modells einlesen und ein dict zurückgeben. Sie läuft ohne Fehlermeldung, ist aber gefährlich: Ein kaputtes Modell-Ergebnis sieht aus wie eine ganz normale, nur leere Antwort. Repariere sie:
- Kein gültiges JSON:
AntwortUngueltigwerfen, mit dem ursprünglichen JSON-Fehler als Ursache. - Gültiges JSON, aber kein Objekt (zum Beispiel eine Liste oder ein String): ebenfalls
AntwortUngueltig. - Ein gültiges, leeres Objekt
{}ist eine gültige Antwort und kommt unverändert zurück.
Zwei getrennte Probleme: Was passiert mit dem Fehler im except-Zweig, und was prüft der Code nach dem erfolgreichen Einlesen gar nicht? Achte beim zweiten darauf, dass {} im Boolean-Kontext falsch ist.
def parse_antwort(text):
try:
daten = json.loads(text)
except json.JSONDecodeError as e:
raise AntwortUngueltig("Modell lieferte kein JSON") from e
if not isinstance(daten, dict):
raise AntwortUngueltig(f"JSON-Objekt erwartet, bekommen: {type(daten).__name__}")
return daten
parse_antwortÜbung 5: Wo wird geloggt? (mittel)
Ein Dienst hat drei Schichten. Der Client ruft das Modell auf und wirft bei einem Netzwerkproblem Zeitueberschreitung (Wiederholbar). Der Service wiederholt bis zu dreimal und wirft danach RetryErschoepft. Die Route fängt RetryErschoepft und beantwortet die Anfrage mit HTTP 503. Im Betrieb steht in jeder Schicht logger.error(..., exc_info=True), und pro gescheiterter Anfrage tauchen fünf ERROR-Einträge mit Traceback auf. Wo soll der Eintrag mit Traceback stehen, und was loggt der Rest?
- a) In jeder Schicht, denn jede Schicht kennt ihren eigenen Ausschnitt des Vorfalls. Dubletten stören nicht, die Korrelations-ID sortiert sie.
- b) Nur in der Route, weil dort die Behandlung endet. Der Service meldet einzelne Wiederholungen als WARNING ohne Traceback.
- c) Nur im Client, weil dort die Ursache entsteht. Spätere Schichten übersetzen den Fehler nur und fügen keine Information hinzu.
- d) Nur im Service, weil allein er die Zahl der Versuche kennt. Die Route gibt den Fehler ohne eigene Meldung weiter an den Aufrufer.
Trage den Buchstaben als String ein.
Frage dich bei jeder Option: Wer entscheidet am Ende, was mit dem Fehler passiert, und welche Schicht kennt den ganzen Vorgang? Zähle auch, wie viele der Log-Zeilen dir neue Information liefern.
antwort = "b"
antwortProjektaufgabe: Fehler-Hierarchie und Logging (M0-Abschluss)
Das ist die letzte Aufgabe von M0 (Baustein 08 und Abschluss-Check 09). Sie läuft lokal im Lernlabor, nicht im Browser. Es werden keine Pakete installiert und keine API-Schlüssel gebraucht.
Aufgabe (Baustein 08): In lernlabor/uebung/beispieldaten.json stehen unter ungueltig drei Fehlerarten: id_zu_kurz, unbekannter_enum_wert, pflichtfeld_fehlt. Baue in lernlabor/src/lernlabor/fehler.py:
- eine Basisklasse
DatenFehler(Exception)und drei Unterklassen, eine pro Fehlerart, - eine Funktion
pruefe_zeile(zeile: dict) -> None, die für jede ungültige Zeile die passende Klasse wirft und bei den zwei gültigen Zeilen nichts tut. Prüfemarktlokations_id(11 Ziffern, laut Data Contract ohne führende 0),sparte(lautdata_contract_marktlokation.yaml) und das Pflichtfeldnetzbetreiber_codenummer.
Wenn du dafür dein Pydantic-Modell aus Lektion 02 nutzt und dessen ValidationError übersetzt: setze raise ... from, damit die Ursache erhalten bleibt. Das Hilfsskript meldet fehlendes from.
Das Hilfsskript prüft die Hierarchie und schreibt pro Zeile einen JSON-Logeintrag mit Korrelations-ID auf die Konsole (stdlib logging, derselbe Aufbau wie in Schritt 2):
cd lernlabor && uv run python uebung/ki/ki_04_fehler_logging.pySolange fehler.py fehlt, macht das Skript nur einen Trockenlauf und zeigt, was es erwartet.
Aufgabenliste M0 (Quelle, ca. 18 bis 22 h gesamt): Die Quelle führt für diesen Baustein “Strukturiertes Logging und eigene Exception-Hierarchie bauen” mit 2 bis 3 h. Die weiteren Punkte kennst du aus den Lektionen 01 bis 03 (uv, Projektstruktur, Type Hints, Pydantic, async, pytest, ruff).
Abschluss-Check M0 (Quelle): Repo in einer frischen Umgebung öffnen (ein frisch geklontes Verzeichnis oder ein frischer Docker-Container). Dort nur uv sync ausführen, danach diese drei Befehle. Alle müssen ohne Handarbeit grün sein:
uv run pytest
uv run ruff check src test
# dazu der Typechecker (die Quelle nennt in Baustein 03 pyright als Beispiel)Warum src test und nicht nur ruff check: Ohne Pfad prüft ruff auch den Ordner uebung/. Dort liegen Übungsdateien, teils mit absichtlichen Lint-Funden (zum Beispiel ki_01_lint_beispiel.py), und eigene Skizzen von dir gehören nicht zum Projekt. Dein Projekt sind src/ und test/. Willst du lieber das nackte uv run ruff check grün haben, nimmst du uebung in der ruff-Konfiguration in exclude auf (das ändert pyproject.toml, entscheide das bewusst).
Ein Test für deine Hierarchie gehört dazu: Für jede ungueltig-Zeile prüft ein parametrisierter Test, dass pruefe_zeile die erwartete Klasse wirft (pytest.raises). Der Typechecker (pyright oder ein vergleichbares Werkzeug) ist im Lernlabor noch nicht eingerichtet. Er ist ein optionales Ziel: Wenn du ihn willst, nimmst du ihn als Dev-Abhängigkeit auf (das ändert pyproject.toml und uv.lock).
Stolperfalle (Quelle): Erst wenn es in der frischen Umgebung läuft, ist M0 wirklich fertig, nicht wenn es nur “bei mir” läuft. Typisch: eine Abhängigkeit, die nur in deinem lokalen venv steckt, aber nicht in pyproject.toml.
Fertig, wenn:
fehler.pyhat die eigene BasisklasseDatenFehler(Exception)und drei Unterklassen, und das Hilfsskript meldet für jede ungültige Zeile die erwartete Klasse.- Beide gültigen Zeilen laufen ohne Fehler durch
pruefe_zeile. - Wo ein anderer Fehler (zum Beispiel
ValidationError) übersetzt wird, stehtfrom, und das Hilfsskript meldet keinen Hinweis zu fehlendemfrom. - Das Hilfsskript schreibt pro Zeile eine gültige JSON-Zeile mit
korrelation_id. - In der frischen Umgebung laufen
uv sync,uv run pytestunduv run ruff check src testohne Handarbeit durch (der Typechecker ist ein optionales Ziel, bis er eingerichtet ist).
Selbstcheck:
Merksatz
Eine Exception-Hierarchie ist ein Vokabular für Fehler: Je klarer die Klassen (wiederholbar oder endgültig), desto gezielter reagiert der Aufrufer, und ein Fehler wird nie verschluckt, sondern mit from übersetzt und einmal geloggt (Quelle).
Prüfstein
Dein Dienst ruft ein Modell auf, das manchmal nach 30 Sekunden mit einem Timeout antwortet und manchmal kaputtes JSON liefert. Welche beiden Fehlerklassen legst du an, in welchem Zweig der Hierarchie stehen sie, was soll bei jeder passieren, und welche vier Felder stehen in der JSON-Logzeile, mit der du den Fall später in 2 Minuten findest?
Quelle: quellen/kursbuch-lerninhalte.md, Modul M0, Baustein “08 Logging & Fehlerbehandlung” und “09 Abschluss-Check” mit der Aufgabenliste M0 (Zeilen 253 bis 300). Aus der Quelle stammen: die Aussage zu print gegen strukturierte Logs, das Beispiel mit Basisklasse und abgeleiteter Klasse, der Merksatz, die Übung mit den drei Fehlerarten und der Abschluss-Check (uv sync, uv run pytest, uv run ruff check, Typechecker, frische Umgebung). Über die Quelle hinaus (allgemeines Fachwissen, mit echtem Python 3.13 ausgeführt): Log-Level-Raster für LLM-Dienste, JSON-Formatter, Filter mit ContextVar als Korrelations-ID, extra, exc_info, die Aufteilung in Wiederholbar und Endgueltig, die Zuordnung typischer Anbieterfehler (bitte je Anbieter prüfen), raise ... from mit __cause__ und __context__, der Hinweis, dass der Traceback bei fehlendem from “During handling of the above exception, another exception occurred” ausgibt (mit Python 3.13 ausgeführt), die Regel “einmal an der Grenze loggen” und die Empfehlung, keine Schlüssel und Prompts zu loggen. Die Daten der Projektaufgabe sind erfunden (lernlabor/uebung/README.md). Welcher Typechecker verbindlich ist, steht in der Quelle nur als Beispiel (pyright, Baustein 03): bitte prüfen.