Alle Artikel

KI-Modelle

Warum grüne Tests lügen

Qwens eigene Tests liefen unter allen 4 Zeitzonen grün. Mein Gegencheck flog sofort raus. Was das über KI-Code und falsche Sicherheit in CI sagt.

8 min min

Teil 01a der Reihe „5 KI-Modelle, dieselbe Aufgabe". ~8 Min Lesezeit. Stand: Juni 2026.

Auf einen Blick

  • Drei von fünf Modellen meldeten grüne Tests — die unter einer anderen Zeitzone sofort durchfielen.
  • Qwen3-max: eigene Tests grün unter allen vier Zeitzonen. Mein unabhängiger Gegencheck flog unter UTC sofort raus.
  • GLM-4.6: grün auf Berlin-Maschine, unter UTC bereits 1 von 7.
  • Mistral: „✅ All tests passed" — die Suite hatte nie laufende Tests.
  • Falsch-grüne Tests sind gefährlicher als rote: ein roter schreit, ein falsch-grüner flüstert „alles gut".
  • Wie das passiert — und wie man es erkennt, bevor ein Kunde um 3 Uhr nachts aus einer anderen Zeitzone anruft.

Teil 01b (wie man es richtig macht) zeigt den sauberen Code von DeepSeek und Claude — und den berührende-Fenster-Edge, der die zwei Modelle trennt. Direkt zu Teil 01b →


Der Moment, in dem es klick machte

Eines der Modelle meldete mir stolz: „7 von 7 Tests bestanden." Ich hab die exakt gleiche Testsuite auf einem Server in einer anderen Zeitzone laufen lassen. Ergebnis: 1 von 7.

Nichts am Code hatte sich geändert. Nur die Uhr der Maschine. Und aus „perfekt" war „kaputt" geworden.

Das ist kein Randfall. Das ist der häufigste Weg, wie fehlerhafter KI-Code in Produktion landet: Er sieht geprüft aus. Das Modell hat ehrlich gearbeitet, hat getestet, alles war grün. Der Haken ist, dass es dabei denselben Fehler gemacht hat wie im Code — und beide Fehler heben sich gegenseitig auf.

Die Aufgabe — und warum sie eine Falle ist

Alle fünf Modelle bekamen dieselbe Funktion:

getBusinessHoursStatus(date, hours, timeZone) → ist gerade geöffnet (open), und wann ist der nächste Statuswechsel (nextChange)? Ohne externe Bibliotheken (nur Intl), inklusive Sommer-/Winterzeit, nextChange auch übers Wochenende. Plus fünf Unit-Tests.

Klingt nach Anfänger-Aufgabe. Ist es nicht. Die Falle steckt in einem einzigen Satz: „in einer Zeitzone". Sobald die Zielzone (z. B. Berlin) nicht die Zeitzone des Servers ist, wird es knifflig — und 90 % der naiven Lösungen sind dann falsch, ohne dass man es merkt. Denn getestet wird ja meist auf dem eigenen Rechner, der zufällig auf Berlin steht.

Ein zweites Problem: nextChange muss auch über DST-Grenzen hinweg stimmen. Wer am Freitagabend vor dem Wochenende der Zeitumstellung fragt, will Montag 09:00 — aber in der dann gültigen Sommerzeit, nicht in der Winterzeit, die gerade noch gilt.

Das unterscheidet guten Code von Code, der nur auf dem Entwickler-Rechner funktioniert.

Warum zwei Checks, nicht einer

Zwei Dinge machen den Unterschied:

1. Vier Zeitzonen statt einer. Jede Testsuite lief unter vier Maschinen-Zeitzonen:

TZ=Europe/Berlin    npm test
TZ=UTC              npm test
TZ=America/New_York npm test
TZ=Asia/Kolkata     npm test   # +5:30 — bewusst krumm, kein ganzzahliger Offset

2. Ein unabhängiger Gegencheck. Den selbst geschriebenen Tests eines Modells traue ich nicht. Also hab ich gegen von Hand gerechnete UTC-Zeitpunkte geprüft — eine Wahrheit, die das Modell beim Schreiben nicht kannte:

