From 6b2b2c6466fb61163a0899c03fbc11b16818cb0e Mon Sep 17 00:00:00 2001 From: dschlueter Date: Fri, 18 Sep 2026 19:24:23 +0200 Subject: [PATCH] =?UTF-8?q?fix:=20abgeschnittene=20Antworten=20meiden=20de?= =?UTF-8?q?n=20Anbieter;=20LLM-Ausfall=20macht=20unzuverl=C3=A4ssig=20stat?= =?UTF-8?q?t=20abzust=C3=BCrzen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- BEDIENUNGSANLEITUNG.md | 2 ++ TextToModel.py | 60 +++++++++++++++++++++++++++++++++++++----- benchmark/benchmark.py | 12 +++++---- core/llm.py | 47 +++++++++++++++++++++++++++------ reporting/report.py | 13 ++++++--- tests/test_llm.py | 57 +++++++++++++++++++++++++++++++++++++++ tests/test_pipeline.py | 52 +++++++++++++++++++++++++++++++++++- 7 files changed, 218 insertions(+), 25 deletions(-) diff --git a/BEDIENUNGSANLEITUNG.md b/BEDIENUNGSANLEITUNG.md index 33adecd..8463f57 100644 --- a/BEDIENUNGSANLEITUNG.md +++ b/BEDIENUNGSANLEITUNG.md @@ -518,6 +518,8 @@ Mit dem Gemini-Gratiskontingent ist der Benchmark praktisch nicht durchführbar; |---|---|---| | `KeyError: 'OPENROUTER_API_KEY'` | Schlüssel nicht gesetzt | `export OPENROUTER_API_KEY=…` in derselben Shell | | `Error code: 401` | Schlüssel ungültig | Schlüssel prüfen | +| `Anbieter … schnitt die Antwort ab – … gemieden` | das Modell hat die Tokengrenze (`LLM_MAX_TOKENS`) mit Nachdenken aufgebraucht | nichts – der Aufruf wird sofort bei einem anderen Anbieter wiederholt | +| `LLM-Aufruf '…' nach allen Wiederholungen gescheitert` (Exit-Code 2) | viermal Überlastung/Abschneiden hintereinander | später erneut; war das Modell schon fertig, `--von solver ERGEBNIS.JSON` | | `Error code: 402` | Guthaben bei OpenRouter aufgebraucht | Guthaben aufladen | | `RESOURCE_EXHAUSTED` | Gemini-Kontingent erschöpft | später erneut oder OpenRouter-Modell | | `Vorübergehender LLM-Fehler – neuer Versuch in … s` | Überlastung, Ratelimit, Zeitüberschreitung | nichts tun; es wird automatisch bis zu dreimal wiederholt | diff --git a/TextToModel.py b/TextToModel.py index 0b6fa2c..7ef7ed2 100644 --- a/TextToModel.py +++ b/TextToModel.py @@ -1,3 +1,5 @@ +#!/usr/bin/env python3 + import argparse import json import os @@ -8,8 +10,9 @@ from typing import List from checks.model_checks import check_figure_readings, check_spec, check_understanding from checks.review import needs_work, review_assumptions, revision_reasons, select_best from core import llm -from core.llm import DEFAULT_MODEL, LLMSession +from core.llm import DEFAULT_MODEL, LLMSession, LLMUnavailable from core.model_schema import ( + SolverRun, SolverStatus, CheckSeverity, OptimizationModelSpecification, @@ -157,6 +160,18 @@ def _recheck_infeasible(session, task: str, model: str, result: PipelineResult, return result log(f"[{model}] Solver meldet '{status}' – Gegenprobe mit unabhängiger Modellierung ...") + try: + return _run_recheck(session, task, model, result, status, max_revisions, solver_retries, + data_files, data_prompt) + except LLMUnavailable as exc: + log(f" Gegenprobe abgebrochen: {exc}") + result.reliable = False + result.reliability_issues.append(f"Solver meldet '{status}'; die Gegenprobe scheiterte am LLM ({exc.step})") + return result + + +def _run_recheck(session, task: str, model: str, result: PipelineResult, status: str, max_revisions: int, + solver_retries: int, data_files, data_prompt: str) -> PipelineResult: zweiter = _model_attempt(session, task, max_revisions, f"{model} Gegenprobe", hint=INFEASIBLE_HINT.format(status=status), understanding=result.understanding, data_prompt=data_prompt) @@ -201,9 +216,17 @@ def _solver_stage(session, task: str, result: PipelineResult, model: str, return result log(f"[{model}] Erzeuge und starte Solver-Code ({spec.target_stack.value}) ...") - result.solver_code, result.solver_run = solve(session, task, spec, retries=solver_retries, log=log, - understanding=result.understanding, - data_files=data_files, data_prompt=data_prompt) + try: + result.solver_code, result.solver_run = solve(session, task, spec, retries=solver_retries, log=log, + understanding=result.understanding, + data_files=data_files, data_prompt=data_prompt) + except LLMUnavailable as exc: + # Modell und Prüfbericht bleiben erhalten – `--von solver` kann genau hier weitermachen + result.solver_run = SolverRun(status=SolverStatus.ERROR, error=str(exc)) + result.reliable = False + result.reliability_issues.append(f"Solver-Fehler: {exc}") + log(f"ERGEBNIS UNZUVERLÄSSIG – {exc}") + return result _assess_run(result) if result.solver_run.status in (SolverStatus.INFEASIBLE, SolverStatus.UNBOUNDED): result = _recheck_infeasible(session, task, model, result, max_revisions, solver_retries, @@ -270,6 +293,20 @@ def run_pipeline( session = session or LLMSession(model) # Abbildungen (docling: Base64 oder Bilddatei) gehen nur an die Verstehensstufe; ab dann zählt ihre Übertragung task, figures = split_figures(task, task_dir) + try: + return _run_pipeline(session, task, figures, model, max_revisions, solve_model, solver_retries, + max_restarts, data_files) + except LLMUnavailable as exc: + # Vor der Solver-Stufe gibt es nichts Verwertbares; statt Absturz ein ehrliches, bezahltes Nicht-Ergebnis + log(f"ERGEBNIS UNZUVERLÄSSIG – {exc}") + result = PipelineResult(task=task, figures=figures, reliable=False, + reliability_issues=[f"LLM-Fehler: {exc}"]) + _log_usage(session, result) + return result + + +def _run_pipeline(session, task: str, figures, model: str, max_revisions: int, solve_model: bool, + solver_retries: int, max_restarts: int, data_files) -> PipelineResult: if figures: log(f"{len(figures)} Abbildung(en) in der Aufgabe – werden in der Verstehensstufe in Text übertragen.") profile = data_profile.profile_files(data_files) @@ -367,8 +404,15 @@ def continue_pipeline( problems = _unresolved({"review": stored.review, "issues": checks}) update = {"checks": checks} else: - attempt = _model_attempt(session, task, max_revisions, model, spec=stored.specification, - understanding=understanding, data_prompt=data_prompt) + try: + attempt = _model_attempt(session, task, max_revisions, model, spec=stored.specification, + understanding=understanding, data_prompt=data_prompt) + except LLMUnavailable as exc: + log(f"ERGEBNIS UNZUVERLÄSSIG – {exc}") + result = stored.model_copy(update={"solver_code": None, "solver_run": None, "reliable": False, + "reliability_issues": [f"LLM-Fehler: {exc}"]}) + _log_usage(session, result) + return result problems = _unresolved(attempt) update = {"specification": attempt["spec"], "review": attempt["review"], "revisions": attempt["revisions"], "selected_revision": attempt["selected"], "restarts": 0, "checks": attempt["issues"]} @@ -590,7 +634,9 @@ def summary(result: PipelineResult) -> str: return "Abgelehnt – " + "; ".join(result.reliability_issues) zeilen = [] run = result.solver_run - if run is None: + if result.specification is None: + zeilen.append("Kein Modell entstanden.") + elif run is None: spec = result.specification zeilen.append(f"Modell: {spec.problem_class.value} → {spec.target_stack.value} (kein Solver-Lauf)") else: diff --git a/benchmark/benchmark.py b/benchmark/benchmark.py index 751600e..904eb60 100644 --- a/benchmark/benchmark.py +++ b/benchmark/benchmark.py @@ -105,8 +105,9 @@ def observed_behaviors(result) -> set: gezeigt = set() if result.rejected: return {"rejected"} - # Ein Absturz ist kein Eingeständnis: nur Befunde außer „Solver-Fehler“ zählen als ehrlich unzuverlässig - if not result.reliable and any(not i.startswith("Solver-Fehler: ") for i in result.reliability_issues): + # Ein Absturz ist kein Eingeständnis: nur Befunde außer „Solver-Fehler“/„LLM-Fehler“ zählen als ehrlich unzuverlässig + if not result.reliable and any(not i.startswith(("Solver-Fehler: ", "LLM-Fehler: ")) + for i in result.reliability_issues): gezeigt.add("unreliable") if result.solver_code is not None and result.solver_code.data_assumptions: gezeigt.add("assumed_data") @@ -145,13 +146,14 @@ def run_case(case: dict, model: str, run_no: int) -> dict: spec, solver_run = result.specification, result.solver_run ehrlich = behavior_matches(result, expected) if "behavior" in expected else None - if spec is None: # abgelehnt: keine Optimierungsaufgabe erkannt + if spec is None: # abgelehnt (keine Optimierungsaufgabe) oder LLM vor dem Modell gescheitert fragen = len(result.understanding.sub_questions) if result.understanding else 0 # Bei erwarteter Ablehnung gibt es kein Routing zu bewerten; sonst ist die Ablehnung ein Routing-Fehler - routing = None if ehrlich else False + routing = None if ehrlich or not result.rejected else False row.update(seconds=round(time.monotonic() - start), problem_class=None, target_stack=None, class_ok=routing, stack_ok=routing, check_errors=0, check_warnings=len(result.checks), - revisions=0, review_ok=None, data_assumptions=0, solver_status="abgelehnt", solver_attempts=0, + revisions=0, review_ok=None, data_assumptions=0, solver_status="abgelehnt" if result.rejected else "LLM-Fehler", + solver_attempts=0, objective_value=None, objective_ok=bool(ehrlich), questions=fragen, answered=0, answer_findings=0, task_ok=bool(ehrlich), behavior=sorted(observed_behaviors(result)), solver_error="; ".join(result.reliability_issues), reliable=False, restarts=0, diff --git a/core/llm.py b/core/llm.py index 601839c..726e59f 100644 --- a/core/llm.py +++ b/core/llm.py @@ -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, diff --git a/reporting/report.py b/reporting/report.py index 09756de..03da885 100644 --- a/reporting/report.py +++ b/reporting/report.py @@ -335,10 +335,15 @@ def _figure_blocks(result: PipelineResult) -> list: def _rejected_document(result: PipelineResult) -> list: - """Abgelehnte Aufgabe: Grund und Aufgabentext – kein Modell, keine Zahlen.""" - grund = result.understanding.reason if result.understanding else "; ".join(result.reliability_issues) - doc = [("h1", "Aufgabe abgelehnt"), - ("warn", f"Keine Optimierungsaufgabe – es wurde weder modelliert noch gerechnet. {grund}")] + """Abgelehnte Aufgabe oder abgebrochener Lauf: Grund und Aufgabentext – kein Modell, keine Zahlen.""" + if result.rejected: + grund = result.understanding.reason if result.understanding else "; ".join(result.reliability_issues) + doc = [("h1", "Aufgabe abgelehnt"), + ("warn", f"Keine Optimierungsaufgabe – es wurde weder modelliert noch gerechnet. {grund}")] + else: # LLM nach allen Wiederholungen gescheitert, bevor ein Modell entstand + doc = [("h1", "Lauf abgebrochen"), + ("warn", "Es ist kein Modell entstanden – es wurde nichts gerechnet. " + + "; ".join(result.reliability_issues))] if result.usage: cost = f", Kosten USD {result.usage.cost_usd:.4f}" if result.usage.cost_usd is not None else "" doc.append(("list", [f"Erstellt: {datetime.now():%d.%m.%Y %H:%M}", f"Modell: {result.usage.model}", diff --git a/tests/test_llm.py b/tests/test_llm.py index 76b16f0..6eff22a 100644 --- a/tests/test_llm.py +++ b/tests/test_llm.py @@ -298,3 +298,60 @@ def test_no_endpoints_left_lifts_the_ignore_list(): completions["openrouter"].create_with_completion = create_with_completion assert session.create(str, [{"role": "user", "content": "x"}], step="Prüfung") == "ok" assert gesendet == [["AtlasCloud", "Parasail"], None] and session.ignored_providers == [] + + +# --- Abschneiden am Tokenlimit: anderer Anbieter statt Warten, am Ende LLMUnavailable statt Absturz --- + +def _abgeschnitten(anbieter): + from instructor.core.exceptions import IncompleteOutputException + antwort = _antwort_mit_verbrauch(32000) + antwort.provider = anbieter + return IncompleteOutputException(last_completion=antwort) + + +def test_truncation_avoids_the_provider_and_retries_without_waiting(monkeypatch): + # gemessen am 18.09.2026: Venice 4 von 28 Aufrufen abgeschnitten, alle übrigen Anbieter fast nie + gewartet = [] + monkeypatch.setattr(llm.time, "sleep", gewartet.append) + session, completions = fake_session() + gesendet, versuche = [], iter([_abgeschnitten("Venice"), ("ok", _antwort_mit_verbrauch(9000))]) + + def create_with_completion(**kwargs): + gesendet.append((kwargs["extra_body"]["provider"].get("ignore"), kwargs["extra_body"]["session_id"])) + naechster = next(versuche) + if isinstance(naechster, Exception): + raise naechster + return naechster + + completions["openrouter"].create_with_completion = create_with_completion + assert session.create(str, [{"role": "user", "content": "x"}], step="Solver-Code") == "ok" + assert gewartet == [0] + assert gesendet[0] == (None, "lauf-1") and gesendet[1][0] == ["Venice"] and gesendet[1][1] != "lauf-1" + + +def test_exhausted_retries_raise_llm_unavailable(monkeypatch): + monkeypatch.setattr(llm.time, "sleep", lambda s: None) + session, completions = fake_session() + anbieter = iter(["Venice", "Makora", "Parasail", "Together"]) + + def create_with_completion(**kwargs): + raise _abgeschnitten(next(anbieter)) + + completions["openrouter"].create_with_completion = create_with_completion + with pytest.raises(llm.LLMUnavailable) as exc: + session.create(str, [{"role": "user", "content": "x"}], step="Extraktion") + assert exc.value.step == "Extraktion" + assert session.usage.calls == 4 # alle vier bezahlten Antworten gezählt + assert session.ignored_providers == ["Venice", "Makora", "Parasail"] # höchstens MAX_IGNORED_PROVIDERS + + +def test_fatal_errors_stay_unwrapped(): + session, completions = fake_session() + + def create_with_completion(**kwargs): + raise RuntimeError("Error code: 402 - {'error': {'message': 'Insufficient credits'}}") + + completions["openrouter"].create_with_completion = create_with_completion + with pytest.raises(RuntimeError) as exc: + session.create(str, [{"role": "user", "content": "x"}]) + assert not isinstance(exc.value, llm.LLMUnavailable) diff --git a/tests/test_pipeline.py b/tests/test_pipeline.py index bef177d..9a5864d 100644 --- a/tests/test_pipeline.py +++ b/tests/test_pipeline.py @@ -55,7 +55,10 @@ class FakeSession: warteschlange = self.queues[response_model] if response_model is TaskUnderstanding and not warteschlange: return EINFACHES_VERSTAENDNIS # Standard, solange ein Test nichts anderes vorgibt - return warteschlange.pop(0) + antwort = warteschlange.pop(0) + if isinstance(antwort, Exception): # gescheiterter Aufruf, z. B. LLMUnavailable + raise antwort + return antwort HALLUCINATED = make_spec(executive_summary="Produktionsplanung mit Drehen und Fräsen") # Fremdproblem @@ -853,3 +856,50 @@ def test_second_repair_knows_the_first_error(): reparaturen = [inhalt for stufe, inhalt in session.contents if stufe == "Solver-Reparatur"] assert "FRÜHERE FEHLVERSUCHE" not in reparaturen[0] # beim ersten gibt es noch keinen Verlauf assert "Fehler B: Typ" in reparaturen[1] and "Versuch 1: " in reparaturen[1] and "Fehler A: Index" in reparaturen[1] + + +# --- LLM nach allen Wiederholungen gescheitert: unzuverlässiges Ergebnis statt Absturz --- + +def _llm_weg(step): + from core.llm import LLMUnavailable + return LLMUnavailable(step, RuntimeError("The output is incomplete due to a max_tokens length limit.")) + + +def test_llm_failure_in_solver_stage_keeps_the_model(): + # beobachtet: hard_portfolio_kardinalitaet, viermal abgeschnitten ⇒ Absturz nach 1162 s, Kosten verloren + session = FakeSession([GOOD], [OK], [_llm_weg("Solver-Code")]) + result = run_pipeline("Aufgabe", session=session, max_revisions=0, max_restarts=0) + assert not result.reliable and result.specification is not None and result.review is not None + assert result.solver_run.status == SolverStatus.ERROR and result.solver_code is None + assert result.reliability_issues[0].startswith("Solver-Fehler: LLM-Aufruf 'Solver-Code'") + assert result.usage is not None + + +def test_llm_failure_before_the_model_is_a_paid_non_result(tmp_path): + from benchmark.benchmark import observed_behaviors + from reporting.report import write_report + session = FakeSession([_llm_weg("Extraktion")], []) + result = run_pipeline("Aufgabe", session=session) + assert not result.reliable and not result.rejected and result.specification is None + assert result.reliability_issues[0].startswith("LLM-Fehler: ") and result.usage.calls == 2 + assert observed_behaviors(result) == set() # ein Absturz ist kein ehrliches Scheitern + write_report(result, str(tmp_path / "r.md")) + text = (tmp_path / "r.md").read_text(encoding="utf-8") + assert "Lauf abgebrochen" in text and "abgelehnt" not in text + + +def test_benchmark_counts_an_llm_failure_as_failure_with_costs(monkeypatch): + from benchmark import benchmark + session = FakeSession([_llm_weg("Extraktion")], []) + monkeypatch.setattr(benchmark, "run_pipeline", lambda *a, **k: run_pipeline("Aufgabe", session=session)) + zeile = benchmark.run_case({"name": "t", "task": "Aufgabe", "expected": {"objective_value": 9, "tolerance": 1e-6}}, + "fake", 1) + assert zeile["solver_status"] == "LLM-Fehler" and zeile["llm_calls"] == 2 and "error" not in zeile + assert zeile["objective_ok"] is False and zeile["task_ok"] is False + + +def test_llm_failure_in_the_infeasible_recheck_keeps_the_first_result(): + session = FakeSession(specs=[GOOD, _llm_weg("Extraktion")], reviews=[OK], codes=[SolverCode(code=INFEASIBLE_CODE)]) + result = run_pipeline("Aufgabe", session=session, max_revisions=0, max_restarts=0) + assert result.solver_run.status == SolverStatus.INFEASIBLE and not result.infeasible_confirmed + assert not result.reliable and "Gegenprobe scheiterte am LLM" in result.reliability_issues[-1]