feat: verworfene Antworten mitzählen, langsamen Anbieter nicht festhalten
- discarded_completions: an LLM_MAX_TOKENS abgeschnittene oder am Schema gescheiterte Antworten sind bezahlt und zählen jetzt als Stufe „<Stufe> (verworfen)“ in Kosten, Tokens und Aufrufen (wagner_whitin_dijkstra: ~32.000 Tokens fehlten). - OPENROUTER_SWITCH_BELOW (Default = OPENROUTER_MIN_THROUGHPUT, 75): liefert ein Aufruf ab 1000 Tokens weniger Tokens/s, wird der Anbieter per provider.ignore für den Rest des Laufs gemieden (höchstens 3) und eine neue session_id vergeben; „No endpoints found“ hebt die Sperrliste auf. Anlass: Sticky Routing hielt einen Lauf 1575 s bei ~65 Tokens/s (Median über 1012 Aufrufe: 156). provider.ignore mit echtem Aufruf geprüft. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
20ac3f14d7
commit
6890c0f272
1 changed files with 181 additions and 10 deletions
86
core/llm.py
86
core/llm.py
|
|
@ -35,6 +35,14 @@ OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"
|
|||
# Es ist eine Präferenz, kein harter Filter: gibt es keinen so schnellen Anbieter mit JSON-Schema,
|
||||
# leitet OpenRouter trotzdem weiter (geprüft mit qwen3-coder -> SiliconFlow, ~25 Tokens/s).
|
||||
OPENROUTER_MIN_THROUGHPUT = int(os.environ.get("OPENROUTER_MIN_THROUGHPUT", "75")) # Tokens/s (p50)
|
||||
# Sticky Routing (session_id) hält einen Lauf bei einem Anbieter – auch bei einem langsamen. Beobachtet an
|
||||
# wagner_whitin_dijkstra (18.09.2026): alle Aufrufe bei AtlasCloud mit ~65 Tokens/s, 1575 s statt ~150 s. Liefert
|
||||
# ein Aufruf mit mindestens SWITCH_MIN_TOKENS weniger als diese Rate, wird der Anbieter für den Rest des Laufs
|
||||
# gemieden und eine neue session_id vergeben. Gemessen an 1012 Aufrufen: Median 156 Tokens/s, 3,8 % unter 75.
|
||||
# 0 schaltet das ab. Der Cache der Folgeaufrufe geht dabei verloren – er bringt nur bei wiederholten Stufen etwas.
|
||||
OPENROUTER_SWITCH_BELOW = int(os.environ.get("OPENROUTER_SWITCH_BELOW", str(OPENROUTER_MIN_THROUGHPUT)))
|
||||
SWITCH_MIN_TOKENS = 1000 # darunter misst die Rate vor allem die Wartezeit bis zum ersten Token
|
||||
MAX_IGNORED_PROVIDERS = 3 # sonst bliebe womöglich kein Anbieter mit JSON-Schema übrig
|
||||
LLM_TIMEOUT = int(os.environ.get("LLM_TIMEOUT", "420")) # Sekunden je Aufruf
|
||||
# Ohne Vorgabe schneiden manche OpenRouter-Anbieter die Antwort ab (beobachtet: abgebrochene
|
||||
# Modellspezifikation in einer Korrekturrunde). Großzügig setzen; bezahlt werden nur erzeugte Tokens.
|
||||
|
|
@ -64,6 +72,25 @@ def is_transient_error(message: str) -> bool:
|
|||
return not is_fatal_error(message) and any(marker in message for marker in TRANSIENT_MARKERS)
|
||||
|
||||
|
||||
def discarded_completions(exc: BaseException) -> list:
|
||||
"""Antworten, die ein Fehler verworfen hat – bezahlt, aber ohne Ergebnis.
|
||||
|
||||
instructor hängt sie an: `IncompleteOutputException.last_completion` (an LLM_MAX_TOKENS abgeschnitten) und
|
||||
`InstructorRetryException.failed_attempts[*].completion` (Schema verletzt). Beobachtet an wagner_whitin_dijkstra
|
||||
(18.09.2026): eine abgeschnittene Solver-Antwort mit ~32.000 Tokens fehlte in Kosten und Tokenzählung.
|
||||
"""
|
||||
gefunden = []
|
||||
for fehler in (exc, exc.__cause__):
|
||||
if fehler is None:
|
||||
continue
|
||||
kandidaten = [getattr(fehler, "last_completion", None)]
|
||||
kandidaten += [getattr(a, "completion", None) for a in getattr(fehler, "failed_attempts", None) or []]
|
||||
for c in kandidaten:
|
||||
if c is not None and all(c is not g for g in gefunden):
|
||||
gefunden.append(c)
|
||||
return gefunden
|
||||
|
||||
|
||||
def provider_for(model: str) -> str:
|
||||
return "openrouter" if "/" in model else "gemini"
|
||||
|
||||
|
|
@ -107,37 +134,70 @@ class LLMSession:
|
|||
# Routing), dessen Prompt-Cache wiederholte Präfixe billiger liest. Ohne sie wechselten die Anbieter
|
||||
# je Aufruf (beobachtet: Modal, Together, Venice in einem Lauf) und kein Cache konnte greifen.
|
||||
self.session_id = uuid.uuid4().hex
|
||||
self.ignored_providers = [] # in diesem Lauf zu langsame Anbieter (siehe OPENROUTER_SWITCH_BELOW)
|
||||
|
||||
def overrides(self) -> dict:
|
||||
"""Stufen mit abweichendem Modell – für die Fortschrittsausgabe."""
|
||||
return {step: m for step, m in self.step_models.items() if m != self.model}
|
||||
|
||||
def _kwargs(self, model: str, response_model, messages) -> dict:
|
||||
kwargs = {"model": model, "response_model": response_model, "messages": messages}
|
||||
if provider_for(model) == "openrouter":
|
||||
kwargs["max_tokens"] = LLM_MAX_TOKENS
|
||||
# nur Anbieter wählen, die strukturierte Ausgabe (response_format) wirklich unterstützen
|
||||
anbieter = {"require_parameters": True, "preferred_min_throughput": {"p50": OPENROUTER_MIN_THROUGHPUT}}
|
||||
if self.ignored_providers:
|
||||
anbieter["ignore"] = list(self.ignored_providers)
|
||||
kwargs["extra_body"] = {"provider": anbieter, "session_id": self.session_id}
|
||||
if REASONING_EFFORT:
|
||||
kwargs["extra_body"]["reasoning"] = {"effort": REASONING_EFFORT}
|
||||
return kwargs
|
||||
|
||||
def create(self, response_model, messages, step: str = "unbenannt"):
|
||||
model = self.step_models.get(step, self.model)
|
||||
provider = provider_for(model)
|
||||
kwargs = {"model": model, "response_model": response_model, "messages": messages}
|
||||
if provider == "openrouter":
|
||||
kwargs["max_tokens"] = LLM_MAX_TOKENS
|
||||
# nur Anbieter wählen, die strukturierte Ausgabe (response_format) wirklich unterstützen
|
||||
kwargs["extra_body"] = {"provider": {
|
||||
"require_parameters": True,
|
||||
"preferred_min_throughput": {"p50": OPENROUTER_MIN_THROUGHPUT},
|
||||
}, "session_id": self.session_id}
|
||||
if REASONING_EFFORT:
|
||||
kwargs["extra_body"]["reasoning"] = {"effort": REASONING_EFFORT}
|
||||
|
||||
for wait in (*RETRY_WAITS, None):
|
||||
waits = list(RETRY_WAITS)
|
||||
while True:
|
||||
kwargs = self._kwargs(model, response_model, messages)
|
||||
started = time.monotonic()
|
||||
try:
|
||||
result, completion = self._clients[provider].chat.completions.create_with_completion(**kwargs)
|
||||
self._record(completion, step, time.monotonic() - started, model)
|
||||
if provider == "openrouter":
|
||||
self._avoid_if_slow()
|
||||
return result
|
||||
except Exception as exc:
|
||||
if wait is None or not is_transient_error(str(exc)):
|
||||
# Verworfene Antworten sind bezahlt – sonst fehlen sie in Kosten, Tokens und Aufrufzahl
|
||||
for verworfen in discarded_completions(exc):
|
||||
self._record(verworfen, f"{step} (verworfen)", time.monotonic() - started, model)
|
||||
if "No endpoints found" in str(exc) and self.ignored_providers:
|
||||
# die Sperrliste hat alle passenden Anbieter ausgeschlossen – lieber langsam als gar nicht
|
||||
print(f"Kein Anbieter ohne {self.ignored_providers} verfügbar – Sperrliste aufgehoben.",
|
||||
file=sys.stderr)
|
||||
self.ignored_providers.clear()
|
||||
continue
|
||||
if not waits or not is_transient_error(str(exc)):
|
||||
raise
|
||||
wait = waits.pop(0)
|
||||
print(f"Vorübergehender LLM-Fehler – neuer Versuch in {wait} s: {str(exc)[:120]}", file=sys.stderr)
|
||||
time.sleep(wait)
|
||||
|
||||
def _avoid_if_slow(self) -> None:
|
||||
"""War der letzte Aufruf zu langsam, den Anbieter für den Rest des Laufs meiden (siehe OPENROUTER_SWITCH_BELOW)."""
|
||||
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):
|
||||
return
|
||||
rate = letzter.completion_tokens / letzter.seconds
|
||||
if rate >= OPENROUTER_SWITCH_BELOW:
|
||||
return
|
||||
self.ignored_providers.append(letzter.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)
|
||||
|
||||
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,
|
||||
self.usage.cached_tokens)
|
||||
|
|
|
|||
Loading…
Reference in a new issue