Saltar al contenido
openplate

El runtime de inferencia

Trae tu propio runtime

Apunta esto a una instancia de llama.cpp, Ollama o vLLM que ya ejecutes, el requisito de gramáticas forzadas, trampas de configuración según el runtime

Esta página es una traducción automática de la documentación en inglés.

Si ya ejecutas llama.cpp, Ollama o cualquier otra herramienta compatible con el protocolo de OpenAI, apunta este servicio hacia ella. En este modo no descarga ningún modelo ni arranca una segunda copia en tu hardware.

Consulta la matriz de compatibilidad antes de elegir un runtime: este flujo necesita decodificación restringida por gramática, y no todo lo que anuncia compatibilidad con OpenAI la ofrece. Se ha comprobado que llama.cpp, Ollama y vLLM en una GPU funcionan correctamente. la versión para CPU de vLLM falla en el primer análisis: misma versión, sin acelerador, proceso bloqueado.

El recorrido de un análisis es corto y la gramática se ubica en el medio. El navegador envía una foto a este servicio, que valida la clave, admite la petición, reduce la resolución de la imagen y hace una sola llamada de visión bajo un esquema JSON. Tu runtime es el único componente que hace cumplir ese esquema, por lo que la tabla inferior se centra en el cumplimiento normativo más que en la velocidad. Lo que devuelve son nombres y gramos; los macronutrientes se consultan después a partir de la fuente de alimentos configurada, y el modelo nunca genera ninguno.

El navegador envía una foto a este servicio, que invoca tu runtime una vez bajo un esquema JSON y consulta los macronutrientes antes de responder.
Código fuente del diagrama
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 es todo el interruptor. Omite la descarga de pesos y no inicia ningún llama-server, por lo que no hay volumen /models.

Variables del modo externo

  • MODEL_RUNTIME_URL: sin /v1 al final, el servicio añade las rutas de OpenAI por sí mismo. Debe resolverse desde dentro del contenedor, así que localhost se refiere al contenedor, no a tu host. Asignarle una dirección diferente sin MODEL_PROFILE=external produce un error de arranque en lugar de una anulación silenciosa: un contenedor integrado siempre sirve desde su propio bucle invertido. (Asignarle exactamente la dirección de bucle invertido integrada está permitido y no cambia nada: las copias antiguas de .env.example incluían esa línea descomentada).
  • MODEL_ID: se envía a tu runtime. llama.cpp lo ignora, pero tanto vLLM como Ollama necesitan el nombre exacto del modelo servido: vLLM rechaza cualquier discrepancia y Ollama lo usa para seleccionar y cargar el modelo. El id que usan los clientes es siempre openplate-plate-1 sin importar esto.
  • MODEL_RUNTIME_API_KEY: opcional, se envía como Authorization: Bearer … a tu runtime. Configúralo para vllm serve --api-key … o para un proxy de autenticación. Es independiente de API_KEYS, que es lo que los clientes que llaman presentan a este servicio.
  • RUNTIME_COMPLETION_TIMEOUT_MS: límite total para una llamada de completado, en milisegundos. Valor predeterminado 600000 (10 minutos); 0 lo desactiva. Se aplica en el modo integrado también: un llama-server bloqueado queda tan mudo como un proxy mal enrutado. Si tu hardware legítimamente necesita más de diez minutos por plato, auméntalo o establécelo en 0; el mensaje de fallo indicará el nombre de la variable.

    Esto no es LATENCY_CEILING_MS. Aquello es política de admisión: rechazar trabajo que no puedes terminar a tiempo, antes de empezarlo. Esto es vigencia: liberar una ranura de worker que un upstream nunca va a devolver.

    Ya existía un límite antes de que esta variable existiera: el fetch de Node establece por defecto headersTimeout en 300 s, y como un completado sin streaming escribe sus cabeceras solo cuando termina la generación, eso actúa como un tope total de 300 s, alcanzable en el hardware que este proyecto admite. Definir RUNTIME_COMPLETION_TIMEOUT_MS explícitamente relaja ese valor predeterminado.

    No significa "esperar para siempre" porque con CONCURRENCY=2, dos escaneos bloqueados en curso retienen permanentemente ambas ranuras de worker mientras /readyz sigue en verde: los sondeos de preparación consultan un endpoint diferente y no pueden detectarlo.

