Mit KI arbeiten (b): Fehlermuster, Kontext und Alternativen

Track Konzepte · Arbeit mit KI-Werkzeugen · ca. 40 Min.

Worum es geht

Eine KI schreibt json.parse(text), und der Code sieht richtig aus, weil du es aus JavaScript kennst. In Python gibt es die Funktion nicht. Solche Fehler wiederholen sich, und es lohnt sich, sie zu kennen und automatisch abzufangen. Dazu kommen drei Fragen aus dem Alltag: Wie steuerst du, was die KI über dein Projekt weiß? Wie zwingst du sie, Alternativen zu nennen, statt die erste Lösung zu nehmen? Und wie sieht ein fester Ablauf aus, den du bei jedem Auftrag wiederholst?

Was du aus Teil a brauchst: Du kennst Verträge und Vertragstests, Leitplanken mit dem Modul ast (Call, Name, Attribute) und den Blast Radius. Teil a: Verträge, Leitplanken und Review nach Risiko.

Am Ende von Teil b kannst du:

  • halluzinierte APIs und Pakete automatisch erkennen (Übung 1),
  • zwischen zwei KI-Vorschlägen anhand von Randbedingungen entscheiden (Übung 2),
  • Regeln für die Regeldatei prüfbar formulieren und nach Fehlversuchen richtig reagieren (Übung 3).

Alles läuft im Browser mit simulierten KI-Antworten.

Plane ehrlich 40 Minuten ein: etwa 15 Minuten Lesen (drei Schritte), 25 Minuten Übungen (drei Stück).

Von JS/TS her gedacht

Idee JS/TS Python (hier)
Existiert die Funktion? typeof JSON.parse === "function", tsc meldet Fehler hasattr(modul, "name"), importlib.util.find_spec
Paket vorhanden? npm ls, npm view (bitte prüfen) importlib.util.find_spec("paket")
Imports lesen Babel- oder TS-Parser ast.Import und ast.ImportFrom
Regeldatei für die KI CLAUDE.md, .cursorrules gleiche Dateien, sprachunabhängig

Der gefährlichste Unterschied: JavaScript-Namen wie JSON.parse oder Math.clamp landen gern in Python-Code, weil sie vertraut aussehen.

Konzept

Schritt 1: Typische Fehlermuster von KI-Code

Die Quelle nennt sechs Muster. Sie sind wiederkehrend, also lohnt sich ein fester Blick darauf:

Muster Woran du es erkennst Gegenmittel
Halluzinierte API oder Paket Funktion oder Import existiert nicht (json.parse, ein erfundenes Paket) Automatisch prüfen (Import und hasattr), CI-Lauf
Plausibel wirkende Nebenläufigkeit Lock oder async an der falschen Stelle, Check-then-act Simulation und Test mit verschachtelten Schritten (Lektion 01)
Fehlende Fehlerpfade Nur der Happy Path, except: pass, kein Timeout bei Netzwerkaufrufen Vertragstests mit Fehlerverhalten, Leitplanke
Overengineering Fabrik, Interface und Konfiguration für eine Funktion Frage: “Welche Anforderung verlangt das?”
Duplizierte Logik Dieselbe Regel an zwei Stellen, die sich später auseinanderentwickelt Suche nach ähnlichen Blöcken, eine Stelle als Quelle der Wahrheit
Veraltete Muster Alte API, die zwar läuft, aber abgelöst ist Linter-Regeln, Versionen im Kontext nennen

Bei zwei der sechs hilft ein Programm besonders gut: Ob eine Funktion in der Standardbibliothek existiert, lässt sich prüfen. Zuerst: Gibt es das Modul überhaupt (importlib.util.find_spec), und hat es den Namen (hasattr)?

json.parse kommt aus JavaScript (JSON.parse), und datetime.now() fehlt, weil now zur Klasse datetime.datetime gehört, nicht zum Modul. Beide Fehler sehen für ein JS-geprägtes Auge richtig aus. Das Paket schnell_json_utils ist erfunden: find_spec liefert None. Gefährlich ist das nicht nur als Fehler. Erfundene Paketnamen sind ein bekanntes Einfallstor: Jemand kann unter dem Namen ein bösartiges Paket veröffentlichen. Darum installierst du nie ungeprüft, was die KI importiert.

