Mit KI arbeiten (a): Verträge, Leitplanken und Review nach Risiko

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

Worum es geht

Du gibst einer KI einen kurzen Auftrag: “Schreibe eine Funktion, die eine Liste in Seiten aufteilt.” Zehn Sekunden später steht dort sauberer Code mit Docstring. Du probierst Seite 1 und Seite 2, beides stimmt, du übernimmst ihn. Der Code besteht den Happy Path (den Normalfall ohne Überraschungen). Was er bei Seite 0, bei einer halb vollen letzten Seite oder bei einer Liste tut, die du danach noch brauchst, hast du nie gesehen. Bei den ersten Versuchen merkst du das nicht, später kostet es.

Das ist der Kern der Arbeit mit KI auf deinem Niveau: Es geht weniger darum, bessere Prompts zu schreiben, als ein System aus Vorgaben und Gegenprüfungen um die KI zu bauen. Die KI darf schnell und oft falsch liegen, solange etwas Automatisches die Fehler abfängt und du dein Review dorthin lenkst, wo ein Fehler wehtut.

In Teil a baust du das Fundament, in drei Übungen. Du schreibst Vertragstests (contract tests), die schlechte KI-Varianten durchfallen lassen. Du ordnest Änderungen nach Blast Radius (Schadensradius) und legst die Review-Tiefe fest. Du schreibst eine Leitplanke (guardrail) als kleine Fitness Function. Teil b (Fehlermuster, Kontext und Alternativen) behandelt halluzinierte APIs, Regeldateien, Alternativen erzwingen und einen Alltagsablauf.

Alles läuft im Browser. KI-Antworten werden simuliert: Die “KI-Implementierungen” sind feste Funktionen, die dir vorgegeben werden. Es gibt keinen echten Modellaufruf und keinen API-Schlüssel.

Die Lektion beantwortet den Prüfstein: Wie sorgst du dafür, dass KI-Code deine Anforderungen einhält, ohne jede Zeile selbst zu lesen?

Plane ehrlich 50 Minuten ein: etwa 22 Minuten Lesen, 28 Minuten Übungen (drei Stück, Übung 1 ist die längste).

Von JS/TS her gedacht

Das Prinzip kennst du aus deinem Alltag, nur eine Stufe strenger:

Idee JS/TS Python (hier)
Vertrag als Typ TypeScript-Interface, zod-Schema Typannotationen plus pydantic (Lektion im KI-Track)
Vertragstests Jest/Vitest: expect(() => f(0)).toThrow() try ... except ValueError (hier) oder pytest.raises
Linter als Leitplanke ESLint-Regeln (no-eval, eigene Regeln) ruff, flake8; hier: eigene Regel mit ast
Architekturtest dependency-cruiser, eslint-plugin-boundaries Fitness Function mit ast (Lektion 11)
Review-Schwerpunkt CODEOWNERS für auth/ und payments/ gleiche Idee, sprachunabhängig
Regeldatei für die KI CLAUDE.md, .cursorrules, ADRs im Repo gleiche Dateien, sprachunabhängig

Der Unterschied zur Sprache ist oft kleiner, als man denkt, aber nicht null. Dieselbe naive Formel für einen Rabatt in Cent verhält sich in JavaScript und Python an einer Stelle anders. Das ist der Grund, warum der Vertrag in Tests stehen muss und nicht nur im Kopf. Beide Varianten, mit Node und Python ausgeführt:

const rabatt = (preisCent, prozent) => {
  if (!(prozent >= 0 && prozent <= 100) || preisCent < 0) throw new RangeError("ungueltig");
  return preisCent - Math.floor(preisCent * prozent / 100);
};
const naiv = (p, r) => Math.round(p * (1 - r / 100));
console.log(rabatt(10000, 10), rabatt(5, 50), naiv(5, 50), naiv(1, 50));
// 9000 3 3 1

