[Core] Include the LoRA path in prefix-cache block hashes (#59335)

Signed-off-by: Nick Hill <nickhill123@gmail.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

Nick Hill committed Sep 30, 2026 at 17:37 UTC c055c1e075ed461cff922aa710d387281f651940
3 files changed +43 -7
tests/v1/core/test_kv_cache_utils.py
+24 -2
@@ -1022,7 +1022,7 @@ def test_generate_block_hash_extra_keys_lora():
1022 )
1023
1024 extra_keys, _ = generate_block_hash_extra_keys(request, 0, 3, 0)
1025 - assert extra_keys == (("lora", "test_lora_adapter"),)
1025 + assert extra_keys == (("lora", "test_lora_adapter", "/path/to/lora"),)
1026
1027 request.lora_request = None
1028 extra_keys, _ = generate_block_hash_extra_keys(request, 0, 3, 0)
@@ -1048,6 +1048,28 @@ def test_lora_name_and_cache_salt_block_hashes_do_not_collide(hash_fn):
1048 assert lora_req.block_hashes[0] != salted_req.block_hashes[0]
1049
1050
1051 +@pytest.mark.parametrize("hash_fn", [sha256, sha256_cbor])
1052 +def test_lora_path_change_changes_block_hashes(hash_fn):
1053 + """Re-pointing a LoRA name at another adapter must not reuse its blocks."""
1054 +
1055 + def make_lora_request(lora_path: str) -> Request:
1056 + return Request(
1057 + request_id=lora_path,
1058 + prompt_token_ids=[0, 1, 2],
1059 + sampling_params=SamplingParams(max_tokens=1),
1060 + pooling_params=None,
1061 + lora_request=LoRARequest(
1062 + lora_name="foo", lora_int_id=1, lora_path=lora_path
1063 + ),
1064 + block_hasher=get_request_block_hasher(3, hash_fn),
1065 + )
1066 +
1067 + first = make_lora_request("/path/to/lora_a")
1068 + second = make_lora_request("/path/to/lora_b")
1069 +
1070 + assert first.block_hashes[0] != second.block_hashes[0]
1071 +
1072 +
1073 def test_to_event_extra_keys_keeps_untagged_event_format():
1074 """KV events keep publishing the extra-key shapes consumers already parse."""
1075 request = make_request(
@@ -1064,7 +1086,7 @@ def test_to_event_extra_keys_keeps_untagged_event_format():
1086 extra_keys, _ = generate_block_hash_extra_keys(request, 0, 5, 0)
1087
1088 assert extra_keys == (
1067 - ("lora", "adapter"),
1089 + ("lora", "adapter", "/path/to/lora"),
1090 ("mm", "hash1", 2),
1091 ("cache_salt", "salt"),
1092 )
vllm/entrypoints/openai/models/serving.py
+10
@@ -212,6 +212,16 @@ class OpenAIServingModels:
212 status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
213 )
214
215 + previous = self.lora_requests.get(lora_name)
216 + if previous is not None and previous.lora_path == lora_path:
217 + logger.warning(
218 + "Reloaded LoRA adapter '%s' in place from the same path '%s'. "
219 + "Prefix-cache blocks computed with its previous weights are "
220 + "not invalidated; load changed weights from a new path to "
221 + "avoid reusing them.",
222 + lora_name,
223 + lora_path,
224 + )
225 self.lora_requests[lora_name] = lora_request
226 logger.info(
227 "Loaded new LoRA adapter: name '%s', path '%s'", lora_name, lora_path
vllm/v1/core/kv_cache_utils.py
+9 -5
@@ -565,20 +565,24 @@ def _gen_mm_extra_hash_keys(
565 return extra_keys, curr_mm_idx
566
567
568 -def _gen_lora_extra_hash_keys(request: Request) -> list[tuple[str, str]]:
568 +def _gen_lora_extra_hash_keys(request: Request) -> list[tuple[str, str, str]]:
569 """Generate extra keys related to LoRA for block hash computation.
570
571 + The adapter path is included so that re-pointing a LoRA name at a different
572 + adapter does not reuse KV computed with the previous one.
573 +
574 Args:
575 request: The request object.
576
577 Returns:
575 - Return LoRA name of the request if it is a LoRA request. Return empty
576 - list otherwise.
578 + Return the LoRA name and path of the request if it is a LoRA request.
579 + Return empty list otherwise.
580
581 """
579 - if not request.lora_request:
582 + lora_request = request.lora_request
583 + if not lora_request:
584 return []
581 - return [("lora", request.lora_request.lora_name)]
585 + return [("lora", lora_request.lora_name, lora_request.lora_path)]
586
587
588 def _gen_prompt_embeds_extra_hash_keys(