cat posts/agent-im-loop-05-wenn-der-loop-luegt.md
Agent im Loop (Teil 5): Wenn der Loop lügt
Explodierende Würfel und Vorteil/Nachteil zeigen, wie grüne Tests täuschen können: Overfitting, geschwächte Tests, unvollständige Semantik und Hänger. Property-Based Testing macht die Schleife robuster.
Bisher war die Schleife ziemlich bequem: roten Test schreiben, Agent arbeiten lassen, grün bekommen. In Teil 4 haben wir damit sogar den kompletten Unterbau der Würfel-Engine ausgetauscht.
Jetzt kommt der unangenehme Teil: Eine grüne Testsuite bedeutet nur, dass der Code die vorhandenen Tests besteht. Ob diese Tests das richtige Verhalten vollständig beschreiben, ist eine andere Frage.
Ein Coding-Agent verschärft das Problem. Gibst Du ihm ausschließlich das Ziel „bring die Tests grün“, optimiert er auf genau dieses Signal. Normalerweise entsteht dabei vernünftiger Code. Ist im Testnetz aber eine Lücke, kann auch eine falsche Implementierung perfekt grün aussehen.
Explodierende Würfel sind dafür ein schönes Beispiel.
Vier Arten, wie der Loop Dich täuschen kann
1. Overfitting auf die Beispiele
Angenommen, der Test enthält nur:
assert roll("1d6!", ...) == 8
Dann wäre theoretisch sogar so etwas möglich:
if notation == "1d6!":
return 8
Das ist ein extremes Beispiel, aber das Grundproblem ist real: Wenige konkrete Beispiele lassen Raum für Implementierungen, die genau diese Beispiele erfüllen, ohne die allgemeine Regel korrekt umzusetzen.
Mehr unterschiedliche Beispiele helfen. Noch stärker werden die Tests, wenn sie allgemeine Eigenschaften prüfen und ihre Eingaben variieren.
2. Der Maßstab wird passend gemacht
Der einfachste Weg von Rot zu Grün führt manchmal durch den Test selbst:
assert result == 14
wird zu
assert result >= 12
oder der störende Test verschwindet gleich ganz.
Das heißt nicht, dass ein Agent niemals Tests ändern darf. Gerade beim gemeinsamen Entwickeln kann es sinnvoll sein, Tests erzeugen oder verbessern zu lassen. Änderungen an Tests brauchen aber dieselbe Aufmerksamkeit wie Änderungen am Produktivcode.
Wenn der Code nur deshalb grün wird, weil gleichzeitig die Erwartung abgeschwächt wurde, hat die Schleife ihren Maßstab verloren.
3. Grün, aber semantisch falsch
Das ist die subtilste Variante. Der Test ist korrekt, prüft aber nicht genug.
Bei explodierenden Würfeln kann eine Implementierung beispielsweise genau eine Explosion unterstützen. Solange unser Test ebenfalls nur eine Explosion enthält, sehen wir den Fehler nicht.
Die Testsuite sagt dann nichts Falsches. Wir lesen nur mehr aus ihrem Grün heraus, als sie tatsächlich geprüft hat.
4. Der Loop liefert gar keine Antwort mehr
Eine falsche Abbruchbedingung kann dafür sorgen, dass ein Test niemals endet. Dann haben wir nicht einmal mehr Rot oder Grün.
Streng genommen lügt der Loop hier nicht – er verstummt.
Gerade bei Schleifen, Rekursion, Retries und externer Kommunikation gehört deshalb auch die Frage zur Spezifikation: Was verhindert, dass diese Operation unbegrenzt läuft?
Explodierende Würfel
Wir erweitern unsere Grammatik um !:
1d6!
3d6!
Die Regel lautet: Zeigt ein Würfel sein Maximum, wird derselbe Würfel erneut geworfen und das Ergebnis addiert. Ist auch dieser Wurf maximal, explodiert er erneut.
Aus
6, 6, 2
wird bei einem W6 also:
6 + 6 + 2 = 14
Jeder Würfel einer Gruppe explodiert unabhängig.
Für diesen Teil erlauben wir pro Würfelgruppe entweder einen keep/drop-Selektor oder
!, aber noch keine Kombination aus beidem:
dice := [number] "d" number [modifier]
modifier := selector | "!"
selector := ("kh" | "kl" | "dh" | "dl") number
4d6kh3 bleibt damit gültig und 3d6! kommt neu dazu. Was 4d6kh3! genau bedeuten
soll, entscheiden wir noch nicht – also akzeptiert unsere Sprache es auch noch nicht.
Kleine Erweiterung am Parser
Der Tokenizer muss zunächst ! erkennen:
TOKEN_RE = re.compile(r"kh|kl|dh|dl|d|!|[+-]|\d+")
Unser Dice-Knoten bekommt zusätzlich die Information, ob der Würfel explodiert:
class Dice:
def __init__(self, count, sides, selector=None, explode=False):
self.count = count
self.sides = sides
self.selector = selector
self.explode = explode
Im Parser reicht dann eine kleine Erweiterung in _dice():
def _dice(self, count):
self._expect("d")
sides = self._number("Seitenzahl nach 'd' erwartet")
selector = None
explode = False
if self._peek() == "!":
self._next()
explode = True
elif self._peek() in ("kh", "kl", "dh", "dl"):
kind = self._next()
amount = self._number(
f"Anzahl nach Selektor {kind!r} erwartet"
)
selector = (kind, amount)
return Dice(
count,
sides,
selector=selector,
explode=explode,
)
Der interessante Teil steckt aber nicht im Parser. Er steckt in der Schleife, die einen einzelnen Würfel auswertet.
Der Beispiel-Fallstrick
Eine erste Implementierung könnte so aussehen:
def _roll_die(rng, sides, explode):
value = rng.randint(1, sides)
if explode and value == sides:
value += rng.randint(1, sides)
return value
Und dazu schreiben wir:
def test_explodierender_wuerfel():
rng = SequenceRng([
(6, 6),
(6, 2),
])
assert roll("1d6!", rng) == 8
Rot, implementieren, grün.
Nur ist die Implementierung falsch.
Die Regel lautet nicht „würfle bei einer 6 genau einmal zusätzlich“, sondern „würfle solange weiter, wie das Maximum fällt“.
Unser Beispiel hat diese Unterscheidung nie geprüft.
Ein zweiter Test trifft die Kante:
@pytest.mark.parametrize(
"notation,wuerfe,erwartet",
[
(
"1d6!",
[(6, 6), (6, 6), (6, 2)],
14,
),
(
"3d6!",
[(6, 6), (6, 2), (6, 3), (6, 1)],
12,
),
],
)
def test_explodierende_wuerfel(notation, wuerfe, erwartet):
rng = SequenceRng(wuerfe)
assert roll(notation, rng) == erwartet
rng.assert_exhausted()
Der erste Fall erzwingt zwei Explosionen:
6 -> 6 -> 2
Eine Implementierung, die nur einmal nachwürfelt, liefert 12 statt 14 und wird rot.
Der zweite Fall prüft zusätzlich, dass bei mehreren Würfeln nur der betroffene Würfel weiter explodiert:
erster W6: 6 + 2
zweiter W6: 3
dritter W6: 1
----
12
Der Unterschied liegt nicht in der Menge der Tests, sondern in ihrer Auswahl. Ein einziger Fall an der richtigen Kante ist wertvoller als zehn Fälle, die alle dieselbe Lücke haben.
Eine echte Implementierung braucht eine Abbruchstrategie
Die naheliegende Schleife lautet:
def _roll_die(rng, sides, explode):
value = rng.randint(1, sides)
total = value
while explode and value == sides:
value = rng.randint(1, sides)
total += value
return total
Für normale Würfel sieht das vernünftig aus. Aber was passiert bei
1d1!
Ein W1 zeigt immer eine 1. Eine 1 ist gleichzeitig sein Maximum. Der Würfel explodiert also für immer.
Auch eine kaputte oder absichtlich kontrollierte RNG könnte bei einem W6 unbegrenzt Sechsen liefern.
Wir brauchen deshalb eine fachliche Grenze. Wichtig ist dabei, nach Erreichen dieser Grenze kein Teilergebnis als gültigen Wurf zurückzugeben. Das würde einen unvollständigen Wurf still als korrekt ausgeben.
MAX_EXPLOSIONS = 100
def _roll_die(rng, sides, explode):
if explode and sides == 1:
raise ValueError("Ein explodierender W1 terminiert nicht")
value = rng.randint(1, sides)
total = value
explosions = 0
while explode and value == sides:
if explosions >= MAX_EXPLOSIONS:
raise RuntimeError("Explosionsgrenze erreicht")
value = rng.randint(1, sides)
total += value
explosions += 1
return total
1d1! lehnen wir direkt ab. Bei allen anderen Würfeln dient
MAX_EXPLOSIONS als defensive Sicherung.
Der Evaluator verwendet die Funktion anschließend für jeden einzelnen Würfel:
if isinstance(node, Dice):
rolls = [
_roll_die(rng, node.sides, node.explode)
for _ in range(node.count)
]
selected = _apply_selector(rolls, node.selector)
return sum(selected)
Einen Hänger kann der Test nicht selbst verhindern
Jetzt könnten wir schreiben:
def test_explosionsgrenze():
with pytest.raises(RuntimeError):
roll("1d6!", AlwaysMaxRng())
Das prüft die Grenze – solange sie funktioniert.
Wenn ein Agent die Abbruchbedingung versehentlich entfernt, hängt genau dieser Test
endlos. Ein normaler assert hilft dann nicht mehr, weil Python ihn nie erreicht.
Dafür brauchen wir eine Sicherung außerhalb der getesteten Schleife.
Eine Möglichkeit ist pytest-timeout:
uv add --dev pytest-timeout
Unser Testdouble liefert absichtlich immer das Maximum:
class AlwaysMaxRng:
def randint(self, start, end):
return end
Und der Test bekommt einen äußeren Timeout:
@pytest.mark.timeout(1)
def test_explosionsgrenze_verhindert_endlosschleife():
with pytest.raises(RuntimeError, match="Explosionsgrenze"):
roll("1d6!", AlwaysMaxRng())
Der Timeout ist hier keine Performance-Messung. Ob die Funktion 20 oder 30 Millisekunden braucht, interessiert uns nicht. Er ist die letzte Sicherung für den Fall, dass die eigentliche Terminierungslogik komplett versagt.
Dasselbe Prinzip gilt im CI: Auch ein kompletter Testlauf sollte nicht unbegrenzt laufen dürfen.
Vorteil und Nachteil: der unterscheidende Test
Vorteil und Nachteil brauchen inzwischen gar keinen neuen Produktivcode mehr. Die Selektoren aus Teil 4 können beides bereits ausdrücken:
Vorteil: 2d20kh1
Nachteil: 2d20kl1
Gerade deshalb eignen sie sich gut für eine weitere Testfalle.
Nehmen wir diesen Test für Vorteil:
rng = SequenceRng([
(20, 7),
(20, 19),
])
assert roll("2d20kh1", rng) == 19
Er sagt noch nichts darüber aus, ob kl1 korrekt implementiert ist.
Und selbst wenn wir beide Varianten mit zwei identischen Würfen testen:
12, 12
könnte die Implementierung kh und kl vertauschen, ohne dass wir es merken.
Wir brauchen Werte, bei denen die Semantik einen sichtbaren Unterschied erzeugt:
def test_vorteil_und_nachteil_unterscheiden_sich():
rolls = [
(20, 7),
(20, 19),
]
assert roll("2d20kh1", SequenceRng(rolls)) == 19
assert roll("2d20kl1", SequenceRng(rolls)) == 7
Das ist ein unterscheidender Testfall: Die gewählten Eingaben sorgen dafür, dass zwei mögliche Implementierungen nicht zufällig dasselbe Ergebnis liefern.
Genau solche Fälle sollten wir suchen, wenn mehrere plausible Implementierungen durch denselben einfachen Test passen würden.
Property-Based Testing statt immer längerer Tabellen
Wir können die Tabelle natürlich weiter ausbauen:
1d6
2d6
3d8
4d10
5d20
...
Irgendwann schreiben wir aber nur noch Varianten desselben Tests von Hand.
Hier kommt Property-Based Testing ins Spiel. Wir beschreiben nicht mehr einzelne Eingaben, sondern wie gültige Eingaben aussehen und welche Eigenschaft für alle davon gelten soll.
Für Python verwenden wir Hypothesis:
uv add --dev hypothesis
Aus Teil 2 (erweitert in Teil 3) haben wir bereits SequenceRng. Damit können wir
eine stärkere Eigenschaft formulieren als nur „das Ergebnis liegt ungefähr im
richtigen Bereich“.
Wir erzeugen Würfelanzahl, Seitenzahl und konkrete Würfelergebnisse:
from hypothesis import given, strategies as st
@st.composite
def dice_cases(draw):
count = draw(st.integers(min_value=1, max_value=10))
sides = draw(st.integers(min_value=2, max_value=100))
values = draw(
st.lists(
st.integers(min_value=1, max_value=sides),
min_size=count,
max_size=count,
)
)
return count, sides, values
Und daraus wird unsere Eigenschaft:
@given(dice_cases())
def test_ndm_addiert_genau_die_generierten_wuerfe(case):
count, sides, values = case
rng = SequenceRng([
(sides, value)
for value in values
])
assert roll(f"{count}d{sides}", rng) == sum(values)
rng.assert_exhausted()
Der Test sagt nicht:
3d6 mit 4, 1 und 6 ergibt 11.
Er sagt:
Für beliebige gültige N und M gilt:
Wenn die Zufallsquelle N konkrete Wurfergebnisse liefert,
muss NdM genau deren Summe ergeben.
Hypothesis erzeugt daraus viele unterschiedliche Testfälle. Findet es einen Fehler, versucht es anschließend, das Gegenbeispiel zu verkleinern. Statt einer riesigen, schwer lesbaren Eingabe bekommst Du dadurch häufig einen kleinen Fall, an dem sich der Bug direkt nachvollziehen lässt.
Das macht konkrete Beispieltests nicht überflüssig. 1d6! mit zwei Explosionen ist
weiterhin ein hervorragender Test, weil er eine bestimmte semantische Kante
dokumentiert.
Property-Based Testing ergänzt diese Beispiele um Variation.
Mehr dazu steht im Begleitartikel Property-Based Testing mit Hypothesisgeplant.
Auch Property-Based Tests können schwach sein
Hypothesis macht eine schlechte Eigenschaft nicht automatisch gut.
Dieser Test sieht beispielsweise fleißig aus:
@given(
count=st.integers(1, 10),
sides=st.integers(2, 100),
)
def test_irgendwas(count, sides):
result = roll(f"{count}d{sides}")
assert result > 0
Er probiert viele Eingaben – prüft aber fast nichts.
Eine Implementierung mit
return 1
würde weiterhin bestehen.
Die eigentliche Arbeit bleibt deshalb dieselbe wie bei normalen Tests: Wir müssen eine Eigenschaft finden, die falsche Implementierungen tatsächlich voneinander unterscheidet.
Hypothesis automatisiert die Variation der Eingaben. Es entscheidet nicht für uns, was „korrekt“ bedeutet.
Den Agenten gegen die Tests arbeiten lassen – aber nicht gegen ihren Sinn
Für Claude Code können wir den Auftrag inzwischen enger formulieren:
Implementiere explodierende Würfel für NdM!.
Ein Würfel explodiert bei seinem Maximalwert wiederholt, bis ein kleinerer Wert fällt.
1d1! ist ungültig. Nach 100 aufeinanderfolgenden Explosionen muss die Auswertung mit
einem Fehler abbrechen.
Ändere bestehende Tests nicht, um Fehler grün zu bekommen.
Führe anschließend die komplette Test-Suite aus.
Das ist deutlich besser als:
Mach die neuen Tests grün.
Die Tests liefern weiterhin das maschinenprüfbare Feedback. Die Prosa beschreibt zusätzlich Semantik und Grenzen, die sich aus einzelnen Beispielen nicht unbedingt offensichtlich ergeben.
Prompt und Tests arbeiten zusammen. Keines von beiden ersetzt das andere.
Woran Du ein verdächtiges Grün erkennst
Nach einem Agentenlauf lohnt sich deshalb mehr als ein Blick auf
passed
Interessanter sind Fragen wie:
- Hat sich außer dem Produktivcode auch ein Test verändert?
- Wurde eine Assertion schwächer?
- Gibt es Sonderfälle im Code, die verdächtig genau auf einzelne Tests passen?
- Trifft mindestens ein Test jede wichtige semantische Kante?
- Gibt es Schleifen oder Retries, deren Abbruch ebenfalls getestet wird?
- Formuliert ein Property-Test wirklich eine starke Eigenschaft oder nur eine triviale Wahrheit?
Das ist kein Misstrauen gegenüber dem Agenten. Dieselben Fragen gelten für Code, den wir selbst schreiben. Ein Agent macht nur schneller sichtbar, wie leicht wir grünes Feedback mit Korrektheit verwechseln.
Stolperfallen
- Mehr Tests bedeuten nicht automatisch bessere Tests. Entscheidend ist, ob sie unterschiedliche Fehler sichtbar machen können.
- Teständerungen sind nicht verboten, aber prüfenswert. Besonders verdächtig sind Änderungen, die zeitgleich mit einer fehlschlagenden Implementierung Erwartungen abschwächen.
- Ein Explosionslimit darf kein falsches Ergebnis liefern. Wird die Grenze erreicht, sollte die Operation sichtbar fehlschlagen statt eine abgeschnittene Summe zurückzugeben.
- Ein Timeout ist die äußere Sicherung, nicht die fachliche Abbruchbedingung. Die Anwendung muss selbst terminieren können; der Test-Timeout verhindert nur, dass ein Fehler den gesamten Testlauf blockiert.
- Property-Based Testing braucht starke Eigenschaften. Viele generierte Eingaben retten keine Assertion, die kaum etwas über das gewünschte Verhalten aussagt.
- Gute Beispiele unterscheiden konkurrierende Implementierungen. Für
Vorteil/Nachteil sind
7und19informativer als zweimal12.
Grün ist ein Signal, kein Beweis
Der Loop hat nie wirklich gelogen. Wir haben ihm nur gelegentlich Fragen gestellt, die zu wenig unterscheiden konnten.
Ein grüner Test bestätigt eine konkrete Erwartung. Eine grüne Suite bestätigt viele davon. Je besser diese Erwartungen gewählt sind, desto wertvoller wird das Feedback für Mensch und Agent.
Explodierende Würfel haben uns dabei gleich mehrere Schwächen gezeigt: Ein Beispiel mit nur einer Explosion prüft keine wiederholte Explosion. Eine fehlende Abbruchbedingung kann den gesamten Loop stoppen. Und viele zufällig erzeugte Fälle helfen nur dann, wenn die geprüfte Eigenschaft stark genug ist.
In Teil 6 bekommt der Agent deshalb weitere Feedback-Kanäle. Neben Tests kommen Typen, Linter, Coverage und automatische Hooks dazu. Aus einem einzelnen grünen Balken wird langsam eine Pipeline, die verschiedene Arten von Fehlern unabhängig voneinander sichtbar macht.
Weiterlesen
- Agent im Loop (Teil 4): Refactoring unter Testschutz
- Property-Based Testing mit Hypothesisgeplant – der Begleitartikel.
Sources: Hypothesis – Dokumentation, pytest – Dokumentation, pytest-timeout.
0 Kommentare
Noch keine Kommentare. Sei der/die Erste!
Anmelden um einen Kommentar zu hinterlassen.