Bisher konnte jeder Client per Modellname llama-swap zum Wechseln bringen — ein Fremdprogramm haette ARIA das Modell unter den Fuessen weggetauscht. Jetzt: - Jede LLM-Karte einer Box (LLM_GPUS=1,2, alt LLM_GPU=1) haelt GENAU ein Modell, dauerhaft. Welches, legt nur die Diagnostic fest. Karten, die nicht fuers LLM vorgesehen sind, tauchen beim LLM nicht auf und werden als Ziel abgelehnt. - Clients (ARIA, Fremdprogramme ueber RVS, OpenHands ueber den OpenAI-Port) sehen nur die geladenen Modelle und laden nie: Anfrage fuer ein anderes → model_not_loaded mit Liste des Geladenen (RVS) bzw. HTTP 404 (OpenAI-Port). Nichts wird verdraengt. - Mehrere Clients koennen dasselbe Modell gleichzeitig nutzen. llm-adapter: Belegung in /models/aria_slots.json. Die llama-swap-Config enthaelt nur belegte Karten: pro Modell CUDA_VISIBLE_DEVICES=<Karte>, ttl 0, groups.swap=false, hooks.on_startup.preload. Alles gegen llama-swap ausprobiert (zwei Modelle gleichzeitig ready, keins verdraengt, Unbekanntes 404, env kommt beim jeweiligen llama-server an). llama-swap laeuft mit --watch-config; die frueher aufgerufenen Reload-Endpunkte (/reload, /api/config/reload) gibt es nicht (404). Neu: llm_unload_model (Karte leeren), llm_models_request → llm_models (Erkundung, jede Box antwortet einzeln). Fehlgeschlagenes Laden setzt die Karte auf ihr vorheriges Modell zurueck, sonst risse jedes Neuladen die anderen Karten mit. Uebernahme: ohne Belegungsdatei landet LLM_MODEL auf der ersten LLM-Karte. Compose: llama-swap sieht alle Karten (NVIDIA_VISIBLE_DEVICES=all) und zaehlt sie wie nvidia-smi (CUDA_DEVICE_ORDER=PCI_BUS_ID); das Pinnen pro Modell macht der Adapter. Bridge: uebernimmt models/slots auch aus worker_ping (nach "Entladen" sonst 30 s veraltet) und sagt sofort ab, wenn das Modell auf keiner Box liegt, statt per Broadcast in den Timeout zu laufen. Diagnostic: Flotte zeigt pro LLM-Karte Modell + Zustand + "Entladen"; Katalog waehlt eine Ziel-KARTE (leere vorausgewaehlt, Ersetzen mit Rueckfrage); Download-Fenster nennt Karte und was ersetzt wird. RVS: llm_unload_model, llm_models_request, llm_models freigegeben. docs/LLM-SCHNITTSTELLE.md: Schnittstelle fuer Drittprogramme (RVS + OpenAI- Port), Fehlercodes, Gleichzeitigkeit, Python-Beispiel. Tests: 23 gegen llama-swap (Adapter direkt) + Ende-zu-Ende ueber den echten RVS mit dem Adapter als Prozess: Laden per RVS mit Fortschritt, das Doku-Beispiel unveraendert (nur Adresse) findet beide Karten und bekommt eine Antwort, Absage/Schweigen wie dokumentiert, zwei Clients parallel. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
296 lines
11 KiB
Markdown
296 lines
11 KiB
Markdown
# 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://<RVS_HOST>:<RVS_PORT>/?token=<RVS_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": "<typ>", "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://<IP der Box>:<LLM_OPENAI_PORT>/v1 (Standard-Port 8081)
|
||
```
|
||
|
||
- `GET /v1/models` listet **nur die geladenen** Modelle dieser Box.
|
||
- `POST /v1/chat/completions` mit `"model": "<Name>"` 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 <KEY>` 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.
|