diff --git a/backend/services/radar.py b/backend/services/radar.py index db9d965..e1ef125 100644 --- a/backend/services/radar.py +++ b/backend/services/radar.py @@ -102,6 +102,7 @@ HF_BASIS = "https://huggingface.co" ROLLEN = ("hirn", "coder") ALIAS = {"hirn": "hermes", "coder": "coder"} # llama-swap-Alias der Rolle heute ZWILLING = {"hirn": "vision", "coder": "coder-bild"} # Bild-Zwilling der Rolle (seit 24.09.) +ZWILLING_CTX = {"hirn": 65536, "coder": 131072} # Kontext der Zwillinge wie im Betrieb DISCOVER_ROLLEN = {"coder": "coder", "fast": "hirn", "hermes": "hirn"} PARALLEL = {"hirn": 2, "coder": 1} # Slots im Betrieb, wie heute KANDIDAT_FELDER = ("id", "name", "rolle", "repo", "datei", "groesse_gb", "passt", "eng", "quelle", "status", @@ -109,7 +110,8 @@ KANDIDAT_FELDER = ("id", "name", "rolle", "repo", "datei", "groesse_gb", "passt" TEST_FELDER = ("id", "kandidat", "rolle", "datum", "ergebnis", "werte", "vergleich", "zusammenfassung") _REIHENFOLGE = {"wartet": 0, "neu": 1, "getestet": 2, "bestanden": 3, "uebernommen": 4, "durchgefallen": 5, "verworfen": 6} -_SCHRITTE = {"download": "lädt herunter", "baseline": "misst das heutige Modell", "test": "testet den Kandidaten"} +_SCHRITTE = {"download": "lädt herunter", "baseline": "misst das heutige Modell", + "draft": "sucht die schnellste Draft-Variante", "test": "testet den Kandidaten"} class Abbruch(Exception): @@ -712,7 +714,8 @@ def _bewerte(fund: dict) -> dict: kv = (kv_je_token_gb(kopf) if kopf else None) or _kv_schaetzung(repo, groesse / 1e9) passung = speicher_passung(rolle, groesse / 1e9, kv, eng_markiert=bool(fund.get("eng"))) k.update(groesse_gb=round(groesse / 1e9, 1), passt=passung["passt"], eng=passung["eng"], ctx=passung["ctx"], - bedarf_gb=passung["bedarf_gb"], tauglich=passung["passt"], arch=arch) + bedarf_gb=passung["bedarf_gb"], tauglich=passung["passt"], arch=arch, + mtp=bool((kopf or {}).get(f"{arch}.nextn_predict_layers"))) k["begruendung"] = f"{herkunft} {passung['text']}{notiz}" if not kopf and not passung["passt"]: # Ohne Kopf rechnet die Schätzung den KV-Cache grob zu groß — darauf hin nichts verwerfen, @@ -724,7 +727,7 @@ def _bewerte(fund: dict) -> dict: _UEBERNEHMEN = ("name", "rolle", "repo", "quant", "quelle", "rang", "datei", "dateien", "mmproj", "draft", "flags", - "groesse_gb", "passt", "eng", "ctx", "bedarf_gb", "arch", "begruendung") + "groesse_gb", "passt", "eng", "ctx", "bedarf_gb", "arch", "mtp", "begruendung") def _suche_anwenden(stand: dict, bewertet: list[dict], quellen_ok: dict[str, bool], zeit: float) -> dict: @@ -1101,6 +1104,74 @@ def _baseline(rolle: str, frist: float) -> dict | None: protokoll=_protokoll) +_ARCH_CACHE: dict[tuple[str, float], str | None] = {} + + +def _lokale_architektur(pfad: str) -> str | None: + """general.architecture einer lokalen GGUF-Datei (die ersten 8 MB reichen, der Kopf steht vorn).""" + try: + schluessel = (pfad, os.path.getmtime(pfad)) + except OSError: + return None + if schluessel not in _ARCH_CACHE: + try: + with open(pfad, "rb") as f: + kopf = gguf_kopf_aus_bytes(f.read(8 << 20)) + except OSError: + kopf = None + _ARCH_CACHE[schluessel] = (kopf or {}).get("general.architecture") + return _ARCH_CACHE[schluessel] + + +def _live_draft(heute: dict) -> dict | None: + """Spekulatives Dekodieren des heutigen Modells der Rolle: Typ, Draft-Datei, n_max und die Architektur.""" + befehl = str(heute.get("cmd") or "") + typ = re.search(r"--spec-type\s+(\S+)", befehl) + if not typ: + return None + pfad = re.search(r"--spec-draft-model\s+(\S+)", befehl) + n_max = re.search(r"--spec-draft-n-max\s+(\d+)", befehl) + return {"typ": typ.group(1), "pfad": pfad.group(1) if pfad else None, + "n_max": int(n_max.group(1)) if n_max else None, + "arch": _lokale_architektur(str(heute.get("gguf_path") or ""))} + + +def _draft_varianten(k: dict, pfade: dict) -> list[dict]: + """Womit der Kandidat spekulativ dekodieren könnte. Fairness: das heutige Modell misst mit seinem Draft + (Hirn 106 t/s, ohne Draft 68) — Ornith fiel am 24.09. ohne Draft durch, obwohl es dieselbe Architektur hat + und den Draft des Hirns hätte nutzen können. Ausprobiert werden: ohne Draft, ein eingebauter MTP-Kopf und + der Draft des heutigen Modells (nur bei gleicher Architektur und wenn der Speicher reicht). Ein Draft aus + der Merkliste ist eine bewusste Wahl und wird allein genommen. „ort“ sagt, wo die Draft-Datei liegt: + im Kandidaten-Ordner (wandert beim Übernehmen mit) oder fremd (fester Pfad).""" + ps = _pruefstand() + if (d := k.get("draft") or {}).get("typ"): + return [{"name": "Draft aus der Merkliste", "flags": ps.draft_flags(pfade.get("draft"), d["typ"], d.get("n_max")), + "draft": {"typ": d["typ"], "ort": "kandidat" if pfade.get("draft") else None, "n_max": d.get("n_max")}}] + varianten = [{"name": "ohne Draft", "flags": [], "draft": None}] + if k.get("mtp"): + varianten.append({"name": "eingebauter MTP-Kopf", "flags": ps.draft_flags(None, "draft-mtp"), + "draft": {"typ": "draft-mtp", "ort": None, "n_max": None}}) + live = _live_draft(_heutige_modelle().get(k["rolle"]) or {}) + if live and live["pfad"] and k.get("arch") and live["arch"] == k["arch"]: + try: + draft_gb = os.path.getsize(live["pfad"]) / 1e9 + except OSError: + draft_gb = None + if draft_gb is not None and float(k.get("bedarf_gb") or 0) + draft_gb <= SPEICHER_GRENZE_GB - WARMSET_GB: + varianten.append({"name": "Draft des heutigen Modells", + "flags": ps.draft_flags(live["pfad"], live["typ"], live["n_max"]), + "draft": {"typ": live["typ"], "ort": "fremd", "pfad": live["pfad"], "n_max": live["n_max"]}}) + return varianten + + +def _draft_text(wahl: dict) -> str: + """„Gemessen mit …“ für die Begründung: gewählte Variante und die Tempi aller Varianten.""" + tempi = [f"{name} {str(wert).replace('.', ',')} t/s" if isinstance(wert, (int, float)) else f"{name}: {wert}" + for name, wert in (wahl.get("messungen") or {}).items()] + return f" Gemessen {'mit' if wahl.get('draft') else ''} {wahl['name']}".replace(" ", " ") + ( + f" (13k: {'; '.join(tempi)})." if len(tempi) > 1 else ".") + + def teste(kid: str, frist: float) -> dict | None: """Einen Kandidaten prüfen: Download → Baseline → nur Warm-Set in llama-swap → Kandidat auf :5899 → Urteil. Gibt den Test-Eintrag zurück (None, wenn nur gemessen wurde oder es den Kandidaten nicht gibt).""" @@ -1110,24 +1181,31 @@ def teste(kid: str, frist: float) -> dict | None: return None _markiere_lauf(kid, "download", frist) ergebnis, satz, werte, vergleich, zaehlt = "abgebrochen", "", None, None, True - waechter, von_aussen = None, None + waechter, von_aussen, draft_betrieb = None, None, None try: ordner = _download(k, frist - TESTZEIT_MIN * 60) pfade = _lokale_pfade(k, ordner) _markiere_lauf(kid, "baseline", frist) basis = _baseline(k["rolle"], frist) - _markiere_lauf(kid, "test", frist) waechter = _Nachtwaechter(_nur_warmset()) waechter.start() - draft = k.get("draft") or {} - flags = ps.draft_flags(pfade["draft"], draft.get("typ"), draft.get("n_max")) + list(k.get("flags") or []) + varianten = _draft_varianten(k, pfade) + if len(varianten) > 1: + _markiere_lauf(kid, "draft", frist) + wahl = ps.waehle_draft(pfade["gguf"], varianten, frist=frist, protokoll=_protokoll) + else: + wahl = {**varianten[0], "messungen": {}} + _markiere_lauf(kid, "test", frist) + flags = list(wahl["flags"]) + list(k.get("flags") or []) lauf = ps.pruefe_kandidat(k["rolle"], pfade["gguf"], mmproj=pfade["mmproj"], flags=flags, ctx=int(k.get("ctx") or ps.CTX_STANDARD), basis=(basis or {}).get("werte"), frist=frist, protokoll=_protokoll) werte, vergleich = lauf["werte"], lauf["vergleich"] + werte["draft"] = {"gewaehlt": wahl["name"], "messungen": wahl.get("messungen") or {}} + draft_betrieb = wahl.get("draft") if basis: werte["baseline"] = {"modell": (basis.get("werte") or {}).get("modell"), "gemessen": _iso(basis.get("zeit"))} - ergebnis, satz = lauf["ergebnis"], lauf["zusammenfassung"] + ergebnis, satz = lauf["ergebnis"], lauf["zusammenfassung"] + _draft_text(wahl) except ps.Zeitende: satz = f"Das Nachtfenster endete um {FENSTER_ENDE}, bevor der Test fertig war." except ps.ServerFehler as e: @@ -1153,6 +1231,8 @@ def teste(kid: str, frist: float) -> dict | None: def abschluss(stand: dict): stand["lauf"] = None + if kid in stand["kandidaten"]: # der Draft, mit dem gemessen wurde, läuft auch im Betrieb + stand["kandidaten"][kid]["draft_betrieb"] = draft_betrieb return _abschluss(stand, kid, ergebnis, satz, werte=werte, vergleich=vergleich, zaehlt=zaehlt) test = _aendere(abschluss) log.info("radar: %s → %s (%s)", kid, ergebnis, satz) @@ -1197,23 +1277,53 @@ def notstopp() -> None: # --- Übernehmen und Verwerfen ---------------------------------------------------------------- def betriebs_befehl(rolle: str, pfade: dict, k: dict) -> str: - """llama-swap-Befehl für den übernommenen Kandidaten: die getesteten Flags plus die Betriebs-Flags - der Rolle wie heute (Hirn 2 Slots, Coder 1 Slot, KV q8_0, Prompt-Cache 16 GB).""" + """llama-swap-Befehl für den übernommenen Kandidaten wie heute im Betrieb: ohne Projektor (Bilder gehen an + den Zwilling), mit dem Draft, mit dem er getestet wurde, dazu die Betriebs-Flags der Rolle (Hirn 2 Slots, + Coder 1 Slot, KV q8_0, Prompt-Cache 16 GB).""" teile = ["llama-server", "-m", pfade["gguf"], "--host", "127.0.0.1", "--port", "${PORT}", - "-c", str(int(k.get("ctx") or CTX_ROLLE[rolle])), "-ngl", "999", "-fa", "on", "--load-mode", "none"] - if pfade.get("mmproj"): - teile += ["--mmproj", pfade["mmproj"]] - teile += ["--jinja", "--parallel", str(PARALLEL[rolle]), "-cram", "16384", "-ctk", "q8_0", "-ctv", "q8_0"] - draft = k.get("draft") or {} - if draft.get("typ"): + "-c", str(int(k.get("ctx") or CTX_ROLLE[rolle])), "-ngl", "999", "-fa", "on", "--load-mode", "none", + "--jinja", "--parallel", str(PARALLEL[rolle]), "-cram", "16384", "-ctk", "q8_0", "-ctv", "q8_0"] + draft = k.get("draft_betrieb") + if draft is None and (k.get("draft") or {}).get("typ"): # vor dem 25.09. getestet: Draft der Merkliste + draft = {"typ": k["draft"]["typ"], "ort": "kandidat", "n_max": k["draft"].get("n_max")} + if draft and draft.get("typ"): teile += ["--spec-type", draft["typ"]] - if pfade.get("draft"): - teile += ["--spec-draft-model", pfade["draft"]] + pfad = pfade.get("draft") if draft.get("ort") == "kandidat" else draft.get("pfad") + if pfad: + teile += ["--spec-draft-model", pfad] if draft.get("n_max"): teile += ["--spec-draft-n-max", str(draft["n_max"])] return " ".join(teile + [str(f) for f in k.get("flags") or []]) +def zwilling_befehl(rolle: str, pfade: dict, k: dict) -> str: + """Bild-Zwilling im Betrieb (seit 24.09.): gleiche Gewichte plus Projektor, OHNE Draft — llama.cpp kann + Draft und Bild nicht zusammen. Das Gateway schickt nur Anfragen mit einem neuen Bild hierher.""" + teile = ["llama-server", "-m", pfade["gguf"], "--host", "127.0.0.1", "--port", "${PORT}", + "-c", str(ZWILLING_CTX[rolle]), "-ngl", "999", "-fa", "on", "--load-mode", "none", + "--mmproj", pfade["mmproj"], "--jinja", "--parallel", "1", "-cram", "8192", "-ctk", "q8_0", "-ctv", "q8_0"] + return " ".join(teile + _pruefstand().ohne_draft([str(f) for f in k.get("flags") or []])) + + +def _zwilling_eintragen(cfg: dict, rolle: str, modell_id: str, pfade: dict, k: dict) -> str: + """Bild-Zwilling des neuen Modells anlegen (Gruppe „bild“, verdrängt nichts). Der alte Zwilling der Rolle + fliegt aus der Config — ohne sein Hauptmodell ist er nutzlos; seine Dateien bleiben liegen und + tauchen im Aufräumen beim alten Modell auf.""" + alias = ZWILLING[rolle] + models = cfg.setdefault("models", {}) + groups = cfg.setdefault("groups", {}) + for alt in [n for n, sp in models.items() if alias in {str(a).lower() for a in (sp or {}).get("aliases") or []}]: + del models[alt] + for gruppe in groups.values(): + if isinstance(gruppe, dict) and alt in (gruppe.get("members") or []): + gruppe["members"] = [m for m in gruppe["members"] if m != alt] + name = f"{modell_id}-Bild" + models[name] = {"cmd": LiteralScalarString(zwilling_befehl(rolle, pfade, k) + "\n"), "ttl": 900, "aliases": [alias]} + bild = groups.setdefault("bild", {"swap": True, "exclusive": False, "persistent": False, "members": []}) + bild["members"] = [m for m in bild.get("members") or [] if m != name] + [name] + return name + + def _steward_neu_starten() -> str | None: """Drop-in neu einlesen und den Steward neu starten (User-Dienst, ohne sudo). Fehlertext oder None.""" if os.name != "posix": @@ -1275,12 +1385,14 @@ def _tausche_ein(k: dict) -> dict: ziel = quelle # schon verschoben (früherer, abgebrochener Versuch) pfade = _lokale_pfade(k, ziel) modell_id = llamaswap.register_model(pfade["gguf"], role=ALIAS[rolle], ctx=int(k.get("ctx") or CTX_ROLLE[rolle]), - mmproj_path=pfade["mmproj"], jinja=True, set_alias=False) + mmproj_path=None, jinja=True, set_alias=False) cfg = llamaswap.read_config() try: # die getesteten Flags statt der Vorlage aus register_model (sonst liefe ein ungetesteter Draft mit) cfg["models"][modell_id]["cmd"] = LiteralScalarString(betriebs_befehl(rolle, pfade, k) + "\n") except (KeyError, TypeError) as e: raise RuntimeError(f"{modell_id} fehlt nach dem Eintragen in der llama-swap-Config.") from e + if pfade.get("mmproj"): + _zwilling_eintragen(cfg, rolle, modell_id, pfade, k) llamaswap.write_config(cfg) hinweis = None if rolle == "hirn": diff --git a/backend/tests/test_radar.py b/backend/tests/test_radar.py index 42cf4e2..7304876 100644 --- a/backend/tests/test_radar.py +++ b/backend/tests/test_radar.py @@ -355,7 +355,10 @@ def _uebernahme_vorbereiten(zustand, monkeypatch, rolle: str, alt: dict) -> tupl stand = radar._leer() stand["kandidaten"]["o"] = _kandidat("o", rolle=rolle, status="bestanden", ordner=str(ordner)) radar._speichere(stand) - aufrufe, cfg = [], {"models": {}} + aufrufe = [] + cfg = {"models": {"alter-zwilling": {"cmd": "llama-server -m /alt.gguf --mmproj /alt-mm.gguf", + "aliases": ["vision" if rolle == "hirn" else "coder-bild"]}}, + "groups": {"bild": {"swap": True, "exclusive": False, "members": ["alter-zwilling"]}}} def eintragen(pfad, **kw): aufrufe.append(("eintragen", pfad, kw["role"], kw["set_alias"])) @@ -386,7 +389,11 @@ def test_uebernehmen_hirn_nutzt_den_hirn_wechsel_und_nimmt_fast_mit(zustand, mon assert [a[0] for a in aufrufe] == ["eintragen", "schreiben", "hirn", "dazu", "steward"] assert aufrufe[0][2:] == ("hermes", False) and aufrufe[3] == ("dazu", "o", "fast") befehl = str(cfg["models"]["o"]["cmd"]) - assert "--parallel 2" in befehl and "-ctk q8_0" in befehl and "--mmproj" in befehl + assert "--parallel 2" in befehl and "-ctk q8_0" in befehl and "--mmproj" not in befehl + # Wie im Betrieb seit 24.09.: Bilder sieht der neue Bild-Zwilling (Projektor, ohne Draft), der alte fliegt raus. + zwilling = cfg["models"]["o-Bild"] + assert zwilling["aliases"] == ["vision"] and "--mmproj" in str(zwilling["cmd"]) and "--spec" not in str(zwilling["cmd"]) + assert "alter-zwilling" not in cfg["models"] and cfg["groups"]["bild"]["members"] == ["o-Bild"] assert (zustand / "models" / "o-GGUF" / "o-Q4_K_M.gguf").exists() and not (radar.RADAR_DIR / "o").exists() k = radar._lade()["kandidaten"]["o"] assert k["status"] == "uebernommen" and "vorher Qwen3.6-35B-A3B" in k["begruendung"] @@ -405,16 +412,98 @@ def test_uebernehmen_coder_nimmt_heavy_mit(zustand, monkeypatch): def test_betriebsbefehl_uebernimmt_die_getesteten_flags(): + pfade = {"gguf": "/srv/models/F-GGUF/f.gguf", "mmproj": "/srv/models/F-GGUF/mm.gguf", "draft": None} k = _kandidat("f", rolle="coder", ctx=65536, draft={"typ": "draft-mtp", "n_max": 2, "datei": None}, flags=["-ub", "512"]) - befehl = radar.betriebs_befehl("coder", {"gguf": "/srv/models/F-GGUF/f.gguf", "mmproj": "/srv/models/F-GGUF/mm.gguf", - "draft": None}, k) + befehl = radar.betriebs_befehl("coder", pfade, k) assert "-c 65536" in befehl and "--parallel 1" in befehl and "-ctk q8_0 -ctv q8_0" in befehl - assert "--mmproj /srv/models/F-GGUF/mm.gguf" in befehl and "--load-mode none" in befehl + assert "--mmproj" not in befehl and "--load-mode none" in befehl # Bilder sieht der Zwilling assert befehl.endswith("--spec-type draft-mtp --spec-draft-n-max 2 -ub 512") + zwilling = radar.zwilling_befehl("coder", pfade, k) + assert "--mmproj /srv/models/F-GGUF/mm.gguf" in zwilling and "--spec" not in zwilling and "-c 131072" in zwilling + assert zwilling.endswith("-ub 512") assert "--parallel 2" in radar.betriebs_befehl("hirn", {"gguf": "x.gguf", "mmproj": None, "draft": None}, _kandidat("h")) +def test_betriebsbefehl_nimmt_den_im_test_gewaehlten_draft(): + """Gewählt wurde der Draft des heutigen Modells: fester Pfad, nicht der Kandidaten-Ordner.""" + pfade = {"gguf": "/srv/models/O-GGUF/o.gguf", "mmproj": "/srv/models/O-GGUF/mm.gguf", "draft": None} + k = _kandidat("o", draft_betrieb={"typ": "draft-dflash", "ort": "fremd", "pfad": "/srv/models/D/d.gguf", + "n_max": None}) + assert radar.betriebs_befehl("hirn", pfade, k).endswith("--spec-type draft-dflash --spec-draft-model /srv/models/D/d.gguf") + k["draft_betrieb"] = None # im Test war „ohne Draft“ am schnellsten + assert "--spec" not in radar.betriebs_befehl("hirn", pfade, k) + + +def _gguf_datei(pfad, arch: str) -> None: + """Winzige GGUF-Datei mit general.architecture (genug für _lokale_architektur).""" + wert = arch.encode() + schluessel = b"general.architecture" + daten = (b"GGUF" + struct.pack(" dict: + """Bild-Probe mit Fehlerfang: ein Absturz der Probe ist ein Messwert, kein Programmfehler.""" + try: + ergebnis = messe_bild(basis, modell, rolle, frist) + except Zeitende: + raise + except Exception as e: + ergebnis = {"direkt": 0.0, "gefunden": [], "agentisch": False, "fehler": str(e)[:160]} + protokoll(f" bild : {ergebnis.get('direkt')} direkt, im Ablauf {'ja' if ergebnis.get('agentisch') else 'nein'}") + return ergebnis + + # --- Kandidaten-Server ------------------------------------------------------------------- class Server: @@ -908,30 +911,91 @@ def starte_server(gguf: str, *, mmproj: str | None = None, ctx: int = CTX_STANDA raise -def pruefe_kandidat(rolle: str, gguf: str, *, mmproj: str | None = None, flags=(), ctx: int = CTX_STANDARD, - basis: dict | None = None, frist: float | None = None, protokoll=log) -> dict: - """Ganzer Durchlauf eines Kandidaten: Server starten → messen → stoppen → urteilen. - basis = Werte der Baseline (ohne sie gibt es kein Urteil, ergebnis None). - Rückgabe: {"werte", "ergebnis", "vergleich", "zusammenfassung"}.""" +def mit_server(gguf: str, arbeit, *, mmproj: str | None = None, ctx: int = CTX_STANDARD, flags=(), + frist: float | None = None, protokoll=log): + """Kandidat starten, arbeit() laufen lassen, stoppen. Gibt (Ergebnis, Server) zurück; ein Absturz + während der Arbeit wird zu ServerFehler — ein toter Server darf nicht wie ein schlechtes Modell aussehen.""" server = starte_server(gguf, mmproj=mmproj, ctx=ctx, flags=flags, frist=frist, protokoll=protokoll) def absturz() -> str: return f"Während des Tests abgestürzt (Code {server.prozess.returncode}): {log_ende(SERVER_LOG, 300)}" try: - werte = pruefe(rolle, KANDIDAT_URL, "kandidat", bild=bool(mmproj), frist=frist, protokoll=protokoll) + ergebnis = arbeit() except Exception as e: if not isinstance(e, Zeitende) and server.prozess.poll() is not None: raise ServerFehler(absturz()) from e raise else: - # Einzelproben fangen Fehler ab — ein toter Server darf nicht wie ein schlechtes Modell aussehen. - if server.prozess.poll() is not None: + if server.prozess.poll() is not None: # Einzelproben fangen Fehler ab — darum hier nachsehen raise ServerFehler(absturz()) finally: stoppe_server(server) time.sleep(3) # Speicher freigeben lassen, bevor jemand anderes lädt - werte.update(ladezeit_s=server.ladezeit, ctx=ctx, befehl=" ".join(server.befehl)) + return ergebnis, server + + +_SPEC_FLAGS = ("--spec-type", "--spec-draft-model", "--spec-draft-n-max") + + +def ohne_draft(flags) -> list[str]: + """Flags ohne spekulatives Dekodieren (jeweils Schalter plus Wert).""" + rest, flags, i = [], list(flags), 0 + while i < len(flags): + if flags[i] in _SPEC_FLAGS: + i += 2 + continue + rest.append(flags[i]) + i += 1 + return rest + + +def waehle_draft(gguf: str, varianten: list[dict], *, frist: float | None = None, protokoll=log) -> dict: + """Schnellste Art des spekulativen Dekodierens für einen Kandidaten. Jede Variante {"name", "flags", …} + wird gestartet und bei 13k Tiefe gemessen; startet eine nicht (z. B. passt das Vokabular des Drafts + nicht), fällt sie raus. Spekulatives Dekodieren ändert die Antworten nicht, nur das Tempo — darum + entscheidet allein das Tempo. Rückgabe: die gewählte Variante plus "messungen" (Name → t/s bzw. Grund).""" + messungen: dict[str, object] = {} + beste: tuple[dict, float] | None = None + for v in varianten: + try: + tempo, _ = mit_server(gguf, lambda: messe_tempo(KANDIDAT_URL, "kandidat", frist), ctx=32768, + flags=v["flags"], frist=frist, protokoll=protokoll) + except Zeitende: + raise + except Exception as e: + messungen[v["name"]] = f"geht nicht: {str(e)[:120]}" + protokoll(f" Draft-Variante {v['name']}: geht nicht ({str(e)[:120]})") + continue + tiefe = tempo.get("tiefe") or {} + tps = float(tiefe.get("tg_tps") or 0) + messungen[v["name"]] = tps + protokoll(f" Draft-Variante {v['name']}: {tps} t/s bei 13k (Akzeptanz {tiefe.get('acc')})") + if beste is None or tps > beste[1]: + beste = (v, tps) + gewaehlt = beste[0] if beste else next((v for v in varianten if not v["flags"]), varianten[0]) + return {**gewaehlt, "messungen": messungen} + + +def pruefe_kandidat(rolle: str, gguf: str, *, mmproj: str | None = None, flags=(), ctx: int = CTX_STANDARD, + basis: dict | None = None, frist: float | None = None, protokoll=log) -> dict: + """Ganzer Durchlauf eines Kandidaten: Server starten → messen → stoppen → urteilen. + basis = Werte der Baseline (ohne sie gibt es kein Urteil, ergebnis None). + Mit Draft wie im Betrieb seit 24.09.: Text auf einem Server ohne Projektor, die Bild-Probe auf einem + Zwilling ohne Draft — llama.cpp kann Draft und Bild nicht zusammen (HTTP 500). + Rückgabe: {"werte", "ergebnis", "vergleich", "zusammenfassung"}.""" + mit_draft = "--spec-type" in list(flags) + text_mmproj = None if mit_draft else mmproj + werte, server = mit_server( + gguf, lambda: pruefe(rolle, KANDIDAT_URL, "kandidat", bild=bool(text_mmproj), frist=frist, protokoll=protokoll), + mmproj=text_mmproj, ctx=ctx, flags=flags, frist=frist, protokoll=protokoll) + befehle = [" ".join(server.befehl)] + if mit_draft and mmproj: + werte["bild"], zwilling = mit_server( + gguf, lambda: bild_probe(KANDIDAT_URL, "kandidat", rolle, frist, protokoll), + mmproj=mmproj, ctx=min(ctx, 32768), flags=ohne_draft(flags), frist=frist, protokoll=protokoll) + befehle.append(" ".join(zwilling.befehl)) + werte.update(ladezeit_s=server.ladezeit, ctx=ctx, befehl=" | ".join(befehle)) if not basis: return {"werte": werte, "ergebnis": None, "vergleich": {}, "zusammenfassung": "Gemessen, aber ohne Vergleichswerte des heutigen Modells."}