In JavaScript liefert naiv(5, 50) das richtige 3 (Math.round rundet .5 auf), in Python liefert dieselbe Formel mit round die 2 (round rundet .5 zur geraden Zahl, banker’s rounding). Eine KI, die den Code zwischen Sprachen “übersetzt”, übernimmt oft die Formel, aber nicht das Verhalten.

Konzept

Schritt 1: Der Happy Path ist nicht der Vertrag

Ein Prompt beschreibt den Normalfall. Ein Vertrag (contract) beschreibt, was immer gelten muss. Er hat vier Teile, so steht es in der Quelle unter “Verträge statt Prompts” und “Präzise spezifizieren”:

  1. Invarianten (invariants): Was gilt für jede Eingabe? (“Das Ergebnis ist nie negativ.”)
  2. Randfälle (edge cases): Leer, null, Grenze, das Letzte, das Erste.
  3. Fehlerverhalten (error behavior): Was passiert bei ungültiger Eingabe? Exception, Fehlercode, Standardwert? Genau eines, schriftlich.
  4. Nichtziele (non-goals): Was die Funktion bewusst nicht tut (“keine Währungsumrechnung”). Damit die KI nichts dazu erfindet.

Ein Beispiel mit anderer Domäne als später in den Übungen. Der Prompt lautet: “Schreibe rabatt(preis_cent, prozent), die den Preis nach Rabatt in Cent liefert.” Drei plausible KI-Antworten, die alle den Happy Path rabatt(10000, 10) == 9000 bestehen:

Alle drei liefern 9000. Jetzt der Vertrag. Er sagt: Preis und Prozent sind ganze Zahlen, prozent liegt zwischen 0 und 100, sonst ValueError. Der Rabattbetrag wird auf ganze Cent abgerundet (der Kunde bekommt nie mehr Rabatt als versprochen). rabatt(preis, 0) == preis, rabatt(preis, 100) == 0. Nichtziel: keine Steuer, keine Währung. Daraus werden Tests:

Du siehst: Alle drei prüfen nichts (rabatt_v3(100, 120) ergibt -20, also eine Auszahlung an den Kunden, und auch die anderen beiden werfen keinen ValueError). Zusätzlich verletzen rabatt_v1 und rabatt_v2 die Rundung (rabatt_v1(1, 50) ergibt 0, der Kunde bekommt einen Cent zu viel Rabatt), rabatt_v3 rundet richtig. Alle drei haben den Happy Path bestanden. Die Tests mit Fehlerverhalten und Randfall sind es, die sie trennen.

Wichtig: Die Tests sind kurz, und du kannst sie vor dem Code schreiben. Dann bekommt die KI sie mit in den Auftrag (“Mach diese Tests grün”), oder du lässt die KI mehrere Varianten schreiben und wählst mit den Tests aus. Wie du Eigenschaften über viele Zufallseingaben prüfst und messt, ob Tests etwas festnageln, steht in Lektion 16. Hier geht es darum, aus einem knappen Prompt einen Vertrag zu machen.

Schritt 2: Leitplanken, die ohne dich laufen

Dein Review ist knapp. Darum baust du eine Gegenprüfung, die bei jedem Commit automatisch läuft (so die Quelle: “Typen, Linter, Architekturtests, Contract Tests, CI als automatisierte Gegenprüfung”). Jede Stufe fängt eine andere Fehlerart:

Stufe Fängt Beispiel
Typen (mypy, TypeScript) falsche Form der Daten Funktion bekommt str, erwartet int
Linter (ruff, ESLint) verbotene Muster, unbenutzte Namen eval, verschluckte Fehler
Architekturtest (Fitness Function) verbotene Abhängigkeiten, Modulgrenzen ui importiert repository
Contract Test Schnittstelle weicht vom Vertrag ab API liefert Feld nicht mehr
CI (Continuous Integration) alles davon, bei jedem Push, ohne dass jemand daran denken muss Pipeline wird rot