Wichtig für die Reihenfolge: Ein solcher Check zeigt nur, dass etwas nicht existiert. Dass es existiert und das Richtige tut, zeigen erst die Vertragstests aus Teil a.

Damit ein Programm das automatisch für fremden Code tut, muss es die Imports aus dem Syntaxbaum lesen. Dafür gibt es zwei Knotentypen, Import und ImportFrom. Beide haben eine Liste names, deren Einträge name (der echte Name) und asname (der Alias oder None) tragen. Bei ImportFrom steht der Modulname zusätzlich in module. Mit ast.parse(zeile).body[0] an drei Zeilen ausprobiert (ausgeführt):

import ast
for z in ["import json", "import datetime as dt", "from statistics import mean, average"]:
    k = ast.parse(z).body[0]
    if isinstance(k, ast.Import):
        print(type(k).__name__, [(a.name, a.asname) for a in k.names])
    else:
        print(type(k).__name__, k.module, [(a.name, a.asname) for a in k.names])
Import [('json', None)]
Import [('datetime', 'dt')]
ImportFrom statistics [('mean', None), ('average', None)]

Für einen Alias wie dt gilt: Im Code steht dt.today(), das Modul heißt aber datetime. Ein Prüfer muss sich also merken, welcher lokale Name zu welchem echten Modul gehört. Genau das verlangt Übung 1.

Der Lerneffekt gilt auch für duplizierte Logik: Zwei KI-Antworten in verschiedenen Dateien bringen gern zwei ähnliche Rundungsfunktionen mit. Eine zweite Rundungsregel mit anderem Verhalten (siehe Rabatt oben) ist ein Bug, der erst Monate später auffällt. Gegenmittel: Beim Review aktiv nach “gibt es das schon?” suchen.

Schritt 2: Kontext steuern und Alternativen erzwingen

Die KI kennt deine Entscheidungen nur, wenn sie im Kontext stehen. Die Quelle nennt: “Architekturentscheidungen, Konventionen und Modulgrenzen in der Codebasis dokumentieren, damit die KI sie einhält.” In der Praxis ist das eine kurze Regeldatei im Repository (bei Claude Code CLAUDE.md, andere Werkzeuge haben eigene Namen) und ein Verzeichnis mit ADRs. Ein Auszug, so kurz wie möglich, mit Grund pro Regel:

# Regeln für dieses Repository

- Schichten: ui -> service -> repository. Nie nach oben importieren
  (wird in CI per Fitness Function geprüft, siehe ADR 7).
- Geld ist immer ganzzahlig in Cent. Nie float. Rabatte werden abgerundet.
- Jeder Netzwerkaufruf hat ein Timeout. Fehler werden geloggt oder weitergereicht,
  nie verschluckt.
- Neue Abhängigkeiten nur nach Rückfrage. Keine Pakete erfinden:
  vorher prüfen, ob es das Paket und die Funktion wirklich gibt.
- Vor jedem Vorschlag zu Auth, Migrationen, Geld oder Nebenläufigkeit:
  Annahmen und Ausfallverhalten nennen.

Das ist keine Magie: Die Datei wirkt nur, wenn sie kurz, konkret und prüfbar ist. Regeln, die eine Leitplanke aus Teil a prüft, sind doppelt abgesichert. Übrigens: Fremder Text, den die KI liest (Webseiten, Tickets, Mails), ist Daten, keine Anweisung. Das Risiko dahinter, Prompt Injection, ist in Lektion 15 als Überblick und im KI-Track in der Tiefe behandelt. Hier wird es nicht wiederholt.

