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>
358 lines
14 KiB
Markdown
358 lines
14 KiB
Markdown
# Lokale Sprachmodelle nutzen — Schnittstelle für Programme
|
|
|
|
Wie ein Programm die lokalen Sprachmodelle auf den KI-Boxen von ARIA nutzt.
|
|
|
|
**Kurz:** Du fragst ein **Modell**, nicht eine Box oder Karte. Der Cluster
|
|
sucht sich aus, wo es läuft. Eigene Programme sprechen dafür über den RVS
|
|
(Abschnitt A). Programme, die nur „OpenAI-kompatibel" können, bekommen den
|
|
OpenAI-RVS-Wrapper neben sich gestellt (Abschnitt B).
|
|
|
|
## Die Grundregel
|
|
|
|
**Programme wählen ein Modell 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 wo liegt, legt nur der Betreiber in der ARIA-Diagnostic fest
|
|
(Einstellungen → LLM-Karten).
|
|
- Ein Programm sieht die geladenen Modelle des ganzen Clusters — jedes
|
|
**einmal**, egal auf wie vielen Karten es liegt — und wählt eins.
|
|
- Liegt das Modell auf mehreren Karten, **verteilt der Cluster bei Last**.
|
|
Fällt eine Box mitten in einer Anfrage aus, übernimmt eine andere mit
|
|
demselben Modell. Davon merkt das Programm nichts.
|
|
- Ist das Modell nirgends geladen, gibt es einen **Fehler** — nie eine normale
|
|
Antwort mit Fehlertext. Ein Programm würde so einen Text sonst als Aussage
|
|
der KI in seinen Verlauf schreiben.
|
|
- Mehrere Programme dürfen **gleichzeitig dasselbe Modell** nutzen. Die Karte
|
|
bedient sie parallel; jede einzelne Antwort wird dabei etwas langsamer.
|
|
|
|
## Welcher Zugang?
|
|
|
|
| | A) Cluster über RVS | B) OpenAI-RVS-Wrapper | C) Direkt an eine Box |
|
|
|---|---|---|---|
|
|
| Für | eigene Programme | OpenHands, Continue, Open WebUI … | Sonderfälle |
|
|
| Sieht | alle Modelle, jedes einmal | alle Modelle, jedes einmal | nur diese Box |
|
|
| Verteilung + Ausfall-Ersatz | ja | ja | nein |
|
|
| Braucht | ausgehende Verbindung zum RVS | Wrapper-Container auf demselben Rechner | ausgehende Verbindung zum RVS |
|
|
|
|
---
|
|
|
|
## A) Cluster über RVS — empfohlen
|
|
|
|
### Verbinden
|
|
|
|
```
|
|
wss://<RVS_HOST>:<RVS_PORT>/?token=<RVS_TOKEN>
|
|
```
|
|
|
|
- Das **Token ist der Raum** — dasselbe `RVS_TOKEN` wie bei ARIA und den
|
|
KI-Boxen. Wer es hat, ist im Raum.
|
|
- Der Programm-Rechner braucht nur eine **ausgehende** Verbindung zum RVS.
|
|
Keine Portfreigabe, kein VPN — er kann in jedem Netz stehen.
|
|
- Der RVS ist ein **Verteiler**: Jede Nachricht geht an alle anderen im Raum.
|
|
Du siehst also auch Antworten für andere — ordne sie immer über die
|
|
`requestId` zu.
|
|
- Unbekannte Nachrichtentypen verwirft der RVS **ohne Fehlermeldung**. Die hier
|
|
beschriebenen sind freigegeben.
|
|
- **Nur ein Leser pro Verbindung.** Schickst du mehrere Anfragen gleichzeitig,
|
|
liest eine Schleife alle Nachrichten und verteilt sie per `requestId` an die
|
|
Wartenden (siehe Beispiel). Mehrere gleichzeitige `recv()` auf derselben
|
|
Verbindung gehen nicht.
|
|
|
|
Jede Nachricht hat diese Hülle:
|
|
|
|
```json
|
|
{ "type": "<typ>", "payload": { … }, "timestamp": 1760000000000 }
|
|
```
|
|
|
|
Beantwortet werden `llm_cluster_*`-Nachrichten von der **ARIA-Bridge** — sie
|
|
kennt alle Boxen und entscheidet, wohin eine Anfrage geht. Läuft ARIA nicht,
|
|
kommt keine Antwort (dein Programm sollte einen Timeout haben).
|
|
|
|
### 1. Welche Modelle gibt es? — `llm_cluster_models_request`
|
|
|
|
```json
|
|
{ "type": "llm_cluster_models_request", "payload": { "requestId": "a1" } }
|
|
```
|
|
|
|
Antwort:
|
|
|
|
```json
|
|
{
|
|
"type": "llm_cluster_models",
|
|
"payload": {
|
|
"requestId": "a1",
|
|
"models": [
|
|
{
|
|
"model": "qwen3-8b",
|
|
"ctx": 8192,
|
|
"available": 2,
|
|
"cards": [
|
|
{ "node": "ki-box", "instanceId": "llm@ki-box", "gpu": "1", "gpuName": "NVIDIA GeForce RTX 3060", "state": "ready" },
|
|
{ "node": "werkstatt", "instanceId": "llm@werkstatt", "gpu": "0", "gpuName": "NVIDIA RTX A4000", "state": "ready" }
|
|
]
|
|
}
|
|
]
|
|
}
|
|
}
|
|
```
|
|
|
|
| Feld | Bedeutung |
|
|
|---|---|
|
|
| `model` | Name — genau so in der Anfrage angeben. **Das** zeigst du dem Nutzer zur Auswahl. |
|
|
| `ctx` | **Kontextfenster in Tokens** (Eingabe + Ausgabe zusammen). Das kleinste unter allen Karten mit diesem Modell — darauf kannst du dich verlassen, egal wohin verteilt wird. Nicht mehr annehmen. `0` = unbekannt. |
|
|
| `available` | auf wie vielen Karten es gerade liegt |
|
|
| `cards` | nur zur Info (Anzeige, Fehlersuche) — für die Anfrage nicht nötig |
|
|
|
|
### 2. Fragen — `llm_cluster_request`
|
|
|
|
```json
|
|
{
|
|
"type": "llm_cluster_request",
|
|
"payload": {
|
|
"requestId": "a2",
|
|
"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) |
|
|
| `model` | ja* | aus `llm_cluster_models`. *Darf fehlen, wenn im Cluster genau ein Modell geladen ist. |
|
|
| `messages` | ja | Verlauf im OpenAI-Format (`system` / `user` / `assistant` / `tool`) |
|
|
| `max_tokens` | nein | Standard 512 |
|
|
| `temperature` | nein | Standard 0.7 |
|
|
| `stop` | nein | Liste von Stop-Strings |
|
|
| `tools` | nein | Tool-Definitionen im OpenAI-Format; die Antwort enthält dann ggf. `tool_calls` |
|
|
|
|
### 3. Antwort — `llm_cluster_response`
|
|
|
|
Erfolg:
|
|
|
|
```json
|
|
{
|
|
"type": "llm_cluster_response",
|
|
"payload": {
|
|
"requestId": "a2",
|
|
"ok": true,
|
|
"content": "Die Zugspitze ist 2962 Meter hoch.",
|
|
"model": "qwen3-8b",
|
|
"node": "werkstatt",
|
|
"gpu": "0",
|
|
"usage": { "prompt_tokens": 31, "completion_tokens": 12, "total_tokens": 43 },
|
|
"elapsedMs": 640,
|
|
"attempts": 1
|
|
}
|
|
}
|
|
```
|
|
|
|
`tool_calls` steht drin, wenn das Modell ein Werkzeug aufrufen will (OpenAI-Format).
|
|
`node`/`gpu` sagen, wer geantwortet hat; `attempts: 2` heißt, die erste Box ist
|
|
ausgefallen und eine andere hat übernommen.
|
|
|
|
Fehler — `ok: false`, dazu `error` (Text für Menschen) und `errorCode`:
|
|
|
|
| `errorCode` | Bedeutung | Was tun |
|
|
|---|---|---|
|
|
| `model_unavailable` | Das Modell ist gerade auf keiner Karte im Cluster geladen. `available` nennt, was es gibt. | Dem Nutzer anzeigen; Liste neu holen. |
|
|
| `bad_request` | Anfrage unvollständig, z.B. `messages` leer oder `model` fehlt bei mehreren Modellen | Anfrage korrigieren |
|
|
| `timeout` | Keine Antwort innerhalb der Frist (Standard 600 s) — auch nach Ersatzversuch | später nochmal |
|
|
| `box_offline` | Box ist während der Anfrage weggefallen, und es gab keine Ersatz-Box | später nochmal |
|
|
| `model_error` | Das Modell selbst hat einen Fehler gemeldet, z.B. Kontext zu lang. Wird nicht anderswo wiederholt — wäre dort genauso. | `error` lesen |
|
|
|
|
**Fehler nie als Antwort der KI behandeln.** Zeig `error` dem Nutzer als
|
|
Fehlermeldung an, aber schreib ihn nicht als Assistant-Nachricht in den
|
|
Verlauf.
|
|
|
|
### Beispiel (Python)
|
|
|
|
Braucht Python 3.11+ und `websockets` (`pip install websockets`). Ein Leser
|
|
pro Verbindung, mehrere Anfragen gleichzeitig möglich.
|
|
|
|
```python
|
|
import asyncio, json, uuid
|
|
import websockets
|
|
|
|
RVS = "wss://rvs.example.org:444/?token=DEIN_RVS_TOKEN"
|
|
|
|
|
|
class AriaCluster:
|
|
"""Eine RVS-Verbindung, ein Leser. Antworten gehen per requestId an die
|
|
wartende Anfrage — der Raum ist ein Verteiler, Fremdes wird ignoriert."""
|
|
|
|
def __init__(self, ws):
|
|
self.ws, self.wait = ws, {}
|
|
self.reader = asyncio.create_task(self._read())
|
|
|
|
async def _read(self):
|
|
async for raw in self.ws:
|
|
msg = json.loads(raw)
|
|
p = msg.get("payload") or {}
|
|
fut = self.wait.get((msg.get("type"), p.get("requestId")))
|
|
if fut and not fut.done():
|
|
fut.set_result(p)
|
|
|
|
async def _call(self, mtype, answer, payload, timeout):
|
|
rid = str(uuid.uuid4())
|
|
fut = asyncio.get_running_loop().create_future()
|
|
self.wait[(answer, rid)] = fut
|
|
try:
|
|
await self.ws.send(json.dumps({"type": mtype, "payload": dict(payload, requestId=rid)}))
|
|
return await asyncio.wait_for(fut, timeout)
|
|
finally:
|
|
self.wait.pop((answer, rid), None)
|
|
|
|
async def models(self):
|
|
res = await self._call("llm_cluster_models_request", "llm_cluster_models", {}, 6)
|
|
return res["models"]
|
|
|
|
async def chat(self, model, messages, timeout=660, **opts):
|
|
res = await self._call("llm_cluster_request", "llm_cluster_response",
|
|
dict(opts, model=model, messages=messages), timeout)
|
|
if not res["ok"]:
|
|
raise RuntimeError(f"{res.get('errorCode')}: {res.get('error')}")
|
|
return res
|
|
|
|
|
|
async def main():
|
|
async with websockets.connect(RVS, max_size=2**24) as ws:
|
|
cluster = AriaCluster(ws)
|
|
models = await cluster.models()
|
|
for m in models:
|
|
print(f"{m['model']} (Kontext {m['ctx']}, auf {m['available']} Karte(n))")
|
|
if models:
|
|
res = await cluster.chat(models[0]["model"],
|
|
[{"role": "user", "content": "Sag Hallo in einem Satz."}],
|
|
max_tokens=64)
|
|
print(res["content"], f"— von {res['node']} / Karte {res['gpu']}")
|
|
|
|
asyncio.run(main())
|
|
```
|
|
|
|
### Umstellen von der direkten Box-Ansprache
|
|
|
|
Wer schon gegen Abschnitt C gebaut hat (`llm_models_request` /
|
|
`llm_request` mit `targetInstance`), stellt so um:
|
|
|
|
1. **Modelliste:** `llm_cluster_models_request` statt `llm_models_request`.
|
|
Statt einer Antwort pro Box kommt **eine** Antwort mit jedem Modell
|
|
einmal. Kein Einsammeln über Sekunden mehr.
|
|
2. **Auswahl:** Dem Nutzer nur den **Modellnamen** zeigen — nicht mehr
|
|
„Modell · Karte · Box". Wo es läuft, entscheidet der Cluster.
|
|
3. **Kontextfenster:** `ctx` aus der Liste übernehmen statt einen eigenen Wert
|
|
einzustellen. Die Box gibt nur so viel her, wie dort steht — rechnet ein
|
|
Programm mit mehr, scheitern lange Aufträge oder werden falsch gekürzt.
|
|
4. **Anfrage:** `llm_cluster_request` statt `llm_request`; **kein**
|
|
`targetInstance` mehr. Die Antwort kommt als `llm_cluster_response`.
|
|
5. **Fehler:** `ok: false` als Fehler anzeigen, nicht als Antwort in den
|
|
Verlauf. Neue Codes siehe Tabelle oben.
|
|
6. **Lesen:** ein Leser pro Verbindung, Antworten per `requestId` zuordnen.
|
|
|
|
Felder und Format von `messages`, `tools`, `tool_calls` und `usage` bleiben
|
|
gleich.
|
|
|
|
---
|
|
|
|
## B) OpenAI-RVS-Wrapper — für Programme, die nur OpenAI können
|
|
|
|
Ein kleiner Container, der **auf demselben Rechner** wie das Programm läuft.
|
|
Für das Programm ist der Cluster dann ein ganz normaler OpenAI-Endpunkt:
|
|
|
|
```
|
|
Programm ──HTTP──▶ Wrapper (127.0.0.1:8099) ──RVS──▶ ARIA-Bridge ──▶ KI-Box / Karte
|
|
```
|
|
|
|
Liegt im Repo unter [`xtts/openai_rvs_wrapper/`](../xtts/openai_rvs_wrapper/):
|
|
|
|
```bash
|
|
cd xtts/openai_rvs_wrapper
|
|
cp .env.example .env # RVS_HOST, RVS_PORT, RVS_TOKEN eintragen
|
|
docker compose up -d --build
|
|
```
|
|
|
|
Im Browser zeigt **http://127.0.0.1:8099/** eine Hilfsseite: ob die Verbindung
|
|
steht und welche Modelle es gibt. Im Programm eintragen:
|
|
|
|
| Feld | Wert |
|
|
|---|---|
|
|
| Base URL | `http://127.0.0.1:8099/v1` |
|
|
| API-Key | beliebig — oder `WRAPPER_API_KEY`, falls gesetzt |
|
|
| Modell | ein Name von der Hilfsseite |
|
|
| Kontextfenster | der Wert von der Hilfsseite |
|
|
|
|
- `GET /v1/models` — jedes Modell einmal, mit `context_length`
|
|
- `POST /v1/chat/completions` — auch `stream: true` (kommt derzeit in einem
|
|
Rutsch) und `tools`
|
|
- Fehler als HTTP-Fehler mit OpenAI-Fehlerobjekt: nicht geladenes Modell →
|
|
404 `model_not_found`, Cluster nicht erreichbar → 503, Zeitüberschreitung → 504
|
|
- Antwort-Header `X-Aria-Node` / `X-Aria-Gpu` sagen, wer geantwortet hat
|
|
- **Läuft das Programm selbst in Docker**, ist `127.0.0.1` dort der eigene
|
|
Container. Dann das Docker-Netz `aria-llm` anhängen und
|
|
`http://openai-rvs-wrapper:8099/v1` nehmen (Details in der README des Wrappers).
|
|
|
|
Mit dem offiziellen OpenAI-Python-SDK getestet (`models.list`,
|
|
`chat.completions.create` mit und ohne Stream, Fehlerklassen).
|
|
|
|
---
|
|
|
|
## C) Direkt an eine Box — Sonderfall
|
|
|
|
Ohne den Cluster, eine bestimmte Box gezielt. Keine Verteilung, kein
|
|
Ausfall-Ersatz. Nur nötig, wenn ein Programm bewusst eine bestimmte Box will
|
|
oder ARIA nicht läuft.
|
|
|
|
**Modelle der Boxen:** `llm_models_request` `{requestId}` → **jede** Box
|
|
antwortet einzeln mit `llm_models` `{requestId, instanceId, node, models:
|
|
[{model, gpu, gpuName, ctx, state}]}`. Ein paar Sekunden einsammeln.
|
|
|
|
**Anfrage:** `llm_request` `{requestId, targetInstance, model, messages, …}`
|
|
→ `llm_response` `{requestId, ok, content, tool_calls, model, instanceId,
|
|
node, gpu, usage, elapsedMs}`. Bei Fehler `errorCode`:
|
|
`model_not_loaded` (mit `loadedModels`) oder `no_model_loaded`.
|
|
|
|
Ohne `targetInstance` antworten nur Boxen, die das Modell geladen haben — die
|
|
anderen schweigen. Hat keine es, kommt **keine** Antwort. Darum hier immer
|
|
`targetInstance` setzen.
|
|
|
|
**OpenAI-Port einer Box:** Jede Box hat zusätzlich einen eigenen
|
|
OpenAI-Endpunkt (`http://<IP der Box>:8081/v1`, Standard nur `localhost` der
|
|
Box). Er sieht nur diese Box; nicht geladene Modelle → HTTP 404. Für mehrere
|
|
Boxen den Wrapper (B) nehmen.
|
|
|
|
---
|
|
|
|
## Was Programme nicht senden
|
|
|
|
`llm_provision_model`, `llm_unload_model` und `llm_remove_model` sind der
|
|
ARIA-Diagnostic vorbehalten. Technisch könnte jeder im Raum sie senden — der
|
|
RVS unterscheidet die Teilnehmer nicht. Ein Programm, das sich nicht daran
|
|
hält, tauscht anderen das Modell unter den Füßen weg.
|
|
|
|
## Gleichzeitige Nutzung und Geschwindigkeit
|
|
|
|
- Mehrere Anfragen an dasselbe Modell laufen auf einer Karte **parallel**.
|
|
Jede einzelne wird etwas langsamer, in Summe schafft die Karte mehr.
|
|
- Liegt ein Modell auf mehreren Karten, schickt der Cluster neue Anfragen an
|
|
die Karte mit den wenigsten laufenden.
|
|
- Der Kontext (`ctx`) ist bei aktuellen llama.cpp-Versionen ein **gemeinsamer
|
|
Vorrat** aller gleichzeitigen Anfragen auf einer Karte. Schicken mehrere
|
|
Programme gleichzeitig sehr lange Verläufe, wird es dort eng.
|
|
|
|
## Wenn sich etwas ändert
|
|
|
|
- Lädt der Betreiber ein anderes Modell in eine Karte, ist das alte dort weg.
|
|
Liegt es noch auf einer anderen Karte, merkt ein Programm nichts; sonst kommt
|
|
`model_unavailable` — dann die Liste neu holen.
|
|
- Beim Laden oder Entladen startet die betroffene Box **alle** ihre Modelle
|
|
kurz neu (wenige Sekunden). Anfragen in diesem Moment warten oder gehen an
|
|
eine andere Box.
|
|
- Diese Schnittstelle wird erweitert, nicht umgebaut: Neue Felder können
|
|
dazukommen. Unbekannte Felder ignorieren.
|