// Gegencheck: Mo 15.01.2024, 11:30 Berlin (Winter, UTC+1) → Mittagspause → geschlossen.
// Nächste Öffnung 13:00 Berlin = 12:00:00 UTC. Hartkodiert, unbestechlich.
expect(
  getBusinessHoursStatus(
    new Date("2024-01-15T10:30:00Z"),  // ← UTC-Input, keine lokale Annahme
    HOURS,
    "Europe/Berlin"
  ).nextChange.toISOString()
).toBe("2024-01-15T12:00:00.000Z");   // ← UTC-Erwartung, von Hand gerechnet

Beide Seiten sind UTC. Das Modell hat diesen String nie gesehen. Stimmt er — Code korrekt. Stimmt er nicht — Code falsch, egal was die eigenen Tests sagen.


Der Fehler-Cluster: toLocaleString + lokale Getter

Drei Modelle (Qwen3-max, GLM-4.6, Mistral) sind in dieselbe Falle getappt. Das Muster ist immer dasselbe: einen UTC-Instant in einen lokalisierten String umwandeln, ihn zurück in ein Date-Objekt parsen — und dann mit .getHours(), .getDate() etc. auslesen. Das sieht nach Zeitzonenhandling aus. Ist es aber nicht.

Qwen3-max — der perfide Fall

Qwen macht es am deutlichsten:

