# Lokale Sprachmodelle nutzen — Schnittstelle für Drittprogramme Diese Seite beschreibt, wie ein Programm die lokalen Sprachmodelle auf den KI-Boxen von ARIA nutzt: über den RVS (WebSocket) oder direkt über den OpenAI-kompatiblen Port einer Box. ## Die Grundregel **Clients wählen aus, laden aber nie.** - Jede KI-Box hat eine oder mehrere Grafikkarten fürs LLM. Jede dieser Karten hält **genau ein Modell, dauerhaft geladen**. - Welches Modell auf welcher Karte liegt, legt ausschließlich der Betreiber in der ARIA-Diagnostic fest (Modell-Katalog → Laden → Karte). - Ein Client — ARIA selbst, ein Drittprogramm, OpenHands — sieht nur die **geladenen** Modelle aller Boxen und schickt seine Anfrage an eines davon. - Eine Anfrage für ein Modell, das nicht geladen ist, wird **abgelehnt**. Sie lädt nichts nach und verdrängt nichts. Kein Client kann einem anderen das Modell wegnehmen. - Mehrere Clients dürfen **gleichzeitig dasselbe Modell** nutzen. Die Karte bedient sie parallel; jede einzelne Antwort wird dabei etwas langsamer, die Karte schafft in Summe aber mehr. Welche Karten einer Box dem LLM gehören, steht in deren `.env`: ```bash # Beispiel: Karte 0 für Spracherkennung + Sprachausgabe, Karten 1 und 2 fürs LLM WHISPER_GPU=0 F5TTS_GPU=0 LLM_GPUS=1,2 ``` ## Zwei Zugänge | | RVS (WebSocket) | OpenAI-Port der Box | |---|---|---| | Erreichbar | überall, wo der RVS erreichbar ist | nur im Netz der Box (Standard: nur `localhost`) | | Sieht | alle Boxen und ihre Karten | nur diese eine Box | | Format | eigene Nachrichten (unten) | OpenAI `/v1/chat/completions` | | Typische Nutzer | eigene Programme, ARIA | OpenHands, Continue, Open WebUI | --- ## A) Über den RVS ### Verbinden ``` wss://:/?token= ``` - Das **Token ist der Raum**. Es ist dasselbe Token, das ARIA, die Diagnostic und die KI-Boxen nutzen (`RVS_TOKEN`). Wer das Token hat, ist im Raum. - Der RVS ist ein **Verteiler**: Jede Nachricht geht an *alle anderen* Teilnehmer des Raums. Du siehst also auch Antworten, die für andere Clients bestimmt sind — ordne Antworten immer über die `requestId` zu. - Der RVS lässt nur bekannte Nachrichtentypen durch. Unbekannte Typen verschwinden **ohne Fehlermeldung**. Die hier beschriebenen sind freigegeben. Jede Nachricht hat diese Hülle: ```json { "type": "", "payload": { … }, "timestamp": 1760000000000 } ``` ### 1. Welche Modelle gibt es? — `llm_models_request` ```json { "type": "llm_models_request", "payload": { "requestId": "abc-1" } } ``` **Jede** Box im Raum antwortet einzeln mit `llm_models`. Sammle die Antworten ein paar Sekunden lang ein (2–3 s reichen): ```json { "type": "llm_models", "payload": { "requestId": "abc-1", "instanceId": "llm@ki-box", "node": "ki-box", "models": [ { "model": "qwen3-8b", "gpu": "1", "gpuName": "NVIDIA GeForce RTX 3060", "state": "ready" }, { "model": "qwen3-4b", "gpu": "2", "gpuName": "NVIDIA GeForce RTX 3060", "state": "ready" } ] } } ``` | Feld | Bedeutung | |---|---| | `instanceId` | Adresse der Box — brauchst du gleich als `targetInstance` | | `node` | lesbarer Name der Box | | `models[].model` | Modellname, so in der Anfrage angeben | | `models[].gpu` | Karte, auf der es läuft | | `models[].state` | `ready` = bereit, `starting` = startet gerade neu (Anfragen warten dann kurz), `unknown` = Box konnte es nicht prüfen | Es stehen **nur geladene** Modelle in der Liste. Was dort nicht steht, kannst du nicht nutzen. Mitlesen statt fragen: Die Boxen melden sich außerdem regelmäßig selbst (`worker_hello` etwa alle 30 s, `worker_ping` alle 10 s, jeweils mit `service: "llm"`, `models` und `slots`). Ein Client, der länger verbunden bleibt, kann die Liste daraus aktuell halten. ### 2. Fragen — `llm_request` ```json { "type": "llm_request", "payload": { "requestId": "abc-2", "targetInstance": "llm@ki-box", "model": "qwen3-8b", "messages": [ { "role": "system", "content": "Du antwortest kurz." }, { "role": "user", "content": "Wie hoch ist die Zugspitze?" } ], "max_tokens": 256, "temperature": 0.7 } } ``` | Feld | Pflicht | Bedeutung | |---|---|---| | `requestId` | ja | eindeutig pro Anfrage (z.B. UUID) — damit findest du deine Antwort | | `messages` | ja | Verlauf im OpenAI-Format (`system` / `user` / `assistant` / `tool`) | | `model` | dringend empfohlen | eines aus `llm_models`. Ohne Angabe nimmt die Box das Modell ihrer ersten LLM-Karte. | | `targetInstance` | dringend empfohlen | die `instanceId` aus `llm_models`. Siehe „Ohne `targetInstance`". | | `max_tokens` | nein | Standard 512 | | `temperature` | nein | Standard 0.7 | | `stop` | nein | Liste von Stop-Strings | | `tools` | nein | Tool-Definitionen im OpenAI-Format; Antwort enthält dann ggf. `tool_calls` | ### 3. Antwort — `llm_response` ```json { "type": "llm_response", "payload": { "requestId": "abc-2", "ok": true, "content": "Die Zugspitze ist 2962 Meter hoch.", "tool_calls": null, "model": "qwen3-8b", "instanceId": "llm@ki-box", "node": "ki-box", "gpu": "1", "usage": { "prompt_tokens": 31, "completion_tokens": 12, "total_tokens": 43 }, "elapsedMs": 640 } } ``` Im Fehlerfall ist `ok` `false`, dazu kommen `error` (Text für Menschen) und `errorCode` (für Programme): | `errorCode` | Bedeutung | Was tun | |---|---|---| | `model_not_loaded` | Das Modell liegt auf dieser Box nicht. `loadedModels` nennt, was dort geladen ist. | Ein geladenes Modell wählen. Die Liste neu holen (`llm_models_request`). | | `no_model_loaded` | Auf dieser Box ist keine LLM-Karte belegt. | Andere Box wählen. | | *(fehlt)* | Fehler des Modells selbst, z.B. Kontext zu lang | `error` lesen | ### Ohne `targetInstance` Ohne `targetInstance` geht die Anfrage an alle Boxen. Antworten werden nur die, die das Modell geladen haben — die anderen **schweigen**, damit du nicht von jeder Box einen Fehler bekommst. Hat keine Box das Modell, kommt also **gar keine** Antwort und dein Client läuft in seinen Timeout. Darum: immer `targetInstance` setzen. Dann bekommst du bei einem Problem sofort eine klare Absage. Haben mehrere Boxen dasselbe Modell geladen und du lässt `targetInstance` weg, antworten **alle**. Auch das spricht für `targetInstance`. ### Was Clients nicht senden `llm_provision_model`, `llm_unload_model` und `llm_remove_model` sind der Diagnostic vorbehalten. Technisch könnte jeder im Raum sie senden — der RVS unterscheidet die Teilnehmer nicht. Ein Programm, das sich daran nicht hält, tauscht anderen Clients das Modell unter den Füßen weg; genau das soll diese Schnittstelle verhindern. ### Beispiel (Python) Braucht Python 3.11+ und `websockets` (`pip install websockets`). Genau so gegen einen echten RVS, llm-adapter und llama-swap getestet — nur die Adresse war eine andere. ```python import asyncio, json, uuid import websockets RVS = "wss://rvs.example.org:443/?token=DEIN_RVS_TOKEN" async def models(ws, wait=2.5): """Alle geladenen Modelle aller Boxen: [(instanceId, node, model, gpu)].""" rid = str(uuid.uuid4()) await ws.send(json.dumps({"type": "llm_models_request", "payload": {"requestId": rid}})) found = [] loop = asyncio.get_running_loop() end = loop.time() + wait while (left := end - loop.time()) > 0: try: msg = json.loads(await asyncio.wait_for(ws.recv(), left)) except asyncio.TimeoutError: break p = msg.get("payload") or {} if msg.get("type") == "llm_models" and p.get("requestId") == rid: found += [(p["instanceId"], p["node"], m["model"], m["gpu"]) for m in p["models"]] return found async def ask(ws, instance_id, model, prompt, timeout=120): rid = str(uuid.uuid4()) await ws.send(json.dumps({"type": "llm_request", "payload": { "requestId": rid, "targetInstance": instance_id, "model": model, "messages": [{"role": "user", "content": prompt}], "max_tokens": 256}})) async with asyncio.timeout(timeout): while True: msg = json.loads(await ws.recv()) p = msg.get("payload") or {} # Der Raum ist ein Verteiler: nur UNSERE Antwort nehmen. if msg.get("type") == "llm_response" and p.get("requestId") == rid: if not p["ok"]: raise RuntimeError(f"{p.get('errorCode')}: {p.get('error')}") return p["content"] async def main(): async with websockets.connect(RVS, max_size=2**24) as ws: available = await models(ws) for iid, node, model, gpu in available: print(f"{node} / Karte {gpu}: {model} ({iid})") if available: iid, node, model, gpu = available[0] print(await ask(ws, iid, model, "Sag Hallo in einem Satz.")) asyncio.run(main()) ``` --- ## B) Über den OpenAI-Port einer Box Jede KI-Box bietet einen OpenAI-kompatiblen Endpunkt — für Programme, die nur „OpenAI-kompatibel" können (OpenHands, Continue, Open WebUI …). ``` http://:/v1 (Standard-Port 8081) ``` - `GET /v1/models` listet **nur die geladenen** Modelle dieser Box. - `POST /v1/chat/completions` mit `"model": ""` wie gewohnt. - Ein nicht geladenes Modell wird mit **HTTP 404** abgelehnt (`"no router for requested model"`) — es wird nichts nachgeladen. - Ist in der Diagnostic ein API-Key gesetzt, mit `Authorization: Bearer ` schicken. - Standardmäßig hört der Port nur auf `localhost` der Box. Für Zugriff aus dem Netz `LLM_OPENAI_BIND=0.0.0.0` in der `.env` der Box setzen — dann **unbedingt** einen API-Key vergeben. - Dieser Zugang sieht nur seine eigene Box. Wer mehrere Boxen nutzen will, nimmt den RVS. ```bash curl http://192.168.177.153:8081/v1/chat/completions \ -H "Content-Type: application/json" \ -d '{"model": "qwen3-8b", "messages": [{"role": "user", "content": "Hallo"}]}' ``` --- ## Gleichzeitige Nutzung und Geschwindigkeit - Mehrere Clients können **dasselbe Modell gleichzeitig** nutzen. llama.cpp bedient sie parallel (Continuous Batching); jede Antwort wird etwas langsamer, insgesamt schafft die Karte mehr. - Der Kontext (`-c`, Standard 8192 Tokens) ist bei aktuellen llama.cpp-Versionen ein **gemeinsamer Vorrat** aller gleichzeitigen Anfragen. Schicken mehrere Clients gleichzeitig sehr lange Verläufe, wird es dort eng. - Ältere llama.cpp-Versionen arbeiten Anfragen an dasselbe Modell nacheinander ab statt parallel. Welche Version eine Box fährt, hängt am Image von llama-swap (`docker compose pull llama-swap` holt die aktuelle). ## Wenn sich etwas ändert - Lädt der Betreiber ein anderes Modell in eine Karte, ist das alte sofort weg; laufende Clients bekommen dann `model_not_loaded` und sollten die Liste neu holen. - Beim Laden oder Entladen startet die Box **alle** ihre Modelle kurz neu (wenige Sekunden). Anfragen in diesem Moment warten, bis das Modell wieder bereit ist (`state: "starting"`). - Diese Schnittstelle wird erweitert, nicht umgebaut: Neue Felder können dazukommen. Ein Client sollte unbekannte Felder ignorieren.