DEV Community

Cover image for vLLM accepts a LoRA it will never apply, and answers with the base model instead
The Homelab Postmortem
The Homelab Postmortem

Posted on Edited on Originally published at homelabpostmortem.com

vLLM accepts a LoRA it will never apply, and answers with the base model instead

Correction (2026-09-24): the update below was wrong, and acting on it would leave you exposed. vllm-project/vllm#55310 did merge on 2026-09-09 and did close the issue, and it shipped in vLLM 0.30.0 (2026-09-22). But what merged is not the version this post and the update described. The PR was opened as "Reject adapters with no matching target modules", raising an error; it was rewritten before merge to "Log when an adapter applies no weights", and the merged change only counts the layers an adapter was applied to and, when that count is zero, calls logger.debug_once(...). Activation still returns success. vLLM's default log level is INFO, so the new line does not appear unless you start the server with VLLM_LOGGING_LEVEL=DEBUG. The PR description says this is deliberate ("intentionally debug-level rather than an error or warning", because pipeline- or expert-parallel workers can legitimately hold no LoRA weights) and that "adapter activation and registration semantics remain unchanged". So on 0.30.0 an adapter whose modules all fall outside --lora-target-modules is still accepted and still serves the base model, with nothing at the default log level to say so. This correction is from the merged diff and the 0.30.0 source; it has not been re-run on 0.30.0 here. The check in "The fix" below, and the toolkit's check-lora-target-overlap.sh, remain the way to catch it on every version.

Update (2026-09-13): the fix has landed. vllm-project/vllm#55310 merged on 2026-09-09 and closed the issue, so vLLM built from main after that commit rejects an adapter whose modules fall entirely outside --lora-target-modules instead of accepting it and applying it to nothing. Released versions up to and including 0.28.0 — the one this post reproduced on — still have the behaviour described below. The toolkit's check-lora-target-overlap.sh remains the way to catch it on those.

TL;DR: vllm serve --lora-target-modules qkv_proj plus an adapter that only touches down_proj is a combination vLLM accepts. It loads the adapter, compiles the LoRA kernels, and then wraps zero layers with it — every request comes back as the unmodified base model, byte for byte, with no error, no rejection, and no warning that mentions target_modules. The acceptance path reads supported_lora_modules; the application path reads that and lora_config.target_modules. The second list is the one you set on the command line, and only one of the two checks has heard of it.

The symptom

Same adapter file, same prompt, temperature 0, seed 42. The only thing that changes between these two runs is the deployment's allow-list.

--lora-target-modules down_proj      (overlaps the adapter)

  base : " Paris. It is the largest city in the world by population. It is
          located in the south of France. It is"
  lora : ":\nA. Paris\nB. London\nC. Rome\nD. Moscow\nAnswer:\n\nA\n\nWhich of the"
Enter fullscreen mode Exit fullscreen mode
--lora-target-modules qkv_proj       (disjoint from the adapter)

  base : " Paris. It is the largest city in the world by population. It is
          located in the south of France. It is"
  lora : " Paris. It is the largest city in the world by population. It is
          located in the south of France. It is"
Enter fullscreen mode Exit fullscreen mode

The second pair is byte-identical. The request carried a LoRARequest, it returned 200, and it was served by the base model.

Verified on vLLM 0.28.0, Qwen/Qwen2.5-0.5B-Instruct, an RTX 2070 under WSL2.

It is not that the adapter failed to load

That was the first hypothesis and it is wrong, which matters, because "my adapter path was bad" sends you looking in a place where there is nothing to find. vLLM stands up the entire LoRA machinery for a request it is about to serve without any LoRA in it:

WARNING [jit_monitor.py:141] Triton kernel JIT compilation during inference: _lora_shrink_kernel
WARNING [jit_monitor.py:141] Triton kernel JIT compilation during inference: _lora_expand_kernel
Enter fullscreen mode Exit fullscreen mode

Those two kernels are LoRA. A LoRA layer computes y = Wx + B(Ax): shrink is Ax, projecting down to the adapter's rank, and expand is B(...), projecting back up. Both were compiled, for this request, on the run that returned base-model output. The adapter was read, registered and activated. It was simply attached to nothing.

So the search space is not "why was my adapter rejected". Nothing was rejected.

What's really going on

Two checks, and they do not read the same thing. From the installed 0.28.0, not from the report:

>>> "supported_lora_modules" in inspect.getsource(WorkerLoRAManager._load_adapter)
True
>>> "target_modules" in inspect.getsource(WorkerLoRAManager._load_adapter)
False
Enter fullscreen mode Exit fullscreen mode

