D16d-Fix: volles Warm-Set nachwärmen statt nur das Hirn

Auto-Rewarm (Nudge + Idle-Tick) lädt jetzt das GANZE Warm-Set über
deploy/warmup.sh nach (fast+vision via chat, embed via /v1/embeddings,
Agent-Prompt-Prefill) statt nur einen Brain-Ping. Erkennt TEIL-Kälte
(Mitglied fehlt in /running), nicht nur den komplett leeren Zustand —
genau der Fall nach einem watch-config-Reload/Deploy (Augen+Gedächtnis
fielen raus, Hirn blieb warm). deploy.sh ruft am Ende warmup.sh.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-07-03 19:54:26 +02:00
parent ce55752249
commit 5ee3f8f73d
2 changed files with 72 additions and 42 deletions
+65 -41
View File
@@ -1,19 +1,26 @@
""" """
Hält das Agent-Hirn (Rolle `hermes`) dauerhaft warm. Hält das ganze WARM-SET (Hirn + Augen/vision + Gedächtnis/embed) dauerhaft warm.
Hintergrund: llama-swap ist EIN-Gruppen-resident — lädt ein on-demand-Modell außerhalb Hintergrund: llama-swap ist EIN-Gruppen-resident — lädt ein on-demand-Modell außerhalb
der `brains`-Gruppe, wird die ganze Gruppe (inkl. Hirn) verdrängt. `persist: true` der `brains`-Gruppe, wird die ganze Gruppe verdrängt. `persist: true` verhindert nur
verhindert nur Idle-Unload, NICHT die Gruppen-Verdrängung; neu vorgewärmt wird sonst erst Idle-Unload, NICHT die Gruppen-Verdrängung; auch ein `-watch-config`-Reload (jede Config-
beim nächsten llama-swap-(Re)Start. Dieser Wächter schließt die Lücke: ist die Box idle Änderung/Deploy) verwirft das Set, ohne llama-swaps ExecStartPost-Warmup neu auszulösen.
(nichts geladen), pingt er das Hirn vor. Während aktiver Last (irgendetwas geladen) hält Dieser Wächter schließt die Lücke: ist die Box idle (nichts geladen), lädt er das ganze
er sich raus, verdrängt also nie ein gerade genutztes Modell. Set über deploy/warmup.sh nach — NICHT nur das Hirn (sonst blieben Augen+embed kalt, live
vom Review-Wächter beobachtet). Während aktiver Last (irgendetwas geladen) hält er sich
raus, verdrängt also nie ein gerade genutztes Modell.
Abschaltbar/justierbar via Env: MC_REWARM_ENABLED=0, MC_REWARM_INTERVAL, MC_REWARM_MODEL. warmup.sh deckt korrekt ab: fast+vision über /v1/chat/completions, embed über /v1/embeddings
(anderer Endpunkt!) und das Vorkauen des ~70-KB-Agent-Prompts. Es ist selbst-detachend.
Abschaltbar/justierbar via Env: MC_REWARM_ENABLED=0, MC_REWARM_INTERVAL. Welche Modelle
warmup.sh lädt: MC_WARMUP_MODELS (Default 'fast vision') + MC_WARMUP_EMBED (Default 'embed').
""" """
import asyncio import asyncio
import logging import logging
import os import os
import subprocess
import httpx import httpx
@@ -26,6 +33,10 @@ INTERVAL = int(os.environ.get("MC_REWARM_INTERVAL", "90")) # Sekunden zwisch
START_DELAY = int(os.environ.get("MC_REWARM_START_DELAY", "25")) START_DELAY = int(os.environ.get("MC_REWARM_START_DELAY", "25"))
NUDGE_GRACE = int(os.environ.get("MC_REWARM_NUDGE_GRACE", "3")) # llama-swap den Reload abschließen lassen NUDGE_GRACE = int(os.environ.get("MC_REWARM_NUDGE_GRACE", "3")) # llama-swap den Reload abschließen lassen
# Das geteilte Warm-Skript (auch llama-swaps ExecStartPost) — EINE Quelle für „was ist das
# Warm-Set und wie wärmt man es korrekt", statt hier eine zweite, ärmere Logik zu pflegen.
_WARMUP_SH = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "deploy", "warmup.sh"))
# Wecksignal für einen sofortigen Vorwärm-Check (statt bis zum nächsten INTERVAL-Tick zu warten). # Wecksignal für einen sofortigen Vorwärm-Check (statt bis zum nächsten INTERVAL-Tick zu warten).
# Wird von write_config() nach einer Config-Änderung gesetzt: llama-swap (-watch-config) lädt die # Wird von write_config() nach einer Config-Änderung gesetzt: llama-swap (-watch-config) lädt die
# neue Config und verwirft dabei ALLE Modelle inkl. Hirn — ohne Nudge bliebe es bis zu INTERVAL # neue Config und verwirft dabei ALLE Modelle inkl. Hirn — ohne Nudge bliebe es bis zu INTERVAL
@@ -44,56 +55,67 @@ def nudge() -> None:
pass pass
def _brain_model() -> str: def _run_warmup() -> bool:
"""Aktives Agent-Hirn = Hermes' model.default (sonst model.model). So wärmt der Wächter """Volles Warm-Set via deploy/warmup.sh nachladen (fast+vision über /v1/chat/completions,
immer das WIRKLICH genutzte Hirn (passt sich Hirn-Wechseln an). Override: MC_REWARM_MODEL.""" embed über /v1/embeddings, plus Agent-Prompt-Prefill). Selbst-detachend, blockiert nicht.
forced = os.environ.get("MC_REWARM_MODEL") False, wenn das Skript fehlt."""
if forced: if not os.path.exists(_WARMUP_SH):
return forced log.warning("rewarm: warmup.sh nicht gefunden (%s) — kein Nachwärmen möglich", _WARMUP_SH)
return False
try: try:
from ruamel.yaml import YAML subprocess.Popen(["bash", _WARMUP_SH],
from config import HERMES_HOME env={**os.environ, "MC_LLAMA_SWAP_URL": LLAMA_SWAP_URL},
p = HERMES_HOME / "config.yaml" stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
if p.exists(): return True
with p.open(encoding="utf-8") as f:
cfg = YAML().load(f) or {}
m = (cfg.get("model") or {}) if isinstance(cfg, dict) else {}
v = m.get("default") or m.get("model")
if v:
return str(v)
except Exception: except Exception:
log.debug("rewarm: Hirn-Lookup fehlgeschlagen", exc_info=True) log.debug("rewarm: warmup.sh-Start fehlgeschlagen", exc_info=True)
return "fast" return False
async def _running_empty() -> bool: def _warmset_members() -> set[str]:
"""Soll-Warm-Set = Mitglieder aller ko-residenten Gruppen (swap:false bzw. persist/persistent)."""
try:
from services import llamaswap
groups = llamaswap.list_groups() or {}
except Exception:
return set()
want: set[str] = set()
for g in groups.values():
if isinstance(g, dict) and (g.get("swap") is False or g.get("persist") or g.get("persistent")):
want.update(g.get("members") or [])
return want
async def _warmset_missing() -> list[str] | None:
"""Warm-Set-Mitglieder, die NICHT 'ready' in /running sind. [] = alles warm, None = /running
nicht lesbar. Erkennt auch TEIL-Kälte (nur Hirn warm, Augen/embed rausgefallen) — genau der
Fall, der nach einem watch-config-Reload/Deploy auftritt."""
try:
async with httpx.AsyncClient(timeout=8.0) as c: async with httpx.AsyncClient(timeout=8.0) as c:
r = await c.get(f"{LLAMA_SWAP_URL}/running") r = await c.get(f"{LLAMA_SWAP_URL}/running")
data = r.json() or {} data = r.json() or {}
return not (data.get("running") or []) except Exception:
return None
ready = {str(x.get("model")) for x in (data.get("running") or []) if x.get("state") == "ready"}
async def _warm(model: str) -> None: want = _warmset_members()
async with httpx.AsyncClient(timeout=180.0) as c: if not want: # Set unbekannt → alte Heuristik: nur bei ganz leer
await c.post(f"{LLAMA_SWAP_URL}/v1/chat/completions", json={ return [] if ready else ["<leer>"]
"model": model, "max_tokens": 1, return [m for m in want if m not in ready]
"messages": [{"role": "user", "content": "ping"}],
})
async def rewarm_loop() -> None: async def rewarm_loop() -> None:
"""Endlos-Schleife (Hintergrund-Task): prüft periodisch (und sofort nach einem Config- """Endlos-Schleife (Hintergrund-Task): hält das ganze Warm-Set warm. Prüft periodisch, ob ein
Reload-Nudge), wärmt bei Idle vor.""" Mitglied fehlt (Teil-Kälte!), und sofort nach einem Config-Reload-Nudge — lädt via warmup.sh nach."""
global _loop, _wake global _loop, _wake
_loop = asyncio.get_running_loop() _loop = asyncio.get_running_loop()
_wake = asyncio.Event() _wake = asyncio.Event()
await asyncio.sleep(START_DELAY) # Box/Engine nach MC-Start setzen lassen await asyncio.sleep(START_DELAY) # Box/Engine nach MC-Start setzen lassen
while True: while True:
try: try:
if await _running_empty(): missing = await _warmset_missing()
model = _brain_model() if missing:
log.info("rewarm: Box idle → Hirn '%s' wird vorgewärmt", model) log.info("rewarm: Warm-Set unvollständig (%s) → warmup.sh", ", ".join(missing))
await _warm(model) _run_warmup()
except Exception: except Exception:
log.debug("rewarm: Tick fehlgeschlagen", exc_info=True) log.debug("rewarm: Tick fehlgeschlagen", exc_info=True)
# Bis zum nächsten Tick warten ODER sofort auf einen Config-Reload-Nudge reagieren. # Bis zum nächsten Tick warten ODER sofort auf einen Config-Reload-Nudge reagieren.
@@ -101,5 +123,7 @@ async def rewarm_loop() -> None:
await asyncio.wait_for(_wake.wait(), timeout=INTERVAL) await asyncio.wait_for(_wake.wait(), timeout=INTERVAL)
_wake.clear() _wake.clear()
await asyncio.sleep(NUDGE_GRACE) # llama-swap den Reload abschließen lassen await asyncio.sleep(NUDGE_GRACE) # llama-swap den Reload abschließen lassen
log.info("rewarm: Config-Reload → warmup.sh (volles Warm-Set nachladen)")
_run_warmup()
except asyncio.TimeoutError: except asyncio.TimeoutError:
pass pass
+6
View File
@@ -93,4 +93,10 @@ command -v ttyd >/dev/null 2>&1 && systemctl --user restart hermes-terminal 2>/d
sleep 2 sleep 2
echo "--- Health ---" echo "--- Health ---"
curl -sf http://127.0.0.1:9001/api/health && echo curl -sf http://127.0.0.1:9001/api/health && echo
# Warm-Set (Hirn + Augen/vision + Gedächtnis/embed) nach dem Deploy nachladen — ein Deploy bzw.
# watch-config-Reload verwirft es sonst und die erste Anfrage wäre kalt (D16d). warmup.sh ist
# selbst-detachend (blockiert den Deploy nicht) und lädt jedes Modell über den richtigen Endpunkt.
bash "$SRC/deploy/warmup.sh" || true
echo "OK — Mission Control 2.0 läuft auf :9001 (User-Dienst, sudo-frei)." echo "OK — Mission Control 2.0 läuft auf :9001 (User-Dienst, sudo-frei)."