init
This commit is contained in:
238
backend/llm.py
Normal file
238
backend/llm.py
Normal file
@@ -0,0 +1,238 @@
|
||||
"""MiniMax-Client (R3/R9). Fehlerklassen strikt getrennt: Infra (429/Timeout)
|
||||
→ 3 Retries mit Backoff 8/16/32 s + Jitter, erschöpft → LaufPause (fail-closed).
|
||||
Inhalt (leer/kaputt) → begrenzte Restarts, dann None. cap (stop=max_tokens)
|
||||
→ SOFORT None, kein Retry (deterministisch — Aufrufer halbiert den Chunk).
|
||||
Ein 429 drosselt ALLE Calls (globaler Cooldown + adaptive Breite)."""
|
||||
import asyncio
|
||||
import random
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
import httpx
|
||||
|
||||
from . import config, db, skills
|
||||
from .ledger import BudgetErschoepft, budget_pruefen, log_call # noqa: F401
|
||||
|
||||
_INFRA_MARKER = ("429", "rate limit", "rate_limit", "timeout", "timed out",
|
||||
"connection", "network", "overloaded", "unavailable",
|
||||
'"status_code":1002', '"status_code":1041', '"status_code":2045')
|
||||
|
||||
|
||||
class LaufPause(Exception):
|
||||
"""Infrastruktur erschöpft — Lauf pausieren, nie fail-open weiterrechnen."""
|
||||
|
||||
|
||||
class ManuellePause(LaufPause):
|
||||
"""Nutzer hat pausiert — Wartende brechen sofort ab, Laufende laufen aus."""
|
||||
|
||||
|
||||
@dataclass
|
||||
class ApiErgebnis:
|
||||
rc: int
|
||||
text: str = ""
|
||||
err: str = ""
|
||||
tokens: dict = field(default_factory=dict)
|
||||
|
||||
|
||||
# ── globale Drossel (ein 429 bremst alle) ──
|
||||
_sem = asyncio.Semaphore(config.MAX_PARALLEL_LLM)
|
||||
_cooldown_bis = 0.0
|
||||
_breite = config.MAX_PARALLEL_LLM
|
||||
_inflight = 0
|
||||
_erfolge = 0
|
||||
|
||||
|
||||
def drossel_melden(sekunden: float) -> None:
|
||||
global _cooldown_bis, _breite, _erfolge
|
||||
_cooldown_bis = max(_cooldown_bis, time.monotonic() + sekunden)
|
||||
_breite = max(config.DROSSEL_MIN_BREITE, _breite // 2)
|
||||
_erfolge = 0
|
||||
|
||||
|
||||
def _erfolg_melden() -> None:
|
||||
global _breite, _erfolge
|
||||
_erfolge += 1
|
||||
if _erfolge >= config.DROSSEL_ERFOLGE_JE_PLUS and _breite < config.MAX_PARALLEL_LLM:
|
||||
_breite += 1
|
||||
_erfolge = 0
|
||||
|
||||
|
||||
_naechster_start = 0.0
|
||||
|
||||
|
||||
async def _slot() -> float:
|
||||
"""Cooldown abwarten, dann Takt (1 Start je TAKT_SEKUNDEN) ODER — bei
|
||||
Takt 0 — adaptive Breite. Rückgabe: Wartezeit in Sekunden."""
|
||||
global _inflight, _naechster_start
|
||||
t0 = time.monotonic()
|
||||
while time.monotonic() < _cooldown_bis:
|
||||
await asyncio.sleep(min(1.0, _cooldown_bis - time.monotonic()))
|
||||
if config.TAKT_SEKUNDEN > 0:
|
||||
while True:
|
||||
jetzt = time.monotonic()
|
||||
if jetzt >= _naechster_start:
|
||||
_naechster_start = max(jetzt, _naechster_start) + config.TAKT_SEKUNDEN
|
||||
break
|
||||
await asyncio.sleep(min(0.2, _naechster_start - jetzt))
|
||||
else:
|
||||
while _inflight >= _breite:
|
||||
await asyncio.sleep(0.2)
|
||||
_inflight += 1
|
||||
return time.monotonic() - t0
|
||||
|
||||
|
||||
def _slot_frei() -> None:
|
||||
global _inflight
|
||||
_inflight = max(0, _inflight - 1)
|
||||
|
||||
|
||||
def _ist_infra(err: str) -> bool:
|
||||
e = err.lower()
|
||||
return any(m in e for m in _INFRA_MARKER)
|
||||
|
||||
|
||||
async def _api(prompt: str, role: str, timeout: float) -> ApiErgebnis:
|
||||
opts = dict(config.ROLLEN.get(role) or config.ROLLEN["judge"])
|
||||
body = {"model": opts.pop("model"), "max_tokens": config.LLM_MAX_TOKENS,
|
||||
"messages": [{"role": "user", "content": prompt}],
|
||||
"temperature": opts.pop("temperature"),
|
||||
"thinking": {"type": opts.pop("thinking")}}
|
||||
headers = {"x-api-key": __import__("os").environ.get("MINIMAX_API_KEY", ""),
|
||||
"anthropic-version": "2023-06-01"}
|
||||
try:
|
||||
async with httpx.AsyncClient(
|
||||
timeout=httpx.Timeout(timeout, connect=config.FETCH_CONNECT_S)) as client:
|
||||
resp = await asyncio.wait_for(
|
||||
client.post(config.LLM_ENDPOINT, json=body, headers=headers),
|
||||
timeout=timeout)
|
||||
except (httpx.TimeoutException, asyncio.TimeoutError):
|
||||
return ApiErgebnis(1, err="timeout")
|
||||
except httpx.HTTPError as e:
|
||||
return ApiErgebnis(1, err=f"{type(e).__name__}: {e}")
|
||||
if resp.status_code == 429:
|
||||
try:
|
||||
nach = float(resp.headers.get("retry-after", "15"))
|
||||
except ValueError:
|
||||
nach = 15.0
|
||||
drossel_melden(min(nach, 60.0))
|
||||
return ApiErgebnis(1, err="HTTP 429: rate limited")
|
||||
if resp.status_code != 200:
|
||||
return ApiErgebnis(1, err=f"HTTP {resp.status_code}: {resp.text[:300]}")
|
||||
daten = resp.json()
|
||||
text = "".join(b.get("text", "") for b in daten.get("content", [])
|
||||
if b.get("type") == "text")
|
||||
u = daten.get("usage") or {}
|
||||
tokens = {"input": int(u.get("input_tokens") or 0),
|
||||
"output": int(u.get("output_tokens") or 0),
|
||||
"cache_read": int(u.get("cache_read_input_tokens") or 0),
|
||||
"cache_write": int(u.get("cache_creation_input_tokens") or 0)}
|
||||
if _ist_infra(resp.text[:500]) and not text.strip():
|
||||
return ApiErgebnis(1, err=resp.text[:300], tokens=tokens)
|
||||
if not text.strip():
|
||||
return ApiErgebnis(1, err=f"leere Antwort (stop={daten.get('stop_reason')})",
|
||||
tokens=tokens)
|
||||
_erfolg_melden()
|
||||
return ApiErgebnis(0, text=text, tokens=tokens)
|
||||
|
||||
|
||||
async def _versuch(prompt: str, role: str, timeout: float) -> tuple[ApiErgebnis, bool]:
|
||||
"""Ein Versuch inkl. Hedge-Zwilling (Lektion 4/5). → (ergebnis, war_hedge)."""
|
||||
wait_s = await _slot()
|
||||
try:
|
||||
haupt = asyncio.ensure_future(_api(prompt, role, timeout))
|
||||
schwelle = max(config.HEDGE_NACH_S, timeout / 2) if config.HEDGE_NACH_S else None
|
||||
if schwelle is None:
|
||||
return await haupt, False
|
||||
fertig, _ = await asyncio.wait({haupt}, timeout=schwelle)
|
||||
if fertig:
|
||||
return haupt.result(), False
|
||||
zwilling = asyncio.ensure_future(_api(prompt, role, timeout))
|
||||
fertig, offen = await asyncio.wait({haupt, zwilling},
|
||||
return_when=asyncio.FIRST_COMPLETED)
|
||||
gewinner = fertig.pop()
|
||||
for t in offen:
|
||||
t.cancel() # Verlierer wird vom Aufrufer als hedge_cancel verbucht
|
||||
return gewinner.result(), True
|
||||
finally:
|
||||
_slot_frei()
|
||||
# wait_s in Meta des Aufrufers — hier via Attribut zurückgeben
|
||||
_versuch.last_wait = wait_s # type: ignore[attr-defined]
|
||||
|
||||
|
||||
async def call(*, run_id: int, stufe: str, knoten: str, item: str,
|
||||
skill_namen: list[str], werte: dict, role: str = "judge",
|
||||
n: int = 0) -> str | None:
|
||||
"""Zentraler LLM-Engpass. Rückgabe: Antworttext oder None (Inhaltsfehler/cap).
|
||||
Wirft LaufPause (Infra erschöpft/manuell) oder BudgetErschoepft."""
|
||||
prompt, skill_hash, paare = skills.komponieren(skill_namen, werte)
|
||||
timeout = config.timeout_fuer(knoten, n)
|
||||
infra_rest = config.INFRA_MAX_RETRIES
|
||||
inhalt_rest = config.INHALT_MAX_RESTARTS
|
||||
|
||||
while True:
|
||||
run = db.one("SELECT status FROM runs WHERE id=?", run_id)
|
||||
if run and run["status"] != "running":
|
||||
raise ManuellePause("Lauf nicht mehr running")
|
||||
budget_pruefen(run_id)
|
||||
|
||||
if config.FAKE:
|
||||
from . import fakes
|
||||
t0 = time.monotonic()
|
||||
rc, text, err = fakes.antwort(prompt)
|
||||
log_call(run_id, stufe=stufe, knoten=knoten, item=item,
|
||||
skills_liste=[p[0] for p in paare], skill_hash=skill_hash,
|
||||
model="fake", role=role, status="ok" if rc == 0 else "error",
|
||||
dur_ms=int((time.monotonic() - t0) * 1000),
|
||||
meta={"err": err[:200]} if err else {},
|
||||
prompt=prompt, antwort=text or err)
|
||||
if rc != 0:
|
||||
raise RuntimeError(f"fake: {err}")
|
||||
return text
|
||||
|
||||
t0 = time.monotonic()
|
||||
status, err, tokens, text = "error", "", {}, ""
|
||||
hedge = False
|
||||
try:
|
||||
res, hedge = await _versuch(prompt, role, timeout)
|
||||
tokens, err, text = res.tokens, res.err, res.text
|
||||
if res.rc == 0:
|
||||
status = "ok"
|
||||
elif "stop=max_tokens" in err:
|
||||
status = "cap"
|
||||
elif _ist_infra(err):
|
||||
status = "infra"
|
||||
else:
|
||||
status = "error"
|
||||
except asyncio.CancelledError:
|
||||
status, err = "pause", "abbruch"
|
||||
raise
|
||||
finally:
|
||||
log_call(run_id, stufe=stufe, knoten=knoten, item=item,
|
||||
skills_liste=[p[0] for p in paare], skill_hash=skill_hash,
|
||||
model=config.ROLLEN.get(role, {}).get("model", ""), role=role,
|
||||
status=status, dur_ms=int((time.monotonic() - t0) * 1000),
|
||||
wait_ms=int(getattr(_versuch, "last_wait", 0) * 1000),
|
||||
tokens=tokens, meta={"err": err[:300]} if err else {},
|
||||
prompt=prompt, antwort=text or err)
|
||||
if hedge: # Verlierer sichtbar machen — sonst fehlen Tokens/Kennzahlen
|
||||
log_call(run_id, stufe=stufe, knoten=knoten, item=f"{item}-hedge",
|
||||
skill_hash=skill_hash, role=role, status="hedge_cancel")
|
||||
budget_pruefen(run_id)
|
||||
|
||||
if status == "ok":
|
||||
return text
|
||||
if status == "cap":
|
||||
return None # deterministisch: gleicher Prompt läuft wieder ins Cap
|
||||
if status == "infra":
|
||||
infra_rest -= 1
|
||||
if infra_rest < 0:
|
||||
raise LaufPause(f"Infra erschöpft: {err[:120]}")
|
||||
pause = config.INFRA_BACKOFF_BASE * 2 ** (
|
||||
config.INFRA_MAX_RETRIES - 1 - infra_rest)
|
||||
pause *= 1 + random.random() * 0.25 # Jitter
|
||||
drossel_melden(pause)
|
||||
await asyncio.sleep(pause)
|
||||
continue
|
||||
inhalt_rest -= 1
|
||||
if inhalt_rest < 0:
|
||||
return None
|
||||
Reference in New Issue
Block a user