radar: Kandidaten messen mit der schnellsten Draft-Variante, Bild-Probe und Betrieb wie heute mit Zwilling
Ampel / ampel (push) Failing after 22s

Das heutige Modell misst mit seinem Draft (Hirn 106 t/s, ohne 68), Kandidaten liefen ohne -
Ornith fiel am 24.09. deshalb durch, obwohl es dieselbe Architektur hat. Jetzt probiert das Radar
kurz: ohne Draft, eingebauten MTP-Kopf und den Draft des heutigen Modells (gleiche Architektur,
Speicher reicht) und testet mit der schnellsten. Spekulatives Dekodieren aendert die Antworten
nicht, nur das Tempo.

Mit Draft laeuft die Bild-Probe auf einem Zwilling ohne Draft (llama.cpp: Draft und Bild = HTTP
500). Uebernehmen baut wie der Betrieb seit 24.09.: Hauptmodell mit dem gewaehlten Draft ohne
Projektor, dazu ein Bild-Zwilling; der alte Zwilling fliegt raus.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-09-24 13:26:08 +02:00
co-authored by Claude Opus 5.5
parent 42363229d4
commit 4a05bc2a05
3 changed files with 308 additions and 43 deletions
+131 -19
View File
@@ -102,6 +102,7 @@ HF_BASIS = "https://huggingface.co"
ROLLEN = ("hirn", "coder") ROLLEN = ("hirn", "coder")
ALIAS = {"hirn": "hermes", "coder": "coder"} # llama-swap-Alias der Rolle heute 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 = {"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"} DISCOVER_ROLLEN = {"coder": "coder", "fast": "hirn", "hermes": "hirn"}
PARALLEL = {"hirn": 2, "coder": 1} # Slots im Betrieb, wie heute PARALLEL = {"hirn": 2, "coder": 1} # Slots im Betrieb, wie heute
KANDIDAT_FELDER = ("id", "name", "rolle", "repo", "datei", "groesse_gb", "passt", "eng", "quelle", "status", 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") TEST_FELDER = ("id", "kandidat", "rolle", "datum", "ergebnis", "werte", "vergleich", "zusammenfassung")
_REIHENFOLGE = {"wartet": 0, "neu": 1, "getestet": 2, "bestanden": 3, "uebernommen": 4, "durchgefallen": 5, _REIHENFOLGE = {"wartet": 0, "neu": 1, "getestet": 2, "bestanden": 3, "uebernommen": 4, "durchgefallen": 5,
"verworfen": 6} "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): 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) 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"))) 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"], 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}" k["begruendung"] = f"{herkunft} {passung['text']}{notiz}"
if not kopf and not passung["passt"]: if not kopf and not passung["passt"]:
# Ohne Kopf rechnet die Schätzung den KV-Cache grob zu groß — darauf hin nichts verwerfen, # 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", _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: 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) 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: def teste(kid: str, frist: float) -> dict | None:
"""Einen Kandidaten prüfen: Download → Baseline → nur Warm-Set in llama-swap → Kandidat auf :5899 → """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).""" 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 return None
_markiere_lauf(kid, "download", frist) _markiere_lauf(kid, "download", frist)
ergebnis, satz, werte, vergleich, zaehlt = "abgebrochen", "", None, None, True ergebnis, satz, werte, vergleich, zaehlt = "abgebrochen", "", None, None, True
waechter, von_aussen = None, None waechter, von_aussen, draft_betrieb = None, None, None
try: try:
ordner = _download(k, frist - TESTZEIT_MIN * 60) ordner = _download(k, frist - TESTZEIT_MIN * 60)
pfade = _lokale_pfade(k, ordner) pfade = _lokale_pfade(k, ordner)
_markiere_lauf(kid, "baseline", frist) _markiere_lauf(kid, "baseline", frist)
basis = _baseline(k["rolle"], frist) basis = _baseline(k["rolle"], frist)
_markiere_lauf(kid, "test", frist)
waechter = _Nachtwaechter(_nur_warmset()) waechter = _Nachtwaechter(_nur_warmset())
waechter.start() waechter.start()
draft = k.get("draft") or {} varianten = _draft_varianten(k, pfade)
flags = ps.draft_flags(pfade["draft"], draft.get("typ"), draft.get("n_max")) + list(k.get("flags") or []) 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, 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"), ctx=int(k.get("ctx") or ps.CTX_STANDARD), basis=(basis or {}).get("werte"),
frist=frist, protokoll=_protokoll) frist=frist, protokoll=_protokoll)
werte, vergleich = lauf["werte"], lauf["vergleich"] werte, vergleich = lauf["werte"], lauf["vergleich"]
werte["draft"] = {"gewaehlt": wahl["name"], "messungen": wahl.get("messungen") or {}}
draft_betrieb = wahl.get("draft")
if basis: if basis:
werte["baseline"] = {"modell": (basis.get("werte") or {}).get("modell"), "gemessen": _iso(basis.get("zeit"))} 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: except ps.Zeitende:
satz = f"Das Nachtfenster endete um {FENSTER_ENDE}, bevor der Test fertig war." satz = f"Das Nachtfenster endete um {FENSTER_ENDE}, bevor der Test fertig war."
except ps.ServerFehler as e: except ps.ServerFehler as e:
@@ -1153,6 +1231,8 @@ def teste(kid: str, frist: float) -> dict | None:
def abschluss(stand: dict): def abschluss(stand: dict):
stand["lauf"] = None 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) return _abschluss(stand, kid, ergebnis, satz, werte=werte, vergleich=vergleich, zaehlt=zaehlt)
test = _aendere(abschluss) test = _aendere(abschluss)
log.info("radar: %s%s (%s)", kid, ergebnis, satz) log.info("radar: %s%s (%s)", kid, ergebnis, satz)
@@ -1197,23 +1277,53 @@ def notstopp() -> None:
# --- Übernehmen und Verwerfen ---------------------------------------------------------------- # --- Übernehmen und Verwerfen ----------------------------------------------------------------
def betriebs_befehl(rolle: str, pfade: dict, k: dict) -> str: 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 """llama-swap-Befehl für den übernommenen Kandidaten wie heute im Betrieb: ohne Projektor (Bilder gehen an
der Rolle wie heute (Hirn 2 Slots, Coder 1 Slot, KV q8_0, Prompt-Cache 16 GB).""" 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}", 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"] "-c", str(int(k.get("ctx") or CTX_ROLLE[rolle])), "-ngl", "999", "-fa", "on", "--load-mode", "none",
if pfade.get("mmproj"): "--jinja", "--parallel", str(PARALLEL[rolle]), "-cram", "16384", "-ctk", "q8_0", "-ctv", "q8_0"]
teile += ["--mmproj", pfade["mmproj"]] draft = k.get("draft_betrieb")
teile += ["--jinja", "--parallel", str(PARALLEL[rolle]), "-cram", "16384", "-ctk", "q8_0", "-ctv", "q8_0"] if draft is None and (k.get("draft") or {}).get("typ"): # vor dem 25.09. getestet: Draft der Merkliste
draft = k.get("draft") or {} draft = {"typ": k["draft"]["typ"], "ort": "kandidat", "n_max": k["draft"].get("n_max")}
if draft.get("typ"): if draft and draft.get("typ"):
teile += ["--spec-type", draft["typ"]] teile += ["--spec-type", draft["typ"]]
if pfade.get("draft"): pfad = pfade.get("draft") if draft.get("ort") == "kandidat" else draft.get("pfad")
teile += ["--spec-draft-model", pfade["draft"]] if pfad:
teile += ["--spec-draft-model", pfad]
if draft.get("n_max"): if draft.get("n_max"):
teile += ["--spec-draft-n-max", str(draft["n_max"])] teile += ["--spec-draft-n-max", str(draft["n_max"])]
return " ".join(teile + [str(f) for f in k.get("flags") or []]) 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: def _steward_neu_starten() -> str | None:
"""Drop-in neu einlesen und den Steward neu starten (User-Dienst, ohne sudo). Fehlertext oder None.""" """Drop-in neu einlesen und den Steward neu starten (User-Dienst, ohne sudo). Fehlertext oder None."""
if os.name != "posix": if os.name != "posix":
@@ -1275,12 +1385,14 @@ def _tausche_ein(k: dict) -> dict:
ziel = quelle # schon verschoben (früherer, abgebrochener Versuch) ziel = quelle # schon verschoben (früherer, abgebrochener Versuch)
pfade = _lokale_pfade(k, ziel) pfade = _lokale_pfade(k, ziel)
modell_id = llamaswap.register_model(pfade["gguf"], role=ALIAS[rolle], ctx=int(k.get("ctx") or CTX_ROLLE[rolle]), 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() cfg = llamaswap.read_config()
try: # die getesteten Flags statt der Vorlage aus register_model (sonst liefe ein ungetesteter Draft mit) 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") cfg["models"][modell_id]["cmd"] = LiteralScalarString(betriebs_befehl(rolle, pfade, k) + "\n")
except (KeyError, TypeError) as e: except (KeyError, TypeError) as e:
raise RuntimeError(f"{modell_id} fehlt nach dem Eintragen in der llama-swap-Config.") from 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) llamaswap.write_config(cfg)
hinweis = None hinweis = None
if rolle == "hirn": if rolle == "hirn":
+94 -5
View File
@@ -355,7 +355,10 @@ def _uebernahme_vorbereiten(zustand, monkeypatch, rolle: str, alt: dict) -> tupl
stand = radar._leer() stand = radar._leer()
stand["kandidaten"]["o"] = _kandidat("o", rolle=rolle, status="bestanden", ordner=str(ordner)) stand["kandidaten"]["o"] = _kandidat("o", rolle=rolle, status="bestanden", ordner=str(ordner))
radar._speichere(stand) 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): def eintragen(pfad, **kw):
aufrufe.append(("eintragen", pfad, kw["role"], kw["set_alias"])) 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 [a[0] for a in aufrufe] == ["eintragen", "schreiben", "hirn", "dazu", "steward"]
assert aufrufe[0][2:] == ("hermes", False) and aufrufe[3] == ("dazu", "o", "fast") assert aufrufe[0][2:] == ("hermes", False) and aufrufe[3] == ("dazu", "o", "fast")
befehl = str(cfg["models"]["o"]["cmd"]) 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() assert (zustand / "models" / "o-GGUF" / "o-Q4_K_M.gguf").exists() and not (radar.RADAR_DIR / "o").exists()
k = radar._lade()["kandidaten"]["o"] k = radar._lade()["kandidaten"]["o"]
assert k["status"] == "uebernommen" and "vorher Qwen3.6-35B-A3B" in k["begruendung"] 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(): 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"]) 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", befehl = radar.betriebs_befehl("coder", pfade, k)
"draft": None}, k)
assert "-c 65536" in befehl and "--parallel 1" in befehl and "-ctk q8_0 -ctv q8_0" in befehl 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") 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}, assert "--parallel 2" in radar.betriebs_befehl("hirn", {"gguf": "x.gguf", "mmproj": None, "draft": None},
_kandidat("h")) _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("<I", 3) + struct.pack("<Q", 0) + struct.pack("<Q", 1)
+ struct.pack("<Q", len(schluessel)) + schluessel + struct.pack("<I", 8)
+ struct.pack("<Q", len(wert)) + wert)
pfad.write_bytes(daten)
def test_draft_varianten_probieren_den_draft_des_heutigen_modells_bei_gleicher_architektur(tmp_path, monkeypatch):
hirn_gguf, draft = tmp_path / "hirn.gguf", tmp_path / "dflash.gguf"
_gguf_datei(hirn_gguf, "qwen35moe")
draft.write_bytes(b"x" * 1000)
heute = {"name": "Hirn", "gguf_path": str(hirn_gguf),
"cmd": f"llama-server -m {hirn_gguf} --spec-type draft-dflash --spec-draft-model {draft}"}
monkeypatch.setattr(radar, "_heutige_modelle", lambda: {"hirn": heute})
pfade = {"gguf": "/x/k.gguf", "mmproj": "/x/mm.gguf", "draft": None}
gleich = radar._draft_varianten(_kandidat("k", arch="qwen35moe", bedarf_gb=24.2, mtp=True), pfade)
assert [v["name"] for v in gleich] == ["ohne Draft", "eingebauter MTP-Kopf", "Draft des heutigen Modells"]
assert gleich[2]["flags"] == ["--spec-type", "draft-dflash", "--spec-draft-model", str(draft)]
assert gleich[2]["draft"]["ort"] == "fremd"
fremd = radar._draft_varianten(_kandidat("k", arch="qwen3next", bedarf_gb=24.2), pfade)
assert [v["name"] for v in fremd] == ["ohne Draft"] # andere Architektur: nicht probieren
zu_gross = radar._draft_varianten(_kandidat("k", arch="qwen35moe", bedarf_gb=88.0), pfade)
assert [v["name"] for v in zu_gross] == ["ohne Draft"] # Draft passt nicht mehr in den Speicher
merkliste = radar._draft_varianten(_kandidat("k", arch="qwen35moe", draft={"typ": "draft-simple"}),
{**pfade, "draft": "/x/d.gguf"})
assert [v["name"] for v in merkliste] == ["Draft aus der Merkliste"]
def test_waehle_draft_nimmt_die_schnellste_variante_die_startet(ps, monkeypatch):
tempi = {"": 67.4, "draft-mtp": 81.0, "draft-dflash": 98.2}
def mit_server(gguf, arbeit, *, flags=(), **kw):
typ = flags[1] if flags else ""
if typ == "draft-mtp":
raise ps.ServerFehler("vocab mismatch")
return {"tiefe": {"tg_tps": tempi[typ], "acc": 0.8 if typ else None}}, None
monkeypatch.setattr(ps, "mit_server", mit_server)
varianten = [{"name": "ohne Draft", "flags": []},
{"name": "eingebauter MTP-Kopf", "flags": ["--spec-type", "draft-mtp"]},
{"name": "Draft des heutigen Modells", "flags": ["--spec-type", "draft-dflash"]}]
wahl = ps.waehle_draft("/x/k.gguf", varianten, protokoll=lambda *_: None)
assert wahl["name"] == "Draft des heutigen Modells"
assert wahl["messungen"]["ohne Draft"] == 67.4 and "geht nicht" in wahl["messungen"]["eingebauter MTP-Kopf"]
assert "Gemessen mit Draft des heutigen Modells" in radar._draft_text({**wahl, "draft": {"typ": "draft-dflash"}})
def test_mit_draft_laeuft_die_bild_probe_auf_einem_zwilling_ohne_draft(ps, monkeypatch):
"""Draft und Bild gehen in llama.cpp nicht zusammen (HTTP 500) — der Prüfstand trennt sie wie der Betrieb."""
starts = []
def mit_server(gguf, arbeit, *, mmproj=None, flags=(), **kw):
starts.append((mmproj, list(flags)))
return ({"bild": None, "tiefe": {}} if len(starts) == 1 else {"direkt": 1.0, "agentisch": True}), \
type("S", (), {"befehl": ["llama-server"], "ladezeit": 4})()
monkeypatch.setattr(ps, "mit_server", mit_server)
lauf = ps.pruefe_kandidat("hirn", "/x/k.gguf", mmproj="/x/mm.gguf",
flags=["--spec-type", "draft-dflash", "--spec-draft-model", "/d.gguf", "-ub", "512"])
assert starts[0] == (None, ["--spec-type", "draft-dflash", "--spec-draft-model", "/d.gguf", "-ub", "512"])
assert starts[1] == ("/x/mm.gguf", ["-ub", "512"])
assert lauf["werte"]["bild"] == {"direkt": 1.0, "agentisch": True}
# --- Prüfstand: Urteil, Programmieraufgaben, Bild ---------------------------------------------------- # --- Prüfstand: Urteil, Programmieraufgaben, Bild ----------------------------------------------------
@pytest.fixture(scope="module") @pytest.fixture(scope="module")
+81 -17
View File
@@ -763,18 +763,21 @@ def pruefe(rolle: str, basis: str, modell: str, *, bild: bool = False, frist: fl
else: else:
werte["code"] = messe_code(basis, modell, frist) werte["code"] = messe_code(basis, modell, frist)
protokoll(f" code : {werte['code']['ok']}/{werte['code']['von']} in {werte['code']['sekunden']} s") protokoll(f" code : {werte['code']['ok']}/{werte['code']['von']} in {werte['code']['sekunden']} s")
werte["bild"] = None werte["bild"] = bild_probe(basis, bild_modell or modell, rolle, frist, protokoll) if bild else None
if bild: werte["dauer_s"] = round(time.time() - t0)
return werte
def bild_probe(basis: str, modell: str, rolle: str, frist: float | None = None, protokoll=log) -> dict:
"""Bild-Probe mit Fehlerfang: ein Absturz der Probe ist ein Messwert, kein Programmfehler."""
try: try:
werte["bild"] = messe_bild(basis, bild_modell or modell, rolle, frist) ergebnis = messe_bild(basis, modell, rolle, frist)
except Zeitende: except Zeitende:
raise raise
except Exception as e: except Exception as e:
werte["bild"] = {"direkt": 0.0, "gefunden": [], "agentisch": False, "fehler": str(e)[:160]} ergebnis = {"direkt": 0.0, "gefunden": [], "agentisch": False, "fehler": str(e)[:160]}
protokoll(f" bild : {werte['bild'].get('direkt')} direkt, im Ablauf " protokoll(f" bild : {ergebnis.get('direkt')} direkt, im Ablauf {'ja' if ergebnis.get('agentisch') else 'nein'}")
f"{'ja' if werte['bild'].get('agentisch') else 'nein'}") return ergebnis
werte["dauer_s"] = round(time.time() - t0)
return werte
# --- Kandidaten-Server ------------------------------------------------------------------- # --- Kandidaten-Server -------------------------------------------------------------------
@@ -908,30 +911,91 @@ def starte_server(gguf: str, *, mmproj: str | None = None, ctx: int = CTX_STANDA
raise raise
def pruefe_kandidat(rolle: str, gguf: str, *, mmproj: str | None = None, flags=(), ctx: int = CTX_STANDARD, def mit_server(gguf: str, arbeit, *, mmproj: str | None = None, ctx: int = CTX_STANDARD, flags=(),
basis: dict | None = None, frist: float | None = None, protokoll=log) -> dict: frist: float | None = None, protokoll=log):
"""Ganzer Durchlauf eines Kandidaten: Server starten → messen stoppen → urteilen. """Kandidat starten, arbeit() laufen lassen, stoppen. Gibt (Ergebnis, Server) zurück; ein Absturz
basis = Werte der Baseline (ohne sie gibt es kein Urteil, ergebnis None). während der Arbeit wird zu ServerFehler — ein toter Server darf nicht wie ein schlechtes Modell aussehen."""
Rückgabe: {"werte", "ergebnis", "vergleich", "zusammenfassung"}."""
server = starte_server(gguf, mmproj=mmproj, ctx=ctx, flags=flags, frist=frist, protokoll=protokoll) server = starte_server(gguf, mmproj=mmproj, ctx=ctx, flags=flags, frist=frist, protokoll=protokoll)
def absturz() -> str: def absturz() -> str:
return f"Während des Tests abgestürzt (Code {server.prozess.returncode}): {log_ende(SERVER_LOG, 300)}" return f"Während des Tests abgestürzt (Code {server.prozess.returncode}): {log_ende(SERVER_LOG, 300)}"
try: try:
werte = pruefe(rolle, KANDIDAT_URL, "kandidat", bild=bool(mmproj), frist=frist, protokoll=protokoll) ergebnis = arbeit()
except Exception as e: except Exception as e:
if not isinstance(e, Zeitende) and server.prozess.poll() is not None: if not isinstance(e, Zeitende) and server.prozess.poll() is not None:
raise ServerFehler(absturz()) from e raise ServerFehler(absturz()) from e
raise raise
else: else:
# Einzelproben fangen Fehler ab — ein toter Server darf nicht wie ein schlechtes Modell aussehen. if server.prozess.poll() is not None: # Einzelproben fangen Fehler ab — darum hier nachsehen
if server.prozess.poll() is not None:
raise ServerFehler(absturz()) raise ServerFehler(absturz())
finally: finally:
stoppe_server(server) stoppe_server(server)
time.sleep(3) # Speicher freigeben lassen, bevor jemand anderes lädt 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: if not basis:
return {"werte": werte, "ergebnis": None, "vergleich": {}, return {"werte": werte, "ergebnis": None, "vergleich": {},
"zusammenfassung": "Gemessen, aber ohne Vergleichswerte des heutigen Modells."} "zusammenfassung": "Gemessen, aber ohne Vergleichswerte des heutigen Modells."}