Files
ARIA-AGENT/docs/LLM-SCHNITTSTELLE.md
T
duffyduckandClaude Opus 5.5 23feb3ba2d feat(llm): ein Modell pro Karte, Clients waehlen nur aus — und Doku fuer Drittprogramme
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>
2026-10-18 16:04:24 +02:00

296 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.