diff --git a/admin/incidents-llm-gpu.md b/admin/incidents-llm-gpu.md index e8c6dbe..77b10d8 100644 --- a/admin/incidents-llm-gpu.md +++ b/admin/incidents-llm-gpu.md @@ -124,8 +124,29 @@ tentative) et renvoie 502. (⚠️ redémarre LiteLLM — service partagé Hermes/n8n/Open WebUI/Ghostfolio). 3. **Purge immédiate** (sans attendre l'IaC) : `sudo systemctl restart litellm` vide le pool. -### Reste à faire (cause racine) +### Fix racine — heartbeat (déployé) -Tant que `llama-server` se fige et nécessite des redémarrages, le pool LiteLLM se re-périme. -La vraie prévention = **watchdog de génération réelle** qui détecte le freeze et, idéalement, -bounce **à la fois** llama-server **et** litellm pour repartir sur des connexions saines. +Le timeout/retry ne fait que **borner** la casse : la 1ʳᵉ demande après une pause restait lente +(la connexion keep-alive devient inactive → llama-server la ferme → LiteLLM garde le socket mort). +La **vraie** cause = la connexion qui devient *périmée pendant l'inactivité*. + +**Solution** : `llm-heartbeat` (rôle `litellm`) — un service systemd qui appelle `hermes-default` +toutes les `llm_heartbeat_interval` s (défaut 15 s, `max_tokens:1`, `/no_think` → ~10 ms GPU). +La connexion LiteLLM↔llama-server n'est **jamais** inactive → jamais périmée → la 1ʳᵉ vraie +demande tombe toujours sur un tuyau vivant. Logge les échecs (`journalctl -u llm-heartbeat`), +donc sert aussi de **sonde** (génération réelle, pas juste `/health`). + +```bash +ansible-playbook -i inventory.yml playbooks/storage-01.yml --tags litellm # déploie heartbeat + config +systemctl status llm-heartbeat ; journalctl -u llm-heartbeat -f # vérifier +``` + +⚠️ Le heartbeat appelle `hermes-default` en continu → si on bascule cet alias sur **Claude** +(API facturée), mettre `llm_heartbeat_enabled: false`. + +### Reste à faire (durci, mais pas zéro-risque) + +Le heartbeat empêche la *péremption par inactivité*. Si `llama-server` **se fige** carrément +(wedge ROCm), le heartbeat le **détecte** (échecs loggués) mais ne le **répare pas** encore. +Évolution possible : sur N échecs consécutifs du heartbeat → restart auto de `llama-server` +**et** `litellm` (watchdog actif). Pour l'instant : détection + alerte manuelle. diff --git a/ansible/roles/litellm/README.md b/ansible/roles/litellm/README.md index fb7c95d..60b8fc2 100644 --- a/ansible/roles/litellm/README.md +++ b/ansible/roles/litellm/README.md @@ -10,6 +10,10 @@ Proxy LLM multi-modèles sur storage-01 (`:4000`) — point d'entrée unique pou - `/usr/local/bin/ask-agent` — délégation inter-agents (texte → profil system/monitor/brain) - `/usr/local/bin/hermes-switch` — bascule l'alias `hermes-default` entre qwen (local) et claude (API), puis restart litellm - `/usr/local/bin/funk-cluster` — orchestration start/stop/status du homelab + - `/usr/local/bin/llm-heartbeat` (+ service `llm-heartbeat`) — garde chaud le tuyau + LiteLLM↔llama-server : petit appel à `hermes-default` toutes les `llm_heartbeat_interval` s. + Empêche la connexion keep-alive de devenir périmée → supprime le 502 « 1ʳᵉ demande après + une pause » (cf. `admin/incidents-llm-gpu.md`). Logge les échecs (`journalctl -u llm-heartbeat`). ## Variables principales @@ -19,8 +23,19 @@ Proxy LLM multi-modèles sur storage-01 (`:4000`) — point d'entrée unique pou | `litellm_master_key` | `lm-studio` (clé API attendue par les clients) | | `litellm_local_llm_base_url` | `http://192.168.10.20:1234/v1` | | `litellm_anthropic_api_key` | `{{ vault_anthropic_api_key }}` | +| `llm_heartbeat_enabled` | `true` | +| `llm_heartbeat_interval` | `15` (s — doit rester < à l'idle keep-alive du llama-server) | +| `llm_heartbeat_model` / `llm_heartbeat_timeout` | `hermes-default` / `20` | + +## Timeouts & résilience (anti-502 « connexion périmée ») + +`litellm_settings` : `request_timeout: 20` (court → un socket périmé échoue vite) + `num_retries: 2` +(rejoue sur une connexion neuve). Combiné au **heartbeat** (connexion jamais inactive) et au +retry côté STT-server : la chaîne tolère les blips et les redémarrages de llama-server. +⚠️ `request_timeout` est global → si `hermes-default` passe sur **Claude** (raisonnement long), le remonter. ## Caveat - L'alias `hermes-default` est **ce que Hermes consomme** — changer le modèle derrière l'alias ne nécessite pas de reconfigurer Hermes - `hermes-switch` modifie `/etc/litellm/config.yaml` **en dehors d'Ansible** : un `--tags litellm` réécrit la config et peut écraser un switch manuel vers claude +- Le **heartbeat** appelle `hermes-default` en continu : si tu bascules sur Claude (API facturée), pense à `llm_heartbeat_enabled: false` pour ne pas générer de coût en arrière-plan diff --git a/ansible/roles/litellm/defaults/main.yml b/ansible/roles/litellm/defaults/main.yml index c9188dd..1bd09eb 100644 --- a/ansible/roles/litellm/defaults/main.yml +++ b/ansible/roles/litellm/defaults/main.yml @@ -13,3 +13,11 @@ litellm_anthropic_api_key: "{{ vault_anthropic_api_key }}" # Endpoint llama-server sur gpu-01 litellm_local_llm_base_url: "http://192.168.10.20:1234/v1" litellm_local_llm_api_key: "lm-studio" + +# Heartbeat — garde chaud le tuyau LiteLLM↔llama-server (anti-502 « connexion périmée »). +# Petit appel à hermes-default toutes les N s : la connexion ne devient jamais inactive → +# la 1ʳᵉ vraie demande après une pause tombe toujours sur un tuyau vivant. +llm_heartbeat_enabled: true +llm_heartbeat_model: "hermes-default" # l'alias que consomme le STT (même tuyau) +llm_heartbeat_interval: 15 # secondes — doit être < à l'idle keep-alive du llama-server +llm_heartbeat_timeout: 20 # = request_timeout LiteLLM diff --git a/ansible/roles/litellm/handlers/main.yml b/ansible/roles/litellm/handlers/main.yml index 0b21d82..f2323cf 100644 --- a/ansible/roles/litellm/handlers/main.yml +++ b/ansible/roles/litellm/handlers/main.yml @@ -7,3 +7,8 @@ ansible.builtin.systemd: name: litellm state: restarted + +- name: Restart llm-heartbeat + ansible.builtin.systemd: + name: llm-heartbeat + state: restarted diff --git a/ansible/roles/litellm/tasks/main.yml b/ansible/roles/litellm/tasks/main.yml index d1ce6ef..d604279 100644 --- a/ansible/roles/litellm/tasks/main.yml +++ b/ansible/roles/litellm/tasks/main.yml @@ -60,3 +60,27 @@ enabled: true daemon_reload: true state: started + +# ── Heartbeat : garde chaud le tuyau LiteLLM↔llama-server (anti-502 connexion périmée) ── +- name: Deploy llm-heartbeat script + ansible.builtin.template: + src: llm-heartbeat.sh.j2 + dest: /usr/local/bin/llm-heartbeat + mode: '0755' + notify: Restart llm-heartbeat + +- name: Deploy llm-heartbeat service + ansible.builtin.template: + src: llm-heartbeat.service.j2 + dest: /etc/systemd/system/llm-heartbeat.service + mode: '0644' + notify: + - Reload systemd + - Restart llm-heartbeat + +- name: Enable and start llm-heartbeat + ansible.builtin.systemd: + name: llm-heartbeat + enabled: "{{ llm_heartbeat_enabled }}" + state: "{{ 'started' if llm_heartbeat_enabled else 'stopped' }}" + daemon_reload: true diff --git a/ansible/roles/litellm/templates/config.yaml.j2 b/ansible/roles/litellm/templates/config.yaml.j2 index 09e0a0a..f9e6409 100644 --- a/ansible/roles/litellm/templates/config.yaml.j2 +++ b/ansible/roles/litellm/templates/config.yaml.j2 @@ -30,12 +30,16 @@ model_list: litellm_settings: drop_params: true - # 600s = 10 min : une connexion keep-alive périmée vers llama-server (après un - # restart de ce dernier) fait alors hanger LiteLLM 10 min avant d'abandonner → - # 502 en cascade pour tous les consommateurs. 60s borne ce hang. - request_timeout: 60 - # Rejoue une requête échouée (timeout/erreur réseau) : sur la 2ᵉ tentative la - # connexion morte du pool est évincée → la requête repart sur une connexion neuve. + # Timeout PAR tentative. Doit rester COURT : sur une connexion keep-alive périmée + # (llama-server ferme les sockets inactifs), la 1ʳᵉ tentative hang jusqu'à ce timeout. + # Il doit être < au budget total du STT-server (2×30s) pour que `num_retries` + # ci-dessous ait le temps de rejouer AVANT que le client abandonne (sinon 502 sec). + # qwen3-8b /no_think répond en <8s → 20s laisse une grosse marge. + # ⚠️ Si on bascule hermes-default sur Claude (raisonnement long), remonter cette valeur. + request_timeout: 20 + # Rejoue une requête échouée (timeout/erreur réseau) : la 2ᵉ tentative évince la + # connexion morte du pool → repart sur une connexion neuve. Avec request_timeout=20, + # un blip « connexion périmée » se résorbe en ~20s au lieu d'un 502. num_retries: 2 general_settings: diff --git a/ansible/roles/litellm/templates/llm-heartbeat.service.j2 b/ansible/roles/litellm/templates/llm-heartbeat.service.j2 new file mode 100644 index 0000000..a7c3b53 --- /dev/null +++ b/ansible/roles/litellm/templates/llm-heartbeat.service.j2 @@ -0,0 +1,16 @@ +[Unit] +Description=LiteLLM heartbeat — garde chaud le tuyau LiteLLM↔llama-server (anti-502 connexion périmée) +After=litellm.service network-online.target +Wants=litellm.service + +[Service] +Type=simple +ExecStart=/usr/local/bin/llm-heartbeat +Restart=always +RestartSec=10 +StandardOutput=journal +StandardError=journal +SyslogIdentifier=llm-heartbeat + +[Install] +WantedBy=multi-user.target diff --git a/ansible/roles/litellm/templates/llm-heartbeat.sh.j2 b/ansible/roles/litellm/templates/llm-heartbeat.sh.j2 new file mode 100644 index 0000000..1c2b9c4 --- /dev/null +++ b/ansible/roles/litellm/templates/llm-heartbeat.sh.j2 @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +# llm-heartbeat — garde « chaud » le tuyau LiteLLM ↔ llama-server. +# +# Pourquoi : llama-server ferme les connexions keep-alive inactives, mais LiteLLM +# garde le socket mort dans son pool → la 1ʳᵉ requête après une pause part dans le +# vide → timeout → 502 (cf. admin/incidents-llm-gpu.md). Un petit appel régulier +# maintient la connexion vivante (et la re-crée AVANT la vraie demande si elle est morte). +# +# Double rôle (watchdog d'observation) : logge un échec → visible dans +# `journalctl -u llm-heartbeat`. Déployé/géré par le rôle Ansible `litellm`. +set -u + +URL="http://127.0.0.1:{{ litellm_port }}/v1/chat/completions" +KEY="{{ litellm_master_key }}" +MODEL="{{ llm_heartbeat_model }}" +TIMEOUT="{{ llm_heartbeat_timeout }}" +INTERVAL="{{ llm_heartbeat_interval }}" + +# `/no_think` + max_tokens:1 → génération quasi instantanée (~10 ms GPU), coût négligeable. +payload="{\"model\":\"${MODEL}\",\"messages\":[{\"role\":\"user\",\"content\":\"ping /no_think\"}],\"max_tokens\":1}" + +fails=0 +while true; do + code=$(curl -s -m "$TIMEOUT" -o /dev/null -w '%{http_code}' \ + "$URL" \ + -H "Authorization: Bearer ${KEY}" \ + -H "Content-Type: application/json" \ + -d "$payload" 2>/dev/null) || code="000" + + if [ "$code" = "200" ]; then + fails=0 + else + fails=$((fails + 1)) + echo "heartbeat KO: HTTP ${code} (${MODEL}) — chaîne LLM non réactive (${fails} échec(s) consécutif(s))" + fi + + sleep "$INTERVAL" +done