Für selbst gebaute Regeln nimmst du das Modul ast (Abstract Syntax Tree, Syntaxbaum). Lektion 11 hat damit Imports geprüft. Hier prüfst du Aufrufe. Python zerlegt Quelltext in Knoten. Ein Aufruf os.system('ls') ist ein Knoten Call, dessen func ein Attribute ist (Objekt os, Name system):

Ein Aufruf einer einfachen Funktion wie print(...) hat dagegen als func einen Name. Beide Formen musst du unterscheiden. Eine kleine Regel: “Kein print und kein os.system”, mit Zeilennummern (lineno) der Fundstellen. ast.walk durchläuft alle Knoten, auch die in Funktionen:

Zeile 2 und Zeile 4 werden gefunden, die zweite steckt in einer Funktion. Für “fehlende Fehlerpfade” brauchst du noch einen Knotentyp: ExceptHandler (ein except-Block). Er hat .type (die abgefangene Klasse, bei einem blanken except: ist es None) und .body (die Anweisungen darin):

Ein except mit nur pass verschluckt den Fehler: Das Programm läuft weiter, als wäre nichts gewesen. KI-Code tut das gern, weil es “robust” aussieht. Das ist eine der Regeln in Übung 3.

Schritt 3: Blast Radius und Review-Tiefe

Du kannst nicht jede Änderung gleich gründlich prüfen. Der Blast Radius (Schadensradius) fragt: Wie groß ist der Schaden, wenn diese Änderung falsch ist, und wie schwer ist er rückgängig zu machen? Die Quelle nennt vier Bereiche, die zuerst und besonders genau geprüft werden: Auth, Migrationen, Geldflüsse und Nebenläufigkeit.

Damit die Staffelung nachvollziehbar bleibt, bekommt jedes Kriterium Punkte. Das ist ein Beispielschema für diese Lektion, kein Standard. Für Übung 2 gilt genau diese Tabelle:

Kriterium Punkte
Auth (Anmeldung, Rechte, Rollen) 4
Geldfluss (Beträge, Zahlungen, Rechnungen, Guthaben) 4
Datenmigration oder Schemaänderung 3
Nebenläufigkeit (Queues, Worker, Locks, parallele Verarbeitung) 3
Nicht rückgängig zu machen (Daten gehen verloren, Mail ist gesendet) 2
Nur Text, Doku, Styling 0

Eine Änderung kann mehrere Kriterien treffen, die Punkte werden addiert. Die Review-Tiefe richtet sich nach der Summe: ab 6 Punkten tief (zwei Personen, Tests lesen, Szenarien durchspielen), 3 bis 5 normal, unter 3 kurz. Bei gleicher Punktzahl entscheidet die Id alphabetisch. Wichtig: Es zählt, was die Änderung tatsächlich ändert, nicht welches Stichwort in der Beschreibung vorkommt.

Durchgerechnet an vier Änderungen, die in der Übung nicht vorkommen:

w4 trifft zwei Kriterien (4 + 3 = 7) und bekommt die tiefste Prüfung. w3 ist reiner Text und bekommt keine. Der Nutzen: Du verbringst die Zeit dort, wo ein Fehler Geld oder Daten kostet. Bei Textänderungen reicht ein Blick.

Falle

Die häufigste Falle: Du lässt die KI Code und Tests schreiben und prüfst nur, ob alles grün ist. Die Tests der KI beschreiben den Code der KI, nicht deinen Vertrag. Sie haben denselben blinden Fleck wie der Code. Dein Vertrag muss von dir kommen, und er muss die unangenehmen Fälle enthalten.

Die zweite Falle: Gleichmäßiges Review. Wer jede Änderung gleich lang liest, liest den Hilfetext genauso gründlich wie die Zahlungslogik und wird bei der Zahlungslogik müde. Staffle nach Blast Radius.

Übungen

Übung 1: Vertragstests, die schlechte KI-Varianten durchfallen lassen (ca. 12 Min.)

