Engineering5 min read

vLLM: "There is no module or parameter named 'visual' in Qwen3_5Model" — loading a Qwen3.5 classification head

Serving OpenJev (Qwen3_5ForSequenceClassification) on vLLM fails at weight loading because the checkpoint carries the vision tower and vLLM's fallback picks the text-only class. The fix is a text-only copy of the checkpoint. Also: 'UVA is not available' on WSL2, and why vllm serve cannot take a Hugging Face subfolder.

The Paralon capybara holding two puzzle pieces that do not fit, a graphics card on the bench

If you are here from the error message: the checkpoint you are loading has a vision tower, vLLM resolved its architecture to the text-only Qwen3.5 class, and the weight loader has nowhere to put model.visual.*. Make a text-only copy of the checkpoint (script below), point vllm serve at it, and it loads. Everything after the script is why.

(EngineCore pid=1147) ValueError: There is no module or parameter named 'visual' in Qwen3_5Model.
The available parameters belonging to (Qwen3_5Model) are: {'layers.23.input_layernorm.weight', ...}
(APIServer pid=954) RuntimeError: Engine core initialization failed. See root cause above.

We hit it on 21 September 2026 putting OpenJev, a Qwen3.5-4B NLI cross-encoder, behind /v1/classify on vLLM 0.29.0. Three things had to be true before it served; this page is all three.

1. The architecture is not in the registry, and that is fine

The checkpoint's config.json says "architectures": ["Qwen3_5ForSequenceClassification"]. vLLM's model registry has Qwen3_5ForCausalLM and Qwen3_5ForConditionalGeneration, and no sequence-classification class for Qwen3.5. It does not fail there, though. ModelRegistry._normalize_arch replaces the ForSequenceClassification suffix with the ones it knows and lands on Qwen3_5ForCausalLM; with --runner pooling --convert classify the as_seq_cls_model adapter wraps that class, expects a single linear layer named score in the checkpoint, and pools the last token's hidden state through it. OpenJev is exactly that: Qwen3.5 with lm_head replaced by a 3-way score.weight. So far so good.

2. The checkpoint carries the vision tower; the text class does not

Qwen3.5 is natively multimodal. The v2 OpenJev checkpoint was fine-tuned from the full model and saved with 297 model.visual.* tensors next to the 426 language-model tensors. The fallback picked Qwen3_5ForCausalLM, which builds a Qwen3_5Model without a visual submodule, and the strict loader raises on the first tensor it cannot place. Pointing the override at Qwen3_5ForConditionalGeneration instead does not help either: the folder has no preprocessor_config.json, so the multimodal processor fails to initialise before the weights are read.

The fix is to make the checkpoint match the class. Drop the vision tensors, rename model.language_model.* to model.* (the multimodal layout nests the text stack one level deeper than the text class expects), keep score.weight, and declare the text architecture:

from huggingface_hub import snapshot_download
from safetensors import safe_open
from safetensors.torch import save_file
import json, shutil

snapshot_download("AlexWortega/openjev", allow_patterns=["qwen3.5-4b-nli-v2/*"], local_dir="oj")
src, dst = "oj/qwen3.5-4b-nli-v2", "openjev-text"
out = {}
with safe_open(f"{src}/model.safetensors", "pt") as f:
    for k in f.keys():
        if ".visual." in k:
            continue
        out[k.replace("model.language_model.", "model.")] = f.get_tensor(k)
save_file(out, f"{dst}/model.safetensors", metadata={"format": "pt"})
for fn in ("tokenizer.json", "tokenizer_config.json", "chat_template.jinja"):
    shutil.copy(f"{src}/{fn}", f"{dst}/{fn}")
cfg = json.load(open(f"{src}/config.json"))
cfg["architectures"] = ["Qwen3_5ForCausalLM"]
json.dump(cfg, open(f"{dst}/config.json", "w"))
vllm serve ./openjev-text --served-model-name openjev-4b \
  --runner pooling --convert classify \
  --max-model-len 4096 --max-num-seqs 16 --gpu-memory-utilization 0.8

427 tensors, 7.9 GB in bf16. The config.json keeps its vision_config; the text class ignores it. What you lose is image input; for a text NLI service that is nothing. Loading took 3.8 s on a 4090 and the first /classify call answered entailment at 0.94 for "a man is playing a guitar / someone is making music".

Why not --hf-overrides '{"architectures": ["Qwen3_5ForCausalLM"]}' on the original folder? Because the override changes which class is built, not which tensors are in the file; the loader still meets model.visual.patch_embed… and raises. The copy is the only route that does not need a patched vLLM.

3. vllm serve takes a repo, not a subfolder

The OpenJev repo keeps its four checkpoints in subfolders (qwen3.5-0.8b-nli-v2s-long/, qwen3.5-4b-nli/, qwen3.5-4b-nli-v2/, qwen3.5-35b-a3b-nli/). vllm serve AlexWortega/openjev downloads the whole repo and then finds no config.json at the root. There is no subfolder flag. snapshot_download with allow_patterns pulls the one you want, about 9 GB for the 4B, and you serve the local path. On our network the agent used to pre-download whatever hf_model names, whole repo; for this row that is over 100 GB and the first placement died in that step, so the row downloads its own subfolder instead.

Bonus: "RuntimeError: UVA is not available" on Docker Desktop / WSL2

Same model, second node, a Windows machine running the agent under Docker Desktop:

(EngineCore pid=242) File ".../vllm/v1/worker/gpu/buffer_utils.py", line 47, in __init__
(EngineCore pid=242)     raise RuntimeError("UVA is not available")

Not a VRAM problem; the card had 15 GB free. vLLM 0.29's GPU model runner allocates Unified Virtual Addressing buffers at init, and WSL2 keeps pinned host memory off by default. vLLM has a switch for exactly this case:

VLLM_WSL2_ENABLE_PIN_MEMORY=1 vllm serve ...

No-op on a Linux host. With it set, the same node loaded the fp8 weights in 4.94 GiB and registered in three minutes. If your kernel is older than 4.19.121 the flag cannot help; VLLM_USE_V2_MODEL_RUNNER=0 falls back to the previous runner, which does not need UVA.

One more thing that cost us twenty minutes: the crash excerpt our agent ships back is the last 4 KB of the container log, which is the API server's traceback. The root cause is the EngineCore's traceback, further up. Read the full log, or you will be debugging Engine core initialization failed instead of the line that matters.

What we run in production

The strip runs once per node, inside the worker's start command, into the shared cache; every later start goes straight to vllm serve. We add --quantization fp8 (4.9 GiB loaded instead of 7.9 GB) so the model fits next to a 16 GB card's headroom. The endpoint, the measurements, and the model's limits are in OpenJev on an OpenAI-compatible API; the request shape is in the Classify docs.

Keep reading

Related Articles