[Bugfix] handle grammar compilation failures to avoid engine crash (#47312)
Signed-off-by: zhuhaoran <zhuhaoran.zhr@alibaba-inc.com> Signed-off-by: Nick Hill <nickhill123@gmail.com> Co-authored-by: Nick Hill <nickhill123@gmail.com>
zhrrr committed
Jul 23, 2026 at 18:13 UTC
12213c67951b71cd6d750cc059a87a53641e8730
7 files changed
+123
-41
tests/v1/core/test_async_scheduler.py
+3
-1
@@ -9,6 +9,7 @@ from vllm.v1.core.sched.async_scheduler import AsyncScheduler
9
from vllm.v1.core.sched.output import CachedRequestData, SchedulerOutput
10
from vllm.v1.outputs import ModelRunnerOutput
11
from vllm.v1.request import RequestStatus
12
+from vllm.v1.structured_output import StructuredOutputGrammar
13
from vllm.v1.utils import ConstantList
14
15
from .utils import create_requests, create_scheduler
@@ -262,7 +263,7 @@ def test_abort_request_when_structured_output_fsm_cannot_advance():
263
scheduler = object.__new__(AsyncScheduler)
264
request = create_requests(num_requests=1, num_tokens=1)[0]
265
request.structured_output_request = Mock()
265
- request.structured_output_request.grammar = Mock()
266
+ request.structured_output_request.grammar = Mock(spec=StructuredOutputGrammar)
267
request.structured_output_request.grammar.accept_tokens.return_value = False
268
request.status = RequestStatus.RUNNING
269
request.num_computed_tokens = request.num_tokens
@@ -284,6 +285,7 @@ def test_abort_request_when_structured_output_fsm_cannot_advance():
285
scheduler.kv_event_publisher = Mock()
286
scheduler.finished_req_ids = set()
287
scheduler.finished_req_ids_dict = None
288
+ scheduler.grammar_compile_error_reqs = set()
289
scheduler.vllm_config = Mock()
290
scheduler.vllm_config.model_config.enable_return_routed_experts = False
291
scheduler.enable_return_routed_experts = False
tests/v1/core/test_scheduler.py
+56
-2
@@ -1,6 +1,7 @@
1
# SPDX-License-Identifier: Apache-2.0
2
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
import dataclasses
4
+from concurrent.futures import Future
5
from unittest.mock import Mock
6
7
import pytest
@@ -39,7 +40,7 @@ from vllm.v1.kv_cache_interface import (
40
)
41
from vllm.v1.outputs import DraftTokenIds, KVConnectorOutput, ModelRunnerOutput
42
from vllm.v1.request import Request, RequestStatus
42
-from vllm.v1.structured_output import StructuredOutputManager
43
+from vllm.v1.structured_output import StructuredOutputGrammar, StructuredOutputManager
44
45
from .utils import EOS_TOKEN_ID, create_requests, create_scheduler, mock_kv
46
@@ -3144,6 +3145,58 @@ def test_schedule_skip_tokenizer_init_structured_output_request():
3145
assert len(scheduler.skipped_waiting) == 1
3146
3147
3148
+@pytest.mark.parametrize("async_grammar", [True, False])
3149
+def test_grammar_compile_error_finishes_only_request(async_grammar: bool):
3150
+ scheduler = create_scheduler()
3151
+ manager = scheduler.structured_output_manager
3152
+ manager.backend = Mock()
3153
+ manager.backend.compile_grammar.side_effect = RuntimeError(
3154
+ "forced FSM compilation error"
3155
+ )
3156
+ manager._use_async_grammar_compilation = async_grammar
3157
+
3158
+ sampling_params = SamplingParams(
3159
+ max_tokens=16,
3160
+ structured_outputs=StructuredOutputsParams(json='{"type": "object"}'),
3161
+ )
3162
+ sampling_params.update_from_generation_config({}, EOS_TOKEN_ID)
3163
+ request = Request(
3164
+ request_id="grammar-error",
3165
+ prompt_token_ids=[0, 1],
3166
+ sampling_params=sampling_params,
3167
+ pooling_params=None,
3168
+ )
3169
+
3170
+ manager.grammar_init(request)
3171
+ assert request.structured_output_request is not None
3172
+ grammar_future = request.structured_output_request._grammar
3173
+ assert isinstance(grammar_future, Future)
3174
+ assert isinstance(grammar_future.exception(timeout=5), RuntimeError)
3175
+
3176
+ scheduler.add_request(request)
3177
+ scheduler_output = scheduler.schedule()
3178
+ assert not scheduler_output.num_scheduled_tokens
3179
+
3180
+ engine_core_outputs = scheduler.update_from_output(
3181
+ scheduler_output,
3182
+ ModelRunnerOutput(req_ids=[], req_id_to_index={}),
3183
+ )
3184
+
3185
+ assert request.status == RequestStatus.FINISHED_ERROR
3186
+ assert request.request_id not in scheduler.requests
3187
+ output = engine_core_outputs[0].outputs[0]
3188
+ assert output.request_id == request.request_id
3189
+ assert output.finish_reason == FinishReason.ERROR
3190
+ assert output.stop_reason is None
3191
+
3192
+ healthy_request = create_requests(num_requests=1, req_ids=["healthy-request"])[0]
3193
+ scheduler.add_request(healthy_request)
3194
+ next_output = scheduler.schedule()
3195
+ assert [req.req_id for req in next_output.scheduled_new_reqs] == [
3196
+ healthy_request.request_id
3197
+ ]
3198
+
3199
+
3200
def test_abort_request_when_structured_output_fsm_cannot_advance():
3201
scheduler = object.__new__(Scheduler)
3202
sampling_params = SamplingParams(ignore_eos=True, max_tokens=4)
@@ -3157,7 +3210,7 @@ def test_abort_request_when_structured_output_fsm_cannot_advance():
3210
pooling_params=None,
3211
)
3212
request.structured_output_request = Mock()
3160
- request.structured_output_request.grammar = Mock()
3213
+ request.structured_output_request.grammar = Mock(spec=StructuredOutputGrammar)
3214
request.structured_output_request.grammar.accept_tokens.return_value = False
3215
request.status = RequestStatus.RUNNING
3216
request.num_computed_tokens = request.num_tokens
@@ -3178,6 +3231,7 @@ def test_abort_request_when_structured_output_fsm_cannot_advance():
3231
scheduler.kv_event_publisher = Mock()
3232
scheduler.finished_req_ids = set()
3233
scheduler.finished_req_ids_dict = None
3234
+ scheduler.grammar_compile_error_reqs = set()
3235
scheduler.vllm_config = Mock()
3236
scheduler.vllm_config.model_config.enable_return_routed_experts = False
3237
scheduler.enable_return_routed_experts = False
vllm/v1/core/sched/interface.py
+3
-3
@@ -145,7 +145,7 @@ class SchedulerInterface(ABC):
145
self,
146
request_ids: str | Iterable[str] | None,
147
finished_status: "RequestStatus",
148
- ) -> list[tuple[str, int]]:
148
+ ) -> "list[Request]":
149
"""Finish the requests in the scheduler's internal queue. If the request
150
is not in the queue, this method will do nothing for that request.
151
@@ -159,8 +159,8 @@ class SchedulerInterface(ABC):
159
finished_status: The finished status of the given requests.
160
161
Returns:
162
- Tuple of (req_id, client_index) for requests that were aborted. Will not
163
- include any that were already finished.
162
+ List of requests that were aborted. Will not include any that were
163
+ already finished.
164
"""
165
raise NotImplementedError
166
vllm/v1/core/sched/scheduler.py
+24
-12
@@ -60,7 +60,7 @@ from vllm.v1.outputs import DraftTokenIds, KVConnectorOutput, ModelRunnerOutput
60
from vllm.v1.request import Request, RequestStatus, StreamingUpdate
61
from vllm.v1.spec_decode.dynamic.utils import build_dynamic_sd_schedule_lookup
62
from vllm.v1.spec_decode.metrics import SpecDecodingStats
63
-from vllm.v1.structured_output import StructuredOutputManager
63
+from vllm.v1.structured_output import StructuredOutputGrammar, StructuredOutputManager
64
from vllm.v1.utils import record_function_or_nullcontext
65
66
logger = init_logger(__name__)
@@ -201,6 +201,10 @@ class Scheduler(SchedulerInterface):
201
self.finished_recving_kv_req_ids: set[str] = set()
202
self.failed_recving_kv_req_ids: set[str] = set()
203
204
+ # Grammar compilation failures to finish as per-request errors in
205
+ # update_from_output.
206
+ self.grammar_compile_error_reqs: set[str] = set()
207
+
208
# Encoder-related.
209
# Calculate encoder cache size if applicable
210
supports_mm_inputs = mm_registry.supports_multimodal_inputs(
@@ -1716,7 +1720,7 @@ class Scheduler(SchedulerInterface):
1720
struct_output_request = request.structured_output_request
1721
assert struct_output_request is not None
1722
grammar = struct_output_request.grammar
1719
- assert grammar is not None
1723
+ assert isinstance(grammar, StructuredOutputGrammar)
1724
# new_token_ids can be a mixed block of reasoning content, then
1725
# the reasoning end marker, then the start of the grammar content.
1726
# Trim the reasoning content so the grammar only sees grammar content.
@@ -1846,10 +1850,16 @@ class Scheduler(SchedulerInterface):
1850
# This is a rare case and unlikely to impact performance.
1851
self.waiting.remove_requests(stopped_preempted_reqs)
1852
1853
+ error_req_ids = set(self.grammar_compile_error_reqs)
1854
+ self.grammar_compile_error_reqs.clear()
1855
if failed_kv_load_req_ids and not self.recompute_kv_load_failures:
1850
- requests = [self.requests[req_id] for req_id in failed_kv_load_req_ids]
1851
- self.finish_requests(failed_kv_load_req_ids, RequestStatus.FINISHED_ERROR)
1852
- for request in requests:
1856
+ error_req_ids.update(failed_kv_load_req_ids)
1857
+
1858
+ if error_req_ids:
1859
+ error_reqs = self.finish_requests(
1860
+ error_req_ids, RequestStatus.FINISHED_ERROR
1861
+ )
1862
+ for request in error_reqs:
1863
outputs[request.client_index].append(
1864
EngineCoreOutput(
1865
request_id=request.request_id,
@@ -2079,8 +2089,7 @@ class Scheduler(SchedulerInterface):
2089
# Filter out spec tokens which do not adhere to the grammar.
2090
if self.structured_output_manager.should_advance(request):
2091
metadata = request.structured_output_request
2082
- assert metadata is not None and metadata.grammar is not None
2083
- spec_token_ids = metadata.grammar.validate_tokens(spec_token_ids)
2092
+ spec_token_ids = metadata.grammar.validate_tokens(spec_token_ids) # type: ignore[union-attr]
2093
# Pad to original number of spec tokens.
2094
num_invalid_tokens = orig_num_spec_tokens - len(spec_token_ids)
2095
if num_invalid_tokens:
@@ -2121,7 +2130,7 @@ class Scheduler(SchedulerInterface):
2130
2131
def finish_requests(
2132
self, request_ids: str | Iterable[str] | None, finished_status: RequestStatus
2124
- ) -> list[tuple[str, int]]:
2133
+ ) -> list[Request]:
2134
"""Handles the finish signal from outside the scheduler.
2135
2136
For example, the API server can abort a request when the client
@@ -2130,8 +2139,8 @@ class Scheduler(SchedulerInterface):
2139
If request_ids is None, all requests will be finished.
2140
2141
Returns:
2133
- Tuple of (req_id, client_index) for requests that were aborted. Will not
2134
- include any that were already finished.
2142
+ List of requests that were aborted. Will not include any that were
2143
+ already finished.
2144
"""
2145
assert RequestStatus.is_finished(finished_status)
2146
if isinstance(request_ids, str):
@@ -2180,7 +2189,7 @@ class Scheduler(SchedulerInterface):
2189
request.status = finished_status
2190
self._free_request(request, delay_free_blocks=delay_free_blocks)
2191
2183
- return [(r.request_id, r.client_index) for r in valid_requests]
2192
+ return valid_requests
2193
2194
def _free_request(
2195
self, request: Request, delay_free_blocks: bool = False
@@ -2580,7 +2589,10 @@ class Scheduler(SchedulerInterface):
2589
2590
if request.status == RequestStatus.WAITING_FOR_STRUCTURED_OUTPUT_GRAMMAR:
2591
structured_output_req = request.structured_output_request
2583
- if not (structured_output_req and structured_output_req.grammar):
2592
+ if not structured_output_req or structured_output_req.grammar is None:
2593
+ return False
2594
+ if isinstance(structured_output_req.grammar, Exception):
2595
+ self.grammar_compile_error_reqs.add(request.request_id)
2596
return False
2597
request.status = RequestStatus.WAITING
2598
return True
vllm/v1/engine/core.py
+3
-3
@@ -1830,13 +1830,13 @@ class EngineCoreProc(EngineCore):
1830
) -> None:
1831
self._send_finish_outputs_to_client(req_ids, client_index, FinishReason.ERROR)
1832
1833
- def _send_abort_outputs(self, aborted_reqs: list[tuple[str, int]]) -> None:
1833
+ def _send_abort_outputs(self, aborted_reqs: list[Request]) -> None:
1834
# TODO(nick) this will be moved inside the scheduler
1835
if aborted_reqs:
1836
# Map client_index to list of request_ids that belong to that client.
1837
by_client = defaultdict[int, set[str]](set)
1838
- for req_id, client_index in aborted_reqs:
1839
- by_client[client_index].add(req_id)
1838
+ for request in aborted_reqs:
1839
+ by_client[request.client_index].add(request.request_id)
1840
for client_index, req_ids in by_client.items():
1841
self._send_abort_outputs_to_client(list(req_ids), client_index)
1842
vllm/v1/structured_output/__init__.py
+25
-14
@@ -164,24 +164,33 @@ class StructuredOutputManager:
164
else:
165
raise ValueError(f"Unsupported structured output backend: {backend}")
166
167
+ grammar: Future[StructuredOutputGrammar] | StructuredOutputGrammar
168
if self._use_async_grammar_compilation:
169
grammar = self.executor.submit(self._create_grammar, request)
170
else:
170
- grammar = self._create_grammar(request) # type: ignore[assignment]
171
- request.structured_output_request.grammar = grammar # type: ignore[assignment]
171
+ try:
172
+ grammar = self._create_grammar(request)
173
+ except Exception as e:
174
+ grammar = Future()
175
+ grammar.set_exception(e)
176
+ request.structured_output_request.grammar = grammar
177
178
def _create_grammar(self, request: "Request") -> StructuredOutputGrammar:
174
- key = request.structured_output_request.structured_output_key # type: ignore[union-attr]
175
-
179
+ struct_request = request.structured_output_request
180
+ assert struct_request is not None
181
# Note that the request was validated in the engine core client,
177
- # so at this point we know it is a supported type of request.
178
- #
179
- # TODO: we still need to handle xgrammar compilation failures,
180
- # though it should be unlikely as we test that up front as well.
181
- request_type, grammar_spec = key
182
-
183
- assert self.backend is not None
184
- return self.backend.compile_grammar(request_type, grammar_spec)
182
+ # so at this point we know it is a supported type of request. Grammar
183
+ # compilation may still fail; the Future carries that error to the
184
+ # scheduler so it can fail only this request.
185
+ try:
186
+ request_type, grammar_spec = struct_request.structured_output_key
187
+ assert self.backend is not None
188
+ return self.backend.compile_grammar(request_type, grammar_spec)
189
+ except Exception:
190
+ logger.exception(
191
+ "Failed to compile grammar for request %s", request.request_id
192
+ )
193
+ raise
194
195
def _fill_bitmasks(
196
self, batch: Iterable[tuple[StructuredOutputGrammar, int, bool]]
@@ -244,8 +253,9 @@ class StructuredOutputManager:
253
structured_output_request = request.structured_output_request
254
if TYPE_CHECKING:
255
assert structured_output_request is not None
247
- assert structured_output_request.grammar is not None
256
grammar = structured_output_request.grammar
257
+ if TYPE_CHECKING:
258
+ assert isinstance(grammar, StructuredOutputGrammar)
259
260
apply_bitmask = self.should_fill_bitmask(request)
261
batch.append((grammar, cumulative_index, apply_bitmask))
@@ -268,8 +278,9 @@ class StructuredOutputManager:
278
279
if TYPE_CHECKING:
280
assert structured_output_request is not None
271
- assert structured_output_request.grammar is not None
281
grammar = structured_output_request.grammar
282
+ if TYPE_CHECKING:
283
+ assert isinstance(grammar, StructuredOutputGrammar)
284
apply_bitmask = self.should_fill_bitmask(request)
285
286
reasoner = self._get_reasoner(request)
vllm/v1/structured_output/request.py
+9
-6
@@ -21,7 +21,9 @@ if TYPE_CHECKING:
21
@dataclasses.dataclass
22
class StructuredOutputRequest:
23
params: StructuredOutputsParams
24
- _grammar: Future[StructuredOutputGrammar] | StructuredOutputGrammar | None = None
24
+ _grammar: (
25
+ Future[StructuredOutputGrammar] | StructuredOutputGrammar | Exception | None
26
+ ) = None
27
reasoning_ended: bool | None = None
28
# Absolute index into the request's all_token_ids of the last reasoning
29
# token (the reasoning-end marker). Tokens at or before this index are
@@ -52,6 +54,8 @@ class StructuredOutputRequest:
54
self._grammar = self._grammar.result(timeout=0.0001)
55
except TimeoutError:
56
return False
57
+ except Exception as e:
58
+ self._grammar = e
59
return True
60
61
@property
@@ -59,11 +63,10 @@ class StructuredOutputRequest:
63
return self._check_grammar_completion()
64
65
@property
62
- def grammar(self) -> StructuredOutputGrammar | None:
63
- completed = self._check_grammar_completion()
64
- return (
65
- cast(StructuredOutputGrammar | None, self._grammar) if completed else None
66
- )
66
+ def grammar(self) -> StructuredOutputGrammar | Exception | None:
67
+ if not self._check_grammar_completion():
68
+ return None
69
+ return cast(StructuredOutputGrammar | Exception | None, self._grammar)
70
71
@grammar.setter
72
def grammar(