Zum Schluss Alternativen erzwingen: Statt “mach X” fragst du: “Nenne zwei bis drei Lösungen für X. Pro Lösung: wann sie gewinnt, wann sie verliert, welches Qualitätsszenario sie erfüllt.” Dann entscheidest du anhand deiner Randbedingungen (Lektion 11: Qualitätsszenarien statt Schlagworte), nicht die KI. Das schützt vor der ersten Lösung, die nur deshalb gewinnt, weil sie zuerst kam. In Übung 2 übst du genau das.

Schritt 3: Ein Ablauf für den Alltag

Die Bausteine oben ergeben einen festen Ablauf, den du bei jedem Auftrag an ein KI-Werkzeug (Chat, Editor-Assistent, Coding-Agent) wiederholen kannst. Zuerst der Unterschied im Auftrag selbst. Vorher: “Schreibe eine Paginierung für die Bestellliste.” Nachher:

Aufgabe: Funktion `seite(items, nr, pro_seite)` in `app/paging.py`.

Vertrag:
- Seiten zählen ab 1, Ergebnis ist eine neue Liste, Eingabe bleibt unverändert.
- Seite hinter dem Ende liefert []. nr < 1 oder pro_seite < 1: ValueError.
- Nichtziel: kein Sortieren, kein Caching.

Tests: tests/test_paging.py liegt schon im Repo (rot). Mach sie grün.
Ändere die Tests nicht. Wenn du einen Test für falsch hältst, sag es.

Rahmen: keine neuen Abhängigkeiten. Nutze nur die Standardbibliothek.
Zuerst ein Plan in höchstens fünf Zeilen, dann der Diff.

Und dann der Ablauf:

  1. Tests zuerst, und rot sehen. Ein Test, der nie rot war, beweist nichts. Er kann auch gegen eine falsche Funktion grün sein.
  2. Plan vor Code. Den Plan zu lesen kostet eine Minute, ihn zu korrigieren auch. Dieselbe Korrektur nach 300 Zeilen Diff kostet eine halbe Stunde.
  3. Kleine Aufträge. Ein Auftrag soll einen Diff ergeben, den du in etwa zehn Minuten wirklich liest. Ist er größer, teile ihn. Große Diffs werden nur überflogen.
  4. Diff nach Risiko lesen (Blast Radius, Teil a): zuerst Auth, Geld, Migrationen, Nebenläufigkeit, dann den Rest. Prüfe bei den Tests als Erstes, ob die KI sie mitgeändert hat. Ein Agent, der Tests anpasst, bis sie grün sind, hat dein Ziel durch ein einfacheres ersetzt.
  5. Selbst ausführen. Tests, Linter und Typprüfung laufen bei dir oder in der CI. “Die Tests laufen durch” in der Antwort der KI ist eine Behauptung, kein Ergebnis.
  6. Nach dem dritten Fehlversuch neu anfangen. Wiederholt die KI denselben Fehler, liegt es meist am Auftrag oder an einem Kontext voller Sackgassen. Schreib den Vertrag um und starte mit frischem Kontext, statt zum fünften Mal zu korrigieren (Erfahrungsregel, keine Messung).
  7. Rechte eng halten, wenn ein Agent Befehle ausführt. Löschen, Migrationen, Deployments und alles mit Zugangsdaten bleiben bei dir. Zugangsdaten und API-Schlüssel gehören nie in den Kontext oder in eine Regeldatei.

Das ist dieselbe Idee wie in den Übungen, nur im Takt deines Arbeitstags: Der Vertrag kommt von dir, die Gegenprüfung läuft automatisch, und dein Review geht dorthin, wo ein Fehler wehtut.

Falle

Die häufigste Falle in diesem Teil: Du hältst den Namen einer API für echt, weil er plausibel klingt. json.parse, math.clamp und ein erfundenes Paket sehen für ein JS-geprägtes Auge richtig aus. Prüfe automatisch, ob es das gibt, bevor du etwas installierst oder ausführst (Übung 1).

Die zweite Falle: Der Agent passt die Tests an, bis sie grün sind. Dann hat er dein Ziel durch ein einfacheres ersetzt. Lies bei jedem Diff zuerst, ob Tests mitgeändert wurden (Übung 3).

