[Bugfix][Pooling] Handle multimodal cache misses without crashing (#58975)
Signed-off-by: linchaoying6 <linchaoying6@jd.com> Co-authored-by: linchaoying6 <linchaoying6@jd.com> Co-authored-by: OpenAI Codex <codex@openai.com>
Chaoying committed
Oct 2, 2026 at 22:14 UTC
592c6f3fbcefeb3a4e0c0b461531329bf4ee0489
21 files changed
+525
-17
tests/entrypoints/pooling/scoring/test_io_processor_unit.py
+62
-1
@@ -1,20 +1,25 @@
1
# SPDX-License-Identifier: Apache-2.0
2
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
-"""Unit tests for ScoringIOProcessor post-tokenization helpers."""
3
+"""Unit tests for ScoringIOProcessor helpers."""
4
5
from dataclasses import dataclass
6
from types import SimpleNamespace
7
8
import pytest
9
+import torch
10
from tokenizers import Tokenizer, models, pre_tokenizers, processors
11
from transformers import TokenizersBackend
12
13
from vllm import TokensPrompt
14
from vllm.entrypoints.pooling.scoring.io_processor import (
15
+ BiEncoderIOProcessor,
16
CrossEncoderIOProcessor,
17
+ JinaRankingIOProcessor,
18
+ LateInteractionIOProcessor,
19
_apply_post_tokenization_to_token_type_ids,
20
)
21
from vllm.entrypoints.pooling.scoring.utils import compress_token_type_ids
22
+from vllm.outputs import PoolingOutput, PoolingRequestOutput, RequestError
23
from vllm.renderers import TokenizeParams
24
25
pytestmark = pytest.mark.skip_global_cleanup
@@ -113,3 +118,59 @@ def test_token_type_ids_stay_aligned_with_a_truncated_padded_prompt():
118
assert first_doc == 10
119
assert prompt_token_ids[:first_doc] == list(range(10, num_query))
120
assert prompt_token_ids[first_doc:40] == list(range(num_query, 50))
121
+
122
+
123
+def _pooling_output(
124
+ request_id: str,
125
+ data: torch.Tensor,
126
+ error: RequestError | None = None,
127
+) -> PoolingRequestOutput:
128
+ return PoolingRequestOutput(
129
+ request_id=request_id,
130
+ outputs=PoolingOutput(data),
131
+ prompt_token_ids=[1],
132
+ num_cached_tokens=0,
133
+ finished=True,
134
+ error=error,
135
+ )
136
+
137
+
138
+@pytest.mark.parametrize(
139
+ "processor_type", [BiEncoderIOProcessor, LateInteractionIOProcessor]
140
+)
141
+@pytest.mark.parametrize("failed_index", [0, 1])
142
+def test_paired_scoring_preserves_request_error(processor_type, failed_index):
143
+ processor = processor_type.__new__(processor_type)
144
+ processor.pad_token_id = None
145
+ error = RequestError(
146
+ code="multimodal_cache_miss",
147
+ message="Multi-modal processor cache miss.",
148
+ retryable=True,
149
+ )
150
+ outputs = [
151
+ _pooling_output("query", torch.tensor([1.0, 2.0])),
152
+ _pooling_output("document", torch.tensor([3.0, 4.0])),
153
+ ]
154
+ outputs[failed_index] = _pooling_output(
155
+ outputs[failed_index].request_id,
156
+ torch.empty(0),
157
+ error,
158
+ )
159
+
160
+ (result,) = processor._post_process(outputs, n_queries=1)
161
+
162
+ assert result.request_id == "query_document"
163
+ assert result.error is error
164
+ assert result.outputs is outputs[failed_index].outputs
165
+
166
+
167
+def test_jina_scoring_preserves_request_error_before_indexing_output():
168
+ processor = JinaRankingIOProcessor.__new__(JinaRankingIOProcessor)
169
+ error = RequestError(
170
+ code="multimodal_cache_miss",
171
+ message="Multi-modal processor cache miss.",
172
+ retryable=True,
173
+ )
174
+ failed = _pooling_output("request", torch.empty(0), error)
175
+
176
+ assert processor._post_process([failed], n_queries=1) == [failed]
tests/entrypoints/pooling/scoring/test_late_interaction_serving.py
+66
-1
@@ -49,10 +49,60 @@ def _query_key(context: PoolingServeContext) -> str:
49
50
51
@pytest.mark.asyncio
52
-async def test_colliding_request_ids_use_distinct_query_cache_keys(
52
+@pytest.mark.skip_global_cleanup
53
+@pytest.mark.parametrize("failed_stage", ["query", "doc"])
54
+async def test_flash_late_interaction_cleans_up_queries_on_failure(
55
+ failed_stage: str,
56
monkeypatch: pytest.MonkeyPatch,
57
):
58
serving = object.__new__(ServingScores)
59
+ serving.io_processor = Mock()
60
+ serving.engine_client = Mock(abort=AsyncMock(), collective_rpc=AsyncMock())
61
+
62
+ context = _make_context()
63
+ monkeypatch.setattr(serving, "_init_ctx", AsyncMock(return_value=context))
64
+ monkeypatch.setattr(serving, "_preprocessing", AsyncMock())
65
+
66
+ async def encode_queries(ctx):
67
+ ctx.late_interaction_query_keys = ["query-key"]
68
+ if failed_stage == "query":
69
+ raise RuntimeError("query failed")
70
+
71
+ async def encode_docs(ctx):
72
+ ctx.late_interaction_doc_keys = ["doc-key"]
73
+ if failed_stage == "doc":
74
+ raise RuntimeError("doc failed")
75
+
76
+ monkeypatch.setattr(
77
+ serving,
78
+ "_flash_late_interaction_encode_queries",
79
+ AsyncMock(side_effect=encode_queries),
80
+ )
81
+ monkeypatch.setattr(
82
+ serving,
83
+ "_flash_late_interaction_encode_docs",
84
+ AsyncMock(side_effect=encode_docs),
85
+ )
86
+
87
+ with pytest.raises(RuntimeError, match=f"{failed_stage} failed"):
88
+ await serving.flash_late_interaction()
89
+
90
+ expected_doc_keys = [] if failed_stage == "query" else ["doc-key"]
91
+ serving.engine_client.abort.assert_awaited_once_with(
92
+ ["query-key", *expected_doc_keys]
93
+ )
94
+ serving.engine_client.collective_rpc.assert_awaited_once_with(
95
+ "release_late_interaction_query_cache",
96
+ args=(["query-key"],),
97
+ )
98
+
99
+
100
+@pytest.mark.asyncio
101
+async def test_colliding_request_ids_use_distinct_late_interaction_keys(
102
+ monkeypatch: pytest.MonkeyPatch,
103
+):
104
+ serving = object.__new__(ServingScores)
105
+ serving.engine_client = Mock(abort=AsyncMock(), collective_rpc=AsyncMock())
106
prepare_generators = AsyncMock()
107
monkeypatch.setattr(serving, "_prepare_generators", prepare_generators)
108
monkeypatch.setattr(serving, "_collect_batch", AsyncMock())
@@ -68,9 +118,24 @@ async def test_colliding_request_ids_use_distinct_query_cache_keys(
118
first_doc_key = _query_key(prepared_contexts[1])
119
second_query_key = _query_key(prepared_contexts[2])
120
second_doc_key = _query_key(prepared_contexts[3])
121
+ first_doc_request_ids = prepared_contexts[1].prompt_request_ids
122
+ second_doc_request_ids = prepared_contexts[3].prompt_request_ids
123
124
assert first_query_key == first_doc_key
125
assert second_query_key == second_doc_key
126
assert first_query_key != second_query_key
127
assert "caller-controlled-id" not in first_query_key
128
assert "caller-controlled-id" not in second_query_key
129
+ assert first_doc_request_ids is not None
130
+ assert second_doc_request_ids is not None
131
+ assert first.late_interaction_doc_keys == first_doc_request_ids
132
+ assert second.late_interaction_doc_keys == second_doc_request_ids
133
+ assert first_doc_request_ids != second_doc_request_ids
134
+ assert all("caller-controlled-id" not in key for key in first_doc_request_ids)
135
+ assert all("caller-controlled-id" not in key for key in second_doc_request_ids)
136
+
137
+ assert first.late_interaction_query_keys is not None
138
+ await serving._cleanup_flash_late_interaction(first)
139
+ serving.engine_client.abort.assert_awaited_once_with(
140
+ [*first.late_interaction_query_keys, *first_doc_request_ids]
141
+ )
tests/test_outputs.py
+43
-1
@@ -2,8 +2,17 @@
2
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4
import pytest
5
+import torch
6
6
-from vllm.outputs import RequestOutput
7
+from vllm.outputs import (
8
+ ClassificationRequestOutput,
9
+ EmbeddingRequestOutput,
10
+ PoolingOutput,
11
+ PoolingRequestOutput,
12
+ RequestError,
13
+ RequestOutput,
14
+ ScoringRequestOutput,
15
+)
16
17
pytestmark = pytest.mark.cpu_test
18
@@ -19,3 +28,36 @@ def test_request_output_forward_compatible():
28
example_arg_added_in_new_version="some_value",
29
)
30
assert output is not None
31
+
32
+
33
+@pytest.mark.parametrize(
34
+ ("output_type", "empty_value"),
35
+ [
36
+ (EmbeddingRequestOutput, []),
37
+ (ClassificationRequestOutput, []),
38
+ (ScoringRequestOutput, 0.0),
39
+ ],
40
+)
41
+@pytest.mark.skip_global_cleanup
42
+def test_specialized_pooling_outputs_preserve_request_error(output_type, empty_value):
43
+ error = RequestError(
44
+ code="multimodal_cache_miss",
45
+ message="Multi-modal processor cache miss.",
46
+ retryable=True,
47
+ )
48
+ base_output = PoolingRequestOutput(
49
+ request_id="request",
50
+ outputs=PoolingOutput(torch.empty(0)),
51
+ prompt_token_ids=[1, 2],
52
+ num_cached_tokens=0,
53
+ finished=True,
54
+ error=error,
55
+ )
56
+
57
+ output = output_type.from_base(base_output)
58
+
59
+ assert output.error is error
60
+ value = getattr(output.outputs, "embedding", None)
61
+ value = getattr(output.outputs, "probs", value)
62
+ value = getattr(output.outputs, "score", value)
63
+ assert value == empty_value
tests/v1/engine/test_llm_engine.py
+34
@@ -2,11 +2,13 @@
2
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
import random
4
from typing import TYPE_CHECKING
5
+from unittest.mock import MagicMock, call
6
7
import pytest
8
9
from vllm import LLM
10
from vllm.sampling_params import SamplingParams, StructuredOutputsParams
11
+from vllm.v1.engine.llm_engine import LLMEngine
12
from vllm.v1.metrics.reader import Counter, Gauge, Histogram, Metric, Vector
13
14
if TYPE_CHECKING:
@@ -18,6 +20,38 @@ MODEL = "facebook/opt-125m"
20
DTYPE = "half"
21
22
23
+@pytest.mark.skip_global_cleanup
24
+def test_step_invalidates_mm_processor_cache_misses():
25
+ engine = object.__new__(LLMEngine)
26
+ engine.should_execute_dummy_batch = False
27
+ engine.log_stats = False
28
+ engine.logger_manager = None
29
+
30
+ mm_processor_cache = MagicMock()
31
+ engine.renderer = MagicMock(mm_processor_cache=mm_processor_cache)
32
+
33
+ engine_core_output = MagicMock(
34
+ mm_cache_miss_hashes=["missing-hash-1", "missing-hash-2"]
35
+ )
36
+ outputs = MagicMock(
37
+ outputs=[engine_core_output],
38
+ timestamp=0.0,
39
+ scheduler_stats=None,
40
+ )
41
+ engine.engine_core = MagicMock()
42
+ engine.engine_core.get_output.return_value = outputs
43
+
44
+ processed_outputs = MagicMock(reqs_to_abort=[], request_outputs=[])
45
+ engine.output_processor = MagicMock()
46
+ engine.output_processor.process_outputs.return_value = processed_outputs
47
+
48
+ assert engine.step() == []
49
+ assert mm_processor_cache.invalidate.call_args_list == [
50
+ call("missing-hash-1"),
51
+ call("missing-hash-2"),
52
+ ]
53
+
54
+
55
def _vllm_model(
56
apc: bool,
57
vllm_runner: type[VllmRunner],
tests/v1/engine/test_output_processor.py
+96
-1
@@ -7,6 +7,7 @@ from unittest.mock import MagicMock, Mock
7
8
import numpy as np
9
import pytest
10
+import torch
11
12
from tests.v1.engine.utils import (
13
NUM_PROMPT_LOGPROBS_UNDER_TEST,
@@ -18,7 +19,12 @@ from tests.v1.engine.utils import (
19
from vllm import PoolingParams
20
from vllm.logprobs import FlatLogprobs, Logprob, PromptLogprobs, SampleLogprobs
21
from vllm.lora.request import LoRARequest
21
-from vllm.outputs import CompletionOutput, RequestOutput, SamplingMask
22
+from vllm.outputs import (
23
+ CompletionOutput,
24
+ PoolingRequestOutput,
25
+ RequestOutput,
26
+ SamplingMask,
27
+)
28
from vllm.sampling_params import RequestOutputKind, SamplingParams
29
from vllm.tokenizers import TokenizerLike
30
from vllm.v1.engine import (
@@ -1630,3 +1636,92 @@ def test_request_output_add_merges_delta_sampling_masks():
1636
1637
assert merged.outputs[0].token_ids == [10, 20]
1638
assert merged.outputs[0].sampling_mask.token_ids == [[10, 11], [20]]
1639
+
1640
+
1641
+@pytest.mark.skip_global_cleanup
1642
+def test_pooling_cache_miss_is_a_request_error_and_batch_continues():
1643
+ output_processor = OutputProcessor(tokenizer=None, log_stats=False)
1644
+ requests = [
1645
+ EngineCoreRequest(
1646
+ request_id=f"request-{idx}-int",
1647
+ external_req_id=f"request-{idx}",
1648
+ prompt_token_ids=[1, 2],
1649
+ mm_features=None,
1650
+ arrival_time=0,
1651
+ lora_request=None,
1652
+ cache_salt=None,
1653
+ data_parallel_rank=None,
1654
+ sampling_params=None,
1655
+ pooling_params=PoolingParams(task="embed"),
1656
+ )
1657
+ for idx in range(2)
1658
+ ]
1659
+ for request in requests:
1660
+ output_processor.add_request(request, prompt=None)
1661
+
1662
+ processed = output_processor.process_outputs(
1663
+ [
1664
+ EngineCoreOutput(
1665
+ request_id=requests[0].request_id,
1666
+ new_token_ids=[],
1667
+ finish_reason=FinishReason.ERROR,
1668
+ mm_cache_miss_hashes=["missing-hash"],
1669
+ ),
1670
+ EngineCoreOutput(
1671
+ request_id=requests[1].request_id,
1672
+ new_token_ids=[],
1673
+ pooling_output=torch.tensor([1.0, 2.0]),
1674
+ finish_reason=FinishReason.STOP,
1675
+ ),
1676
+ ]
1677
+ )
1678
+
1679
+ assert len(processed.request_outputs) == 2
1680
+ failed, succeeded = processed.request_outputs
1681
+ assert isinstance(failed, PoolingRequestOutput)
1682
+ assert failed.error is not None
1683
+ assert failed.error.code == "multimodal_cache_miss"
1684
+ assert failed.error.retryable
1685
+ assert isinstance(succeeded, PoolingRequestOutput)
1686
+ assert succeeded.error is None
1687
+ assert torch.equal(succeeded.outputs.data, torch.tensor([1.0, 2.0]))
1688
+
1689
+
1690
+@pytest.mark.skip_global_cleanup
1691
+def test_async_pooling_cache_miss_does_not_fail_output_processor():
1692
+ output_processor = OutputProcessor(tokenizer=None, log_stats=False)
1693
+ request = EngineCoreRequest(
1694
+ request_id="request-int",
1695
+ external_req_id="request",
1696
+ prompt_token_ids=[1, 2],
1697
+ mm_features=None,
1698
+ arrival_time=0,
1699
+ lora_request=None,
1700
+ cache_salt=None,
1701
+ data_parallel_rank=None,
1702
+ sampling_params=None,
1703
+ pooling_params=PoolingParams(task="embed"),
1704
+ )
1705
+ queue = RequestOutputCollector(
1706
+ output_kind=RequestOutputKind.FINAL_ONLY,
1707
+ request_id=request.request_id,
1708
+ )
1709
+ output_processor.add_request(request, prompt=None, queue=queue)
1710
+
1711
+ processed = output_processor.process_outputs(
1712
+ [
1713
+ EngineCoreOutput(
1714
+ request_id=request.request_id,
1715
+ new_token_ids=[],
1716
+ finish_reason=FinishReason.ERROR,
1717
+ mm_cache_miss_hashes=["missing-hash"],
1718
+ )
1719
+ ]
1720
+ )
1721
+
1722
+ assert processed.request_outputs == []
1723
+ output = queue.get_nowait()
1724
+ assert isinstance(output, PoolingRequestOutput)
1725
+ assert output.error is not None
1726
+ assert output.error.retryable
1727
+ assert not output_processor.has_unfinished_requests()
tests/v1/worker/test_late_interaction_runner.py
+46
@@ -137,6 +137,52 @@ def test_finished_request_releases_unscored_doc_use():
137
)
138
139
140
+@pytest.mark.skip_global_cleanup
141
+def test_release_queries_is_targeted_and_idempotent():
142
+ runner = LateInteractionRunner()
143
+ query_emb = torch.tensor([[1.0, 0.0], [0.0, 1.0]], dtype=torch.float32)
144
+
145
+ for query_key in ("query-release", "query-keep"):
146
+ query_params = _make_pooling_params(
147
+ build_late_interaction_query_params(query_key=query_key, query_uses=1)
148
+ )
149
+ runner.postprocess_pooler_output(
150
+ raw_pooler_output=[query_emb],
151
+ pooling_params=[query_params],
152
+ req_ids=[f"{query_key}-req"],
153
+ finished_mask=[True],
154
+ )
155
+
156
+ released_doc_params = _make_pooling_params(
157
+ build_late_interaction_doc_params(query_key="query-release")
158
+ )
159
+ kept_doc_params = _make_pooling_params(
160
+ build_late_interaction_doc_params(query_key="query-keep")
161
+ )
162
+ runner.register_request("released-doc", released_doc_params)
163
+ runner.register_request("kept-doc", kept_doc_params)
164
+
165
+ runner.release_queries(["query-release"])
166
+ runner.release_queries(["query-release"])
167
+
168
+ with pytest.raises(ValueError, match="query cache miss"):
169
+ runner.postprocess_pooler_output(
170
+ raw_pooler_output=[query_emb],
171
+ pooling_params=[released_doc_params],
172
+ req_ids=["released-doc"],
173
+ finished_mask=[True],
174
+ )
175
+
176
+ kept_output = runner.postprocess_pooler_output(
177
+ raw_pooler_output=[query_emb],
178
+ pooling_params=[kept_doc_params],
179
+ req_ids=["kept-doc"],
180
+ finished_mask=[True],
181
+ )
182
+ assert isinstance(kept_output, list)
183
+ assert kept_output[0] is not None
184
+
185
+
186
def test_invalid_query_uses_raises():
187
runner = LateInteractionRunner()
188
bad_meta = LateInteractionParams(
vllm/entrypoints/pooling/base/serving.py
+5
@@ -21,6 +21,7 @@ from vllm.entrypoints.openai.models.serving import OpenAIServingModels
21
from vllm.entrypoints.serve.engine.serving import BaseServing
22
from vllm.entrypoints.serve.engine.typing import AnyRequest
23
from vllm.entrypoints.serve.utils.request_logger import RequestLogger
24
+from vllm.exceptions import GenerationError, RetryableRequestError
25
from vllm.lora.request import LoRARequest
26
from vllm.renderers.base import BaseRenderer
27
from vllm.tracing import (
@@ -206,6 +207,10 @@ class PoolingBaseServing(ABC, BaseServing):
207
final_res_batch = [None] * num_inputs
208
209
async for i, res in ctx.result_generator:
210
+ if res.error is not None:
211
+ if res.error.retryable:
212
+ raise RetryableRequestError(res.error.message)
213
+ raise GenerationError(res.error.message)
214
final_res_batch[i] = res
215
216
if None in final_res_batch:
vllm/entrypoints/pooling/scoring/io_processor.py
+33
@@ -191,6 +191,27 @@ class ScoringIOProcessor(PoolingIOProcessor):
191
scoring_data = self.valid_inputs(data_1, data_2)
192
return scoring_data
193
194
+ def _pair_error_output(
195
+ self,
196
+ output_1: PoolingRequestOutput,
197
+ output_2: PoolingRequestOutput,
198
+ ) -> PoolingRequestOutput | None:
199
+ failed = output_1 if output_1.error is not None else output_2
200
+ if failed.error is None:
201
+ return None
202
+
203
+ padding = [self.pad_token_id] if self.pad_token_id is not None else []
204
+ return PoolingRequestOutput(
205
+ request_id=f"{output_1.request_id}_{output_2.request_id}",
206
+ outputs=failed.outputs,
207
+ prompt_token_ids=(
208
+ output_1.prompt_token_ids + padding + output_2.prompt_token_ids
209
+ ),
210
+ num_cached_tokens=(output_1.num_cached_tokens + output_2.num_cached_tokens),
211
+ finished=failed.finished,
212
+ error=failed.error,
213
+ )
214
+
215
216
class BiEncoderIOProcessor(ScoringIOProcessor):
217
name = "bi-encoder"
@@ -310,6 +331,10 @@ class BiEncoderIOProcessor(ScoringIOProcessor):
331
332
final_res_batch: list[PoolingRequestOutput] = []
333
for emb_1, emb_2 in zip(emb_data_1, emb_data_2):
334
+ if error_output := self._pair_error_output(emb_1, emb_2):
335
+ final_res_batch.append(error_output)
336
+ continue
337
+
338
pair_score = F.cosine_similarity(
339
emb_1.outputs.data.float(), emb_2.outputs.data.float(), dim=0
340
)
@@ -352,6 +377,10 @@ class LateInteractionIOProcessor(BiEncoderIOProcessor):
377
378
# Compute MaxSim scores
379
for emb_1, emb_2 in zip(emb_data_1, emb_data_2):
380
+ if error_output := self._pair_error_output(emb_1, emb_2):
381
+ final_res_batch.append(error_output)
382
+ continue
383
+
384
# emb_1.outputs.data: [query_len, dim]
385
# emb_2.outputs.data: [doc_len, dim]
386
q_emb = emb_1.outputs.data
@@ -907,6 +936,10 @@ class JinaRankingIOProcessor(LateInteractionIOProcessor, JinaRankingIOProcessorM
936
final_res_batch: list[PoolingRequestOutput] = []
937
938
for i in range(len(outputs)):
939
+ if outputs[i].error is not None:
940
+ final_res_batch.append(outputs[i])
941
+ continue
942
+
943
embeds = outputs[i].outputs.data.float()
944
945
# The JinaForRanking model concatenates docs first, then query.
vllm/entrypoints/pooling/scoring/serving.py
+30
-5
@@ -195,13 +195,36 @@ class ServingScores(PoolingServing):
195
ctx = await self._init_ctx(self.io_processor, *args, **kwargs)
196
await self._preprocessing(self.io_processor, ctx)
197
198
- # stage 1: encode queries and cache token embeddings on workers.
199
- await self._flash_late_interaction_encode_queries(ctx)
200
- # stage 2: encode docs and return scalar scores from workers.
201
- await self._flash_late_interaction_encode_docs(ctx)
198
+ try:
199
+ # stage 1: encode queries and cache token embeddings on workers.
200
+ await self._flash_late_interaction_encode_queries(ctx)
201
+ # stage 2: encode docs and return scalar scores from workers.
202
+ await self._flash_late_interaction_encode_docs(ctx)
203
+ except BaseException:
204
+ try:
205
+ await self._cleanup_flash_late_interaction(ctx)
206
+ except Exception:
207
+ logger.exception("Failed to clean up late-interaction query cache.")
208
+ raise
209
210
return await self._postprocessing_async(self.io_processor, ctx)
211
212
+ async def _cleanup_flash_late_interaction(self, ctx: ScoringServeContext) -> None:
213
+ query_keys = ctx.late_interaction_query_keys
214
+ if not query_keys:
215
+ return
216
+
217
+ doc_keys = ctx.late_interaction_doc_keys or []
218
+
219
+ try:
220
+ # Stop documents before removing the query tensors they reference.
221
+ await self.engine_client.abort([*query_keys, *doc_keys])
222
+ finally:
223
+ await self.engine_client.collective_rpc(
224
+ "release_late_interaction_query_cache",
225
+ args=(query_keys,),
226
+ )
227
+
228
async def _flash_late_interaction_encode_queries(self, ctx: ScoringServeContext):
229
assert ctx.n_queries is not None
230
assert ctx.engine_inputs is not None
@@ -259,7 +282,9 @@ class ServingScores(PoolingServing):
282
query_keys = ctx.late_interaction_query_keys
283
if query_keys is None:
284
raise RuntimeError("Late-interaction query keys were not initialized.")
262
- doc_keys = [f"{ctx.request_id}-doc-{i}" for i in range(n_docs)]
285
+ doc_namespace = random_uuid()
286
+ doc_keys = [f"late-interaction-{doc_namespace}-doc-{i}" for i in range(n_docs)]
287
+ ctx.late_interaction_doc_keys = doc_keys
288
289
for i in range(n_docs):
290
query_idx = 0 if n_queries == 1 else i
vllm/entrypoints/pooling/typing.py
+1
@@ -115,6 +115,7 @@ class PoolingServeContext(Generic[PoolingRequestT]):
115
## for flash-late-interaction
116
query_final_res_batch: list[PoolingRequestOutput] | None = None
117
late_interaction_query_keys: list[str] | None = None
118
+ late_interaction_doc_keys: list[str] | None = None
119
120
121
@dataclass
vllm/entrypoints/serve/exception_handling/error_response.py
+6
-2
@@ -2,7 +2,7 @@
2
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
from http import HTTPStatus
4
5
-from vllm.exceptions import GenerationError
5
+from vllm.exceptions import GenerationError, RetryableRequestError
6
from vllm.logger import init_logger
7
8
from ..engine.protocol import ErrorInfo, ErrorResponse
@@ -56,7 +56,11 @@ def create_error_response(
56
status_code = HTTPStatus.BAD_REQUEST
57
param = None
58
elif isinstance(exc, GenerationError):
59
- err_type = "InternalServerError"
59
+ err_type = (
60
+ "ServiceUnavailableError"
61
+ if isinstance(exc, RetryableRequestError)
62
+ else "InternalServerError"
63
+ )
64
status_code = exc.status_code
65
param = None
66
elif isinstance(exc, VLLMServerError):
vllm/entrypoints/serve/exception_handling/handlers/vllm_error.py
+2
-2
@@ -70,8 +70,8 @@ async def generation_error_handler(req: Request, exc: GenerationError):
70
"""Handle GenerationError without logging stack traces.
71
72
GenerationError is a known, expected error (e.g. KV cache load failure)
73
- that should be returned to the client as a 500 response without polluting
74
- server logs with stack traces.
73
+ that should be returned to the client without polluting server logs with
74
+ stack traces.
75
"""
76
err = create_error_response(exc)
77
return JSONResponse(err.model_dump(), status_code=err.error.code)
vllm/exceptions.py
+8
@@ -130,6 +130,14 @@ class GenerationError(VLLMServerError):
130
self.status_code = HTTPStatus.INTERNAL_SERVER_ERROR
131
132
133
+class RetryableRequestError(GenerationError):
134
+ """A transient request-level failure that is safe for clients to retry."""
135
+
136
+ def __init__(self, message: str = "Request failed; please retry"):
137
+ super().__init__(message)
138
+ self.status_code = HTTPStatus.SERVICE_UNAVAILABLE
139
+
140
+
141
class GracefulHTTPError(VLLMError):
142
"""Exception that should be translated into an HTTP error response.
143
vllm/outputs.py
+37
-4
@@ -18,6 +18,15 @@ from vllm.v1.metrics.stats import RequestSpecDecodeMetrics, RequestStateStats
18
logger = init_logger(__name__)
19
20
21
+@dataclass(frozen=True)
22
+class RequestError:
23
+ """A request-level error that does not mark the engine as unhealthy."""
24
+
25
+ code: str
26
+ message: str
27
+ retryable: bool = False
28
+
29
+
30
@dataclass
31
class SamplingMask:
32
"""Per-token sampling support sets aligned with completion token IDs.
@@ -259,6 +268,7 @@ class PoolingRequestOutput(Generic[_O]):
268
prompt_token_ids (list[int]): A list of token IDs used in the prompt.
269
num_cached_tokens: The number of tokens with prefix cache hit.
270
finished (bool): A flag indicating whether the pooling is completed.
271
+ error: Structured request-level error information, if the request failed.
272
273
"""
274
@@ -269,20 +279,25 @@ class PoolingRequestOutput(Generic[_O]):
279
prompt_token_ids: list[int],
280
num_cached_tokens: int,
281
finished: bool,
282
+ *,
283
+ error: RequestError | None = None,
284
):
285
self.request_id = request_id
286
self.prompt_token_ids = prompt_token_ids
287
self.num_cached_tokens = num_cached_tokens
288
self.finished = finished
289
self.outputs = outputs
290
+ self.error = error
291
292
def __repr__(self) -> str:
293
+ error = f", error={self.error!r}" if self.error is not None else ""
294
return (
295
f"{type(self).__name__}(request_id={self.request_id!r}, "
296
f"outputs={self.outputs!r}, "
297
f"prompt_token_ids={self.prompt_token_ids}, "
298
f"num_cached_tokens={self.num_cached_tokens}, "
285
- f"finished={self.finished})"
299
+ f"finished={self.finished}"
300
+ f"{error})"
301
)
302
303
@@ -319,12 +334,18 @@ class EmbeddingRequestOutput(PoolingRequestOutput[EmbeddingOutput]):
334
def from_base(
335
request_output: PoolingRequestOutput,
336
) -> "EmbeddingRequestOutput":
337
+ outputs = (
338
+ EmbeddingOutput([])
339
+ if request_output.error is not None
340
+ else EmbeddingOutput.from_base(request_output.outputs)
341
+ )
342
return EmbeddingRequestOutput(
343
request_id=request_output.request_id,
324
- outputs=EmbeddingOutput.from_base(request_output.outputs),
344
+ outputs=outputs,
345
prompt_token_ids=request_output.prompt_token_ids,
346
num_cached_tokens=request_output.num_cached_tokens,
347
finished=request_output.finished,
348
+ error=request_output.error,
349
)
350
351
@@ -362,12 +383,18 @@ class ClassificationRequestOutput(PoolingRequestOutput[ClassificationOutput]):
383
def from_base(
384
request_output: PoolingRequestOutput,
385
) -> "ClassificationRequestOutput":
386
+ outputs = (
387
+ ClassificationOutput([])
388
+ if request_output.error is not None
389
+ else ClassificationOutput.from_base(request_output.outputs)
390
+ )
391
return ClassificationRequestOutput(
392
request_id=request_output.request_id,
367
- outputs=ClassificationOutput.from_base(request_output.outputs),
393
+ outputs=outputs,
394
prompt_token_ids=request_output.prompt_token_ids,
395
num_cached_tokens=request_output.num_cached_tokens,
396
finished=request_output.finished,
397
+ error=request_output.error,
398
)
399
400
@@ -402,10 +429,16 @@ class ScoringRequestOutput(PoolingRequestOutput[ScoringOutput]):
429
def from_base(
430
request_output: PoolingRequestOutput,
431
) -> "ScoringRequestOutput":
432
+ outputs = (
433
+ ScoringOutput(0.0)
434
+ if request_output.error is not None
435
+ else ScoringOutput.from_base(request_output.outputs)
436
+ )
437
return ScoringRequestOutput(
438
request_id=request_output.request_id,
407
- outputs=ScoringOutput.from_base(request_output.outputs),
439
+ outputs=outputs,
440
prompt_token_ids=request_output.prompt_token_ids,
441
num_cached_tokens=request_output.num_cached_tokens,
442
finished=request_output.finished,
443
+ error=request_output.error,
444
)
vllm/v1/engine/llm_engine.py
+8
@@ -323,6 +323,14 @@ class LLMEngine:
323
engine_core_timestamp=outputs.timestamp,
324
iteration_stats=iteration_stats,
325
)
326
+
327
+ mm_processor_cache = self.renderer.mm_processor_cache
328
+ if mm_processor_cache is not None:
329
+ for engine_core_output in outputs.outputs:
330
+ if engine_core_output.mm_cache_miss_hashes:
331
+ for mm_hash in engine_core_output.mm_cache_miss_hashes:
332
+ mm_processor_cache.invalidate(mm_hash)
333
+
334
self.output_processor.update_scheduler_stats(outputs.scheduler_stats)
335
336
# 3) Abort any reqs that finished due to stop strings.
vllm/v1/engine/output_processor.py
+19
@@ -16,6 +16,7 @@ from vllm.outputs import (
16
CompletionOutput,
17
PoolingOutput,
18
PoolingRequestOutput,
19
+ RequestError,
20
RequestOutput,
21
SamplingMask,
22
)
@@ -304,6 +305,7 @@ class RequestState:
305
stop_reason: int | str | None,
306
kv_transfer_params: dict[str, Any] | None = None,
307
ec_transfer_params: dict[str, Any] | None = None,
308
+ error: RequestError | None = None,
309
) -> RequestOutput | PoolingRequestOutput | None:
310
finished = finish_reason is not None
311
final_only = self.output_kind == RequestOutputKind.FINAL_ONLY
@@ -342,6 +344,7 @@ class RequestState:
344
external_req_id,
345
[self._new_pooling_output(pooling_output)],
346
finished,
347
+ error=error,
348
)
349
350
output = self._new_completion_output(new_token_ids, finish_reason, stop_reason)
@@ -369,6 +372,7 @@ class RequestState:
372
finished: bool,
373
kv_transfer_params: dict[str, Any] | None = None,
374
ec_transfer_params: dict[str, Any] | None = None,
375
+ error: RequestError | None = None,
376
) -> RequestOutput | PoolingRequestOutput:
377
# If prompt embeds were used, put placeholder prompt token ids
378
prompt_token_ids = self.prompt_token_ids
@@ -385,6 +389,7 @@ class RequestState:
389
num_cached_tokens=self.num_cached_tokens,
390
prompt_token_ids=prompt_token_ids,
391
finished=finished,
392
+ error=error,
393
)
394
assert self.logprobs_processor is not None
395
if self.output_kind == RequestOutputKind.DELTA:
@@ -715,6 +720,19 @@ class OutputProcessor:
720
if engine_core_output.spec_decode_metrics is not None:
721
req_state.spec_decode_metrics = engine_core_output.spec_decode_metrics
722
723
+ request_error = None
724
+ if (
725
+ req_state.detokenizer is None
726
+ and engine_core_output.mm_cache_miss_hashes
727
+ and finish_reason == FinishReason.ERROR
728
+ ):
729
+ request_error = RequestError(
730
+ code="multimodal_cache_miss",
731
+ message="Multi-modal processor cache miss.",
732
+ retryable=True,
733
+ )
734
+ pooling_output = EMPTY_CPU_TENSOR
735
+
736
if pooling_output is None:
737
assert req_state.detokenizer is not None
738
assert req_state.logprobs_processor is not None
@@ -742,6 +760,7 @@ class OutputProcessor:
760
stop_reason,
761
kv_transfer_params,
762
ec_transfer_params,
763
+ error=request_error,
764
):
765
if req_state.streaming_input:
766
request_output.finished = False
vllm/v1/pool/late_interaction_runner.py
+12
@@ -42,6 +42,18 @@ class LateInteractionRunner:
42
self._query_uses.clear()
43
self._doc_query_keys.clear()
44
45
+ def release_queries(self, query_keys: Iterable[str]) -> None:
46
+ query_keys = set(query_keys)
47
+ for query_key in query_keys:
48
+ self._query_cache.pop(query_key, None)
49
+ self._query_uses.pop(query_key, None)
50
+
51
+ self._doc_query_keys = {
52
+ req_id: query_key
53
+ for req_id, query_key in self._doc_query_keys.items()
54
+ if query_key not in query_keys
55
+ }
56
+
57
def register_request(
58
self, req_id: str, pooling_params: PoolingParams | None
59
) -> None:
vllm/v1/worker/gpu/model_runner.py
+4
@@ -1012,6 +1012,10 @@ class GPUModelRunner(LoRAModelRunnerMixin):
1012
if self.pooling_runner is not None:
1013
self.pooling_runner.clear()
1014
1015
+ def release_late_interaction_query_cache(self, query_keys: list[str]) -> None:
1016
+ if self.pooling_runner is not None:
1017
+ self.pooling_runner.release_late_interaction_query_cache(query_keys)
1018
+
1019
@torch.inference_mode()
1020
def profile_cudagraph_memory(self) -> int:
1021
"""Estimate the GPU memory required to capture CUDA graphs."""
vllm/v1/worker/gpu/pool/pooling_runner.py
+3
@@ -98,6 +98,9 @@ class PoolingRunner:
98
def clear(self) -> None:
99
self.late_interaction_runner.clear()
100
101
+ def release_late_interaction_query_cache(self, query_keys: list[str]) -> None:
102
+ self.late_interaction_runner.release_queries(query_keys)
103
+
104
def _get_pooling_metadata(
105
self,
106
input_batch: InputBatch,
vllm/v1/worker/gpu_model_runner.py
+3
@@ -993,6 +993,9 @@ class GPUModelRunner(
993
self.encoder_cache.clear()
994
self.late_interaction_runner.clear()
995
996
+ def release_late_interaction_query_cache(self, query_keys: list[str]) -> None:
997
+ self.late_interaction_runner.release_queries(query_keys)
998
+
999
def _get_positions(self, num_tokens: Any):
1000
if isinstance(num_tokens, int):
1001
if self.uses_mrope:
vllm/v1/worker/worker_base.py
+7
@@ -143,6 +143,13 @@ class WorkerBase:
143
if callable(reset_fn):
144
reset_fn()
145
146
+ def release_late_interaction_query_cache(self, query_keys: list[str]) -> None:
147
+ release_fn = getattr(
148
+ self.model_runner, "release_late_interaction_query_cache", None
149
+ )
150
+ if callable(release_fn):
151
+ release_fn(query_keys)
152
+
153
def get_model(self) -> nn.Module:
154
raise NotImplementedError
155