Prompt an die KI: “Schreibe seite(items, nr, pro_seite), die die Seite nr einer Liste liefert.” Du bekommst drei Implementierungen. Alle liefern für seite(list(range(1, 11)), 2, 3) die Liste [4, 5, 6]. Du schreibst einen Vertrag, damit nur korrekte Varianten bestehen.

Der Vertrag:

  • Seiten zählen ab 1. Das Ergebnis ist eine neue Liste mit höchstens pro_seite Elementen in der Reihenfolge der Eingabe.
  • Die letzte Seite darf kürzer sein. Eine Seite hinter dem Ende liefert []. Eine leere Liste liefert auf Seite 1 [].
  • nr < 1 oder pro_seite < 1 ergibt einen ValueError.
  • Die Eingabeliste bleibt unverändert.
  • Invariante: Alle Seiten hintereinander ergeben genau die Eingabe.

Schreibe vertrag_ok(f). Sie bekommt eine Implementierung f, ruft sie mit eigenen Eingaben auf und gibt True zurück, wenn der Vertrag eingehalten ist, sonst False. Die letzte Zeile der Zelle gibt vertrag_ok zurück. Der Check lässt deine Funktion gegen mehrere korrekte Implementierungen laufen (sie müssen alle True ergeben, auch wenn sie anders geschrieben sind) und gegen die drei Varianten (alle müssen False ergeben). Erwarte keinen ValueError dort, wo der Vertrag ihn nicht verlangt.

Gehe die Punkte des Vertrags einzeln durch und überlege bei jedem Punkt, wie du ihn mit einer Eingabe herausforderst. Für die Reihenfolge brauchst du Daten, die nicht schon sortiert sind. Für “unverändert” musst du dir die Eingabe vorher merken. Für den ValueError brauchst du try und except: Der Test soll schlagen, wenn kein Fehler kommt. Und welche Seitengröße lässt die letzte Seite unvollständig?

def seite_v1(items, nr, pro_seite):
    start = (nr - 1) * pro_seite
    return items[start:start + pro_seite]

def seite_v2(items, nr, pro_seite):
    if nr < 1 or pro_seite < 1:
        raise ValueError("ungueltig")
    start = (nr - 1) * pro_seite
    ende = start + pro_seite
    if ende > len(items):
        return []
    return items[start:ende]

def seite_v3(items, nr, pro_seite):
    if nr < 1 or pro_seite < 1:
        raise ValueError("ungueltig")
    items.sort()
    start = (nr - 1) * pro_seite
    return items[start:start + pro_seite]

def vertrag_ok(f):
    def wirft(*args):
        try:
            f(*args)
        except ValueError:
            return True
        return False

    items = [30, 10, 20, 50, 40]
    kopie = list(items)
    if f(items, 1, 2) != [30, 10]:
        return False                      # Reihenfolge bleibt
    if items != kopie:
        return False                      # Eingabe unverändert
    if f(items, 3, 2) != [40]:
        return False                      # letzte, kürzere Seite
    if f(items, 4, 2) != [] or f([], 1, 3) != []:
        return False                      # hinter dem Ende, leere Liste
    if not (wirft(items, 0, 2) and wirft(items, -1, 2) and wirft(items, 1, 0)):
        return False                      # Fehlerverhalten
    for pro in range(1, 8):               # Invariante: alle Seiten ergeben die Eingabe
        zusammen = []
        for nr in range(1, 8):
            zusammen += f(items, nr, pro)
        if zusammen != items:
            return False
    return True

for f in (seite_v1, seite_v2, seite_v3):
    print(f.__name__, vertrag_ok(f))
vertrag_ok

seite_v1 fällt am Fehlerverhalten durch (Seite 0 liefert still [] statt eines ValueError). seite_v2 fällt an der letzten, kürzeren Seite durch: Die Prüfung “Seite reicht über das Ende” wirft die Reste weg, ein Fehler, den du nur mit einer nicht aufgehenden Seitengröße siehst. seite_v3 sortiert die Eingabe, ändert sie also und verändert die Reihenfolge. Der Fehler zeigt sich nur mit unsortierten Daten. Alle drei bestanden den Happy Path mit range(1, 11), weil dort alles aufgeht und alles schon sortiert ist.