Tu runtime de inferencia debe aplicar decodificación restringida por gramática

Es un requisito estricto. La canalización hace una sola llamada de visión con response_format: {"type": "json_schema", "json_schema": {"name": …, "strict": true, "schema": …}} y confía en la estructura que recibe. No hay ningún bucle de análisis y reintento.

El fallo peligroso no es el rechazo, sino la aceptación con omisión: un runtime de inferencia que toma el campo response_format, lo descarta y devuelve un JSON verosímil que no cumple el contrato. Eso no se manifiesta como un error. Se manifiesta como estimaciones de gramos ligeramente erróneas.

Comprueba el tuyo con un solo comando antes de confiar en él. Pide texto libre mientras exiges un esquema; si la respuesta sigue teniendo la forma del esquema, la gramática es real:

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 objeto JSON con una clave x significa que la restricción funciona. Un haiku significa que no, y este servicio devolverá 502 en cada escaneo con un mensaje indicándolo.

Matriz de compatibilidad

Medido directamente el 15-08-2026. Las filas que no hemos ejecutado nosotros mismos lo indican.

RuntimeVersiónGramáticaEntrada de imagenVeredicto
llama.cpp (llama-server)b10330forzada (GBNF)URL de datos en base64✅ funciona, esto es lo que ejecuta la imagen integrada
Ollama0.32.13forzadaURL de datos en base64✅ funciona, consulta la nota de disponibilidad más abajo
vLLM (compilación para CPU)0.27.1ningunoninguno❌ roto: una petición json_schema tumba el servidor (pin_memory=True requires a CUDA or other accelerator backend). Las solicitudes de completado simples funcionan, por lo que parece correcto hasta que el primer análisis real tumba el proceso
vLLM (compilación para GPU)0.27.1forzada (xgrammar)URL de datos en base64✅ funciona, medido en una RTX 3090 con Qwen3-VL-2B-Instruct
cualquier otra opciónningunoningunoninguno⚠️ no probado, ejecuta el comando curl anterior

Misma versión, resultados opuestos: el fallo está específicamente en la compilación para CPU. La prueba en GPU ejecutó la imagen de vLLM idéntico que falla en CPU (vllm/vllm-openai:latest y v0.27.1 comparten resumen criptográfico), con el mismo esquema y la misma imagen en base64 que envía este servicio. En la GPU devolvió JSON conforme al esquema en 1.8 s y siguió en funcionamiento. Interpreta la fila ❌ como "no ejecutes vLLM sin una GPU", no como "vLLM no es compatible".

Por qué cae la compilación para CPU. La decodificación restringida por gramática construye una máscara de bits de tokens y la asigna en memoria fijada, un búfer bloqueado en páginas diseñado para copiarse rápido a una GPU. Si pides esto sin un acelerador presente, PyTorch lanza una excepción en vez de ignorarla, y como esto ocurre durante el servicio y no durante la validación, tumba el worker en lugar de devolver un error. Es un error de origen, pues tanto llama.cpp como Ollama procesan la misma carga útil sin problemas.