The acceptance path validates the checkpoint against supported_lora_modules — the set of module types vLLM can wrap for this architecture at all. qkv_proj, o_proj, gate_up_proj, down_proj for a Qwen2 model. An adapter targeting down_proj passes, because down_proj is a thing vLLM knows how to wrap.

The application path asks a second question:

def _match_target_modules(self, module_name: str) -> bool:
    if not is_supported_lora_module(module_name, self.supported_lora_modules):
        return False
    return is_in_target_modules(
        module_name,
        self.lora_config.target_modules,
        self.packed_modules_mapping,
    )
Enter fullscreen mode Exit fullscreen mode

lora_config.target_modules is --lora-target-modules. It is consulted here and nowhere upstream of here. So the adapter is judged twice, against a permissive list and then a restrictive one, and only the permissive judgement can produce a message. By the time the restrictive one runs, the answer is not "reject this adapter" — it is "wrap this module: no", asked once per module, and every answer is no.

Zero wrapped modules is not an error state anywhere. It is just a loop that did nothing.

Who this reaches, and who it does not

Not everyone, and the shape of who matters, because it explains why a bug this loud in its consequences is this quiet in the wild.

If you never pass --lora-target-modules, you cannot hit this. The allow-list then defaults to everything the architecture supports, so the restrictive list and the permissive list are the same list and the two checks agree. Most single-adapter deployments look like this.

You hit it by deliberately narrowing the deployment — restricting to qkv_proj to bound memory, say, or to keep a serving profile stable across adapters — and then loading an adapter someone else trained, whose modules you did not check against your own restriction. That is a multi-tenant, many-adapters shape. It is also exactly the shape where nobody is reading individual responses closely enough to notice they got the base model.

The fix

Upstream has not changed the behaviour (see the correction at the top). vllm-project/vllm#55310 was open as "Reject adapters with no matching target modules" when this was written; what merged on 2026-09-09 and shipped in 0.30.0 is a one-time DEBUG-level log line, invisible at the default INFO level. On every release, nothing in vLLM will tell you unless you look.

Check the two lists against each other before you deploy. The adapter states its own in adapter_config.json:

python3 -c '
import json,sys
cfg = json.load(open(sys.argv[1] + "/adapter_config.json"))
adapter = set(cfg["target_modules"])
deployed = set(sys.argv[2].split(","))
print("adapter :", sorted(adapter))
print("deployed:", sorted(deployed))
print("overlap :", sorted(adapter & deployed) or "NONE — this adapter will be ignored")
' /path/to/adapter qkv_proj,o_proj
Enter fullscreen mode Exit fullscreen mode

Note that packed modules make the comparison less obvious than it looks: an adapter naming q_proj, k_proj and v_proj overlaps a deployment naming qkv_proj, because vLLM fuses them. A plain string comparison will report a false alarm there.

And confirm it end to end, once, with a prompt you know the answer to. Send the same prompt with and without the LoRARequest at temperature 0:

identical output  ->  the adapter is doing nothing
different output  ->  it is applied
Enter fullscreen mode Exit fullscreen mode

That is the only signal that cannot be faked by a mechanism that reports success. It costs two requests.

If you are building the adapter yourself to test this, know that peft initialises lora_B to zeros, so a freshly created, untrained adapter produces identical output whether or not it is applied. Mine had to be given non-zero lora_B deliberately, or the control would have proved nothing.

The generalisable habit

The narrow lesson is about validation that runs before the decision it is supposed to guard. Accepting an adapter and applying an adapter were separated by a config field that only the second step could see, and only the first step had a voice. Any time a system says yes in one place and acts in another, the interesting question is not "does it validate?" but "does the thing that validates know everything the thing that acts knows?" Here it did not, by one field.

The wider one is about what counts as a control. My first instinct was to load the adapter, see base-model output, and call it reproduced. That would have been worthless: an adapter that does nothing when ignored also does nothing when applied, if it was never trained. The result only means something because the same adapter file changed the output under a different allow-list. A negative result is evidence only after the positive one has been shown on the same setup — otherwise you have measured your own test rig.

It is the same trap as an Ollama tag that pulls and runs and has no working code in
it
,
where the control had to run first to catch a broken harness. Both times the failing observation was
available immediately and agreed with the report, and both times it would have been the wrong reason.

The same "accepted, then not applied" shape turned up in llama.cpp a few days later:
llama-server takes the json_schema form its own README documents, returns 200, and
generates as if no response_format was sent
.
There the control is even simpler — remove the constraint and see whether the bytes change —
and it is the only way to tell a server that enforced from a model that cooperated.

Toolkit

This post's fix is available as a tested, ready-to-run script in the toolkit.

See the toolkit →

Top comments (0)