mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 19:01:08 +02:00
docs(plans): LLM-fallback via model-aliases — spec
Centralized resilience-layer in mana-llm: callers send semantic aliases (`mana/long-form`, `mana/structured`, …), the router resolves to a provider chain and falls back through unhealthy providers via a 30s health-probe loop. Triggered by today's GPU-server outage that hung the writing-generation endpoint for 75s before 500. 5 milestones, ~3 dev-days, big-bang migration (no live yet → no legacy). All hardcoded `ollama/...` strings move into a single aliases.yaml SSOT, new validate-llm-strings.mjs gate prevents regression. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
aac1e3d55c
commit
e1860234d6
1 changed files with 236 additions and 0 deletions
236
docs/plans/llm-fallback-aliases.md
Normal file
236
docs/plans/llm-fallback-aliases.md
Normal file
|
|
@ -0,0 +1,236 @@
|
|||
# LLM-Fallback via Model-Aliases — Plan
|
||||
|
||||
_Drafted 2026-04-26. Status: **spec**, nicht implementiert. Bau-Trigger: User-Entscheidung nach dem Schreiben-Modul-Generation-Ausfall heute (GPU-Server offline → 75 s Hang → mana-llm 500 → Frontend "Fehlgeschlagen")._
|
||||
|
||||
Macht die LLM-Pipeline resilient gegen Provider-Ausfälle (heute: GPU-Server `mana-gpu` offline, Ollama unerreichbar; morgen: Groq-API-Limit, Anthropic-Outage, …). Statt jedem Consumer eine Retry-Logik mit konkreten Modell-Strings beizubringen, gibt mana-llm zukünftig **Model-Aliases** aus, die Health-Cache-bewusst auf eine Provider-Chain auflösen. Consumer-Code kennt nur noch `mana/long-form`, nicht mehr `ollama/gemma3:12b`.
|
||||
|
||||
**Hintergrund:**
|
||||
- [`services/mana-llm/CLAUDE.md`](../../services/mana-llm/CLAUDE.md) — heutige Provider-Routing-Architektur (Ollama / OpenRouter / Groq / Together / Google), OpenAI-kompatible API
|
||||
- Konkreter Bug: `services/mana-llm` Logs vom 17:48 → `Chat completion failed on ollama: Server disconnected without sending a response.` nach 75 s, ausgelöst durch toten GPU-Server. Generation-Endpoint in `apps/api/src/modules/writing/routes.ts` hat 500 zurückgegeben.
|
||||
- Verwandt: [`docs/plans/agent-loop-improvements-m1.md`](./agent-loop-improvements-m1.md) (mana-ai consumiert mana-llm intensiv und ist heute genauso fragil)
|
||||
|
||||
## Ziel in einem Satz
|
||||
|
||||
Ein Consumer schickt einen semantischen Alias (`"model": "mana/long-form"`); mana-llm löst auf eine Provider-Chain auf, überspringt unhealthy Provider auf Basis eines kontinuierlichen Health-Cache, ruft den ersten verfügbaren auf, und liefert die Antwort plus einen `X-Mana-LLM-Resolved`-Header mit dem tatsächlich genutzten Modell.
|
||||
|
||||
## Nicht-Ziele
|
||||
|
||||
- **Latenz-/Kosten-optimierte Provider-Auswahl.** Kein "wenn Groq billiger ist, nimm Groq". Erstes _healthy_ Glied der Chain gewinnt — Reihenfolge ist die Strategie, nichts anderes. Die Chain definiert auch die Präferenz.
|
||||
- **Per-User-Routing.** User A kriegt nicht ein anderes Modell als User B. Alias → Chain ist global. Per-User-Auswahl gehört in die Frontend-Layer (z.B. ein expliziter Modell-Picker im Schreiben-Modul), nicht ins Routing.
|
||||
- **Mid-Stream-Fallback.** Wenn ein SSE-Stream nach dem ersten Byte abbricht, schlagen wir hart fehl. Provider-Wechsel mitten im Stream + Output-Verschmelzung wäre eine Quelle subtiler Korruption (zwei Modelle, zwei Tonarten, zwei Halluzinationen). Pre-First-Byte-Fallback ist sauber, mid-stream nicht.
|
||||
- **A/B-Testing zwischen Modellen.** Außerhalb des Scopes. Falls später gewünscht, eigener Mechanismus mit Cohort-Stickiness.
|
||||
- **Verbundene Quotas / Token-Budgets.** Alias-Layer kennt keine User-Tier-/Credits-Logik — das passiert weiter in den Consumern (Credits-Service-Calls in `mana-credits`).
|
||||
- **Caller-spezifische Overrides** ("Schreiben-Modul will heute mal `groq/llama-3.3-70b` direkt"). Direkter Modell-String bleibt im Request akzeptiert (Backwards-Compat _innerhalb_ mana-llm-API), aber kein Code im Repo benutzt das mehr — `grep -r "ollama/" apps services packages` findet nach der Migration nur noch `services/mana-llm/aliases.yaml`.
|
||||
|
||||
## Entscheidungen (gesetzt)
|
||||
|
||||
| Frage | Entscheidung | Warum |
|
||||
|---|---|---|
|
||||
| Wo lebt die Logik | **In `mana-llm`** | Heute schon der Provider-Router; alle Consumer (mana-api, mana-ai, web direkt) profitieren ohne Code-Änderung der Caller-Library. Eine Implementierung, ein Health-Cache, ein Konfig-Punkt. |
|
||||
| Schnittstelle zum Consumer | **Alias als Modell-String** (`"model": "mana/long-form"`) | Behält die OpenAI-kompatible Request-Form. Caller braucht keine neuen Felder, kein neues Schema. Migration = String-Replace. |
|
||||
| Konfigurations-Format | **YAML neben `config.py`**, hot-reload via SIGHUP | Lesbar, diff-bar, ohne Code-Deploy änderbar. Python-stdlib `yaml`-Parser reicht. |
|
||||
| Health-Probe-Interval | 30 s, 3 s Timeout pro Probe | Schneller als die typische 75 s-Failure-Latenz, langsam genug um keinen Provider zu DDoSen. |
|
||||
| Circuit-Breaker | 2 Fehlschläge → 60 s unhealthy → Re-Probe | Standard-Tuning. Verhindert Death-Spiral bei flappy Provider. |
|
||||
| Fehlerklassen, die Fallback auslösen | `httpx.ConnectError`, `httpx.ReadTimeout`, `httpx.RemoteProtocolError`, HTTP 5xx, "Server disconnected" | Genau die Klassen, die heute den Bug erzeugen. **HTTP 4xx wird propagiert** — das ist Caller-Fehler, nicht Provider-Ausfall. |
|
||||
| Streaming | Pre-First-Byte-Fallback, Mid-Stream hart fehlschlagen | Saubere Semantik, keine "halben" Outputs. |
|
||||
| Mana-Aliases als reservierter Namespace | `mana/<class>`, alles andere durchreichen wie heute | Klare Grenze. `ollama/...` / `groq/...` bleiben legaler Direct-Call (für Tests, Debugging) — aber kein Produktcode benutzt es mehr. |
|
||||
| Beobachtbarkeit | Prometheus + Response-Header | Header zeigt _diesem_ Caller welches Modell antwortete (für Token-Cost-Buchhaltung); Prometheus für Aggregat-Sicht. |
|
||||
| Migrations-Strategie | **Big-Bang**, ein PR — kein Legacy | User-Vorgabe ("nicht live, unendliche Ressourcen, sauberste Lösung ohne Legacy"). Alle bestehenden `WRITING_MODEL` / `COMIC_STORYBOARD_MODEL` / `MANA_LLM_DEFAULT_MODEL` etc. Env-Vars entfallen ersatzlos. |
|
||||
|
||||
## Architektur
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────┐
|
||||
Consumer │ POST /v1/chat/completions │
|
||||
("mana/long- │ { "model": "mana/long-form", ... } │
|
||||
form") └────────────────────┬─────────────────────┘
|
||||
│
|
||||
┌────────────────────▼─────────────────────┐
|
||||
│ AliasRegistry.resolve() │
|
||||
│ mana/long-form → [ │
|
||||
│ "ollama/gemma3:12b", │
|
||||
│ "groq/llama-3.3-70b-versatile", │
|
||||
│ "openrouter/anthropic/claude-3-haiku" │
|
||||
│ ] │
|
||||
└────────────────────┬─────────────────────┘
|
||||
│
|
||||
┌────────────────────▼─────────────────────┐
|
||||
│ Router.execute_with_fallback() │
|
||||
│ for model in chain: │
|
||||
│ if not health.is_healthy(provider): │
|
||||
│ continue │
|
||||
│ try: return provider.complete(...) │
|
||||
│ except ConnectError/5xx: │
|
||||
│ health.mark_unhealthy(provider) │
|
||||
│ continue │
|
||||
│ raise NoHealthyProviderError │
|
||||
└────────────────────┬─────────────────────┘
|
||||
│
|
||||
▼
|
||||
Response + Header `X-Mana-LLM-Resolved: groq/llama-3.3-70b`
|
||||
|
||||
|
||||
Hintergrund-Loop (alle 30 s):
|
||||
┌─────────────────────────────────────────┐
|
||||
│ HealthProbe.tick() │
|
||||
│ for provider in [ollama, groq, ...]: │
|
||||
│ probe(provider, timeout=3s) │
|
||||
│ update health_cache │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Aliases (initialer Stand)
|
||||
|
||||
`services/mana-llm/aliases.yaml`:
|
||||
|
||||
```yaml
|
||||
# Alle aktiv genutzten Klassen. Reihenfolge im chain[] = Präferenz.
|
||||
# Erstes healthy Glied gewinnt.
|
||||
aliases:
|
||||
mana/fast-text:
|
||||
description: "Kurze Antworten, Klassifikation, Single-shot Q&A"
|
||||
chain:
|
||||
- ollama/qwen2.5:7b
|
||||
- groq/llama-3.1-8b-instant
|
||||
- openrouter/anthropic/claude-3-haiku
|
||||
|
||||
mana/long-form:
|
||||
description: "Schreiben, Essays, Stories, längere Prosa"
|
||||
chain:
|
||||
- ollama/gemma3:12b
|
||||
- groq/llama-3.3-70b-versatile
|
||||
- openrouter/anthropic/claude-3.5-haiku
|
||||
|
||||
mana/structured:
|
||||
description: "JSON-Output (Comic-Storyboard, Research-Subqueries, Tag-Vorschläge)"
|
||||
chain:
|
||||
- ollama/qwen2.5:7b
|
||||
- groq/llama-3.1-8b-instant
|
||||
# OpenRouter unterstützt strict JSON-Schema bei den meisten Modellen
|
||||
- openrouter/openai/gpt-4o-mini
|
||||
|
||||
mana/reasoning:
|
||||
description: "Agent-Missions, Tool-Calls, Mehrstufige Plans"
|
||||
chain:
|
||||
# Bewusst Cloud zuerst — lokale 4B-Modelle reichen nicht für Tool-Calls
|
||||
- openrouter/anthropic/claude-3.5-sonnet
|
||||
- groq/llama-3.3-70b-versatile
|
||||
|
||||
mana/vision:
|
||||
description: "Multimodal (Bild + Text)"
|
||||
chain:
|
||||
- ollama/llava:7b
|
||||
- google/gemini-2.0-flash-exp
|
||||
- openrouter/openai/gpt-4o
|
||||
```
|
||||
|
||||
5 Aliases sind genug für den heutigen Stand. Neue kommen dazu wenn ein Use-Case sie braucht — kein "wir bauen schon mal `mana/translation` weil's sein könnte".
|
||||
|
||||
**Default-Alias bei undefiniertem `model`-Feld:** `mana/fast-text`. Bisheriger `OLLAMA_DEFAULT_MODEL`-Env entfällt (= "kein Caller darf vergessen, ein Modell zu schicken; wenn er's tut, kriegt er den günstigsten").
|
||||
|
||||
## Komponenten in mana-llm
|
||||
|
||||
| File | Zweck |
|
||||
|---|---|
|
||||
| `services/mana-llm/src/aliases.py` (neu) | `AliasRegistry`-Klasse, lädt + reloaded `aliases.yaml`. Methoden: `resolve(name) -> list[str]`, `is_alias(name) -> bool`, `reload()`. |
|
||||
| `services/mana-llm/src/health.py` (neu) | `ProviderHealthCache` mit per-Provider-State `{healthy, last_check, consecutive_failures, unhealthy_until}`. Methoden: `is_healthy(provider_id)`, `mark_unhealthy(provider_id, reason)`, `mark_healthy(provider_id)`. |
|
||||
| `services/mana-llm/src/health_probe.py` (neu) | Hintergrund-`asyncio.Task`. Probe-Strategie pro Provider-Typ: `GET /api/tags` (Ollama) bzw. `GET /v1/models` (OpenAI-compat). Startet bei `app.on_startup`. |
|
||||
| `services/mana-llm/src/providers/router.py` (umbau) | `chat_completion()` und `chat_completion_stream()` werden zu Wrappern um eine neue `_execute_with_fallback(model_or_alias, request)`-Methode. Heutige Logik (Provider-Picking via "/" splitten) wandert nach innen. |
|
||||
| `services/mana-llm/src/main.py` (klein erweitert) | Response-Header setzen (`X-Mana-LLM-Resolved`). SIGHUP-Handler für Alias-Reload. Neue Endpunkte `GET /v1/aliases`, `GET /v1/health`. |
|
||||
| `services/mana-llm/src/utils/metrics.py` (klein erweitert) | Neue Counter `mana_llm_fallback_total{from_model, to_model, reason}`, `mana_llm_alias_resolved_total{alias, target}`, Gauge `mana_llm_provider_healthy{provider}`. |
|
||||
| `services/mana-llm/aliases.yaml` (neu) | Konfigurations-Datei, im Repo eingecheckt. |
|
||||
| `services/mana-llm/CLAUDE.md` (update) | Neue Sektion "Aliases & Fallback". Tabelle aller 5 Aliases. Beispiel-Calls aktualisiert. |
|
||||
| `services/mana-llm/tests/test_aliases.py` (neu) | YAML-Parsing, Reload, unbekannte Alias → Fehler. |
|
||||
| `services/mana-llm/tests/test_fallback.py` (neu) | Mock-Provider mit injizierbaren Failures, Sequenz-Tests: erstes healthy → erstes nimmt. Erstes unhealthy → zweites. Alle unhealthy → 503. |
|
||||
|
||||
## Consumer-Migration
|
||||
|
||||
Eigene zentrale Konstanten-Datei statt verstreutem `process.env.WRITING_MODEL || 'ollama/...'`:
|
||||
|
||||
`apps/api/src/lib/llm-aliases.ts` (neu):
|
||||
|
||||
```ts
|
||||
/**
|
||||
* Mana LLM model aliases — single source of truth for which class of model
|
||||
* each backend feature uses. Resolved server-side by mana-llm; consumers
|
||||
* never see the underlying provider/model unless they explicitly need to
|
||||
* (rare — mainly for token-cost accounting via the X-Mana-LLM-Resolved
|
||||
* response header).
|
||||
*/
|
||||
export const MANA_LLM = {
|
||||
FAST_TEXT: 'mana/fast-text',
|
||||
LONG_FORM: 'mana/long-form',
|
||||
STRUCTURED: 'mana/structured',
|
||||
REASONING: 'mana/reasoning',
|
||||
VISION: 'mana/vision',
|
||||
} as const;
|
||||
```
|
||||
|
||||
Ersetzt:
|
||||
- `apps/api/src/modules/writing/routes.ts:22` — `DEFAULT_MODEL = process.env.WRITING_MODEL || 'ollama/gemma3:4b'` → `MANA_LLM.LONG_FORM`. `WRITING_MODEL`-Env weg.
|
||||
- `apps/api/src/modules/comic/routes.ts:32` — `STORYBOARD_MODEL = process.env.COMIC_STORYBOARD_MODEL || 'ollama/gemma3:4b'` → `MANA_LLM.STRUCTURED`. Env weg.
|
||||
- `apps/api/src/modules/context/routes.ts:14` — `DEFAULT_SUMMARY_MODEL = process.env.MANA_LLM_DEFAULT_MODEL || 'gemma3:4b'` → `MANA_LLM.FAST_TEXT`. Env weg.
|
||||
- `apps/api/src/modules/research/orchestrator.ts:233` — Modell-Strings im `llmJson()`-Call → `MANA_LLM.STRUCTURED`.
|
||||
- `services/mana-ai/src/planner/*` — sämtliche Modell-Strings → `mana/reasoning` über äquivalente shared Konstante in `services/mana-ai/src/llm-aliases.ts`.
|
||||
- Web-App: kein direkter Aufruf von mana-llm aus dem Browser heute (geht alles über mana-api), daher hier nichts zu tun.
|
||||
|
||||
`grep -r "ollama/\|groq/\|openrouter/\|together/\|google/" apps services packages` muss nach der Migration leer sein, mit Ausnahme von:
|
||||
- `services/mana-llm/aliases.yaml` (das ist die SSOT)
|
||||
- `services/mana-llm/tests/` (Test-Fixtures)
|
||||
- `services/mana-llm/CLAUDE.md` (Beispiele)
|
||||
|
||||
Wir validieren das mit einem neuen `validate-llm-strings.mjs` in `scripts/`, eingebunden in `validate:all`.
|
||||
|
||||
## Test-Plan
|
||||
|
||||
**Unit (Python, mana-llm):**
|
||||
- `test_aliases.py` — YAML-Parsing-Edge-Cases (leerer Chain, unbekannter Provider in Chain, doppelter Alias, Reload nach Datei-Edit).
|
||||
- `test_fallback.py` mit Mock-Providern:
|
||||
- Single-Glied-Chain, Provider OK → 1 Call, Erfolg, Response-Header gesetzt.
|
||||
- 3-Glied-Chain, erstes unhealthy laut Cache → 0 Calls auf erstes, 1 Call auf zweites.
|
||||
- 3-Glied-Chain, erstes wirft `ConnectError` → markiert unhealthy, ruft zweites, Erfolg.
|
||||
- 3-Glied-Chain, alle unhealthy → 503 mit `NoHealthyProviderError`.
|
||||
- 4xx Fehler vom ersten Provider → KEIN Fallback, propagiert.
|
||||
- Streaming: erste Bytes flowen → Verbindung bricht → harter Fehler, kein Wechsel.
|
||||
- `test_health_probe.py` — Probe-Loop markiert unhealthy nach 2 Fehlschlägen, healthy nach 1 Erfolg, respektiert 60s Backoff.
|
||||
|
||||
**Integration (Python, mana-llm):**
|
||||
- Live-Smoke-Test gegen lokales Ollama (in CI nicht aktiv, lokal mit `pytest -m integration`).
|
||||
|
||||
**Validation Script (Node, repo-weit):**
|
||||
- `scripts/validate-llm-strings.mjs` — failed wenn Code in `apps/`/`services/` (außer mana-llm selbst) hardcoded Provider-Strings (`ollama/X`, `groq/X`, etc.) enthält. In `validate:all` einbinden.
|
||||
|
||||
**Manual Smoke (vor Merge):**
|
||||
- GPU-Server hochfahren, generation in /writing macht: `X-Mana-LLM-Resolved: ollama/gemma3:12b`.
|
||||
- GPU-Server runterfahren, generation macht: `X-Mana-LLM-Resolved: groq/llama-3.3-70b-versatile` in <1s.
|
||||
- Groq-Key entfernen, generation macht: `X-Mana-LLM-Resolved: openrouter/...`.
|
||||
- Alle Keys raus, generation macht: 503 mit `NoHealthyProviderError`.
|
||||
|
||||
## Milestones
|
||||
|
||||
| M | Inhalt | Aufwand-Schätzung |
|
||||
|---|---|---|
|
||||
| **M1** | `aliases.yaml` + `AliasRegistry` + Unit-Tests. Resolution funktioniert, aber kein Health-Cache, keine Fallback-Loop — nur Alias-→-erstes-Glied-Aufruf. | 0.5 d |
|
||||
| **M2** | `ProviderHealthCache` + Hintergrund-Probe-Loop + Unit-Tests. Cache wird befüllt; Router fragt noch nicht ab. | 0.5 d |
|
||||
| **M3** | `Router.execute_with_fallback()` integriert Cache und Chain-Loop. Streaming-Pre-First-Byte-Logik. Unit-Tests (Mock-Provider). | 1 d |
|
||||
| **M4** | Response-Header, Prometheus-Metrics, `GET /v1/aliases` + `GET /v1/health` Debug-Endpoints, SIGHUP-Reload, `services/mana-llm/CLAUDE.md` Update. | 0.5 d |
|
||||
| **M5** | Consumer-Migration: 4 Files in `apps/api/src/modules/*/routes.ts` + mana-ai planner. Env-Vars aus `.env.development` raus. `validate-llm-strings.mjs` + `validate:all`-Hookup. Manual smoke. | 0.5 d |
|
||||
|
||||
**Gesamt:** ~3 Entwicklertage. Linear (M1 vor M2 vor M3 …), kein parallelisierbarer Pfad.
|
||||
|
||||
## Alternativen, die ich verworfen habe
|
||||
|
||||
- **Per-Request Fallback-Chain im Body** (Caller schickt `models: ["...", "..."]`) — verlagert die Modellauswahl-Logik zurück in jeden Consumer; jeder Caller müsste die aktuelle Provider-Landschaft kennen; kein Health-Cache → jeder Request zahlt 75 s Timeout am toten Provider selbst. Schmutzig und langsam.
|
||||
- **Fallback in `apps/api/src/lib/llm.ts`** — würde nur mana-api-Caller schützen. mana-ai (Background-Mission-Runner) hat seinen eigenen LLM-Client und bleibt fragil. Zwei Implementierungen, zwei Bug-Quellen, doppelte Health-Caches.
|
||||
- **LiteLLM/OpenRouter-as-a-service** als Sidecar statt mana-llm-Erweiterung — mehr Komponenten, weniger Kontrolle, Vendor-Lock-in für eine Funktion die in ~300 Zeilen Python lebt. Nicht Mana-style.
|
||||
- **Cloud-first, Ollama als optionaler Cache** — eine separate strategische Entscheidung (Privacy / Kosten / Offline-Modus). Gehört nicht in eine Resilience-Story; kann später diskutiert werden.
|
||||
- **In-Process-Retry ohne Health-Probe** (Caller versucht erst Ollama, bei Fehler einmal Cloud) — jeder Request zahlt einmal die volle Failure-Latenz beim ersten Treffer auf den toten Provider. UX-killer (15 User × 75 s Hang).
|
||||
- **Dynamische Latency-/Kosten-Optimierung** (mana-llm wählt schnellsten/billigsten Provider) — Premature Optimization. Erst messen, dann optimieren. Statische Reihenfolge in der Chain ist transparent und debugbar.
|
||||
- **Per-User-Routing** (User-Tier entscheidet Modell) — gehört in den Tier-/Credits-Layer, nicht ins LLM-Routing. Soll auf mana-credits-Ebene bleiben.
|
||||
|
||||
## Open Questions
|
||||
|
||||
- **Probe-Strategie für `groq` / `openrouter`:** `GET /v1/models` reicht zum Health-Check, frisst aber bei jedem Tick einen Free-Tier-Quota-Punkt. Bei Groq aktuell 30 RPM unkritisch (= 2 RPM Probe-Last); bei kostenpflichtigen Tiers später erwägen, nur on-demand zu prüfen.
|
||||
- **Wie reagieren wir auf 429 (Rate-Limit)?** Vorschlag: temporär als unhealthy markieren mit kurzem Backoff (15 s, nicht 60 s), aber separat von Connection-Errors zählen — flappy-throttling soll die Zwei-Strikes-Regel nicht triggern. Im M3 nachdenken.
|
||||
- **Alias-Versionierung:** Wenn wir `mana/long-form` morgen umstellen (gemma3 → gemma4), bleibt der Name. Wollen wir je versionierte Aliases (`mana/long-form-v2`)? Heute: nein, Reload ist atomar. Falls A/B-Testing nötig wird, dann.
|
||||
- **Caller-side Model-Awareness für Token-Pricing:** Der Resolved-Header sagt _welches_ Modell antwortete; mana-credits muss daraus den Preis ableiten. Heißt: mana-credits braucht eine Modell-→-Preis-Tabelle. Existiert die schon? Prüfen vor M5.
|
||||
Loading…
Add table
Add a link
Reference in a new issue