Übung 2: Blast-Radius-Triage (ca. 6 Min.)

Ein KI-Agent hat sieben Änderungen vorbereitet. Du hast Zeit für ein gründliches Review bei den riskanten und nur für einen Blick bei den anderen. Wende die Tabelle und die Regeln aus Schritt 3 an: Punkte addieren, Reihenfolge absteigend nach Punkten, bei Gleichstand nach Id alphabetisch, Tiefe tief ab 6, normal 3 bis 5, kurz unter 3.

Id Änderung
a1 Ändert die Berechnung der Mehrwertsteuer auf Rechnungen (neuer Satz für Lieferungen ins Ausland).
a2 Eine Migration löscht die Spalte telefon_alt aus der Tabelle kunden. Die Daten der Spalte sind danach weg.
a3 Ein neuer Worker holt Aufträge parallel aus einer Queue und bucht dabei Guthaben vom Kundenkonto ab.
a4 Korrigiert einen Tippfehler im Hinweistext auf der Zahlungsseite (nur der Text, keine Logik).
a5 Die Rolle support darf zusätzlich Nutzerkonten sperren (Änderung in der Rechteprüfung).
a6 Verschickt beim Bestellabschluss direkt eine Bestätigungs-E-Mail. Eine gesendete Mail lässt sich nicht zurückholen.
a7 Ändert die Hintergrundfarbe der Startseite von Grau zu Weiß.

Gib ein Tupel aus zwei Teilen zurück: reihenfolge, ein Tupel der sieben Ids vom höchsten zum niedrigsten Risiko, und tiefen, ein Tupel mit "tief", "normal" oder "kurz" pro Id in derselben Reihenfolge wie dein reihenfolge.

Ordne zuerst jeder Änderung die Kriterien zu und frage dich dabei: Was ändert sich am Verhalten des Systems wirklich? Ein Stichwort in der Beschreibung ist noch kein Kriterium. Manche Änderungen treffen zwei Kriterien, andere nur eines. Addiere erst, sortiere danach, und lege zuletzt die Tiefe aus der Summe fest.

reihenfolge = ("a3", "a2", "a1", "a5", "a6", "a4", "a7")
tiefen = ("tief", "normal", "normal", "normal", "kurz", "kurz", "kurz")
(reihenfolge, tiefen)

Punkte: a3 = Nebenläufigkeit 3 + Geldfluss 4 = 7 (tief). a2 = Migration 3 + nicht rückgängig 2 = 5. a1 = Geldfluss 4. a5 = Auth 4. a6 = nicht rückgängig 2 (kurz, weil unter 3). a4 und a7 haben 0: a4 erwähnt zwar die Zahlungsseite, ändert aber nur Text. Bei Gleichstand (a1 und a5 mit 4, a4 und a7 mit 0) entscheidet die Id.

Übung 3: Eine Leitplanke schreiben (ca. 10 Min.)

Deine Regeldatei sagt: keine dynamische Codeausführung, jeder Netzwerkaufruf mit Timeout, keine verschluckten Fehler. Du baust daraus eine Leitplanke, die in der CI läuft und KI-Code ohne Review abfängt.

Schreibe leitplanke(quelltext). Sie gibt eine sortierte Liste von Tupeln (zeile, regel) zurück, ohne Duplikate. Drei Regeln:

  • R1: Aufruf von eval(...) oder exec(...) (als einfacher Name). Ein Aufruf wie parser.eval(x) zählt nicht.
  • R2: Aufruf von requests.get, requests.post, requests.put, requests.delete oder requests.patch, ohne Schlüsselwortargument timeout. Ein **optionen zählt nicht als timeout. session.get(...) oder requests.Session() zählen nicht.
  • R3: Ein except-Block, dessen Rumpf nur aus pass besteht (auch ein blankes except:). Ein except, das etwas anderes tut (z. B. loggt), ist erlaubt.