Die dritte Falle: eine Regeldatei, die niemand prüfen kann (“schreibe sauberen Code”). Regeln müssen kurz, konkret und prüfbar sein (Übung 3).

Übungen

Übung 1: Halluzinierte APIs automatisch erkennen (ca. 10 Min.)

Die KI hat einen kleinen Auswertungsbaustein geschrieben. Er liest gut und ist mit JavaScript im Kopf geschrieben. Drei Dinge darin gibt es in Python nicht, und ein Paket gibt es nirgends:

Schreibe unbekannt(quelltext). Die Funktion gibt eine sortierte Liste ohne Duplikate von Strings zurück:

  • "paket:<name>" für jeden Import, dessen Modul es nicht gibt (erster Namensteil, z. B. paket:schnell_json_utils). Prüfe mit importlib.util.find_spec.
  • "api:<modul>.<name>" für jeden Namen, der im vorhandenen Modul fehlt, mit dem echten Modulnamen (nicht dem Alias). Zwei Formen zählen: Zugriff modul.name (auch über einen Alias aus import ... as ...) und from modul import name. Prüfe mit hasattr.

Nur diese Form: Zugriff direkt auf ein importiertes Modul. Tiefere Ketten wie dt.datetime.now prüfst du nicht, und Module, die nicht existieren, melden nur paket:, keine api:-Zeilen. Imports zählen überall im Quelltext, auch in Funktionen.

Arbeite in zwei Durchläufen über ast.walk: Im ersten sammelst du die Imports (Import und ImportFrom) und merkst dir, welcher lokale Name zu welchem echten Modul gehört. Im zweiten prüfst du Zugriffe (Attribute mit einem Name links) und die importierten Namen aus from ... import. Welche Namen eines ImportFrom brauchst du, und wo steht der Modulname? find_spec kann bei kaputten Namen Fehler werfen.

import ast
import importlib
import importlib.util

def unbekannt(quelltext):
    baum = ast.parse(quelltext)
    funde = set()
    module = {}                      # lokaler Name -> (echter Modulname, Modulobjekt)

    def laden(name):
        try:
            if importlib.util.find_spec(name) is None:
                return None
            return importlib.import_module(name)
        except (ImportError, ValueError):
            return None

    for k in ast.walk(baum):
        if isinstance(k, ast.Import):
            for alias in k.names:
                top = alias.name.split(".")[0]
                m = laden(top)
                if m is None:
                    funde.add("paket:" + top)
                else:
                    module[alias.asname or top] = (top, m)
        elif isinstance(k, ast.ImportFrom) and k.module and k.level == 0:
            top = k.module.split(".")[0]
            m = laden(k.module)
            if m is None:
                funde.add("paket:" + top)
            else:
                for alias in k.names:
                    if alias.name != "*" and not hasattr(m, alias.name):
                        funde.add(f"api:{k.module}.{alias.name}")

    for k in ast.walk(baum):
        if isinstance(k, ast.Attribute) and isinstance(k.value, ast.Name) and k.value.id in module:
            echt, m = module[k.value.id]
            if not hasattr(m, k.attr):
                funde.add(f"api:{echt}.{k.attr}")
    return sorted(funde)

print(unbekannt(KI_SNIPPET))
unbekannt

Gefunden werden api:datetime.today (über den Alias dt), api:json.parse, api:math.clamp, api:statistics.average und paket:schnell_json_utils. dt.datetime.now() ist korrekt und wird nicht gemeldet. Ein Check dieser Art beweist nur, dass etwas fehlt. Dass der Rest das Richtige tut, zeigen erst Vertragstests.

Übung 2: Zwei KI-Vorschläge gegen Randbedingungen (ca. 8 Min.)

Du hast die KI gebeten, mehrere Lösungen zu nennen. Entscheide jeweils anhand der genannten Randbedingungen. Pro Fall passt genau eine Option am besten. Gib ein Tupel mit zwei Buchstaben zurück, zum Beispiel ("A", "A"). Hintergrund zu Fall 1: Idempotenz und Delivery-Garantien stehen in Lektion 6, das Check-then-act-Rennen in Lektion 1.

