Aller au contenu
openplate

Le moteur d'inférence

Apporte ton propre moteur d'exécution

Pointe ceci vers une instance llama.cpp, Ollama ou vLLM que tu fais déjà tourner, contrainte de grammaire imposée, pièges de configuration par moteur

Cette page est traduite automatiquement à partir de la documentation en anglais.

Si tu fais déjà tourner llama.cpp, Ollama ou tout autre outil compatible avec le protocole OpenAI, pointe ce service dessus. Dans ce mode, il ne télécharge aucun modèle et ne lance aucune seconde copie de modèle sur ton matériel.

Consulte la matrice de prise en charge avant de choisir un moteur d'exécution : ce pipeline nécessite un décodage contraint par grammaire, et tous les outils qui prétendent être compatibles avec OpenAI ne le gèrent pas. llama.cpp, Ollama et vLLM sur un GPU fonctionnent d'après nos mesures. la version processeur de vLLM plante dès la première analyse : même version, pas d'accélérateur, processus arrêté.

Le chemin parcouru par une analyse est court, et la grammaire se trouve au milieu. Le navigateur envoie une photo à ce service, qui vérifie la clé, accepte la requête, réduit l'image et effectue un unique appel de vision avec un schéma JSON. Ton moteur d'exécution est le seul à faire respecter ce schéma, c'est pourquoi la matrice ci-dessous traite du respect des contraintes plutôt que de la vitesse. Ce qui revient, ce sont des noms et des grammes ; les macronutriments sont recherchés ensuite, dans la base alimentaire configurée, et le modèle n'en écrit aucun.

Le navigateur envoie une photo à ce service, qui appelle une fois ton moteur d'exécution sous un schéma JSON, puis cherche les macronutriments avant de répondre.
Source du schéma
flowchart LR
  browser["Browser"]
  subgraph svc["openplate-inference"]
    gate["Key, rate limit, admission"]
    prep["Downscale to 896 px"]
    vision["One vision call, json_schema"]
    macros["Look up macros, food source"]
  end
  runtime["Your model runtime"]

  browser -->|"photo, POST /v1/chat/completions"| gate
  gate --> prep --> vision
  vision -->|"grammar constrained decoding"| runtime
  runtime -->|"names and grams, no macros"| macros
  macros -->|"one plate, as JSON"| browser
bash
docker run -d --name openplate-inference \
  -p 8300:8300 \
  -e MODEL_PROFILE=external \
  -e MODEL_RUNTIME_URL=http://your-runtime.lan:8000 \
  -e MODEL_ID=your-served-model-name \
  -e API_KEYS=opk_your_key \
  ghcr.io/lowcarbcheck/openplate-inference:latest

MODEL_PROFILE=external constitue le commutateur complet. Il saute le téléchargement des poids et ne lance aucun llama-server, il n'y a donc pas de volume /models.

Variables du mode externe

  • MODEL_RUNTIME_URL : pas de /v1 final, le service ajoute lui-même les chemins d'accès OpenAI. La résolution doit se faire depuis à l'intérieur du conteneur, donc localhost désigne le conteneur, pas ton hôte. Le définir sur une adresse différent sans MODEL_PROFILE=external provoque une erreur au démarrage plutôt qu'une substitution silencieuse : un conteneur intégré écoute toujours sur son propre bouclage. (Le définir exactement sur l'adresse de bouclage intégrée est autorisé et ne change rien : les anciennes copies de .env.example fournissaient cette ligne non commentée.)
  • MODEL_ID : envoyé à ton moteur d'exécution. llama.cpp l'ignore, mais vLLM et Ollama exigent tous deux le nom exact du modèle servi : vLLM rejette toute incohérence, et Ollama s'en sert pour sélectionner et charger le modèle. L'identifiant qu'utilisent les clients reste toujours openplate-plate-1 dans tous les cas.
  • MODEL_RUNTIME_API_KEY : facultatif, envoyé sous la forme Authorization: Bearer … à ton moteur d'exécution. Définis-le pour vllm serve --api-key … ou un proxy d'authentification. Il est distinct de API_KEYS, qui correspond à ce que les appelants fournissent à ce service.
  • RUNTIME_COMPLETION_TIMEOUT_MS : limite totale pour un appel de complétion, en millisecondes. Par défaut 600000 (10 minutes) ; 0 la désactive. Elle s'applique en mode intégré aussi : un llama-server bloqué est tout aussi muet qu'un proxy mal acheminé. Si ton matériel a légitimement besoin de plus de dix minutes par assiette, augmente-la ou règle-la sur 0 ; le message d'échec mentionne la variable.

    Il ne s'agit pas de LATENCY_CEILING_MS. Celle-là relève du politique d'admission : refuser le travail que tu ne pourras pas terminer à temps, avant même de le commencer. Celle-ci relève de l'activité : libérer un créneau de traitement qu'un composant en amont ne rendra jamais.

    Une limite s'appliquait déjà avant l'existence de cette variable : le fetch de Node définit par défaut headersTimeout à 300 s, et comme une complétion sans flux n'écrit ses en-têtes qu'à la fin de la génération, cela agit comme un plafond total de 300 s, atteignable sur le matériel pris en charge par ce projet. Définir RUNTIME_COMPLETION_TIMEOUT_MS explicitement assouplit cette valeur par défaut.

    Ce n'est pas « attendre indéfiniment », car avec CONCURRENCY=2, deux analyses en cours bloquées monopolisent les deux créneaux de traitement en permanence alors que /readyz reste au vert : les sondes d'aptitude interrogent un autre point d'accès et ne peuvent pas le voir.