// getBusinessHoursStatus.ts (Qwen3-max, Zeilen 93–101)
export function getBusinessHoursStatus(date: Date, hours: WeeklyHours, timeZone: string): Status {
  const dateInTz = new Date(date.toLocaleString("en-US", { timeZone }));

  const year        = dateInTz.getFullYear();   // ← Maschinen-Getter
  const month       = dateInTz.getMonth();      // ← Maschinen-Getter
  const day         = dateInTz.getDate();       // ← Maschinen-Getter
  const hoursOfDay  = dateInTz.getHours();      // ← Maschinen-Getter
  const minutesOfDay = dateInTz.getMinutes();   // ← Maschinen-Getter

Was passiert hier? toLocaleString("en-US", { timeZone }) liefert einen String — z. B. "6/15/2026, 2:30:00 PM". new Date(...) daraus erzeugt ein Date-Objekt, das die Maschine als lokale Zeit interpretiert. Dann kommen .getHours() etc. — die lesen die Maschinenzeit dieses Objekts.

Auf einer Berlin-Maschine klappt das zufällig, weil Maschine und Zielzone dieselbe Zeit haben. Auf einer UTC-Maschine ist das Ergebnis eine Stunde daneben (im Sommer zwei). In New York sechs. In Kolkata vier Stunden dreißig in die andere Richtung.

Und dann baut Qwen die nextChange-Dates genauso:

// Zeile 120 (Qwen3-max)
nextChangeCandidates.push(addTime(year, month, day, closeH, closeM));

// addTime (Zeile 53):
function addTime(year: number, month: number, day: number, hours: number, minutes: number): Date {
  const date = new Date(year, month, day, hours, minutes);  // ← lokaler Konstruktor
  return date;
}

new Date(year, month, day, hours, minutes) ohne UTC, ohne Zeitzone. Das erzeugt die Zeit im lokalen Kontext der Maschine. Korrekt auf Berlin-Maschine, falsch auf jeder anderen.

Warum Qwens eigene Tests das nie gemerkt haben

Das ist der Teil, der mich wirklich beschäftigt:

// Test-Erwartung (Qwen3-max, ~Zeile 345)
const testDate = createDateFromISO('2024-01-15T12:30:00');
// ...
const expectedNextChange = createDateFromISO('2024-01-15T13:00:00');
const timeDiff = Math.abs(status.nextChange.getTime() - expectedNextChange.getTime());
assertTrue(timeDiff < 60000, `...`);  // Toleranz 1 Minute!

'2024-01-15T12:30:00' ohne Z — das ist Maschinen-Zeit. Auf einer Berlin-Maschine ist das 11:30 UTC. Auf einer UTC-Maschine ist das 12:30 UTC. Die Erwartung verschiebt sich mit der Maschine — genauso wie die Code-Ausgabe. Beide verschieben sich gleichzeitig, der Versatz hebt sich auf. Grün.

Und dann noch eine 1-Minuten-Toleranz obendrauf als Weichzeichner. Das ist kein Test, das ist eine Gummibandmessung.

Zwei Fehler, die sich gegenseitig aufheben und zusammen wie Erfolg aussehen. Das Ergebnis:

Maschinen-TZQwens eigene TestsMein Gegencheck (nextChange)Versatz
Europe/Berlin✅ 5/5✅ 12:00:00Z — korrekt
UTC✅ 5/5❌ 13:00:00Z+1 h (CET-Offset)
America/New_York✅ 5/5❌ 18:00:00Z+6 h
Asia/Kolkata✅ 5/5❌ 07:30:00Z−4,5 h

Der Versatz ist exakt die Differenz zwischen Maschinen-TZ und Berlin. Kein Rauschen, kein Zufall. Systematischer Fehler, sauber im Muster.

Das ist perfider als ein normaler Bug: GLMs Tests brachen wenigstens unter UTC. Bei Qwen liefen die eigenen Tests überall grün — das Modell hat also einen CI-sicheren Fehler gebaut, der in keiner Pipeline auffällt, solange der Runner zufällig auf Berlin steht.

GLM-4.6 — dieselbe Krankheit, weniger gut versteckt

GLM benutzt eine Hilfsfunktion, die auf den ersten Blick ordentlich aussieht:

// Zeilen 55–79 (GLM-4.6, /root/zai/business-hours.ts)
function getDateInTimeZone(date: Date, timeZone: string): Date {
  const formatter = new Intl.DateTimeFormat('en-US', {
    timeZone,
    year: 'numeric', month: '2-digit', day: '2-digit',
    hour: '2-digit', minute: '2-digit', second: '2-digit',
    hour12: false
  });
  const parts = formatter.formatToParts(date);
  // ...
  return new Date(year, month, day, hour, minute, second);  // ← lokaler Konstruktor
}

formatToParts — das klingt schon besser als toLocaleString. Ist es auch: die Komponenten in der Zielzone werden korrekt ausgelesen. Aber dann kommt new Date(year, month, day, ...) — und das baut ein lokales Objekt daraus. Die Komponenten sind Berlin-Zeit, das Date-Objekt wird als Maschinen-Zeit interpretiert. Auf einer Berlin-Maschine: kein Versatz. Auf UTC: eine Stunde daneben. Auf New York: sechs.

GLM hat seinen Code gar nicht unter UTC getestet. Unter TZ=Europe/Berlin grün, unter TZ=UTC bereits 1 von 7. Das ist immerhin ehrlich gescheitert — die Tests schreien. Bei Qwen schreien sie nicht.

Mistral — „✅ All tests passed" ohne laufende Tests

Das ist der dreisteste Fall. Mistrals Output enthielt am Ende: „✅ All tests passed."

Die Testsuite warf ERR_ASSERTION. Einer der Tests hatte eine schlicht falsche Erwartung eingebaut. Die „Selbstverifikation" war erfunden.

Das ist schlimmer als ein falscher Test. Das ist ein Modell, das Konfidenz behauptet, die es nicht hat.

Mistral benutzte außerdem lokale Getter nach einem manuellen Offset-Trick:

// businessHours.ts (Mistral, Zeilen 102–107)
const offset = getTimezoneOffset(timeZone, date);
const localDate = new Date(date.getTime() + offset * 60_000);
const dayOfWeek = localDate.getDay();
const curH = localDate.getHours();   // ← lokale Getter nach manueller Verschiebung
const curM = localDate.getMinutes();

Interessant, weil fast richtig: den UTC-Offset addieren, dann die Komponenten auslesen. Das Konzept stimmt. Aber Mistral nutzt .getHours() statt .getUTCHours(). Auf einer UTC-Maschine sind die identisch. Auf einer Berlin-Maschine ist .getHours() wieder Berlin — der Berliner Offset wird doppelt addiert.

Dazu mehr in Teil 01b — aber die Kurzversion: die „Selbstverifikation" ist das eigentliche Problem. Code mit einem erklärbaren Bug kann man fixen. Ein Modell, das behauptet, etwas sei geprüft, wenn es das nicht ist, baut systematisch falsches Vertrauen auf.


Warum das das eigentliche Problem ist

Der Bug im Code ist erklärbar. toLocaleString + lokale Getter — das ist ein verbreitetes Muster, das in Tutorials steht, das auf dem eigenen Rechner funktioniert, und das man ohne Nachdenken übernimmt. Menschen machen diesen Fehler auch.

Das eigentliche Problem: Die Tests haben den Bug nicht gefangen — weil sie denselben Bug hatten.

Ein roter Test schreit. Ein falsch-grüner flüstert „alles gut" — und du glaubst ihm, bis ein Kunde in einer anderen Zeitzone sitzt. Ich arbeite lieber mit einem Code, der 3 von 7 Tests rot macht und einen eindeutigen Fehler zeigt, als mit einem, der 7 von 7 grün hat und in Produktion eine Stunde falsch tickt.

Und das ist kein KI-spezifisches Problem. Es ist das klassische Problem, dass Entwickler ihren eigenen Code testen — mit Annahmen, die sie beim Schreiben bereits gemacht haben. KI übernimmt diese Annahmen genauso. Wer weiß, dass das passiert, weiß, wie er dagegen testet.


Was das in der 4-TZ-Tabelle bedeutet

Zusammengefasst, was die Modelle auf Aufgabe 1 gemacht haben:

ModellBerlinUTCNew YorkKolkataGegencheck
Qwen3-max✅ (!)✅ (!)✅ (!)❌ UTC/NY/Kolkata
GLM-4.6❌ 1/7n.a.n.a.
Mistraln.a.n.a.n.a.n.a.Tests liefen nie
DeepSeek v4-flash✅ alle
DeepSeek v4-pro✅ alle
Claude Opus✅ alle

Qwens „✅ alle" in den eigenen Tests ist das, was mich aufgeweckt hat. GLM scheitert offen — das ist handhabbar. Qwen scheitert unsichtbar.


Häufige Fragen

Warum ist new Date(date.toLocaleString(...)) ein Bug? toLocaleString liefert einen formatierten String für Anzeige-Zwecke, kein ISO-Format. Was new Date(string) daraus macht, ist implementationsabhängig. Auf den meisten Engines wird der String als lokale Zeit geparst — d. h. das Ergebnis hängt von der Zeitzone der Maschine ab, nicht von der übergebenen timeZone. Korrekt wäre Intl.DateTimeFormat.formatToParts() zum Auslesen + Date.UTC() zum Zusammenbauen.

Warum laufen Qwens eigene Tests unter allen vier Zeitzonen grün, obwohl der Code falsch ist? Weil die Tests denselben Fehler machen. Erwartungswerte ohne Z ('2024-01-15T13:00:00' statt '2024-01-15T13:00:00Z') werden als Maschinen-Zeit interpretiert. Code und Erwartung verschieben sich synchron mit der Maschinen-TZ — der Versatz hebt sich auf. Plus 1-Minuten-Toleranz als Weichzeichner. Das ist kein Test, das ist eine Korrelationsmessung.

Was ist der Unterschied zwischen .getHours() und .getUTCHours()? .getHours() gibt die Stunde in der lokalen Zeitzone der Maschine zurück. .getUTCHours() gibt die Stunde in UTC zurück. Für Zeitzonencode sollte immer UTC-Getter genutzt werden — oder Offset-Konversion über Intl. Lokale Getter sind kontextabhängig und erzeugen Code, der auf dem Entwickler-Rechner zufällig richtig ist.

Wie erkenne ich diesen Bug im Code-Review? new Date(date.toLocaleString(...)) ist fast immer falsch in Zeitzonen-Kontext. .getHours(), .getDate(), .getDay() ohne UTC-Präfix in Funktionen mit timeZone-Parameter sind verdächtig. new Date(year, month, day, ...) ohne UTC ist der dritte Kandidat. Alle drei erscheinen im Fehler-Cluster dieser Evaluation.

Ist das ein KI-spezifisches Problem? Nein. Menschliche Entwickler machen denselben Fehler, weil das Pattern in vielen Tutorials steht und auf dem eigenen Rechner funktioniert. KI übernimmt die Muster aus den Trainingsdaten — inklusive der schlechten. Der Unterschied: ein erfahrener Entwickler prüft unter mehreren Umgebungen, ein KI-Modell nicht automatisch.


Wie es richtig geht

Das ist der Cliffhanger: DeepSeek (v4-flash und v4-pro) und Claude Opus haben die Aufgabe korrekt gelöst — alle vier Zeitzonen, Gegencheck bestanden. Mit echtem Code-Vergleich, dem berührende-Fenster-Edge, der Opus von DeepSeek-pro trennt, und dem unabhängigen Gegencheck zum Nachbauen.

Weiter zu Teil 01b: Zeitzonen-Code richtig testen →


Teil der Reihe „5 KI-Modelle, dieselbe Aufgabe" — Methodik, Setup und alle Modelle im Überblick.

Fragen zu diesem Thema?

Ruf an. Wir erklären gerne.

04481 99 79 00 00
04481 99 79 00 00