feat(llm): Cluster — Programme fragen ein Modell, der Cluster waehlt die Karte

Programme sollen nicht Box und Karte aussuchen muessen. Die ARIA-Bridge kennt
alle Boxen samt Karten-Belegung und verteilt (bridge/llm_cluster.py):

- Auswahl: unter allen online Boxen mit dem Modell die mit den wenigsten
  laufenden Anfragen, bei Gleichstand reihum. Eine Karte / alles frei →
  alles dorthin ("verteilen, wenn noetig").
- Ausfall: geht eine Box waehrend der Anfrage offline (kein Ping mehr), wird
  sie EINMAL an eine andere Box mit demselben Modell gegeben. Modell-Fehler
  (z.B. Kontext zu lang) werden nicht wiederholt — waeren ueberall gleich.
- Fehler kommen als Fehler (ok=false + errorCode), nie als Antwort-Text: ein
  Programm wuerde ihn sonst als Aussage der KI in seinen Verlauf schreiben —
  so ist bei ARIA schon mal ein ganzer Chat kaputtgegangen.

RVS: llm_cluster_models_request → llm_cluster_models (jedes Modell EINMAL,
ctx = kleinstes Kontextfenster unter seinen Karten), llm_cluster_request →
llm_cluster_response. In der RVS-Allow-List freigegeben.

ARIA nutzt denselben Router (mit ihrer 30-s-Frist fuer den lokalen
Schnellweg) — vorher Round-Robin ohne Ausfall-Ersatz, und errorCode ging
verloren.

xtts/openai_rvs_wrapper: fuer Programme, die nur "OpenAI-kompatibel" koennen
(OpenHands & Co.). Kleiner Container NEBEN dem Programm, fuer das der Cluster
dann http://127.0.0.1:8099/v1 ist; braucht nur eine ausgehende Verbindung zum
RVS. /v1/models (jedes Modell einmal, context_length), /v1/chat/completions
(auch stream und tools), Fehler als HTTP mit OpenAI-Fehlerobjekt. Hilfsseite
auf / zeigt Status, Base URL und Modelle. Fuer Programme in Docker: Netz
aria-llm, Name openai-rvs-wrapper. .env.example mit RVS_HOST/PORT/TOKEN.
(Ein OpenAI-Endpunkt direkt auf der VM war kurz drin und ist wieder raus —
der Wrapper braucht keinen offenen Port auf der VM.)

docs/LLM-SCHNITTSTELLE.md neu gegliedert: A) Cluster ueber RVS (empfohlen),
B) Wrapper, C) direkt an eine Box (Sonderfall), plus "Umstellen" fuer
bestehende Clients (Software-Schmiede).