Die Zeile ist lineno des Knotens: bei einem Aufruf über mehrere Zeilen die erste, bei except die Zeile mit dem Schlüsselwort except. Fundstellen zählen überall im Quelltext, auch in Funktionen und Klassen.

Durchlaufe mit ast.walk alle Knoten und unterscheide drei Knotentypen: Call (hier prüfst du func: einfacher Name oder Attribute an requests), und ExceptHandler (prüfe jede Anweisung in .body). Für das Timeout brauchst du die Liste keywords des Aufrufs: Jedes Element hat ein Attribut arg. Ein set verhindert Duplikate, sorted macht die Ausgabe stabil.

import ast

HTTP = {"get", "post", "put", "delete", "patch"}

def leitplanke(quelltext):
    funde = set()
    for k in ast.walk(ast.parse(quelltext)):
        if isinstance(k, ast.Call):
            f = k.func
            if isinstance(f, ast.Name) and f.id in ("eval", "exec"):
                funde.add((k.lineno, "R1"))
            if (isinstance(f, ast.Attribute) and isinstance(f.value, ast.Name)
                    and f.value.id == "requests" and f.attr in HTTP):
                if not any(kw.arg == "timeout" for kw in k.keywords):
                    funde.add((k.lineno, "R2"))
        elif isinstance(k, ast.ExceptHandler):
            if all(isinstance(s, ast.Pass) for s in k.body):
                funde.add((k.lineno, "R3"))
    return sorted(funde)

beispiel = "import requests\ndef f(u):\n    try:\n        return requests.get(u)\n    except OSError:\n        pass\n"
print(leitplanke(beispiel))
leitplanke

Das Beispiel enthält zwei Verstöße in einer Funktion: den Aufruf ohne Timeout in Zeile 4 und den verschluckten Fehler in Zeile 5. Ein **optionen hat kw.arg gleich None und zählt darum nicht als Timeout. Eine solche Regel ist nie vollständig (ein Alias wie import requests as r entgeht ihr), aber sie fängt die typischen Fälle jedes Mal automatisch.

Merksatz

Schreibe den Vertrag selbst (Invarianten, Randfälle, Fehlerverhalten, Nichtziele), lass Leitplanken die Routine prüfen, und verteile dein Review nach Blast Radius: Auth, Migrationen, Geldflüsse und Nebenläufigkeit zuerst.

Prüfstein

  1. Wie sorgst du dafür, dass KI-Code deine Anforderungen einhält, ohne jede Zeile selbst zu lesen? Nenne mindestens drei Mechanismen und sage jeweils, welche Fehlerart sie fängt.
  2. Du hast eine Stunde Review-Zeit für zehn Änderungen, darunter eine Warteschlange mit parallelen Workern und ein korrigierter Tippfehler im Hilfetext. Wohin legst du die meiste Zeit, und warum?

Weiter mit Teil b: Fehlermuster, Kontext und Alternativen.


Quelle: quellen/konzeptuebersicht-software-grundlagen.md, Abschnitt “Querschnitt für die Arbeit mit KI” (Präzise spezifizieren, Ergebnisse beurteilen, Testbarkeit einfordern). quellen/konzeptuebersicht-software-fortgeschritten.docx, Abschnitt “Querschnitt für die Arbeit mit KI” (Verträge statt Prompts, Leitplanken bauen, Blast Radius einschätzen).

Über die Quelle hinaus (allgemeines Fachwissen): die vier Teile des Vertrags (Nichtziele als eigener Punkt), das Punkteschema der Triage (Punkte und Schwellen sind ein Beispielschema dieser Lektion, kein Standard), die Beschreibung der ast-Knoten. Die Ausgaben 9000 3 3 1 (Node), 2 4, die Ergebnisse der Vertragstests und die Ausgaben der ast-Beispiele stammen aus dem Ausführen des Codes mit Python 3 beziehungsweise Node.