Una peculiaridad de vLLM que conviene conocer. Si la instrucción no permite al modelo responder con la estructura requerida, observamos que emite { seguido de espacios en blanco hasta alcanzar el límite de tokens: la gramática se respeta (nunca generó texto libre, ni siquiera al pedirle explícitamente un haiku), pero el modelo rellena los espacios no restringidos en lugar del contenido. Esto se manifiesta aquí como el error finish_reason: length, que hace referencia al límite de tokens. Una foto del plato real no causó este fallo; una instrucción ajena a la tarea, sí.

Si pruebas una combinación que no hayamos probado, abre una incidencia con el resultado.

Tres errores comunes de configuración

1. Ventana de contexto: el síntoma son salidas corruptas, no un error de inicio. Una ventana demasiado pequeña no falla de forma explícita. Descarta el principio de tu instrucción y obtienes incoherencias con total seguridad.

El tamaño de la instrucción depende de tu modelo. Dos mediciones directas de los mismo platos reducidos a 896 px:

ModeloTokens de la instrucción (plato de 896 px)
Qwen3-VL-8B en llama.cppDe 1287 a 3507 en el conjunto de 10 platos
moondream en Ollama746

No calcules el tamaño con estos números: consulta los tuyos. Ejecuta un análisis en tu runtime y revisa lo que devuelve:

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

Dimensiona la ventana por encima de tu peor caso, dejando margen. En vLLM es --max-model-len. En Ollama es num_ctx: establécelo en un Modelfile, ya que comprobamos que OLLAMA_CONTEXT_LENGTH=8192 no conseguía subir el contexto notificado de un modelo por encima de 2048 (Ollama parece limitarse al contexto de entrenamiento del propio modelo, por lo que no puedes darle una ventana mayor a un modelo con una ventana entrenada pequeña).

2. CONCURRENCY debe coincidir con el número real de ranuras de tu runtime. Tener más peticiones en curso que ranuras disponibles en el runtime no aumenta el rendimiento: traslada la cola a un lugar que este servicio no puede ver ni medir, y el controlador de admisión toma decisiones basadas en datos ficticios.

RuntimeEl flag que define las ranuras
llama.cpp--parallel N
vLLM--max-num-seqs N
OllamaOLLAMA_NUM_PARALLEL=N

3. El HEALTHCHECK del contenedor concede 60 minutos para arrancar. Está dimensionado para descargar los pesos en el primer arranque con una conexión doméstica. En modo externo no hay descarga, por lo que un valor incorrecto de MODEL_RUNTIME_URL permanece oculto durante una hora en lugar de fallar en segundos. Como start_period viene integrado al compilar, anúlalo: consulta el bloque comentado en docker/compose.yml.

El estado de preparación y lo que no te dice

/readyz consulta a tu runtime con GET /health, y recurre a GET /v1/models si el runtime no tiene /health (Ollama no lo tiene). Presenta dos limitaciones:

  • Con Ollama, /v1/models comprueba la disponibilidad técnica, no la preparación para responder. Responde 200 sin ningún modelo cargado en memoria (Ollama carga bajo demanda con la primera petición), por lo que un estado preparado significa "accesible", no "listo para servir". Tu primer análisis absorbe el tiempo de carga.
  • /readyz no puede validar MODEL_RUNTIME_API_KEY si /health está abierto. En vLLM, /health no requiere autenticación, pero /v1/chat/completions sí. Por tanto, una clave incorrecta devuelve listo y hace fallar cada análisis con un 502. Si los análisis devuelven 502 contra un servicio supuestamente preparado, comprueba la clave primero.

Un 503 del /health de llama.cpp significa cargando y se reporta como no listo: nunca se interpreta como "este runtime no tiene /health".

/readyz también reporta el runtime de embeddings opcional, como un campo con tres estados que nunca afecta al código de estado (la recuperación solo léxica es el valor predeterminado, no una caída del servicio):

embeddingReadySignificado
nullEMBEDDING_RUNTIME_URL no está definido. La recuperación es solo léxica por configuración. Normal.
trueConfigurado y respondiendo. La recuperación es híbrida.
falseConfigurado y fallando: embeddingReason indica el motivo (por ejemplo, http 401). La clasificación empeora hasta que se solucione.

embeddingReason es null siempre que no haya problemas, así que alertar sobre embeddingReason !== null es seguro. Un EMBEDDING_RUNTIME_API_KEY incorrecto aparece aquí y en ningún otro sitio: degrada la clasificación en lugar de hacer fallar los escaneos.

¿Tu runtime da buenas respuestas?

Compatibilidad no equivale a precisión. Un runtime puede validar el esquema a la perfección y aun así estar emparejado con un modelo que lee mal los platos. eval/run-50img.sh evalúa cualquier endpoint con el mismo conjunto de referencia de 50 imágenes del que salieron todas las cifras de esta documentación: pruébalo con el tuyo antes de confiar en el resultado.

Para ver notas de servicio por modelo (opciones, particularidades y el comportamiento medido de los modelos que ejecutamos), consulta eval/SERVING.md.

Edita esta página en GitHub