Tests: mit echtem RVS und zwei echten llm-adaptern als zwei Boxen —
Modell einmal gelistet, 6 parallele Anfragen 3/3 verteilt, Box friert
mitten in der Anfrage ein → nach 15 s von der anderen beantwortet
(attempts=2), danach direkt die gesunde, nicht geladenes Modell →
model_unavailable ohne content. Wrapper als Docker-Container gegen das
offizielle OpenAI-SDK: models.list, create mit/ohne stream, NotFoundError,
AuthenticationError. Das Doku-Beispiel lief unveraendert (nur Adresse).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-19 01:43:09 +02:00
co-authored by Claude Opus 5.5
parent 60f2d2e087
commit c26b27f09f
14 changed files with 977 additions and 234 deletions
+29 -59
View File
@@ -41,6 +41,7 @@ import websockets
from faster_whisper import WhisperModel
from openwakeword.model import Model as WakeWordModel
from llm_cluster import LlmCluster
from modes import Mode, canonical_id, detect_mode_switch, mode_from_id, should_speak
@@ -806,6 +807,9 @@ class ARIABridge:
# llm_request via RVS an den llm-adapter (AI-Box) und warten auf
# llm_response.
self._pending_llm: dict[str, asyncio.Future] = {}
# LLM-Cluster: ein Eingang fuer alle lokalen Modelle aller Boxen
# (Auswahl nach Last, Ausfall-Ersatz). Siehe llm_cluster.py.
self._llm_cluster = LlmCluster(self)
# User-Message-Counter fuer Auto-Compact. Bei zu langer Konversation
# sprengt die argv-Liste beim Claude-Subprocess-Spawn (E2BIG). Bei
# COMPACT_AFTER erreicht → Sessions reset + Container restart.
@@ -2453,6 +2457,9 @@ class ARIABridge:
msg_type = message.get("type", "")
payload = message.get("payload", {})
if msg_type.startswith("llm_cluster_") and isinstance(payload, dict):
if await self._llm_cluster.handle_rvs(msg_type, payload):
return
if msg_type == "chat":
# Nur User-Nachrichten weiterleiten — ARIA/Diagnostic-Antworten ignorieren (sonst Loop!)
@@ -4252,65 +4259,28 @@ class ARIABridge:
if not isinstance(messages, list) or not messages:
return {"ok": False, "error": "messages leer/ungueltig"}
request_id = str(uuid.uuid4())
loop = asyncio.get_event_loop()
future: asyncio.Future = loop.create_future()
self._pending_llm[request_id] = future
try:
req_payload = {
"requestId": request_id,
"messages": messages,
"max_tokens": max_tokens,
"temperature": temperature,
}
if stop:
req_payload["stop"] = stop
if tools:
req_payload["tools"] = tools
if model:
req_payload["model"] = model
# Redundanz/Multitasking: freie llm-Instanz gezielt adressieren, die
# das gewaehlte Modell fahren kann; None → Broadcast wie bisher.
# Mehrere Boxen mit demselben Modell → Round-Robin (pro Projekt verteilt).
llm_target = self._pick_worker("llm", model=model or None)
if llm_target:
req_payload["targetInstance"] = llm_target
elif model and self._llm_boxes_with_slots():
# Boxen mit Karten-Belegung laden nichts nach und schweigen auf
# Broadcasts fuer fremde Modelle. Ein Broadcast liefe also nur in
# den Timeout — lieber sofort ehrlich absagen.
self._pending_llm.pop(request_id, None)
loaded = sorted({m for w in self._llm_boxes_with_slots() for m in (w.get("models") or [])})
return {"ok": False, "errorCode": "model_not_loaded",
"error": (f"Lokales Modell {model!r} ist auf keiner KI-Box geladen. "
f"Geladen: {', '.join(loaded) or 'nichts'}. "
"Laden: Diagnostic → Modell-Katalog.")}
logger.info("[rvs] llm_request → llm-adapter (id=%s, msgs=%d, max_tokens=%d, tools=%d, model=%s, target=%s)",
request_id[:8], len(messages), max_tokens, len(tools) if tools else 0,
model or "-", llm_target or "(broadcast)")
ok = await self._send_to_rvs({
"type": "llm_request",
"payload": req_payload,
"timestamp": int(time.time() * 1000),
})
if not ok:
return {"ok": False, "error": "llm_request konnte nicht gesendet werden"}
try:
result = await asyncio.wait_for(future, timeout=self._LLM_TIMEOUT_S)
except asyncio.TimeoutError:
return {"ok": False, "error": f"Timeout ({self._LLM_TIMEOUT_S:.0f}s) — AI-Box nicht erreichbar?"}
if not isinstance(result, dict) or not result.get("ok"):
err = (result or {}).get("error") if isinstance(result, dict) else "leeres Resultat"
return {"ok": False, "error": err or "llm-adapter Fehler"}
return {
"ok": True,
"content": result.get("content", ""),
"tool_calls": result.get("tool_calls"),
"model": result.get("model"),
"elapsedMs": result.get("elapsedMs"),
}
finally:
self._pending_llm.pop(request_id, None)
# Derselbe Router wie fuer Fremdprogramme: Box nach Last waehlen, bei
# Ausfall einmal anderswo versuchen, ehrliche Absage, wenn das Modell
# nirgends geladen ist (statt Broadcast + Timeout).
res = await self._llm_cluster.route({
"model": model or "", "messages": messages, "max_tokens": max_tokens,
"temperature": temperature, "stop": stop, "tools": tools,
}, timeout=self._LLM_TIMEOUT_S)
if not res.get("ok"):
err = res.get("error") or "llm-adapter Fehler"
if res.get("errorCode") == "model_unavailable":
err += " Laden: Diagnostic → Einstellungen → LLM-Karten."
return {"ok": False, "error": err, "errorCode": res.get("errorCode")}
logger.info("[cluster] ARIA ← %s (Karte %s, %s ms, Versuche %s)",
res.get("node") or res.get("instanceId"), res.get("gpu", "?"),
res.get("elapsedMs"), res.get("attempts"))
return {
"ok": True,
"content": res.get("content", ""),
"tool_calls": res.get("tool_calls"),
"model": res.get("model"),
"elapsedMs": res.get("elapsedMs"),
}
async def _flux_generate(self, prompt: str, width: int, height: int,
steps: Optional[int], guidance: Optional[float],