# Endpoint Anthropic pour Claude Code, sur une RTX 6000 Ada à 0,84 $/h Un pod RunPod, deux modèles au choix, une API compatible Anthropic. Le pod ne connaît qu'une URL : toute la configuration vit dans `vllm_bootstrap.sh` publié sur le Hub, et **publier une nouvelle version suffit à reconfigurer la machine sans jamais la recréer**. > **Mis à jour le 23/08/2026 au soir.** Le modèle servi est désormais Nemotron > et non Ornith, la RTX 6000 Ada est passée à **0,74 $/h**, et la configuration > retenue a changé sur deux drapeaux. Voir > [« Configuration retenue »](#configuration-retenue-23082026-soir) en fin de > document — les sections antérieures restent valables pour l'historique et le > raisonnement, pas comme consigne de déploiement. ## Choix de la machine (mesures du 23/08) | | 2×A40 | **1× RTX 6000 Ada** | |---|---:|---:| | prix | 0,88 $/h | **0,84 $/h** | | solo, contexte court | 117 tok/s | **134,1** | | solo à 85 k | 100 | **109,4** | | 6 agents (par flux) | **26,4** | 24,7 | | contexte max | **1 M** | 786 k (KV 957 122 jetons) | L'Ada gagne en solo et coûte moins cher ; la paire d'A40 garde l'avantage en multi-agents et sur le 1 M. Une seule carte supprime aussi le TP=2 sans P2P, qui coûtait du rendement (0,25 tok/s par Go/s sur une carte seule contre 0,17 sur la paire). ## Bureau Linux distant `VL_DESKTOP=on` (ou `bash /opt/desktop.sh` à chaud) lance Xvfb + x11vnc + websockify/noVNC + XFCE. Accès navigateur, aucun client VNC nécessaire : ``` https://-6080.proxy.runpod.net/vnc.html?autoconnect=1&resize=remote mot de passe : $VNC_PASSWORD (défaut SECRET) ``` x11vnc n'écoute que sur la loopback (`-localhost`) ; tout passe par websockify, et le port 6080 doit être exposé en `http` sur le pod. Détails de la pile et raison de chaque option dans `desktop_setup.sh`. **Après un restart de conteneur**, les paquets survivent mais les processus meurent : relancer `bash /opt/desktop.sh`. (Un *Stop*, lui, efface tout le disque conteneur.) ## Mise en service ``` image registry.hf.space/patdev-ornith-pod:latest (vLLM precompile) GPU 1x RTX 6000 Ada (49 Go, sm_89) 0,84 $/h disque 200 Go (conteneur ; efface au Stop, pas au restart) port expose 8080/http commande bash -c 'apt-get update -qq >/dev/null 2>&1; apt-get install -y -qq curl ca-certificates >/dev/null 2>&1; curl -sL https://huggingface.co/patdev/k3-a40-bootstrap/resolve/main/vllm_bootstrap.sh -o /run.sh; bash /run.sh' env HF_TOKEN, HF_XET_HIGH_PERFORMANCE=1, VL_MODEL=qwen|kimi ``` `create-pod` de l'API RunPod **n'expose pas de champ `args`** : il faut passer par `create-template` avec `dockerStartCmd`, puis déployer avec `templateId`. Si la création échoue sur « This machine does not have the resources », c'est transitoire — préciser `dataCenterIds: ["EU-SE-1"]` suffit généralement. ### Épingler la version CUDA de l'hôte — indispensable RunPod place le pod sur une machine en CUDA 12.4, 12.8 ou 13.0 **au hasard**. Le vLLM installé par pip embarque un torch compilé pour CUDA 13.x, qui meurt à l'initialisation du moteur sur un hôte 12.8 : ``` RuntimeError: The NVIDIA driver on your system is too old (found version 12080) ``` Le même script marche ou échoue selon le tirage. L'outil MCP n'expose pas le champ, mais **l'API REST l'accepte** : ```bash curl -X POST https://rest.runpod.io/v1/pods -H "Authorization: Bearer $RUNPOD_API_KEY" -H "Content-Type: application/json" -d '{"name":"a40","templateId":"","gpuTypeIds":["NVIDIA A40"], "gpuCount":1,"cloudType":"SECURE","dataCenterIds":["EU-SE-1"], "allowedCudaVersions":["13.0"]}' ``` Vérifier ensuite `cudaVersion` dans la réponse : il doit valoir `13.0`. Le bootstrap journalise aussi `pilote , CUDA runtime ` au démarrage. ## Branchement de Claude Code ``` ANTHROPIC_BASE_URL=https://-8080.proxy.runpod.net ANTHROPIC_API_KEY=peu-importe # le pont ne verifie rien ``` Le port exposé sert l'API **Anthropic** (`/v1/messages`), c'est-à-dire celle que Claude Code consomme. vLLM tourne en interne sur 8081 ; le pont relaie aussi `/v1/chat/completions`, `/v1/completions` et `/metrics` pour les clients OpenAI et les outils de mesure. ## Les deux modèles | | Qwen3-Coder-30B-A3B | Kimi-Linear-48B-A3B | |---|---|---| | dépôt | `cyankiwi/Qwen3-Coder-30B-A3B-Instruct-AWQ-4bit` | `cyankiwi/Kimi-Linear-48B-A3B-Instruct-AWQ-4bit` | | poids | 16,9 Gio | 28,4 Gio | | contexte | 262 144 | **1 048 576** | | cache de préfixe | **oui (16×)** | non | | spéculation | **oui (+86 % en édition de code)** | non — elle corrompt le code | | `VL_MODEL` | `qwen` | `kimi` | **Ils ne tiennent pas ensemble sur une seule A40** : 16,9 + 28,4 = 45,3 Gio de poids avant le moindre KV, sur une carte de 46 Go. `VL_MODEL` choisit lequel est servi ; un endpoint bi-modèle simultané demande deux cartes. ## Réglages, par variable d'environnement | variable | défaut | effet | |---|---|---| | `VL_MODEL` | `qwen` | `qwen` ou `kimi` | | `VL_CTX` | 262144 / 1048576 | longueur de contexte | | `VL_SPEC` | `auto` | `off` pour couper la spéculation | | `VL_SPEC_N` | 12 | profondeur des brouillons | | `VL_LOOK_MIN` / `VL_LOOK_MAX` | 3 / 8 | fenêtre de correspondance n-gram | | `VL_BATCHED` | 16384 | `max-num-batched-tokens` | | `VL_SEQS` | 32 | flux concurrents | | `VL_UTIL` | 0.90 | fraction de VRAM. **Ne pas monter à 0,93** : l'OOM tombe au tout dernier pas, dans le rejection sampler | Changer une valeur demande de recréer le pod (les env sont figées) ; changer un **défaut dans le script** et le publier se propage en 30 s sans rien recréer. ## Correctifs indispensables, et pourquoi **Tokenizer Kimi.** `tokenization_kimi.py` importe `bytes_to_unicode` depuis `transformers.convert_slow_tokenizer`, supprimé en transformers ≥ 5.5.3 — qu'exige tout vLLM ≥ 0.24. Aucun dépôt Kimi-Linear ne fournit de `tokenizer.json` rapide, donc le tokenizer Python est obligatoire. Corrigé par `patdev/kimi-linear-tokenizer-fix` : mêmes fichiers, avec une redéfinition locale de repli, vérifiée identique octet pour octet à la référence transformers avant tout déploiement. **Parser d'outils.** `hermes` ne produit aucun appel sur Kimi-Linear. Son gabarit de chat utilise `<|tool_calls_section_begin|>` / `<|tool_call_begin|>`, le schéma **K2** — donc `kimi_k2`. (`kimi_k3` vise `<|open|>`/`<|close|>`/ `<|sep|>` et ne s'applique pas.) Pour Qwen, c'est `qwen3_coder`. **Guillemets.** `--speculative-config` prend du JSON, et les `args` d'un pod RunPod transitent par un shell qui **mange les guillemets** : il faut entourer le JSON d'apostrophes et n'y mettre aucun espace. Le bootstrap le fait pour vous ; le piège ne concerne que les configurations passées en dur. **Tensor parallelism.** Sur une paire d'A40 RunPod, `NCCL_P2P_DISABLE=1` est nécessaire, sinon l'initialisation NCCL ne rend jamais la main. ## Image précompilée (depuis le 21/08/2026) Le pod ne réinstalle plus rien au démarrage : l'image Docker est construite par le Space `patdev/ornith-vllm-a40` (sdk docker, gratuit, sur CPU) et tirée par RunPod depuis `registry.hf.space/patdev-ornith-vllm-a40:latest` avec un `containerRegistryAuthId` (user `patdev`, mot de passe = token HF). Elle contient vLLM 0.27.1 (cu13) dans `/opt/venv`, `cuda-compat-13-0`, `nvcc` et les en-têtes CUDA 13. Le bootstrap (v35+) lit `/opt/.image_prebaked` et saute la phase d'installation : **~15 s** entre le démarrage du conteneur et `vllm serve`, contre ~8 min avant. Création : `scratchpad/create_pod.py` (template `ornith-vllm-a40-prebaked`, `dockerStartCmd: ["/start_pod.sh"]` = sshd en arrière-plan + bootstrap du Hub). Pièges : - **Un pod arrêté peut refuser de redémarrer** ("not enough free GPUs on the host machine") : l'hôte a été reloué. Recréer, le disque est perdu de toute façon (volume 0). - **Premier pull ~9 min** (18 Go, une seule couche) ; ensuite l'hôte le met en cache. L'image v2 sépare les couches et retire le torch cu128 de la base. - **`NCCL_P2P_DISABLE=1` est obligatoire** (bootstrap v37) : sur deux A40 placées sur deux nœuds NUMA (`nvidia-smi topo` = SYS), le premier collectif après la capture des graphes CUDA tourne en attente active — GPU à 100 %, 0 % d'activité mémoire, workers en `futex_wait`, "No available shared memory broadcast block" toutes les 60 s. Rien dans le journal ne dit "NCCL". - **Le watcher de rechargement n'est actif qu'une fois vLLM sain.** Si vLLM est bloqué au démarrage, publier une nouvelle version ne suffit pas : tuer le serveur (`pkill -9 -f "vllm [s]erve"` — les crochets évitent que pkill se tue lui-même) ; le bootstrap passe en `fatal`, scrute le Hub et recharge. - **Hôte malade, signature complète** (pod `udpfa0l4z65m09`, 21/08) : première génération → `CUDA error: unspecified launch failure` dans `synchronize()` ; ensuite un GPU disparaît pour CUDA (`No CUDA GPUs are available` en le ciblant seul, `nvidia-smi` le liste encore, pstate P0 avec puissance N/A) ; `torch.cuda.device_count()` dit 2 mais `get_device_name(1)` échoue ; vLLM TP=2 meurt sur "DP adjusted local rank 1 is out of bounds for 1 devices" ; et `POST /pods/{id}/restart` répond 500 "context deadline exceeded" vers `hapi.runpod.net`. Rien de tout ça ne se répare depuis le conteneur : supprimer le pod et recréer (la création choisit un autre hôte). - `vllm --version` dans un Dockerfile meurt sur un constructeur sans GPU ("Failed to infer device type") ; vérifier via `importlib.metadata`. ## Leviers mesurés (bootstrap v41, bloc EXPERIENCE en tête de script) | Variable | Valeurs | Effet mesuré (2×A40, Ornith, 22/08) | |---|---|---| | `VL_KV` | `""` (bf16) / `turboquant_k3v4_nc` (défaut) / `turboquant_k8v4` / `fp8` | TurboQuant : KV ×4,33 (12,08 M jetons), débit −6 % à 32 sessions, +7 % solo | | `VL_SPEC` | `off` (défaut) / `dspark` / `mtp` / `on` (ngram) | DSpark : +71 % solo, −1 % à 32 sessions, KV −6 % ; incompatible avec TurboQuant | | `VL_DSPARK_N` | 8 | jetons proposés (tête de confiance adaptative) | | `VL_SWAP` | `ornith:ornithnvfp4,qwen38:qwen38nvfp4` (défaut) | v70 : noms servis → clés de modèle interchangeables ; une requête pour un modèle non chargé déclenche le swap (`/travail/modele_demande`, re-exec avec `VL_MODEL`). Mesuré 28/08 : 368 s à la 1re compilation, pings SSE pendant l'attente (pont v71) | | `VL_SPEC_AUTO` | `on` (défaut) / `off` | v70 : spéculation par modèle — MTP k=3 sur Qwen3.8-27B (73–83 j/s solo sur PRO 5000 NVFP4), off sur Ornith ; `off` rend la main à `VL_SPEC` | | `VL_PRECHARGE` | dépôts HF séparés par des espaces | v70 : poids préchargés en tâche de fond après le premier service, pour un swap sans téléchargement | | `VL_FLASHINFER` | `off` (défaut) | le sampler flashinfer a produit une faute CUDA sur un hôte 580 | | `VL_NUMA`, `VL_EP`, `VL_OPT` | `off` / `off` / `""` (défaut) | mesurés neutres (v43d) ; membind refusé sans `SYS_NICE` ; O3 == O2 | | `VL_FASTOK` | `1` (défaut) | fastokens (tokenizer Rust) : −12 % TTFT médian, +1,5 % solo ; installé à la volée si absent | | `VL_KVBYTES` | `""` (défaut) | `--kv-cache-memory-bytes` : **+22 % de capacité KV** sur Flash-Next (26/08), voir « Le cache KV » ; une valeur trop haute tue le moteur en service, pas au démarrage | | `VL_MM_IMAGES` | `4` | plafond d'images par requête. **À `0`, vLLM rejette toute capture par un 400** dont le texte est le plafond lui-même ; voir « Les images » | | `VL_DP` | `1` (défaut) | parallélisme de données (un réplica par GPU) : +15 % à 32 sessions sur Qwen3.8, perd le solo et la moitié du KV | Modifier les défauts, republier `vllm_bootstrap.sh` sur `patdev/k3-a40-bootstrap` : le pod recharge à chaud (~4 min, poids conservés). `scratchpad/drive_pod.py` automatise publication → attente → banc (`pod_bench.py`). ## Pièges d'exploitation - Le **proxy HTTP de RunPod coupe à ~125 s** (Cloudflare, `error code: 524`). Un prefill à froid plus long ne passe pas en un seul appel — découper le prompt, chaque requête étendant le préfixe caché. - Un **`wait` sans argument** dans le script attend aussi le serveur lancé en arrière-plan, indéfiniment : le serveur répond, mais le watcher ne démarre jamais et les mises à jour publiées sont ignorées en silence. Toujours `wait "$pid"` explicite. Idem pour `%1`, qui désigne le job du serveur. - **Redémarrer un pod efface son disque conteneur.** Le rechargement en place par le watcher évite de retélécharger les poids. --- ## Branchement de Claude Code — validé de bout en bout ```bash export ANTHROPIC_BASE_URL=https://-8080.proxy.runpod.net export ANTHROPIC_API_KEY=dummy # le pont ne verifie rien export CLAUDE_CODE_DISABLE_UNKNOWN_MODEL_WINDOW_ENFORCEMENT=1 claude --model claude-kimi-k3 ``` **Les identifiants de modèle doivent commencer par `claude-`.** Claude Code valide le nom avant d'émettre la requête et refuse tout le reste. Le pont expose donc des alias conformes et ignore le nom reçu pour router vers l'unique modèle chargé : le client choisit une étiquette, pas un moteur. ``` claude-kimi-k3 claude-kimi-k3-linear claude-qwen3-coder claude-sonnet-4-5 claude-3-5-haiku ``` Les deux derniers existent parce que certains clients codent en dur un modèle « rapide » et un « lent ». `CLAUDE_CODE_DISABLE_UNKNOWN_MODEL_WINDOW_ENFORCEMENT=1` évite que Claude Code plafonne la session à 200k alors que l'endpoint en sert 262 144. L'alternative propre est de déclarer le modèle dans le réglage `modelOverrides`. ### Preuve d'exécution Édition réelle d'un fichier via les outils, en 45 s : ```python # avant def divide(a, b): return a / b # apres def divide(a: Union[int, float], b: Union[int, float]) -> Union[int, float]: """Divide two numbers. Raises: ZeroDivisionError: If b is zero """ if b == 0: raise ZeroDivisionError("Cannot divide by zero") return a / b ``` Claude Code a lu le fichier, appelé les outils, appliqué les annotations, ajouté la garde et documenté. Deux avertissements bénins subsistent : le modèle n'est pas au catalogue (fenêtre supposée), et l'outil Advisor se désactive faute de rang dans ce catalogue. ### `/v1/models` sert deux protocoles La distinction se fait sur l'en-tête `anthropic-version` : ``` avec l'en-tete {"data":[{"type":"model","id":"claude-kimi-k3",...}], "has_more":false} sans la reponse native de vLLM, dont les bancs lisent max_model_len ``` ## Configuration finale (bootstrap v50, 22/08/2026) Ornith-1.5-35B-A3B AWQ, TP=2, YaRN ×4 (1M validé à 634 k en bf16), `VL_KV=turboquant_k3v4_nc` (KV ~10,6 M à util 0,85 ; 12,08 M à 0,93), `VL_SPEC=off`, `VL_UTIL=0.85` (marge VRAM pour le prefill TurboQuant), `VL_BATCHED=16384`, fastokens on, numa/EP/O3/kvbytes off (mesurés neutres). Variante vitesse solo : `VL_KV=` + `VL_SPEC=dspark` (171 j/s, KV 2,6 M). Qwen3.8-27B : `FORCE_MODEL="dense"` (W4A16, MTP k=3, 262 k natif ; 1M possible avec YaRN+TurboQuant mais ~12 min de prefill et 3,7 sessions). Pièges de la nuit du 22 : - **TurboQuant + prefill long = marge VRAM obligatoire** : le backend déquantifie le K en cache en bf16 dense à chaque tranche (`turboquant_attn.py _continuation_prefill`) ; à 0,93 d'utilisation, un prompt de 700 k tue le moteur (OOM 516 Mio). 0,85 le règle pour −12 % de KV. - **Banc de prefill et cache de préfixe** : des textes qui partagent un préfixe avec des requêtes précédentes donnent un « froid » à 40 k j/s, faux. Saler chaque prompt (`prefill_bench.py` le fait). - `--max-num-batched-tokens 65536` : neutre sur A40 (prefill borné par le calcul). `--numa-bind` demande `--numa-bind-nodes 0 1` dans un conteneur, et le membind est refusé sans `SYS_NICE`. - Le proxy RunPod renvoie **403 à l'User-Agent Python par défaut** : toujours poser un `User-Agent` dans les clients de test. ## Raisonnement : trois correctifs du pont (22/08 soir, mesurés) 1. **Le raisonnement n'arrivait jamais à Claude Code.** vLLM 0.27.1 diffuse le raisonnement en flux sous `delta.reasoning` (et `reasoning_content` en non-flux) ; le pont ne lisait que le second → jetons dépensés, rien montré. Corrigé : les deux clés. Mesure : 0 → 246 car. de bloc `thinking` sur la même question. 2. **Le niveau de raisonnement (budget) n'était pas transmis.** Anthropic `thinking.budget_tokens` → vLLM `thinking_token_budget` (sampling params, il ferme le bloc à ce nombre de jetons). Mesure sur un problème à étapes : budget 64 → 168 car. de raisonnement, réponse fausse ; 512 → 1 155 car., juste ; 8 000 → 1 443 car. (le modèle s'arrête seul), juste. `disabled` → 0 car., 49 jetons de sortie. `adaptive` (ce qu'envoie Claude Code) = actif sans plafond. 3. **Le raisonnement de l'historique était jeté.** Dans les boucles d'outils, Claude Code renvoie le bloc `thinking` du tour précédent ; le pont l'ignorait → le modèle perdait son plan entre deux appels. Corrigé : les blocs `thinking` d'un tour assistant deviennent `reasoning_content` du message OpenAI ; le gabarit Qwen3.5 le conserve pour le dernier tour (même sémantique qu'Anthropic). Vérifié : historique thinking + tool_use + tool_result accepté par vLLM, réponse cohérente. ## Audit du pont (22/08 soir) — `audit_pont.py`, 13 cas contre le pod Bugs trouvés et corrigés (tous vérifiés avant/après) : - **non-flux** : le raisonnement n'était pas rendu en bloc `thinking` (seul le flux l'était) → `to_anthropic` le fait, avec `signature`. - **`cache_read_input_tokens` jamais exposé** : deux causes — le pont ne lisait pas `prompt_tokens_details.cached_tokens`, et vLLM ne le renseigne qu'avec `--enable-prompt-tokens-details` (bootstrap v51). Claude Code affichait 0 % de cache alors que vLLM en servait ~68 %. - **`count_tokens` ignorait système et outils** (len/4 des seuls messages : 13 jetons pour un appel qui en pesait 398) → compte réel via `/tokenize` de vLLM en forme chat, repli sur l'estimation. - **`stop_sequences`** : la chaîne coupait bien mais `stop_reason` disait `end_turn` → `stop_sequence` + champ `stop_sequence` (vLLM le rapporte dans `choice.stop_reason`). - **`signature_delta`** absent avant la fermeture des blocs thinking → émis aux deux points de fermeture (passage au texte, fin de flux). Vérifiés OK sans changement : `max_tokens` → `stop_reason=max_tokens`, système en liste de blocs + `cache_control`, `tool_use` en flux (`input_json_delta` reconstitue un JSON valide, `stop_reason=tool_use`), `tool_result.is_error`, `redacted_thinking` dans l'historique, `temperature/top_p/top_k/metadata`, préremplissage par un dernier message assistant, modèle inconnu (repli sur le modèle servi, 200). Vérifié après v51 : `cache_read_input_tokens` = 23 360 sur 24 047 au second appel d'un prompt de 24 k (bloc de cache = 784 jetons en mode mamba align : un prompt d'un seul bloc ne rapporte rien, c'est normal). Audit : **13/13**. ### Le pont est-il encore nécessaire ? (vLLM 0.27.1 sert `/v1/messages` nativement) Oui, vLLM expose déjà `/v1/messages` et `/v1/messages/count_tokens` sur le même port que l'API OpenAI (`vllm/entrypoints/anthropic/`) : blocs `thinking` avec signature, `tool_use`/`input_json_delta`, images, `stop_sequence`, `ping`, `cache_read_input_tokens`, `redacted_thinking`, raisonnement de l'historique → `reasoning`, fusion des messages système inline. Testé sur le pod : `curl 127.0.0.1:18081/v1/messages` répond correctement. Ce que le pont ajoute encore (mesuré ou vérifié dans les sources) : - le **niveau de raisonnement** : `thinking.budget_tokens` → `thinking_token_budget` (absent du serveur natif, qui ne connaît que `output_config.effort`) ; - les **alias** `claude-*` / `[1m]` avec `context_window` dans `/v1/models` (Claude Code lit `id`/`display_name` pour la découverte) ; - le **keepalive pendant le préremplissage** (ping toutes les 15 s : le proxy RunPod coupe à ~125 s de silence — un prefill de 634 k dure 4 min) ; - le **garde agentique** (refus des commandes destructrices, 4/4 au test), la console, la trace par requête, la transmission intacte des erreurs amont. Pour un usage direct sans RunPod ni garde, le natif suffit : pointer `ANTHROPIC_BASE_URL` sur le port vLLM et `ANTHROPIC_MODEL=ornith`. ## Pont optionnel (bootstrap v52) : `VL_BRIDGE` | `VL_BRIDGE` | Ce qui écoute sur 8080 | Ce que ça donne | |---|---|---| | `on` | le pont (`anthropic_proxy.py`), vLLM derrière en 18081 | budget de raisonnement respecté, keepalive de prefill (proxy RunPod), `/v1/models` au format Anthropic, garde agentique, console, trace | | `off` | vLLM lui-même (natif) | `/v1/messages` + `count_tokens` natifs vLLM (thinking + signature, outils, images, ping, cache), `/v1/models` au format OpenAI ; **pas** de budget de raisonnement ; keepalive de prefill non garanti derrière le proxy RunPod | Dans les deux modes vLLM sert les alias `--served-model-name ornith claude-ornith "claude-ornith[1m]"` : `ANTHROPIC_MODEL=claude-ornith[1m]` marche partout, et `connect.ps1` aussi (il vérifie l'`id` dans `/v1/models`). Bascule : une ligne du bloc EXPERIENCE + republication (vLLM redémarre, ~4 min). Test du mode natif (`VL_BRIDGE=off`, 22/08 19:13, via le proxy RunPod) : `/v1/models` liste `ornith`, `claude-ornith`, `claude-ornith[1m]` (max_model_len 1 000 000) ; `/v1/messages` non-flux → blocs `thinking` + `text`, usage avec `cache_read_input_tokens` ; flux → `message_start … thinking_delta … message_stop` ; `count_tokens` compte le système. `budget_tokens: 64` → 267 car. de raisonnement : **le budget est ignoré en natif**, comme prévu. Défaut final v53 : `VL_BRIDGE=on`. ### 22/08 soir : natif par défaut (v54), garde corrigé Sur demande, `VL_BRIDGE=off` est le défaut : vLLM écoute 8080 directement. Comme vLLM natif ne répond qu'aux ids servis, `VL_ALIASES` ajoute les noms que Claude Code et ses agents envoient : `claude-3-5-haiku claude-haiku-4-5 claude-sonnet-5 claude-opus-5 deepseek-v4-flash deepseek-v4-pro` (tous → le modèle servi). Le garde-fou du pont (si `VL_BRIDGE=on`) ne couvre plus que les écritures de fichier entier (`write/write_file/create_file`) et ne réagit qu'aux marqueurs explicites (`... rest of file`, `[... previous ...]`, `(reste du fichier`, ``) — les faux positifs `[...]` / `# ...` observés en session réelle sont retirés. ### Temps de démarrage (mesuré v54, caches chauds) : 2 min 33 36 s init API server (tokenizer 248 k, processeurs image/vidéo, appels Hub) · 24 s NCCL/TP + noyaux · 32 s poids · 7 s compile (cache ; 71 s à froid) · 2 s warmup (65 s à froid) · 5 s graphes CUDA. v55 (préparée, non publiée) : `--limit-mm-per-prompt '{"video":0}'` et `HF_HUB_OFFLINE=1` quand le modèle est en cache → visé ~1 min 45. Le vrai levier reste le nombre de redémarrages : en natif tout changement de flag/alias en coûte un ; le pont rechargeait en 5 s. ### Niveau de raisonnement en natif : patch vLLM (v55) Constat (sources 0.27.1) : le serveur Anthropic natif ne lit pas `thinking` (champ absent de `AnthropicMessagesRequest`, jeté par pydantic) et ne traduit `output_config.effort` qu'en `reasoning_effort`, que seul Harmony/GPT-OSS honore. Sur Ornith, effort low = max, et `disabled` n'agit pas. `vllm_anthropic_effort_patch.py` (appliqué par le bootstrap avant `vllm serve`, idempotent, `VL_EFFORT_PATCH=off` pour le couper) ajoute le champ `thinking` et mappe : effort low/medium/high/xhigh/max → `thinking_token_budget` 1 024 / 4 096 / 16 384 / 32 768 / illimité ; `thinking.budget_tokens` explicite gagne ; `thinking.type=disabled` → `enable_thinking=false`. Même mécanisme que le pont (le bloc de raisonnement est fermé au plafond côté échantillonnage). Mesure après v55 (natif, patch actif, problème à étapes « trains ») : `thinking disabled` → 0 car. de raisonnement (avant : ignoré) ; `budget_tokens 64` → 164 car., réponse fausse (plafond appliqué) ; effort low/medium/max → ~1 125 car. chacun, justes : le modèle n'a eu besoin que d'~300 jetons de raisonnement, sous le plafond `low` (1 024) — la plomberie agit (preuve par budget/disabled), le niveau ne devient visible que sur des problèmes plus longs. Seuils modifiables dans `vllm_anthropic_effort_patch.py` (`_BUDGET`). Démarrage v55 : 4 min 37 — le changement de `--limit-mm-per-prompt` a invalidé le cache torch.compile (73 s) et le warmup (67 s) une fois ; la phase avant les poids est passée de 85 s à 64 s (Hub hors-ligne + vidéo off). Prochain redémarrage à config identique attendu ~2 min. ## Régime multi-agents (22/08 soir, bootstrap v56 → v62) — le diagnostic par `/metrics` Symptôme : six sous-agents Claude Code (65–120 k de contexte chacun) « lents ». Ce que disaient les métriques vLLM (`GET /metrics` passe par le proxy RunPod) : 6 requêtes en cours, 0 en attente, KV à 7 %, **génération agrégée 2–85 j/s**, prefill 5–8,6 k j/s quasi continu, cache de préfixe 62 % → ~25 k jetons re-prefillés par tour, et pendant chaque tranche de 16 384 jetons les autres flux n'avançaient que d'un jeton par pas (~2,5 s). Un rejeu de boucle agent (thinking + tool_use + tool_result × 7) a montré que **le préfixe ne se casse pas** : hits exactement sur les multiples du bloc. Les ratés sont réels (gros `tool_result`, démarrages à froid, alignement du bloc). Deux faits de structure : - avec TurboQuant, vLLM monte le **bloc d'attention à 4 672 jetons** pour égaler la page Mamba (784 en bf16) : granularité du cache et re-prefill par tour ; - `--max-num-batched-tokens 16384` gèle les décodes pendant chaque tranche. Banc `agent_bench.py` (6 agents × 4 tours × ~80 k, flux SSE, `claude-ornith[1m]`) : | Version | Config | Décodage / flux | Agrégé | Cache | TTFT (cache chaud) | |---|---|---:|---:|---:|---:| | v55 (avant) | TurboQuant, pas 16 384 | 2–12 j/s (agents réels) | ~25 | 62 % | — | | **v59 = v62** | **bf16, pas 4 096, FA2** | **26,4** | **55** | **99 %** | 2–3 s | | v60 | bf16, pas 4 096, FlashInfer | 28,5 | 61 | 99 % | 2–4 s | | v61 | TurboQuant, pas 4 096 | 15,0 | 40 | 97 % | 2–4 s | Un flux seul fait **100 j/s à 85 k** comme 117 à 1 k : le long contexte ne coûte rien seul. En concurrence, 6 × 85 k × 10 Ko de KV bf16 par GPU = 5 Go lus à chaque pas sur une A40 à 696 Go/s, plus le noyau de décodage : ni FlashInfer (= bruit) ni TurboQuant (déquantification à chaque pas, pire) ne lèvent la limite. Au-delà, c'est la bande passante de la carte, pas un flag. Le prefill à froid de 6 × 80 k se fait en série (~6,7 k j/s, 12 s chacun) : `--max-num-partial-prefills` ne changerait pas le total (borné par le calcul). DSpark (v56–v58) a été essayé puis **écarté** : vLLM 0.27.1 ne l'implémente que dans le Model Runner V2, et V2 **rejette toute requête** portant `thinking_token_budget` (`VLLMValidationError … not yet supported by the V2 model runner`) — or le patch effort en pose un dès que Claude Code envoie `effort`/`budget` : Claude Code recevait des 500. DSpark et niveaux d'effort s'excluent ; `VL_SPEC=dspark` coupe désormais le patch effort automatiquement. À 0,93 d'utilisation, DSpark a aussi frôlé l'OOM à la capture des graphes (220 Mio libres) : 0,90 dans ce cas. Deux bugs de bootstrap corrigés au passage : le brouillon DSpark n'était pas téléchargeable parce que `HF_HUB_OFFLINE=1` était posé dès qu'Ornith était en cache (v57 : pré-téléchargement) ; et `exec bash /run.sh` **hérite de l'environnement**, donc le `HF_HUB_OFFLINE=1` du run précédent survivait au rechargement (v58 : `unset` en tête). Effort en v59+ (natif, patch actif, problème « trains ») : `disabled` → 0 car., `budget 64` → 179 car., `low` → 1 569, `max` → 3 463 (plafonné par max_tokens) : les niveaux se distinguent. Configuration par défaut depuis v62 : `VL_KV=` (bf16), `VL_SPEC=off`, `VL_BATCHED=4096`, `VL_ATTN=` (FA2), util 0,93, KV 2,84 M jetons (1M solo tient ; ~20 agents à 120 k aussi). `VL_KV=turboquant_k3v4_nc` reste là pour >10 sessions à 1M simultanées (util 0,85 automatique). Une requête restée **silencieuse** plus de ~125 s prend un 524 du proxy RunPod. > **Précision du 23/08.** Le critère n'est pas « non streamée » mais « aucun octet > émis » : vLLM n'envoie rien avant la fin du prefill, donc une requête *streamée* > avec un gros prompt froid se fait couper aussi. Mesuré : 352 230 jetons passent > en 97 s, ~427 k rendent un 524 à 126 s, et le délai est le même quelle que soit > la taille. Ce qui protège Claude Code n'est pas le streaming, c'est le **ping > SSE du pont sur `/v1/messages`** — vérifié à ~500 k de prompt froid : premier > jeton à 221,7 s avec 30 pings, et à ~780 k premier jeton à **538,8 s** avec > 72 pings, soit une connexion tenue neuf minutes. Le relais > `/v1/chat/completions`, lui, n'a pas ce keepalive. ### MTP native (v63–v64) : chargée, mesurée, écartée La tête MTP d'`ulkaa/Ornith-1.5-35B-A3B-AWQ-INT4` est en BF16 ; seul le `ignore` du config.json manquait (`re:.*mtp\..*`). Le bootstrap patche le snapshot (glob sur `$HUBDIR`, idempotent, trace `/tmp/mtp_patch.log`) quand `VL_SPEC=mtp`. Mesure n=2 : solo 1 k 100,7 j/s (117 sans), solo 85 k **27,6** (100 sans), 6 agents 14,2/flux (26,4 sans) ; acceptation pos0 55 %, pos1 9 %. Défaut : `VL_SPEC=off` (v65). Détail dans `reports/`. ### Agents « lecteurs », A/B du pas, coupure proxy, pont par défaut (v66 → v68) Régime observé pendant un vrai audit (5 sous-agents à ~150 k qui lisent des fichiers) : +177 k jetons de prompt en 30 s (5,9 k j/s = le max de la carte) pour 667 jetons générés (22 j/s agrégé, ~5 j/s par agent) — **le GPU est saturé par le prefill**, pas bloqué (0 en attente, 0 abandon, TTFT < 20 s). Banc `agent_bench.py 6 3 50 300 15` (6 agents, +65 k de contexte par tour, 80 → 144 k) : | Pas | Décodage / flux | Agrégé | TTFT (cache chaud) | Mur | |---|---:|---:|---:|---:| | 2 048 (v66) | 7,0 j/s | 15 | 37 s | 330 s | | 4 096 (v67) | 7,1 j/s | 15 | 41 s | 304 s | Identique : ce régime est borné par le **calcul** de prefill (390 k jetons par tour de table ≈ 60 s), pas par l'ordonnanceur. Pas gardé à 4 096. **Coupure proxy** : quand le TTFT dépasse ~100 s (file de prefill), le proxy RunPod ferme la connexion (« Response ended prematurely » ; 524 à 125 s en non-flux). vLLM natif n'émet rien avant le premier jeton. Le pont envoie un `ping` SSE toutes les 15 s → **`VL_BRIDGE=on` par défaut depuis v68** ; le pont mappe désormais aussi `output_config.effort` (mêmes seuils que le patch natif). `VL_BRIDGE=off` = natif + patch effort, pour un usage léger. Leviers réels pour ce régime : moins de réflexion pour les sous-agents (`effort: low`), moins d'agents en parallèle, outils qui renvoient moins de texte ; au-delà, du calcul GPU (`reports/2026-08-22-materiel-prix-runpod-hf.md`). ### Pont muet sous charge (22/08 nuit) : fuite de flux amont, pool httpx saturé Symptôme : agents Claude Code à 0 jeton / « failed », `/metrics` via le proxy en timeout ; sur le pod : vLLM sain (health 2 ms, 0 requête, GPU 0 %), **pont vivant (3 % CPU, 117 fd) mais `/health` en timeout**. Journal du pont : 189 `POST /v1/messages` pour 100 traces de fin ; `ss` : 45 connexions amont vers vLLM pour 2 clients. Les flux abandonnés côté client (retry de Claude Code, coupure proxy) gardaient leur connexion amont — vLLM générait pour personne — et à 100 connexions (limite httpx par défaut) le pool bloquait tout, `/health` compris (il passe par l'amont). Correctifs : (1) `stream_anthropic` reçoit la `Request` et **ferme l'amont quand le client est parti** (`request.is_disconnected()` à chaque ping et tous les 32 jetons) → vLLM annule la génération ; (2) `httpx.Limits(max_connections=512, max_keepalive_connections=64)` et `httpx.Timeout(TIMEOUT, connect=10, pool=10)` → une saturation devient un 503 rapide, plus un blocage ; (3) chien de garde dans le watcher du bootstrap (v69) : deux `/health` KO d'affilée → relance du pont seul, journal conservé dans `/tmp/proxy.hung.*.log` (et, posé à la main ce soir, `/opt/pont_watchdog.sh`). Le pont se recharge à chaud depuis le Hub sans toucher à vLLM. ## Intégration Claude Code : les quatre pièges côté pont/client (23/08) Une fois le débit compris, les problèmes restants étaient côté pont ou côté Claude Code, jamais le modèle. Détail et mesures : `reports/2026-08-23-pont-et-integration-claude-code.md`. 1. **Pont muet sous charge** — flux abandonnés par le client → connexions amont fuitées (45 pour 2 clients, 189 requêtes / 100 fins) → pool httpx (100) saturé → `/health` bloqué, agents à 0 jeton. Corrigé : `request.is_disconnected()` ferme l'amont, `httpx.Limits(512)` + `Timeout(pool=10)`, chien de garde du pont (relance sur 2 `/health` KO). Diagnostic : `ss -tn | grep -c :18081` vs nombre de clients. 2. **`[1m]` obligatoire dans le nom de modèle** — sans lui, Claude Code suppose ~200 k et lance la compaction automatique à ~150 k. Sous-agents = `deepseek-v4-flash[1m]` / `deepseek-v4-pro[1m]` ; alias `[1m]` ajoutés à `VL_ALIASES` (v69). 3. **Réponse sans texte → compaction vide** — le modèle mettait tout dans le raisonnement (`texte=0c`). Le pont a un repli : pas de texte ni d'outil → raisonnement rendu en texte. 4. **Compteur de jetons figé** — le pont n'émettait qu'un `message_delta` final ; il émet désormais un `message_delta` toutes les 0,5 s avec `output_tokens` cumulé (`continuous_usage_stats`), comme l'API Anthropic (11 événements au lieu de 1). **Débit solo** : un agent seul = 86–106 tok/s (le plafond solo d'Ornith sur A40), cache de préfixe 94 %. Ça paraît lent parce que c'est un seul flux avec beaucoup de raisonnement (jusqu'à ~3 900 jetons de réflexion en un tour = ~43 s) ; le débit ne monte qu'en parallélisant. Leviers côté client : `effort: low`, plus d'agents en parallèle, sorties/outils moins bavards. **v69 (préparé, non publié)** rassemble : chien de garde du pont, alias `[1m]`, correction de la fuite de connexions, repli réponse vide, usage incrémental. À publier hors charge (un `publish` relance vLLM). Les correctifs du pont ont été déployés à chaud sur le pod entre-temps. ## Bascule NVFP4 (bootstrap v74, 23/08/2026) — configuration servie aujourd'hui VL_MODEL=ornithnvfp4 VL_SPEC=off VL_CTX=1048576 `ornith-ai/Ornith-1.5-35B-A3B-NVFP4` remplace `ulkaa/…-AWQ-INT4`. Raison, calculée sur les formes de tenseurs (octets **lus par jeton** en décodage, tour visuelle exclue) : | dépôt | dense | `lm_head` | 8/256 experts | actif | plafond | |---|---:|---:|---:|---:|---:| | `ulkaa` AWQ-INT4 | 2,86 | 1,02 | 0,63 | 4,51 Go | 213 t/s | | `ornith-ai` FP8 | 2,46 | 1,02 | 1,06 | 4,54 Go | 211 t/s | | **`ornith-ai` NVFP4** | **1,40** | **0,29** | **0,62** | **2,30 Go** | **416 t/s** | L'AWQ ne quantifiait que les experts routés — 531 entrées dans `ignore` — donc **86 % de ce qui est lu à chaque jeton restait en BF16**. Le FP8 officiel est un piège symétrique : il grossit les experts, qui ne pèsent que 14 % de la lecture, sans alléger le dense. **Ce que vLLM en fait sur sm89** : `MarlinNvFp4LinearKernel` et backend MoE `MARLIN` (les autres candidats exigent Blackwell). Poids 21,94 Gio, et la config modelopt impose un **KV en fp8** : cache de **2 103 356 jetons**, soit 2,01× de concurrence à 1 048 576. Le vrai 1 M tient sur une carte, sans TurboQuant. ### Débits mesurés (sortie contrôlée en 4-grammes, 0,82 à 0,99) | session unique | 3 k | 32 k | 114 k | 282 k | 1 011 609 | |---|---:|---:|---:|---:|---:| | AWQ-INT4 | 132,3 | 125,4 | 103,1 | 73,2 | *impossible* | | **NVFP4** | **201,0** | **187,2** | **156,5** | **119,7** | **58,9** | | sessions | 1 | 2 | 4 | 8 | 16 | |---|---:|---:|---:|---:|---:| | agrégé | 127 | 274 | 398 | 646 | **1 108** | Loi de décodage : `t(L) = 4,936 ms + 0,01274 µs × L`, vérifiée à 5 % près à 1 M. ### `VL_SPEC` doit rester à `off` La MTP avait déjà été écartée sur l'AWQ (v63–v65). Elle reste perdante sur le NVFP4, et plus nettement : **66 tok/s contre 201**. La chaîne est causale et vaut la peine d'être retenue — le KV fp8 interdit FA2 sur sm89, vLLM bascule sur FlashInfer, et FlashInfer refuse `CUDAGraphMode.FULL_AND_PIECEWISE` sous spéculation ; vLLM coupe en plus `--async-scheduling`. La spéculation émettait pourtant 1,93 jeton par passe (acceptation 75,7 / 15,4 / 1,8 % par position) : c'est le surcoût par passe, ×3,9, qui noie le gain. ### Nouveautés du bootstrap v74 - `FORCE_MODEL="${VL_MODEL:-ornith}"` : le défaut ne change pas, mais `VL_MODEL` redevient surchargeable — **seul levier quand on n'a pas de shell sur le pod**. - **Journal distant** : la fin de `/tmp/boot.log` et de `/tmp/vllm.log` est republiée toutes les 25 s sur `patdev/k3-a40-bootstrap/etat/.log`, tant que `/v1/models` ne répond pas. Sans lui, un démarrage raté est invisible dès que SSH est indisponible. - Le correctif de config MTP couvre aussi `ornithnvfp4` (sans effet : ce dépôt exclut déjà `mtp*` de la quantification). ### Ce qui est ferme, et qu'il ne faut plus retenter - **TRT-LLM sur MoE Ada** : noyau MoE 4 bits réservé à sm90. - **EP TensorRT sur l'ONNX d'Ornith** : il ne prend que 181 nœuds sur 988, tous de plomberie ; les 441 qui portent les poids restent sur CUDA, et le graphe partagé interdit la capture CUDA. - **Réécriture QDQ du MoE** : TensorRT gagne 2,14× à index d'experts fixe, mais refuse de construire le moteur dès qu'un `Gather` s'intercale entre la constante quantifiée et son `DequantizeLinear`. La sparsité dynamique est incompatible avec un moteur compilé statiquement. Détail complet et chiffré : `reports/2026-08-23-plafond-solo-et-dtype-du-chemin-dense.md`. --- # Configuration retenue (23/08/2026, soir) Ce que 23 mesures de la journée désignent, toutes sur `nvidia/NVIDIA-Nemotron-3.5-Lightning-30B-A3B-NVFP4`. ## Branchement de Claude Code — la version qui marche ```powershell Remove-Item Env:\ANTHROPIC_API_KEY -ErrorAction SilentlyContinue $env:ANTHROPIC_BASE_URL = "https://-8080.proxy.runpod.net" $env:ANTHROPIC_AUTH_TOKEN = "x" $env:ANTHROPIC_MODEL = "claude-nemotron[1m]" $env:CLAUDE_CODE_DISABLE_UNKNOWN_MODEL_WINDOW_ENFORCEMENT = "1" $env:CLAUDE_CODE_AUTO_COMPACT_WINDOW = "1000000" $env:ANTHROPIC_SMALL_FAST_MODEL = "claude-3-5-haiku" $env:ANTHROPIC_DEFAULT_HAIKU_MODEL = "claude-3-5-haiku" $env:ANTHROPIC_DEFAULT_SONNET_MODEL = "claude-nemotron[1m]" $env:ANTHROPIC_DEFAULT_OPUS_MODEL = "claude-nemotron[1m]" claude ``` Ou simplement `.\connect.ps1`, qui lit l'identifiant du pod dans `.pod-nemotron.json`, vérifie l'endpoint, choisit le meilleur identifiant servi et lance Claude Code. **`ANTHROPIC_API_KEY` doit être effacé.** Il prend le pas sur `AUTH_TOKEN` et envoie les requêtes vers l'API officielle : on croit tester le modèle local, et la facture arrive avec. C'est ce qui faisait que Claude Code « ne voyait pas » les modèles servis. **Le 1 M exige les deux variables `CLAUDE_CODE_*`.** Claude Code suppose 200 k pour tout identifiant inconnu et compacte à cette limite ; la première lève la présomption, la seconde est plafonnée par elle et reste sans effet seule. Le suffixe `[1m]` ne suffit pas non plus. **Ne rien publier sur `patdev/k3-a40-bootstrap` pendant qu'un pod sert.** Le bootstrap surveille sa propre URL et se recharge : publier coupe l'endpoint pendant 3 à 5 minutes. ## Cycle de vie du pod ``` python pod.py create # cree (--gpu, --cloud, --replis, --max-prix, --sans-volume) python pod.py wait # bloque jusqu'au service python pod.py status # etat, cout, endpoint python pod.py stop # arrete -- facturation A LA MINUTE, couper tot economise python pod.py stock # cartes disponibles, avec leurs id RunPod exacts ``` ## La commande ``` vllm serve nvidia/NVIDIA-Nemotron-3.5-Lightning-30B-A3B-NVFP4 \ --trust-remote-code \ --max-model-len 1048576 \ --moe-backend marlin \ --kv-cache-dtype fp8 \ --enable-prefix-caching \ --gpu-memory-utilization 0.85 \ --mamba-backend flashinfer \ --mamba-cache-mode align \ --async-scheduling \ --max-num-batched-tokens 8192 \ --reasoning-parser nemotron_v3 \ --tool-call-parser qwen3_coder \ --enable-auto-tool-choice ``` Par variables du bootstrap : `VL_MODEL=nemotron VL_SPEC=off VL_CTX=1048576` **`VL_BATCHED=8192`** (défaut actuel 4096) `VL_ASYNC=on` (déjà le défaut). **Un seul changement par rapport à ce qui tournait** : `VL_BATCHED` de 4096 à 8192. Tout le reste était déjà en place. ## Ce que chaque drapeau vaut, mesuré Dix leviers testés un par un sur RTX PRO 6000, contexte 131 072 : | levier | solo | agrégé@8 | prefill j/s | |---|---:|---:|---:| | référence | 275,3 | 910 | 22 015 | | **`--async-scheduling`** | 281,4 | **995** | 23 845 | | **`--max-num-batched-tokens 8192`** | 276,9 | **1 003** | **24 050** | | `-O3` | 279,0 | 979 | 23 757 | | graphes CUDA `FULL` | 280,3 | 853 | 21 799 | | `--attention-backend TRITON_ATTN` | 280,5 | 902 | **14 918** | | `--mamba-cache-mode all` | 271,3 | 877 | 22 093 | | `--kv-cache-dtype auto` | 277,8 | 904 | 21 700 | | `--max-num-seqs 8` | 278,9 | 830 | 21 935 | | sans cache de préfixe | **283,9** | 911 | 23 021 | **Le débit solo ne bouge pas** : 4,6 % d'écart entre le meilleur et le pire, l'ordre du bruit. Les deux drapeaux retenus rendent ~+10 % **en agrégé et en prefill uniquement** — ils agissent sur l'ordonnancement, pas sur le calcul par jeton. Trois pièges dans ce tableau : - **Ne jamais forcer `TRITON_ATTN`** : −32 % de prefill pour un décodage identique. Notre ancien banc Blackwell le forçait, ce qui explique une partie de ses 237,7 périmés. - **`--kv-cache-dtype fp8` ne rend rien** (277,8 en `auto` contre 275,3). Il est gardé pour la capacité KV, pas pour la vitesse. - **Le meilleur solo est un piège.** « Sans cache de préfixe » donne 283,9, mais le cache rend **×16 sur le TTFT** dès que le préfixe est réutilisé : 2,90 s à froid contre 0,18 s à chaud, sur 63 895 jetons lus dans `usage.prompt_tokens`. Claude Code renvoie le même prompt système à chaque tour. On garde le cache. ## `mamba-cache-mode` : garder `align` partout **Correction du 23/08 au soir.** Une version antérieure de ce document recommandait `all` sur Ada pour ses « +11 % ». **C'était faux**, et l'erreur était une comparaison à deux variables : les 238,5 étaient en mode `all` avec le dtype de cache SSM par défaut, les 215,2 en mode `align` **avec `mamba-ssm-cache-dtype float16`** — et à des contextes différents, 1 M contre 131 k. L'écart a été attribué au mode de cache alors qu'il ne l'était pas. Mesure où le mode de cache est la **seule** variable (Ada, recette + v83, contexte 131 072) : | mode | solo | agrégé@8 | cache KV | |---|---:|---:|---:| | `align` | **209,9** | 648 | **5 108 531** | | `all` | 208,5 | 638 | 1 257 472 | `all` ne rapporte rien et divise la capacité KV par quatre. **Garder `align` partout.** Sur Blackwell le constat était déjà celui-là (271,3 contre 275,3). Lequel des autres facteurs portait les +11 % n'est pas établi — le dtype du cache SSM est le suspect le plus probable, mais il n'a pas été isolé. ## La machine Même modèle, même banc, mode `align`, contexte 1 M : | carte | $/h | solo | agrégé | remarque | |---|---:|---:|---:|---| | RTX 6000 Ada | **0,84** | 209,9 | 648 @8 | contrôle direct, config v83, ctx 131 072 | | RTX PRO 6000 Blackwell | 1,69 | **282,0** | 1 045 @8 | +31 % de solo | | A100 PCIe 80 Go | 1,19 | 154,2 | 2 257 @48 | 18,5 M de KV, 17,64× à 1 M | | H200 SXM | 3,59 | **351,6** | — | meilleur absolu, hors budget | **Sous 2 $/h et pour la latence d'une session unique : RTX PRO 6000 à 1,69 $/h.** Pour le meilleur rapport qualité-prix : l'Ada à 0,74 $/h. **Réserve levée le 23/08 au soir.** Contrôle exécuté sur un pod RunPod avec l'image publique `vllm/vllm-openai:v0.27.1` — la même que les jobs — et la commande identique, contexte 131 072 : | | Ada | RTX PRO 6000 | écart | |---|---:|---:|---:| | solo | 200,7 | 275,3 | **+37,2 %** | | agrégé @8 | 627 | 910 | **+45,2 %** | | prefill j/s | 11 556 | 22 015 | **+90,5 %** | Les +31 % annoncés étaient donc **conservateurs** : l'écart réel de silicium est de +37 %, et le prefill de la Blackwell est presque le double. Pile de noyaux identique des deux côtés (`FLASHINFER` / `MARLIN` / `MarlinNvFp4LinearKernel`). À noter : ces 200,7 sont *inférieurs* aux 215,2 de notre ancienne configuration de pod (`gpu-memory-utilization 0.93`, `max-num-batched-tokens 4096`, `mamba-ssm-cache-dtype float16`, contexte 1 M), qui était donc mieux réglée pour cette carte que la recette NVIDIA brute. ## Ce qui est fermé, et qu'il ne faut plus retenter - **Toute spéculation.** MTP, DSpark, DFlash, à la recette exacte de NVIDIA, sur Ada **et** sur Blackwell — l'architecture que NVIDIA déclare supportée : −62 % à −73 % de débit solo. DSpark n=5 émet *plus* de jetons par passe que n=3 (3,25 contre 2,72) et rend *moins* de débit : le brouillon fait son travail, c'est la passe avant qui s'effondre quand vLLM coupe `--async-scheduling` et les graphes CUDA complets. `VL_SPEC` reste à `off`. - **Chercher un autre noyau.** `--moe-backend humming` et `--linear-backend humming` donnent le même chiffre que Marlin à 1 % près, sur trois architectures. Le noyau MoE porte 36 % du socle actif, le linéaire 64 % : les deux moitiés sont insensibles. - **Requantifier le dépôt en W4A4.** Le format débloquerait cinq noyaux, et les cinq sont plus lents (−20 % à −33 %). Vérifié à 0,61 $ sur `nvidia/Qwen3-8B-FP4`, déjà au bon format, au lieu de 5 à 10 $ de conversion. - **Compter sur une carte plus large.** Rendement mémoire 69,3 % sur Ada, 47,5 % sur Blackwell, **22,6 % sur H200** : le socle actif est de 2,880 Gio par jeton, et le terme dominant n'est pas la mémoire mais un coût fixe par jeton. Détail chiffré : `reports/2026-08-23-plafond-solo-et-dtype-du-chemin-dense.md`, sections 22 à 28. --- # Qwen3.8-Flash-Next sur UNE carte, derrière le pont Anthropic (26/08/2026) Validé de bout en bout : Claude Code parle à `claude-flashnext`, 262 144 de contexte natif, sur **une seule** RTX PRO 6000 à 1,69 $/h. ## Reconstruire le pod `dockerStartCmd` : ``` curl -sL https://huggingface.co/patdev/k3-a40-bootstrap/resolve/main/lancer_flashnext.sh -o /run.sh && bash /run.sh ``` Exigences du pod : image `vllm/vllm-openai:qwen38-flash-next`, 1 GPU de 96 Go, **plus de 110 Go de RAM**, 200 Go de disque conteneur, port `8080/http`. Env : `HF_TOKEN`. Compter ~25 min au premier démarrage (135 Go à télécharger), ~6 min ensuite. ## Les quatre réglages sans lesquels rien ne marche | réglage | ce qui arrive sans lui | |---|---| | `VLLM_PLE_CPU_OFFLOAD=1` | 125,9 Go de poids pour 96 Go de VRAM | | `--distributed-executor-backend mp` | aucun ouvrier PLE, blocage **muet et sans fin** après la capture des graphes | | `patch_ple.py` | échec au 205e shard sur 206, `ngram_embedding.weight_scale` | | `--gpu-memory-utilization 0.93` | à 0,88 le cache KV plafonne à 309 594 jetons et les requêtes s'empilent pour cause de capacité | Ne **pas** poser `PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True` : `pidfd_getfd: Operation not permitted`, faute de `CAP_SYS_PTRACE`. ### Le cache KV, et pourquoi ne pas suivre la suggestion de vLLM Mesuré le 26/08, une seule variable changée : | `--gpu-memory-utilization` | cache KV | jetons | concurrence max à 262 k | |---|---|---|---| | 0.88 | 7,52 GiB | 309 594 | 1,18× | | **0.93** | **12,26 GiB** | **504 841** | **1,93×** | À 0,88, vLLM refusait d'admettre des requêtes — `num_requests_waiting_by_reason{reason="capacity"} = 5` avec 2 seulement en cours. À 0,93 la file est vide. Ne **pas** suivre le `--kv-cache-memory` « to fully utilize gpu memory » que vLLM propose (17,92 GiB ici) : il ne garde aucune marge au-dessus du pic d'activation mesuré à vide (1,55 GiB), et sous charge réelle ce pic est plus haut — c'est cette valeur-là qui avait tué le serveur en plein service. ### Mais l'intervalle, lui, se prend : `--kv-cache-memory-bytes` Mesuré le 26/08. **Le KV est strictement linéaire en octets**, à 26 078 octets par jeton aux trois points : | réglage | cache KV | jetons | concurrence à 262 k | tient 2 prefills pleins ? | |---|---|---|---|---| | `--gpu-memory-utilization 0.93` | 12,26 GiB | 504 841 | 1,93× | oui | | **`--kv-cache-memory-bytes 16106127360`** | **15 GiB** | **617 633** | **2,36×** | **oui — retenu** | | `--kv-cache-memory-bytes 18253611008` | 17 GiB | 700 087 | 2,67× | **NON — `EngineDeadError`** | **Le test qui décide n'est pas un prefill, c'est deux.** À 17 GiB, une requête à pleine fenêtre passe très bien ; deux simultanées tuent le moteur. La VRAM libre le dit : ``` au repos 1 prefill 2 prefills simultanes 17 Gio 269 Mo 273 Mo 83 Mo -> EngineDeadError 15 Gio 4353 Mo 2289 Mo 1137 Mo -> OK ``` Deux prefills concurrents consomment **~3,2 GiB transitoires**. La réserve `1.55 GiB for peak activation` annoncée par vLLM en couvre à peu près *un* : elle est profilée sur une forme de lot, **ce n'est pas une borne pire-cas**. Ne jamais valider une capacité sur une seule requête. ### `--kv-sharing-fast-prefill` : RETIRE le 27/08, il tuait le moteur **Ne pas l'activer.** Mesure et contre-mesure, dans cet ordre : | | KV | solo | agrégé à 8 | prefill chaud | aiguilles | |---|---|---|---|---|---| | 15 GiB | 617 633 | 83,2 | 426,0 | 259 858 | 5/5 | | **15 GiB + `--kv-sharing-fast-prefill`** | **617 633** | **87,3** | **445,4** | **297 085** | **5/5** | Capacité identique au jeton près. **Attention au piège de mesure :** sous `--gpu-memory-utilization`, ce même drapeau semblait coûter −8,6 % de cache (504 841 → 461 280), parce que c'est vLLM qui arbitre et que ses tampons sortent du budget KV. Sous `--kv-cache-memory-bytes`, le cache est *fixé* et le surcoût sort de la marge. Les deux mesures sont exactes et racontent l'inverse : le mode d'allocation est une seconde variable, invisible dans la ligne de commande. Exactitude vérifiée en A/B — cinq aiguilles à 10/30/50/70/90 % de profondeur dans 120 906 jetons, raisonnement coupé : **5/5 des deux côtés**. **Et pourtant il est retiré.** Trois morts du moteur en douze heures, toutes avec ce drapeau actif, aucune sans, sur des dizaines de configurations : ``` File "vllm/v1/ple_offload/connector.py", line 389, in _launch self._request_queue.put_nowait(request) # queue.Queue(maxsize=1) queue.Full ``` Le commentaire du code dit l'invariante : *« each forward consumes its output before the next launch »*. Elle suppose des forwards **sérialisés** ; ce drapeau modifie l'ordonnancement du prefill juste assez pour la casser sous concurrence. Preuve par contraste, mesurée le 27/08 : | | concurrence atteinte | survie | |---|---|---| | avec `--kv-sharing-fast-prefill` | 4 requêtes | **14 min → `queue.Full`** | | sans | **5 à 6 requêtes** | 49 min et plus, zéro incident | La charge sans le drapeau était **supérieure de moitié** à celle qui avait tué le moteur avec. Une seule paire d'observations, donc pas une preuve formelle — mais 4,6 % d'agrégé ne paient pas un moteur qui meurt sous la charge réelle. **Piège de lecture** : pendant 40 min après le retrait, la concurrence n'a jamais dépassé 3. Les « quarante minutes sans incident » ne prouvaient alors rien du tout — comparer deux régimes de charge différents ne vaut pas mieux ici qu'ailleurs. Le résultat n'existe qu'à partir du moment où la charge a repassé le seuil. ### Le bug qui tue le moteur : la file du déchargement PLE Une requête très au-delà de la fenêtre (mesurée à 580 000 jetons, 2,2×) ne rend pas une erreur — elle **tue le moteur** : ``` File "vllm/v1/ple_offload/connector.py", line 389, in _launch self._request_queue.put_nowait(request) queue.Full ``` ```python # PLE rejects DBO, and each forward consumes its output before the # next launch, so one pending request is sufficient. self._request_queue = queue.Queue(maxsize=1) ``` C'est une **invariante violée**, pas une file sous-dimensionnée : augmenter `maxsize` ferait courir deux requêtes sur des tampons de sortie partagés. Non atteignable depuis Claude Code (`CLAUDE_CODE_MAX_CONTEXT_TOKENS = 262144`). Juste au-dessus de la limite, à 262 085 jetons, le serveur rejette proprement avec un `BadRequestError` transmis intact dans le flux SSE, et reste debout. Le symptôme à reconnaître : `/v1/models` répond `200` alors que toute vraie requête rend `500` — le pont survit au moteur qu'il sert. ### Ne pas activer la spéculation MTP sur une seule carte Le modèle embarque bien sa tête brouillon (31 tenseurs `mtp.*` dans le dépôt, `hc_count=4`, `mtp_num_hidden_layers=1`) et vLLM l'accepte. Mais elle consomme **5,79 GiB de VRAM** : le cache KV tombe de 12,26 à 6,47 GiB, alors qu'une seule requête à 262 144 en réclame 7,25 — le moteur refuse de démarrer. ``` ValueError: To serve at least one request with the model's max seq len (262144), 7.25 GiB KV cache is needed, which is larger than the available KV cache memory (6.47 GiB). ... estimated maximum model length is 231264. ``` Même au maximum de mémoire, on obtiendrait ~423 000 jetons contre 504 841 sans MTP : au moins 16 % de capacité cédés pour un gain de décodage plafonné à 2×, alors que la capacité est justement ce qui bride le serveur. Les seules portes de sortie sont de descendre `--max-model-len` à 231 264 ou moins, ou d'ajouter une deuxième carte. ## Côté client ```powershell Remove-Item Env:ANTHROPIC_API_KEY -ErrorAction SilentlyContinue $env:ANTHROPIC_BASE_URL = "https://-8080.proxy.runpod.net" $env:ANTHROPIC_AUTH_TOKEN = "x" # Alias NU, sans prefixe claude- : voir ci-dessous, c'est ce qui debloque 262 k $env:ANTHROPIC_MODEL = "flashnext" $env:ANTHROPIC_DEFAULT_OPUS_MODEL = "flashnext" $env:ANTHROPIC_DEFAULT_SONNET_MODEL = "flashnext" $env:ANTHROPIC_DEFAULT_HAIKU_MODEL = "flashnext" $env:ANTHROPIC_SMALL_FAST_MODEL = "flashnext" $env:CLAUDE_CODE_MAX_CONTEXT_TOKENS = "262144" $env:CLAUDE_CODE_DISABLE_UNKNOWN_MODEL_WINDOW_ENFORCEMENT = "1" claude ``` `ANTHROPIC_API_KEY` doit être effacée : elle prime sur `ANTHROPIC_AUTH_TOKEN` et renvoie vers l'API officielle, facturée, sans le dire. **Le nom servi doit être NU.** Corrigé le 26/08 en décompilant le binaire 2.1.239 : la fonction `JFd` refuse toute fenêtre personnalisée à un identifiant commençant par `claude-`. ```js let n = CLAUDE_CODE_MAX_CONTEXT_TOKENS; if (n !== undefined && n > 0 && !id.startsWith("claude-")) return n; return /* defaut */ 200_000 ``` Avec `claude-flashnext`, ni `CLAUDE_CODE_MAX_CONTEXT_TOKENS` ni `CLAUDE_CODE_AUTO_COMPACT_WINDOW` n'ont le moindre effet et `/context` affiche 200 k quoi qu'on fasse. La bonne variable est **`CLAUDE_CODE_MAX_CONTEXT_TOKENS`**, pas `AUTO_COMPACT_WINDOW` ; il en faut toujours deux, car la présomption de 200 k est rabattue tant que l'application de la règle est active. Le pont ajoute donc `MODEL` nu en tête d'`ALIASES` (`anthropic_proxy.py`). Ne pas utiliser le suffixe `[1m]` : d'autres branches de `JFd` renvoient `1e6` en dur, ce qui ferait croire à Claude Code qu'il dispose d'un million alors que le moteur plafonne à 262 144, et ferait compacter trop tard. **Contrepartie de l'alias nu.** Tout outil qui déduit la fenêtre du NOM du modèle casse. Une ligne d'état qui attend `claude-/` retombe sur son défaut de 200 k et affiche `172k/200k` pendant que `/context` affiche 262,1 k. Le correctif est de consulter la même source que Claude Code, avec sa condition : ```js const env = Number(process.env.CLAUDE_CODE_MAX_CONTEXT_TOKENS); if (Number.isFinite(env) && env > 0 && !base.startsWith("claude-")) return env; return 200_000; ``` ## Ce qu'on obtient Configuration retenue, corrigée le **27/08** après trois morts du moteur en service : ``` --kv-cache-memory-bytes 16106127360 (15 Gio) --enable-flashinfer-autotune ``` `--kv-sharing-fast-prefill` a été **retiré** — voir plus haut : il rendait +4,6 % d'agrégé et déclenchait `queue.Full` dans le connecteur PLE sous concurrence. Les chiffres du 26/08 le comprenaient ; ceux-ci ne le comprennent plus. ``` 26/08 matin 27/08 retenu gain solo 75,3 tok/s 83,2 +10,5 % agrege a 8 335,2 tok/s 426,0 +27,1 % KV 297 148 jetons 617 633 +107,9 % concurrence a 262k 1,13x 2,36x +108,8 % prefill froid 8 535 j/s 11 200 +31,2 % prefill chaud - 259 858 j/s (x23 sur le froid) ``` Tenue en charge vérifiée le 27/08 : **5 à 6 requêtes concurrentes** soutenues, 1 228 j/s de prefill et 322 j/s de génération, sans incident — contre 14 min de survie à 4 requêtes avec le drapeau retiré. **Le prefill froid n'a été amélioré par aucun levier de la soirée** — les 31 % viennent de `0.93` et de l'autotune, acquis plus tôt. Tous les leviers qui le visaient sont fermés : déchargement KV et `prefix-match-unit` par l'alignement Mamba (groupes `[800, 4, 800, 800, 800, 800]`), `indexer_budget` par le noyau `persistent_topk` qui n'accepte que k ∈ {512, 1024, 2048}, soit un budget déjà au plancher. Le vrai gain de prefill est **indirect** : le contrôle d'éviction donne `gain_eviction = 1,00` — un bloc chassé du GPU est définitivement perdu. Seule la taille du cache protège un préfixe. À ~130 k par session, on passe de ~2,3 à ~4,7 sessions dont chaque tour coûte 1,14 s au lieu de 21 s. Trois fois et demie plus lent que Nemotron en solo. Le pari reste que la capacité compense. Détail complet : `reports/2026-08-23-plafond-solo-et-dtype-du-chemin-dense.md`, sections 33 à 36. ## Les images : un 400 qui accuse le modèle et désigne notre réglage ### Le symptôme Une capture collée dans Claude Code fait tomber le tour entier : ``` API Error: 400 {"error":{"message":"At most 0 image(s) may be provided in one prompt. (parameter=image)","type":"BadRequestError","param":"image","code":400}} ``` Le message ressemble à une limite du modèle. C'est **notre** drapeau, lu à voix haute : `--limit-mm-per-prompt '{"image":0,"video":0}'`. vLLM le confirme au démarrage, en clair, dans son propre journal : ``` All limits of multimodal modalities supported by the model are set to 0, running in text-only mode. ``` Le piège de diagnostic est là : l'erreur est renvoyée au client comme une erreur de requête, alors que la cause est une option de service. Chercher dans le modèle ou dans le pont fait perdre l'heure ; il faut lire le journal de démarrage du moteur. ### Ce qui n'était PAS en cause Deux couches vérifiées avant de toucher au moteur, et toutes deux saines : * **le pont** traduit déjà correctement les blocs Anthropic vers OpenAI (`_image_part`, `anthropic_proxy.py`) — `source.base64` devient une URI de données, `source.url` passe tel quel, et les images portées par un `tool_result` sont extraites pour être rattachées au tour utilisateur ; * **les poids de vision sont dans le dépôt** : 333 tenseurs `model.visual.*`, soit **0,90 Go sur les 135,16** du checkpoint (0,449 B paramètres BF16). Décompte complet par catégorie : | | paramètres | taille | |---|---:|---:| | experts MoE | 67,948 B | 67,95 Go | | table PLE n-grammes | 51,200 B | 51,20 Go | | reste (attention, embeddings, `lm_head`) | 4,948 B | 9,90 Go | | tête MTP (non chargée) | 2,607 B | 5,21 Go | | **tour de vision** | **0,449 B** | **0,90 Go** | La tour est donc déjà résidente : le plafond ne gouvernait que la validation des requêtes et le profilage, pas le chargement. ### Pourquoi il a fallu rendre 0,5 GiB de KV en même temps Mesure sur le pod en service avant bascule : ``` VRAM : 96 612 MiB utilisés sur 97 887 -> 1,25 GiB libres ``` Et surtout, dans le journal : ``` reserved 15.0 GiB memory for KV Cache as specified by kv_cache_memory_bytes config and skipped memory profiling. ``` **`--kv-cache-memory-bytes` fait sauter le profilage mémoire.** vLLM ne recalculera donc rien tout seul : il réservera ses 15 GiB quoi qu'il arrive, y compris si l'encodage d'images demande des activations transitoires qu'on n'a jamais mesurées ici. Avec 1,25 GiB libres et trois morts de ce moteur déjà au compteur pour avoir visé trop haut, on rend la marge : | | avant | après | |---|---:|---:| | `--kv-cache-memory-bytes` | 16 106 127 360 (15,0 GiB) | **15 569 256 448 (14,5 GiB)** | | cache KV | 617 633 jetons | ~597 000 | | concurrence à 262 144 | 2,36× | ~2,28× | | images par requête | **0** | **4** | −3,3 % de capacité pour que les captures fonctionnent. La vidéo reste à `0` : `temporal_patch_size 2` et plusieurs images par seconde feraient exploser le budget d'activation, et Claude Code n'en envoie jamais. ### Les deux pièges de la manœuvre **Le chien de garde doit porter le même drapeau.** `chien.py` a sa propre `MOTEUR_CMD`. Si on ne la met pas à jour, la première « réparation » relance le moteur en mode texte seul et la panne revient sans que rien ne l'explique. **Aucun `pkill -f` sur ce pod.** L'agent qui exécute les commandes porte le **texte complet du script** dans sa ligne de commande : tout motif présent littéralement dans le fichier matche l'agent et le tue — constaté, `[FIN] code=-15` dès la première ligne. Soit on brise l'égalité littérale (`vllm [s]erve`, à appliquer à *chaque* motif), soit — plus sûr quand le script arrête plusieurs choses — on lit `/proc/*/cmdline` et on exclut toute son ascendance en remontant `ppid`. Recette dans `bascule_images.sh`. ### La vraie cause : `transformers` ne connaît pas le modèle du tout Le 400 levé, les images sont acceptées et encodées — 223 jetons pour un 448×448 — mais le modèle **ne voit rien de juste** : 0/4 sur des couleurs unies, 0/3 sur du comptage de carrés, et « droite » que le carré soit à gauche ou à droite. Un 200 ne prouvait rien. Premier indice, dans le journal du moteur, répété cinq fois : ``` [transformers] Unrecognized keys in `rope_parameters` for 'rope_type'='default': {'mrope_section', 'mrope_interleaved'} ``` J'ai d'abord conclu « le mRoPE est jeté ». **C'était incomplet**, et l'écart compte : un avertissement sur des clés inconnues ne prouve pas qu'elles sont supprimées. En sondant le vrai chemin de code sur le pod : ``` ValueError: The checkpoint you are trying to load has model type `qwen4_exp` but Transformers does not recognize this architecture. processeur : Qwen3VLProcessor <-- repli sur un AUTRE modèle image_processor : Qwen2VLImageProcessor ``` `transformers` **5.15.1**, embarqué dans l'image vLLM d'aperçu, ne connaît pas `qwen4_exp` : `AutoConfig` **lève**, au lieu de construire un `Qwen4ExpConfig`. Le modèle ne reçoit donc jamais sa configuration propre — `mrope_section [11, 11, 10]` compris, qui est ce qui donne aux patchs d'image leurs positions en 3D. Le texte n'en souffre pas ; l'image perd toute géométrie, tandis que le nombre de jetons, lui, reste plausible. **Deux fausses pistes que la réparation a démenties, et qu'il faut nommer :** * j'ai d'abord accusé `AutoProcessor`, qui tombait en repli sur `Qwen3VLProcessor`. **Faux** : après correction, le processeur est *toujours* `Qwen3VLProcessor` / `Qwen2VLImageProcessor` et la vision marche — c'est simplement le processeur légitime de ce modèle ; * l'avertissement `Unrecognized keys in rope_parameters` **subsiste** dans le journal avec 5.16.1, vision fonctionnelle à l'appui. Il n'a jamais été le mécanisme. C'était le symptôme le plus visible, et le plus trompeur. C'est la signature à retenir : *le compte de jetons peut être juste pendant que le contenu est faux*. Le seul test qui tranche est un test qui **discrimine** — trois couleurs distinctes, deux comptages, deux latéralités. Deux fausses pistes écartées au passage : la tour de vision est **exclue de la quantification** (`model.visual.*` dans `exclude_modules` de `hf_quant_config.json`), donc intacte en BF16 ; et le pont traduisait déjà correctement les blocs Anthropic. Le `smoke_report.json` du dépôt RadixArk est d'ailleurs **purement textuel** : personne n'avait validé la vision de ce quant. **Le correctif est une montée de version** : `transformers 5.16.1` (publié sur PyPI) contient `transformers/models/qwen4_exp/`. L'opération se fait dans le conteneur, avec **revert automatique** vers 5.15.1 si la reconnaissance échoue ou si le moteur ne redémarre pas — un endpoint texte qui marche vaut mieux qu'une vision hypothétique. Script : `monter_transformers.sh`, aides dans `aides/` sur le dépôt bootstrap. ### Résultat mesuré (27/08, 19 h 31) ``` transformers 5.15.1 -> 5.16.1 tokenizers 0.22.2 -> 0.23.1 config class ValueError -> Qwen4ExpConfig / Qwen4ExpTextConfig mrope_section absent -> [11, 11, 10] KV 596 630 jetons, concurrence 2,28x redemarrage 200 s ``` | épreuve | avant | après | |---|---:|---:| | couleurs unies (rouge, vert, bleu) | 0/3 | **3/3** | | comptage (1 et 3 carrés) | 0/2 | **2/2** | | latéralité (gauche, droite) | 0/2 | **2/2** | | **total** | **0/7** | **7/7** | Confirmé indépendamment depuis l'extérieur, par le proxy RunPod — la sonde du pod tapait sur `127.0.0.1`, ce qui ne prouve pas le chemin client. Sur une scène composite : « deux carrés noirs situés à gauche, l'un au-dessus de l'autre », plus le carré rouge ; et une forme en T lue comme un T. ### Deuxième épreuve : lire une vraie capture d'écran Les 7/7 ci-dessus portent sur de la **géométrie** — couleurs, comptage, latéralité. Ils prouvent que le pipeline d'image est correctement câblé, pas que le modèle sait lire un terminal à 14 px. Ce sont deux régimes différents, et c'est le second que Claude Code envoie. Rendu sur le pod avec Pillow et DejaVuSansMono, à des densités réalistes : | épreuve | image | jetons | résultat | |---|---|---:|---| | erreur de build (terminal sombre, 16 px) | 1200×400 | 501 | **OK** — `TS2345 \| src/services/auth.ts \| 142` | | extrait de code (fond clair, 16 px) | 1200×400 | 499 | **OK** — `fibonacci` / `4096` | | transcription à **13 px** | 1200×200 | 258 | **OK** — mot pour mot | | disposition d'interface (deux boutons) | 900×300 | 287 | **OK** — « Enregistrer », bleu | **4/4.** Le troisième est le plus significatif : à 13 px, la ligne `EngineDeadError: ple_offload/connector.py queue full` est restituée exactement, sans approximation. **Budget à connaître : une capture plein écran coûte ~500 jetons de contexte.** Sur un cache de 596 630 jetons partagé entre agents, une dizaine de captures dans une conversation restent négligeables ; une centaine ne le sont plus. **Coût de l'opération** : `--kv-cache-memory-bytes` 15,0 → 14,5 GiB, soit 617 633 → 596 630 jetons de cache (−3,4 %). C'est le prix de la marge pour l'encodeur d'images, pas celui du multimodal lui-même. Le redémarrage coûte plus cher que d'habitude : changer `--limit-mm-per-prompt` **invalide le cache `torch.compile` et le warmup**, soit ~4-5 min au lieu de 2 min 30. Une seule fois ; le redémarrage suivant à configuration identique retrouve son temps normal.