Ton moteur d'exécution doit appliquer un décodage contraint par grammaire

C'est une exigence stricte. Le pipeline effectue un seul appel de vision avec response_format: {"type": "json_schema", "json_schema": {"name": …, "strict": true, "schema": …}} et fait confiance à la structure renvoyée. Il n'y a aucune boucle d'analyse et de réessai.

L'échec dangereux n'est pas le rejet, c'est l'acceptation avec omission : un moteur d'exécution qui reçoit le champ response_format, l'ignore, puis renvoie un JSON plausible qui ne respecte pas le contrat. Cela n'apparaît pas comme une erreur. Cela se manifeste par des estimations en grammes légèrement fausses.

Vérifie le tien en une seule commande avant de lui faire confiance. Demande de la prose tout en exigeant un schéma ; si la réponse respecte toujours la forme du schéma, la grammaire est bien appliquée :

bash
curl -s http://your-runtime.lan:8000/v1/chat/completions \
  -H 'Content-Type: application/json' \
  -d '{"model":"your-model","messages":[{"role":"user","content":"Write a haiku about rain."}],
       "response_format":{"type":"json_schema","json_schema":
         {"name":"t","strict":true,"schema":{"type":"object","properties":{"x":{"type":"string"}},
          "required":["x"],"additionalProperties":false}}}}'

Un objet JSON avec une clé x signifie que la contrainte fonctionne. Un haïku signifie que non, et ce service renverra un code 502 à chaque analyse avec un message l'indiquant.

Matrice de compatibilité

Mesuré directement le 2026-08-15. Les lignes que nous n'avons pas exécutées nous-mêmes le précisent.

Moteur d'exécutionVersionGrammaireEntrée d'imageVerdict
llama.cpp (llama-server)b10330appliquée (GBNF)URL de données base64✅ fonctionne, c'est ce que l'image fournie exécute
Ollama0.32.13appliquéeURL de données base64✅ fonctionne, voir la note sur la disponibilité ci-dessous
vLLM (build CPU)0.27.1aucunaucun❌ cassé : une requête json_schema plante le serveur (pin_memory=True requires a CUDA or other accelerator backend). Les complétions simples fonctionnent, l'état paraît donc normal jusqu'à ce que la première analyse réelle n'interrompe le processus
vLLM (build GPU)0.27.1appliquée (xgrammar)URL de données base64✅ fonctionne, mesuré sur une RTX 3090 avec Qwen3-VL-2B-Instruct
autre choseaucunaucunaucun⚠️ non testé, exécute la commande curl ci-dessus

Même version, résultats opposés : le problème vient spécifiquement du build CPU. Le test GPU a exécuté l'image vLLM identique qui plante sur CPU (vllm/vllm-openai:latest et v0.27.1 partagent le même digest), avec le même schéma et la même image base64 que ce service envoie. Sur le GPU, il a renvoyé du JSON conforme au schéma en 1,8 s et est resté actif. Lis la ligne ❌ comme « ne lance pas vLLM sans GPU », et non comme « vLLM n'est pas pris en charge ».

Pourquoi le build CPU plante. Le décodage contraint par grammaire construit un masque de bits de jetons et l'alloue dans la mémoire épinglé, un tampon verrouillé en mémoire vive destiné aux copies rapides vers un GPU. Si tu demandes cela sans accélérateur présent, PyTorch lève une exception au lieu de l'ignorer, et comme cela se produit pendant le traitement de la requête plutôt que lors de la validation, cela fait planter le worker au lieu de renvoyer une erreur. C'est un bug en amont : llama.cpp et Ollama gèrent correctement cette même charge utile.

Une particularité de vLLM à connaître. Lorsqu'une invite ne peut pas recevoir de réponse dans la forme requise, nous avons vu le modèle émettre { suivi d'espaces jusqu'à atteindre la limite de jetons : la grammaire tient bon (il n'a jamais produit de texte libre, même en lui demandant explicitement un haïku), mais le modèle remplit les espaces non contraints au lieu de générer le contenu. Cela se traduit ici par l'erreur finish_reason: length, qui indique la limite de jetons. Une vraie photo d'assiette ne l'a pas déclenchée, contrairement à une invite hors sujet.

Si tu testes une configuration absente du tableau, ouvre un ticket pour partager le résultat.

Trois pièges de configuration