Fall 1. Ein Zahlungsanbieter ruft deinen Webhook POST /events mindestens einmal pro Ereignis auf, Wiederholungen sind also möglich. Dein Dienst läuft mit 3 Instanzen hinter einem Load Balancer und wird mehrmals täglich neu gestartet. Jedes Ereignis darf genau einmal gebucht werden. Die KI schlägt vor:

  • A: Jede Instanz merkt sich die Event-Ids in einer Menge im Speicher. Kommt eine Id erneut, antwortet der Handler mit 200 und tut nichts.
  • B: Die Event-Id bekommt in der Datenbank einen eindeutigen Schlüssel (unique), geschrieben in derselben Transaktion wie die Buchung.
  • C: Der Handler prüft per SELECT, ob die Event-Id schon in der Tabelle steht, bucht dann und schreibt die Id danach in einem zweiten, eigenen Schritt.
  • D: Der Load Balancer schickt Aufrufe mit gleicher Event-Id immer an dieselbe Instanz, die sich die Ids im Speicher merkt und Duplikate verwirft.

Fall 2. Ein nächtlicher Export schreibt bis zu 5 Millionen Zeilen der Tabelle bestellungen in eine CSV-Datei. Der Container hat 512 MB Speicher. Der Export darf bis zu 30 Minuten dauern. Annahme dieser Aufgabe: Jede einzelne Datenbankanfrage kostet etwa 1 ms Round-Trip. Die KI schlägt vor:

  • A: Alle Zeilen mit fetchall() laden, als Liste von Dicts im Speicher halten und erst am Ende in einem Schritt in die Datei schreiben.
  • B: Pro Zeile eine eigene Anfrage WHERE id = ? stellen, damit jede einzelne Anfrage klein bleibt und wenig Speicher braucht.
  • C: Alle Zeilen vorab in einen Cache-Dienst (Redis) laden, damit der Export selbst nicht auf die Datenbank warten muss und schneller läuft.
  • D: Zeilen blockweise (z. B. 10 000) mit einem Cursor lesen und sofort in die Datei schreiben, immer nur ein Block im Speicher.

Streiche pro Option zuerst alles, was eine genannte Randbedingung verletzt: Was passiert bei einem Neustart? Was bei zwei Instanzen, die gleichzeitig dasselbe Ereignis bekommen? Und rechne bei Fall 2 nach: 5 Millionen Anfragen mal 1 ms ergibt wie viele Minuten, und wie viele Zeilen passen in 512 MB?

antwort = ("B", "D")
antwort

Fall 1, B: Der eindeutige Schlüssel in derselben Transaktion ist atomar und gilt für alle Instanzen und über Neustarts hinweg. A und D verlieren ihr Gedächtnis beim Neustart, und A teilt es nicht zwischen den Instanzen. C hat ein Check-then-act-Rennen: Zwei Instanzen prüfen gleichzeitig, beide finden nichts und buchen doppelt. Fall 2, D: Die Datei wächst, der Speicher nicht. 5 Millionen Einzelanfragen dauern bei 1 ms etwa 83 Minuten und überschreiten die 30 Minuten, und die Daten im Cache bräuchten denselben Speicher noch einmal.

Übung 3: Regeldatei und Fehlversuche (ca. 6 Min.)

Entscheide in beiden Fällen anhand der genannten Randbedingungen. Pro Fall passt genau eine Option am besten. Gib ein Tupel mit zwei Buchstaben zurück, zum Beispiel ("A", "A").

Fall 1. Du ergänzt die Regeldatei deines Repositories um eine Regel. Randbedingungen: Die KI soll sie befolgen können, und die CI soll jeden Verstoß ohne menschliches Review melden. Die Regel soll genau einen Punkt betreffen. Welche Zeile schreibst du hinein?

  • A: Schreibe immer sauberen, gut lesbaren und gut strukturierten Code im ganzen Projekt.
  • B: Denke bei jedem Vorschlag an die Sicherheit der Nutzerdaten im ganzen Projekt.
  • C: Halte Funktionen kurz und teile große Aufgaben sinnvoll in kleinere Teile auf.
  • D: Im Modul domain nie requests importieren, die CI prüft das per ast.

