fix: abgeschnittene Antworten meiden den Anbieter; LLM-Ausfall macht unzuverlässig statt abzustürzen

Grundlinie 20260918-191400: 9 von 22 Läufen hatten Aufrufe, die an LLM_MAX_TOKENS abgeschnitten
wurden (Venice 4/28, Makora 2/43, übrige 0/54); hard_portfolio_kardinalitaet brach nach vier
Abschneidungen ab, Kosten gingen verloren.

- Abschneiden: sofort neu versuchen, Anbieter per provider.ignore meiden (_avoid_provider,
  gemeinsam mit „zu langsam“)
- nach allen Wiederholungen LLMUnavailable (Stufe + Ursache); fatale Fehler bleiben unverpackt
- Solver-Stufe behält Modell und Prüfbericht (Solver-Fehler), Gegenprobe behält erstes Ergebnis,
  davor Ergebnis ohne Spezifikation mit Kosten („LLM-Fehler“, Report „Lauf abgebrochen“)
- Benchmark: Status „LLM-Fehler“ statt ABBRUCH, zählt nicht als ehrliches Scheitern
- TextToModel.py: Shebang

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Dieter Schlüter 2026-09-18 19:24:23 +02:00
commit 6b2b2c6466

View file

@ -63,6 +63,25 @@ TRANSIENT_MARKERS = (
)
TRUNCATION_MARKERS = ("incomplete due to a max_tokens", "IncompleteOutputException")
class LLMUnavailable(RuntimeError):
"""Ein Aufruf ist nach allen Wiederholungen gescheitert (Überlastung, Abschneiden, Zeitüberschreitung).
Eigene Klasse, damit die Pipeline einen solchen Lauf als unzuverlässig meldet – mit Kosten –, statt abzustürzen.
Fatale Fehler (Key, Guthaben, Kontingent) bleiben unverpackt: dort soll der Benchmark stoppen.
"""
def __init__(self, step: str, cause: BaseException):
super().__init__(f"LLM-Aufruf '{step}' nach allen Wiederholungen gescheitert: {str(cause)[:300]}")
self.step = step
def is_truncation(message: str) -> bool:
return any(marker in message for marker in TRUNCATION_MARKERS)
def is_fatal_error(message: str) -> bool:
"""Kontingent erschöpft, Guthaben leer oder Key ungültig."""
return any(marker in message for marker in FATAL_MARKERS)
@ -176,10 +195,19 @@ class LLMSession:
file=sys.stderr)
self.ignored_providers.clear()
continue
if not waits or not is_transient_error(str(exc)):
if not is_transient_error(str(exc)):
raise
if not waits:
raise LLMUnavailable(step, exc) from exc
wait = waits.pop(0)
print(f"Vorübergehender LLM-Fehler – neuer Versuch in {wait} s: {str(exc)[:120]}", file=sys.stderr)
if is_truncation(str(exc)):
# Abschneiden liegt nicht an Überlastung – Warten hilft nicht, ein anderer Anbieter oft schon.
# Gemessen am 18.09.2026: Venice 4 von 28, Makora 2 von 43, alle übrigen 0 von 54 Aufrufen.
wait = 0
for verworfen in discarded_completions(exc)[-1:]:
self._avoid_provider(getattr(verworfen, "provider", None), "schnitt die Antwort ab")
print("Vorübergehender LLM-Fehler – neuer Versuch" + (f" in {wait} s" if wait else "")
+ f": {str(exc)[:120]}", file=sys.stderr)
time.sleep(wait)
def _avoid_if_slow(self) -> None:
@ -187,16 +215,19 @@ class LLMSession:
if OPENROUTER_SWITCH_BELOW <= 0 or not self.usage.call_records:
return
letzter = self.usage.call_records[-1]
if (not letzter.provider or letzter.completion_tokens < SWITCH_MIN_TOKENS or letzter.seconds <= 0
or letzter.provider in self.ignored_providers or len(self.ignored_providers) >= MAX_IGNORED_PROVIDERS):
if letzter.completion_tokens < SWITCH_MIN_TOKENS or letzter.seconds <= 0:
return
rate = letzter.completion_tokens / letzter.seconds
if rate >= OPENROUTER_SWITCH_BELOW:
if rate < OPENROUTER_SWITCH_BELOW:
self._avoid_provider(letzter.provider, f"zu langsam ({rate:.0f} Tokens/s < {OPENROUTER_SWITCH_BELOW})")
def _avoid_provider(self, provider: str | None, grund: str) -> None:
"""Anbieter für den Rest des Laufs meiden (`provider.ignore`), höchstens MAX_IGNORED_PROVIDERS."""
if not provider or provider in self.ignored_providers or len(self.ignored_providers) >= MAX_IGNORED_PROVIDERS:
return
self.ignored_providers.append(letzter.provider)
self.ignored_providers.append(provider)
self.session_id = uuid.uuid4().hex # Sticky Routing an den neuen Anbieter binden
print(f"Anbieter {letzter.provider} zu langsam ({rate:.0f} Tokens/s < {OPENROUTER_SWITCH_BELOW}) – "
f"für den Rest des Laufs gemieden.", file=sys.stderr)
print(f"Anbieter {provider} {grund} – für den Rest des Laufs gemieden.", file=sys.stderr)
def _record(self, completion, step: str = "unbenannt", seconds: float = 0.0, model: str = None) -> None:
before = (self.usage.completion_tokens, self.usage.reasoning_tokens, self.usage.prompt_tokens,