1. Fenêtre de contexte : le symptôme est une sortie incohérente, pas une erreur au démarrage. Une fenêtre trop petite n'échoue pas bruyamment. Elle tronque le début de ton invite et produit des absurdités formulées avec assurance.

La taille de l'invite dépend de ton modèle. Deux mesures directes sur les assiettes réduites à même 896 px :

ModèleJetons de l'invite (assiette en 896 px)
Qwen3-VL-8B sur llama.cpp1 287 à 3 507 sur le corpus de 10 assiettes
moondream sur Ollama746

Ne calibre pas à partir de ces chiffres : mesure les tiens. Lance une analyse sur ton moteur d'exécution et regarde ce qu'il indique :

bash
curl -s .../v1/chat/completions -d '…' | jq .usage.prompt_tokens

Dimensionne la fenêtre au-dessus de ton cas le plus défavorable, avec une marge. Sur vLLM, c'est --max-model-len. Sur Ollama, c'est num_ctx : configure-la dans un Modelfile, car nous avons constaté que OLLAMA_CONTEXT_LENGTH=8192 ne parvient pas à augmenter le contexte rapporté d'un modèle au-delà de 2048 (Ollama semble bloquer la valeur au contexte d'entraînement du modèle, donc impossible d'agrandir la fenêtre d'un modèle entraîné avec une fenêtre étroite).

2. CONCURRENCY doit correspondre au nombre réel d'emplacements de ton moteur d'exécution. Accepter plus de requêtes en cours que le moteur d'exécution n'a d'emplacements n'augmente pas le débit : cela déplace la file d'attente vers un endroit que ce service ne peut ni voir ni mesurer, et le contrôleur d'admission prend alors ses décisions sur des bases fausses.

Moteur d'exécutionL'option qui définit les emplacements
llama.cpp--parallel N
vLLM--max-num-seqs N
OllamaOLLAMA_NUM_PARALLEL=N

3. Le HEALTHCHECK du conteneur accorde 60 minutes pour démarrer. Ce délai est calibré pour télécharger les poids au premier démarrage sur une connexion domestique. En mode externe, il n'y a aucun téléchargement, donc un MODEL_RUNTIME_URL incorrect reste masqué pendant une heure au lieu d'échouer en quelques secondes. start_period est intégré à la construction de l'image, donc remplace-le : regarde le bloc commenté dans docker/compose.yml.

L'état prêt, et ce qu'il ne t'indique pas

/readyz interroge ton moteur d'exécution sur GET /health, et se rabat sur GET /v1/models pour les moteurs qui n'ont pas de /health (c'est le cas d'Ollama). Deux limites :

  • Avec Ollama, /v1/models vérifie la vivacité, pas l'état prêt. Il renvoie 200 sans aucun modèle chargé en mémoire (Ollama charge à la demande sur la première requête), donc un état prêt signifie « accessible », pas « chaud ». Ta première analyse subit le temps de chargement.
  • /readyz ne peut pas valider MODEL_RUNTIME_API_KEY quand /health est accessible sans authentification. Sur vLLM, /health ne requiert aucune authentification, contrairement à /v1/chat/completions. Une clé incorrecte renvoie donc ready et fait échouer chaque analyse avec une erreur 502. Si les analyses renvoient une 502 alors que le service est prêt, vérifie la clé en premier.

Une réponse 503 reçue de /health sur llama.cpp signifie loading et est signalée comme non prête : elle n'est jamais interprétée comme « ce moteur d'exécution n'a pas de /health ».

/readyz indique aussi le runtime d'embeddings optionnel, sous la forme d'un champ à trois états qui ne modifie jamais le code d'état (la recherche purement lexicale est le comportement par défaut, pas une panne) :

embeddingReadySignification
nullEMBEDDING_RUNTIME_URL n'est pas défini. La recherche est purement lexicale d'après la configuration. Normal.
trueConfiguré et répond. La recherche est hybride.
falseConfiguré et défaillant : embeddingReason en donne la raison (par ex. http 401). Le classement est dégradé tant que ce n'est pas corrigé.

embeddingReason vaut null dès que tout fonctionne, donc tu peux alerter sur embeddingReason !== null sans risque. Une valeur incorrecte pour EMBEDDING_RUNTIME_API_KEY apparaît ici et nulle part ailleurs : cela dégrade le classement au lieu de faire échouer les analyses.

Ton runtime donne-t-il de bonnes réponses ?

La compatibilité ne garantit pas la précision. Un runtime peut respecter parfaitement le schéma tout en étant associé à un modèle qui analyse mal les assiettes. eval/run-50img.sh évalue n'importe quel point de terminaison par rapport au même jeu de référence de 50 images d'où proviennent tous les chiffres de cette documentation : teste le tien avant de faire confiance aux résultats.

Pour les notes de déploiement propres à chaque modèle (options, particularités et comportements mesurés pour les modèles testés), consulte eval/SERVING.md.

Modifier cette page sur GitHub