k3-a40-bootstrap / DEPLOY.md
patdev's picture
DEPLOY : VL_SWAP, VL_SPEC_AUTO, VL_PRECHARGE (v70-v72)
8d4b987 verified
|
Raw History Blame Contribute Delete
69.6 kB

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 » 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://<podid>-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 :

curl -X POST https://rest.runpod.io/v1/pods   -H "Authorization: Bearer $RUNPOD_API_KEY" -H "Content-Type: application/json"   -d '{"name":"a40","templateId":"<id>","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 <version>, CUDA runtime <x> au démarrage.

Branchement de Claude Code

ANTHROPIC_BASE_URL=https://<podId>-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

export ANTHROPIC_BASE_URL=https://<podId>-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 :

# 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, <unchanged>) — 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/<POD_ID>.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

Remove-Item Env:\ANTHROPIC_API_KEY -ErrorAction SilentlyContinue
$env:ANTHROPIC_BASE_URL   = "https://<podId>-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
# 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

Remove-Item Env:ANTHROPIC_API_KEY -ErrorAction SilentlyContinue
$env:ANTHROPIC_BASE_URL   = "https://<pod>-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-.

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-<fournisseur>/<modele> 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 :

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.