Fall 2. Ein Agent soll eine Datenbankmigration schreiben. Dreimal hintereinander liefert er denselben Fehler: Bestehende Zeilen bekommen für die neue Spalte keinen Wert. Der Kontext besteht inzwischen aus den drei Fehlversuchen und ihren Fehlermeldungen. Randbedingungen: Dein Zeitbudget ist knapp, und die Migration berührt Produktionsdaten. Wie gehst du vor?

  • A: Den Vertrag um den fehlenden Randfall ergänzen und mit frischem Kontext neu beginnen.
  • B: Denselben Auftrag ein viertes Mal senden und am Ende bitten, diesmal sorgfältiger zu sein.
  • C: Die roten Tests vom Agenten anpassen lassen, damit die Pipeline wieder grün wird.
  • D: Dem Agenten Zugriff auf die Produktionsdatenbank geben, damit er direkt dort testen kann.

Fall 1: Streiche jede Zeile, die eine Maschine nicht eindeutig mit Ja oder Nein beantworten könnte. Fall 2: Frage dich bei jeder Option, was sie am Auftrag, am Kontext und am Risiko für die Produktionsdaten ändert.

antwort = ("D", "A")
antwort

Fall 1: Nur D ist konkret und prüfbar, eine ast-Leitplanke kann jeden Verstoß finden. A bis C sind Geschmacksfragen ohne Messgröße. Fall 2: Nach mehreren gleichen Fehlversuchen liegt es meist am Auftrag oder am Kontext voller Sackgassen. Der Vertrag bekommt den Randfall, und der neue Anlauf beginnt sauber. Tests anpassen oder Produktionszugriff vergeben ersetzt das Ziel durch ein einfacheres oder erhöht das Risiko.

Merksatz

Kenne die typischen Fehlermuster von KI-Code (erfundene APIs zuerst), halte die Regeldatei kurz und prüfbar, erzwinge Alternativen und entscheide selbst anhand deiner Randbedingungen, und fange nach dem dritten Fehlversuch mit besserem Vertrag und frischem Kontext neu an.

Prüfstein

  1. Schreibe fünf Zeilen für eine Regeldatei deines eigenen Projekts, die jeweils von einer Leitplanke geprüft werden könnten. Welche Zeile lässt sich nicht automatisch prüfen?
  2. Ein Agent scheitert dreimal am selben Problem. Was änderst du, und was änderst du ausdrücklich nicht?

Quelle: quellen/konzeptuebersicht-software-grundlagen.md, Abschnitt “Querschnitt für die Arbeit mit KI” (Ergebnisse beurteilen, Optionen bewerten). quellen/konzeptuebersicht-software-fortgeschritten.docx, Abschnitt “Querschnitt für die Arbeit mit KI” (typische KI-Fehlermuster erkennen, Kontext steuern, Alternativen erzwingen) und “Übungsformate für diese Stufe” (Fehler reproduzieren, ADRs schreiben, Systemdesign-Übungen).

Über die Quelle hinaus (allgemeines Fachwissen): der Ablauf in Schritt 3 (Auftragsvorlage, Plan vor Code, kleine Diffs, Erfahrungsregeln zu Fehlversuchen und Rechten, keine Messung), der Hinweis auf erfundene Paketnamen als Angriffsweg, die Beispiele der Regeldatei, die Fälle der Übungen 2 und 3 (die Zahlen in Übung 2, Fall 1 und Fall 2 sind Annahmen der Aufgabe), die Zeilen npm ls und npm view in der Vergleichstabelle (nicht ausgeführt, bitte prüfen). Die Ausgaben der ast-Beispiele stammen aus dem Ausführen des Codes mit Python 3. Übung 1 prüft Namen der Standardbibliothek wie json.parse, math.clamp und datetime.now im aktuellen Python. Dass statistics.average und random.pick in allen Versionen fehlen: bitte prüfen (hier nur für die genutzte Version ausgeführt).