A high-throughput and memory-efficient inference and serving engine for LLMs
Mirrored from https://github.com/tduncan-lum/Nvidia-Optimization-Stack---VLLM.git
Synced Nov 4, 13:45 UTC
[Reasoning] Support for speculative decoding with thinking budget (#34668)
Signed-off-by: rishitdholakia13 <rishit+github@cohere.com> Signed-off-by: rishitdholakia13 <123388671+rishitdholakia13@users.noreply.github.com> Co-authored-by: Nick Hill <nickhill123@gmail.com> Co-authored-by: Cyrus Leung <tlleungac@connect.ust.hk>
rishitdholakia13 committed
Apr 29, 2026 at 02:14 UTC
68dd7db81001267c846907769adc14bb32566190
12 files changed
+1143
-365
tests/entrypoints/openai/chat_completion/test_thinking_token_budget.py
+181
-4
@@ -1,18 +1,62 @@
1
# SPDX-License-Identifier: Apache-2.0
2
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4
-"""E2E tests for thinking_token_budget with reasoning models."""
4
+"""E2E tests for ``thinking_token_budget`` with reasoning models.
5
+
6
+Covers Qwen3-0.6B and Qwen3.5 FP8 + MTP.
7
+"""
8
+
9
+import asyncio
10
+import json
11
+from typing import Literal
12
13
import openai
14
import pytest
15
import pytest_asyncio
16
10
-from tests.utils import RemoteOpenAIServer
17
+from tests.utils import RemoteOpenAIServer, multi_gpu_only, requires_fp8
18
+from vllm.platforms import current_platform
19
+from vllm.tokenizers import get_tokenizer
20
21
MODEL_NAME = "Qwen/Qwen3-0.6B"
22
+QWEN35_FP8_MTP_MODEL = "Qwen/Qwen3.5-35B-A3B-FP8"
23
MESSAGES = [{"role": "user", "content": "What is 1+1? Be concise."}]
24
THINK_BUDGET = 5
25
26
+REASONING_START_STR = "<think>"
27
+REASONING_END_STR = "</think>"
28
+
29
+
30
+def _count_reasoning_decode_token_ids_between_markers(
31
+ full_token_ids: list[int],
32
+ reasoning_start_ids: list[int],
33
+ reasoning_end_ids: list[int],
34
+) -> int | None:
35
+ """Count decode tokens in the thinking span (after last start, before first end)."""
36
+
37
+ if not reasoning_start_ids or not reasoning_end_ids:
38
+ raise ValueError("reasoning marker token id lists must be non-empty")
39
+
40
+ def _last_subseq_index(haystack: list[int], needle: list[int]) -> int:
41
+ n = len(needle)
42
+ if n > len(haystack):
43
+ return -1
44
+ for i in range(len(haystack) - n, -1, -1):
45
+ if haystack[i : i + n] == needle:
46
+ return i
47
+ return -1
48
+
49
+ last_start = _last_subseq_index(full_token_ids, reasoning_start_ids)
50
+ if last_start < 0:
51
+ return None
52
+
53
+ pos_after_start = last_start + len(reasoning_start_ids)
54
+ end_n = len(reasoning_end_ids)
55
+ for j in range(pos_after_start, len(full_token_ids) - end_n + 1):
56
+ if full_token_ids[j : j + end_n] == reasoning_end_ids:
57
+ return j - pos_after_start
58
+ return len(full_token_ids) - pos_after_start
59
+
60
61
@pytest.fixture(scope="module")
62
def server():
@@ -48,6 +92,51 @@ def server_with_auto_reasoning_config():
92
yield remote_server
93
94
95
+@pytest.fixture(scope="module")
96
+def server_qwen35_fp8_mtp_tp2():
97
+ """Qwen3.5-35B FP8 with MTP speculative decoding and tensor parallel size 2."""
98
+ if current_platform.device_count() < 2:
99
+ pytest.skip("Need at least 2 GPUs for --tensor-parallel-size 2")
100
+ if not current_platform.supports_fp8():
101
+ pytest.skip("FP8 is not supported on this platform")
102
+
103
+ spec_cfg = {
104
+ "method": "mtp",
105
+ "num_speculative_tokens": 2,
106
+ "max_model_len": 32768,
107
+ }
108
+ args = [
109
+ "--tensor-parallel-size",
110
+ "2",
111
+ "--max-model-len",
112
+ "32768",
113
+ "--speculative-config",
114
+ json.dumps(spec_cfg),
115
+ "--reasoning-parser",
116
+ "qwen3",
117
+ "--reasoning-config",
118
+ json.dumps(
119
+ {
120
+ "reasoning_start_str": REASONING_START_STR,
121
+ "reasoning_end_str": REASONING_END_STR,
122
+ }
123
+ ),
124
+ ]
125
+ # With 4+ GPUs, run TP=2 on physical devices 2,3 so module-scoped 0.6B servers
126
+ # on 0,1 do not exhaust memory on the same devices as this worker.
127
+ env_dict = None
128
+ if current_platform.device_count() >= 4:
129
+ env_dict = {"CUDA_VISIBLE_DEVICES": "2,3"}
130
+
131
+ with RemoteOpenAIServer(
132
+ QWEN35_FP8_MTP_MODEL,
133
+ args,
134
+ max_wait_seconds=3000,
135
+ env_dict=env_dict,
136
+ ) as remote_server:
137
+ yield remote_server
138
+
139
+
140
@pytest_asyncio.fixture
141
async def client(request, server, server_with_auto_reasoning_config):
142
server_map = {
@@ -89,8 +178,10 @@ async def test_thinking_token_budget_mixed_requests(client: openai.AsyncOpenAI):
178
async def test_thinking_token_budget_limits_reasoning(client: openai.AsyncOpenAI):
179
"""Test that thinking_token_budget limits the number of reasoning tokens.
180
92
- In streaming mode each reasoning delta corresponds to one token, so
93
- counting non-empty reasoning_content chunks gives the exact token count.
181
+ Counts non-empty streaming ``delta.reasoning`` chunks (coarse proxy; each
182
+ chunk may represent multiple decode tokens — see
183
+ ``_count_reasoning_decode_token_ids_between_markers`` and the Qwen3.5 MTP
184
+ test for id-based checks).
185
"""
186
187
reasoning_token_count = 0
@@ -110,3 +201,89 @@ async def test_thinking_token_budget_limits_reasoning(client: openai.AsyncOpenAI
201
f"reasoning tokens ({reasoning_token_count}) exceeded "
202
f"thinking_token_budget ({THINK_BUDGET})"
203
)
204
+
205
+
206
+@pytest.mark.asyncio
207
+@multi_gpu_only(num_gpus=2)
208
+@requires_fp8
209
+async def test_thinking_token_budget_qwen35_fp8_mtp_concurrent_mixed_budget_and_plain(
210
+ server_qwen35_fp8_mtp_tp2,
211
+):
212
+ """Concurrent chat requests: some with ``thinking_token_budget``, some without.
213
+
214
+ Exercises the scheduler / input processor under a mixed batch on the same
215
+ Qwen3.5 FP8 + MTP (TP=2) server. Budgeted calls are checked with
216
+ ``_count_reasoning_decode_token_ids_between_markers`` on full token ids.
217
+ """
218
+
219
+ _batch_spec: list[tuple[Literal["budget"], int] | tuple[Literal["plain"], None]] = [
220
+ ("budget", 1),
221
+ ("budget", 12),
222
+ ("plain", None),
223
+ ("budget", 20),
224
+ ("budget", 14),
225
+ ("plain", None),
226
+ ("plain", None),
227
+ ("budget", 12),
228
+ ("plain", None),
229
+ ]
230
+
231
+ tokenizer = get_tokenizer(tokenizer_name=QWEN35_FP8_MTP_MODEL)
232
+ start_ids = list(tokenizer.encode(REASONING_START_STR, add_special_tokens=False))
233
+ end_ids = list(tokenizer.encode(REASONING_END_STR, add_special_tokens=False))
234
+
235
+ async with server_qwen35_fp8_mtp_tp2.get_async_client() as client:
236
+
237
+ async def budgeted_call(expected_budget: int):
238
+ return await client.chat.completions.create(
239
+ model=QWEN35_FP8_MTP_MODEL,
240
+ messages=MESSAGES,
241
+ max_tokens=256,
242
+ stream=False,
243
+ extra_body={
244
+ "thinking_token_budget": expected_budget,
245
+ "return_token_ids": True,
246
+ },
247
+ )
248
+
249
+ async def plain_call():
250
+ return await client.chat.completions.create(
251
+ model=QWEN35_FP8_MTP_MODEL,
252
+ messages=MESSAGES,
253
+ max_tokens=256,
254
+ stream=False,
255
+ )
256
+
257
+ coros = []
258
+ for row in _batch_spec:
259
+ if row[0] == "budget":
260
+ b = row[1]
261
+ assert isinstance(b, int)
262
+ coros.append(budgeted_call(b))
263
+ else:
264
+ coros.append(plain_call())
265
+ results = await asyncio.gather(*coros)
266
+
267
+ for i, (response, (kind, expected_budget)) in enumerate(
268
+ zip(results, _batch_spec, strict=True)
269
+ ):
270
+ msg = response.choices[0].message
271
+ assert msg.content or getattr(msg, "reasoning", None), (
272
+ f"index {i} ({kind}): empty message"
273
+ )
274
+
275
+ if kind == "budget":
276
+ assert expected_budget is not None
277
+ assert response.prompt_token_ids is not None
278
+ assert response.choices[0].token_ids is not None
279
+ full_ids = list(response.prompt_token_ids) + list(
280
+ response.choices[0].token_ids
281
+ )
282
+ n_reason = _count_reasoning_decode_token_ids_between_markers(
283
+ full_ids, start_ids, end_ids
284
+ )
285
+ assert n_reason is not None, f"index {i}: missing reasoning start in ids"
286
+ assert n_reason == expected_budget, (
287
+ f"index {i}: reasoning decode token ids ({n_reason}) != "
288
+ f"thinking_token_budget ({expected_budget})"
289
+ )
tests/v1/logits_processors/test_correctness.py
+338
-85
@@ -30,10 +30,13 @@ from vllm.v1.sample.logits_processor import (
30
MinPLogitsProcessor,
31
MinTokensLogitsProcessor,
32
MoveDirectionality,
33
- ThinkingTokenBudgetLogitsProcessor,
33
build_logitsprocs,
34
)
35
from vllm.v1.sample.metadata import SamplingMetadata
36
+from vllm.v1.sample.thinking_budget_state import (
37
+ ThinkingBudgetStateHolder,
38
+ maybe_create_thinking_budget_state_holder,
39
+)
40
41
PIN_MEMORY_AVAILABLE = is_pin_memory_available()
42
MAX_NUM_REQS = 256
@@ -48,8 +51,10 @@ MAX_NUM_PROMPT_TOKENS = 64
51
MIN_TOKENS_LEN_THRESHOLD = 5
52
REQS_PER_LOGITPROC = 50
53
STR_NO_LOGITPROC = "none"
54
+# Thinking budget uses ``ThinkingBudgetStateHolder`` (not a logits processor).
55
+STR_THINKING_BUDGET = "thinking_budget"
56
52
-# ThinkingTokenBudgetLogitsProcessor testing constants
57
+# Thinking token budget testing constants
58
THINKING_TOKEN_BUDGET = 5
59
THINK_START_TOKEN_ID = 999
60
THINK_END_TOKEN_ID = 998
@@ -80,15 +85,8 @@ class LogitsProcsRequestParams:
85
if num_tokens > 0:
86
# Use diverse random tokens
87
self.out_tokens = [random.randint(1, 950) for _ in range(num_tokens)]
83
- # Set first token for ThinkingTokenBudget testing
84
- is_thinking_processor = (
85
- logitproc_type is ThinkingTokenBudgetLogitsProcessor
86
- or (
87
- hasattr(logitproc_type, "__name__")
88
- and logitproc_type.__name__ == "ThinkingTokenBudgetLogitsProcessor"
89
- )
90
- )
91
- if is_thinking_processor:
88
+ # Think-start seed for ``STR_THINKING_BUDGET`` rows.
89
+ if logitproc_type == STR_THINKING_BUDGET:
90
self.out_tokens[0] = THINK_START_TOKEN_ID
91
else:
92
self.out_tokens = []
@@ -102,7 +100,7 @@ class LogitsProcsRequestParams:
100
101
102
class MockReasoningConfig:
105
- """Mock reasoning config for testing ThinkingTokenBudgetLogitsProcessor."""
103
+ """Minimal reasoning config for ``ThinkingBudgetStateHolder`` tests."""
104
105
reasoning_start_token_ids = [THINK_START_TOKEN_ID]
106
reasoning_end_token_ids = [THINK_END_TOKEN_ID]
@@ -137,6 +135,18 @@ def _generate_fake_sampling_metadata(
135
is_pin_memory=PIN_MEMORY_AVAILABLE,
136
is_pooling_model=False,
137
)
138
+ num_spec = (
139
+ vllm_config.speculative_config.num_speculative_tokens
140
+ if vllm_config.speculative_config
141
+ else 0
142
+ )
143
+ thinking_holder = maybe_create_thinking_budget_state_holder(
144
+ vllm_config.reasoning_config,
145
+ vllm_config.scheduler_config.max_num_seqs,
146
+ num_spec,
147
+ device,
148
+ PIN_MEMORY_AVAILABLE,
149
+ )
150
fake_sampling_metadata = SamplingMetadata(
151
temperature=torch.full((batch_size,), 0.0),
152
all_greedy=True,
@@ -156,6 +166,7 @@ def _generate_fake_sampling_metadata(
166
allowed_token_ids_mask=None,
167
bad_words_token_ids={},
168
logitsprocs=logitsprocs,
169
+ thinking_budget_state_holder=thinking_holder,
170
)
171
return fake_sampling_metadata
172
@@ -187,7 +198,7 @@ def _sampling_params_from_logitproc(logitproc_type: LogitprocType) -> SamplingPa
198
199
def _generate_mixed_logitsprocs_batch_params(
200
reqs_per_logitproc: int,
190
- logitsprocs_types: list[str],
201
+ logitsprocs_types: list[LogitprocType],
202
) -> list[LogitsProcsRequestParams]:
203
"""Define key params for a batch of requests with a different
204
logitproc enabled per request.
@@ -450,23 +461,21 @@ def _thinking_budget_validate(
461
request_params: LogitsProcsRequestParams,
462
step_idx: int,
463
) -> None:
453
- """Validate thinking token budget processor behavior"""
454
- # Get the ThinkingTokenBudgetLogitsProcessor instance
455
- tb_processor: ThinkingTokenBudgetLogitsProcessor = next(
456
- test_fakes.get_logitsprocs_by_cls(ThinkingTokenBudgetLogitsProcessor)
457
- )
464
+ """Validate ``ThinkingBudgetStateHolder`` thinking-budget behavior.
465
459
- # Get current request state
460
- state = tb_processor._state.get(batch_index)
466
+ State is keyed by **batch slot** (same index space as logits rows), matching
467
+ ``sync_batch`` / sampler integration (see PR #34668 discussion).
468
+ """
469
+ holder = test_fakes.sampling_metadata.thinking_budget_state_holder
470
+ assert holder is not None
471
+ state = holder._state.get(batch_index)
472
params = request_params.params
473
463
- # Validate thinking token budget configuration
474
if hasattr(params, "thinking_token_budget") and params.thinking_token_budget:
465
- # State should exist for requests with thinking_token_budget
475
if state is None:
476
_raise_error_invalid(
477
msg_suffix=(
469
- f"Expected state for batch {batch_index} "
478
+ f"Expected holder state for batch slot {batch_index} "
479
f"with thinking_token_budget={params.thinking_token_budget}"
480
),
481
batch_index=batch_index,
@@ -474,10 +483,8 @@ def _thinking_budget_validate(
483
step_idx=step_idx,
484
)
485
477
- # Validate budget matches what was set
486
expected_budget = params.thinking_token_budget
487
actual_budget = state["thinking_token_budget"]
480
-
488
if actual_budget != expected_budget:
489
_raise_error_invalid(
490
msg_suffix=(
@@ -488,13 +495,9 @@ def _thinking_budget_validate(
495
step_idx=step_idx,
496
)
497
491
- # Check if we're in thinking mode and validate token counting
498
output_tokens = request_params.out_tokens
493
-
494
- # Find if thinking has started in output tokens
499
+ start_tokens = holder.think_start_token_ids
500
thinking_started = False
496
- start_tokens = tb_processor.reasoning_start_token_ids
497
-
501
if len(start_tokens) > 0:
502
for i in range(len(output_tokens) - len(start_tokens) + 1):
503
if output_tokens[i : i + len(start_tokens)] == start_tokens:
@@ -502,61 +505,42 @@ def _thinking_budget_validate(
505
break
506
507
if thinking_started:
505
- # If budget is exceeded, validate end token forcing
508
think_count = state["think_count"]
509
budget = state["thinking_token_budget"]
510
+ if think_count >= budget and not state["in_end"]:
511
+ _raise_error_invalid(
512
+ msg_suffix=(
513
+ f"Budget exceeded ({think_count} >= {budget}) but "
514
+ "in_end is false"
515
+ ),
516
+ batch_index=batch_index,
517
+ request_params=request_params,
518
+ step_idx=step_idx,
519
+ )
520
509
- if think_count >= budget:
510
- if not state["in_end"]:
521
+ end_tokens = holder.think_end_token_ids
522
+ if (
523
+ think_count >= budget
524
+ and state["in_end"]
525
+ and len(end_tokens) > 0
526
+ and holder.has_tracked_requests()
527
+ ):
528
+ expected_end_token_id = end_tokens[
529
+ min(state["end_count"], len(end_tokens) - 1)
530
+ ]
531
+ # Holder bumps forced vocab positions to 1e9 (does not -inf others).
532
+ forced_logit = float(logits_new[batch_index, expected_end_token_id])
533
+ if forced_logit < 1.0e8:
534
_raise_error_invalid(
535
msg_suffix=(
513
- f"Budget exceeded ({think_count} >= "
514
- f"{budget}) but not "
515
- "forcing end tokens"
536
+ f"Expected forced end token {expected_end_token_id} "
537
+ f"with large logit, got {forced_logit}"
538
),
539
batch_index=batch_index,
540
request_params=request_params,
541
step_idx=step_idx,
542
)
543
522
- # Validate that only end tokens are allowed
523
- end_tokens = tb_processor.reasoning_end_token_ids
524
- if len(end_tokens) > 0:
525
- expected_end_token_id = end_tokens[
526
- min(state["end_count"], len(end_tokens) - 1)
527
- ]
528
-
529
- # Check logits masking
530
- batch_logits = logits_new[batch_index]
531
- for token_id in range(len(batch_logits)):
532
- logit_value = batch_logits[token_id]
533
-
534
- if token_id == expected_end_token_id:
535
- # End token should not be masked
536
- if logit_value == -float("inf"):
537
- _raise_error_invalid(
538
- msg_suffix=(
539
- f"End token {token_id} should not be "
540
- "masked but is"
541
- ),
542
- batch_index=batch_index,
543
- request_params=request_params,
544
- step_idx=step_idx,
545
- )
546
- else:
547
- # All other tokens should be masked when forcing end
548
- if logit_value != -float("inf"):
549
- _raise_error_invalid(
550
- msg_suffix=(
551
- f"Token {token_id} should be masked "
552
- f"when forcing end tokens, but "
553
- f"logit={logit_value}"
554
- ),
555
- batch_index=batch_index,
556
- request_params=request_params,
557
- step_idx=step_idx,
558
- )
559
-
544
545
def _none_validate(
546
test_fakes: LogitsprocsTestFakes,
@@ -604,7 +588,7 @@ logitsprocs_test_mapping = {
588
MinTokensLogitsProcessor: LogitsprocTestHelpers(
589
gen_request_fxn=_min_tokens_params, eval_fxn=_min_tokens_validate
590
),
607
- ThinkingTokenBudgetLogitsProcessor: LogitsprocTestHelpers(
591
+ STR_THINKING_BUDGET: LogitsprocTestHelpers(
592
gen_request_fxn=_thinking_budget_params, eval_fxn=_thinking_budget_validate
593
),
594
}
@@ -614,20 +598,17 @@ def _get_test_cases() -> list[list[str]]:
598
"""Each test case is a set of logitsprocs"""
599
logitsprocs_types = list(logitsprocs_test_mapping.keys())
600
617
- # Isolate ThinkingTokenBudgetLogitsProcessor from all other processors
618
- # to avoid unexpected modification of logits interference
619
- thinking_processor = ThinkingTokenBudgetLogitsProcessor
601
+ # Isolate thinking-budget handling from other processors to avoid cross-talk.
602
+ thinking_id: LogitprocType = STR_THINKING_BUDGET
603
other_processors = [
621
- p
622
- for p in logitsprocs_types
623
- if p != STR_NO_LOGITPROC and p != thinking_processor
604
+ p for p in logitsprocs_types if p != STR_NO_LOGITPROC and p != thinking_id
605
]
606
607
return (
608
[[STR_NO_LOGITPROC]]
609
+ [[logitproc_type, STR_NO_LOGITPROC] for logitproc_type in other_processors]
610
+ [other_processors]
630
- + [[thinking_processor]]
611
+ + [[thinking_id]]
612
)
613
614
@@ -802,12 +783,23 @@ def _assert_valid(
783
)
784
785
786
+def _slot_outputs_for_metadata(
787
+ persistent_batch: list[LogitsProcsRequestParams], pad_len: int
788
+) -> list[list[int]]:
789
+ """Per-batch-slot output token ids aligned with ``SamplingMetadata`` rows."""
790
+ rows: list[list[int]] = [[] for _ in range(pad_len)]
791
+ for i, req in enumerate(persistent_batch):
792
+ if i < pad_len:
793
+ rows[i] = list(req.out_tokens)
794
+ return rows
795
+
796
+
797
@create_new_process_for_each_test()
798
@pytest.mark.parametrize("device", DEVICES)
799
@pytest.mark.parametrize("reqs_per_logitproc", [REQS_PER_LOGITPROC])
800
@pytest.mark.parametrize("logitsprocs_under_test", _get_test_cases())
801
def test_logitsprocs(
810
- device: str, reqs_per_logitproc: int, logitsprocs_under_test: list[str]
802
+ device: str, reqs_per_logitproc: int, logitsprocs_under_test: list[LogitprocType]
803
):
804
random.seed(40)
805
torch.set_default_device(device)
@@ -855,9 +847,10 @@ def test_logitsprocs(
847
# Apply fake batch update to logitsprocs
848
fake_update_logitsprocs_state(test_fakes, batch_update)
849
858
- # Emulate application of logits processors in engine
850
+ # Emulate application of logits processors + thinking holder (sampler order).
851
slice_idxs = [req.workload_index for req in persistent_batch]
860
- logits_w_lp = fake_apply_logitsprocs(test_fakes, slice_idxs).cpu()
852
+ slot_rows = _slot_outputs_for_metadata(persistent_batch, workload_size)
853
+ logits_w_lp = fake_apply_logitsprocs(test_fakes, slice_idxs, slot_rows).cpu()
854
855
_assert_valid(
856
batch_size=batch_size,
@@ -869,3 +862,263 @@ def test_logitsprocs(
862
)
863
864
step_idx += 1
865
+
866
+
867
+class MockReasoningNoEndTokens:
868
+ """Reasoning config with no end token ids (disables enforcement in holder)."""
869
+
870
+ reasoning_start_token_ids = [THINK_START_TOKEN_ID]
871
+ reasoning_end_token_ids: list[int] = []
872
+
873
+
874
+def test_maybe_create_thinking_budget_holder_without_reasoning():
875
+ cfg = VllmConfig()
876
+ assert cfg.reasoning_config is None
877
+ assert (
878
+ maybe_create_thinking_budget_state_holder(
879
+ None,
880
+ cfg.scheduler_config.max_num_seqs,
881
+ 0,
882
+ torch.device("cpu"),
883
+ False,
884
+ )
885
+ is None
886
+ )
887
+
888
+
889
+def test_thinking_budget_holder_has_tracked_after_sync_add():
890
+ vc = VllmConfig()
891
+ vc.reasoning_config = MockReasoningConfig()
892
+ h = ThinkingBudgetStateHolder(
893
+ vc.reasoning_config,
894
+ vc.scheduler_config.max_num_seqs,
895
+ 0,
896
+ torch.device("cpu"),
897
+ False,
898
+ )
899
+ assert not h.has_tracked_requests()
900
+ h.sync_batch(
901
+ BatchUpdate(
902
+ batch_size=1,
903
+ removed=(),
904
+ added=[
905
+ (
906
+ 0,
907
+ SamplingParams(thinking_token_budget=3),
908
+ None,
909
+ [THINK_START_TOKEN_ID],
910
+ )
911
+ ],
912
+ moved=(),
913
+ )
914
+ )
915
+ assert h.has_tracked_requests()
916
+ assert h._state[0]["thinking_token_budget"] == 3
917
+
918
+
919
+def test_thinking_budget_holder_sync_remove_clears_state():
920
+ vc = VllmConfig()
921
+ vc.reasoning_config = MockReasoningConfig()
922
+ h = ThinkingBudgetStateHolder(
923
+ vc.reasoning_config,
924
+ vc.scheduler_config.max_num_seqs,
925
+ 0,
926
+ torch.device("cpu"),
927
+ False,
928
+ )
929
+ h.sync_batch(
930
+ BatchUpdate(
931
+ batch_size=1,
932
+ removed=(),
933
+ added=[
934
+ (
935
+ 0,
936
+ SamplingParams(thinking_token_budget=3),
937
+ None,
938
+ [],
939
+ )
940
+ ],
941
+ moved=(),
942
+ )
943
+ )
944
+ assert h.has_tracked_requests()
945
+ h.sync_batch(BatchUpdate(batch_size=0, removed=(0,), added=(), moved=()))
946
+ assert not h.has_tracked_requests()
947
+
948
+
949
+def test_thinking_budget_holder_sync_add_without_budget_drops_row():
950
+ vc = VllmConfig()
951
+ vc.reasoning_config = MockReasoningConfig()
952
+ h = ThinkingBudgetStateHolder(
953
+ vc.reasoning_config,
954
+ vc.scheduler_config.max_num_seqs,
955
+ 0,
956
+ torch.device("cpu"),
957
+ False,
958
+ )
959
+ h.sync_batch(
960
+ BatchUpdate(
961
+ batch_size=1,
962
+ removed=(),
963
+ added=[(0, SamplingParams(), None, [])],
964
+ moved=(),
965
+ )
966
+ )
967
+ assert not h.has_tracked_requests()
968
+
969
+
970
+def test_thinking_budget_holder_swap_exchanges_state():
971
+ vc = VllmConfig()
972
+ vc.reasoning_config = MockReasoningConfig()
973
+ h = ThinkingBudgetStateHolder(
974
+ vc.reasoning_config,
975
+ vc.scheduler_config.max_num_seqs,
976
+ 0,
977
+ torch.device("cpu"),
978
+ False,
979
+ )
980
+ h.sync_batch(
981
+ BatchUpdate(
982
+ batch_size=2,
983
+ removed=(),
984
+ added=[
985
+ (
986
+ 0,
987
+ SamplingParams(thinking_token_budget=3),
988
+ None,
989
+ [],
990
+ ),
991
+ (
992
+ 1,
993
+ SamplingParams(thinking_token_budget=7),
994
+ None,
995
+ [],
996
+ ),
997
+ ],
998
+ moved=(),
999
+ )
1000
+ )
1001
+ b0, b1 = h._state[0]["thinking_token_budget"], h._state[1]["thinking_token_budget"]
1002
+ h.sync_batch(
1003
+ BatchUpdate(
1004
+ batch_size=2,
1005
+ removed=(),
1006
+ added=(),
1007
+ moved=[(0, 1, MoveDirectionality.SWAP)],
1008
+ )
1009
+ )
1010
+ assert h._state[0]["thinking_token_budget"] == b1
1011
+ assert h._state[1]["thinking_token_budget"] == b0
1012
+
1013
+
1014
+def test_thinking_budget_holder_unidirectional_move():
1015
+ vc = VllmConfig()
1016
+ vc.reasoning_config = MockReasoningConfig()
1017
+ h = ThinkingBudgetStateHolder(
1018
+ vc.reasoning_config,
1019
+ vc.scheduler_config.max_num_seqs,
1020
+ 0,
1021
+ torch.device("cpu"),
1022
+ False,
1023
+ )
1024
+ h.sync_batch(
1025
+ BatchUpdate(
1026
+ batch_size=2,
1027
+ removed=(),
1028
+ added=[
1029
+ (
1030
+ 1,
1031
+ SamplingParams(thinking_token_budget=4),
1032
+ None,
1033
+ [],
1034
+ ),
1035
+ ],
1036
+ moved=(),
1037
+ )
1038
+ )
1039
+ assert 1 in h._state and 0 not in h._state
1040
+ h.sync_batch(
1041
+ BatchUpdate(
1042
+ batch_size=2,
1043
+ removed=(),
1044
+ added=(),
1045
+ moved=[(1, 0, MoveDirectionality.UNIDIRECTIONAL)],
1046
+ )
1047
+ )
1048
+ assert 0 in h._state and 1 not in h._state
1049
+ assert h._state[0]["thinking_token_budget"] == 4
1050
+
1051
+
1052
+def test_thinking_budget_holder_update_state_repeat_indices_last_row_wins():
1053
+ vc = VllmConfig()
1054
+ vc.reasoning_config = MockReasoningConfig()
1055
+ h = ThinkingBudgetStateHolder(
1056
+ vc.reasoning_config,
1057
+ vc.scheduler_config.max_num_seqs,
1058
+ 0,
1059
+ torch.device("cpu"),
1060
+ False,
1061
+ )
1062
+ h.sync_batch(
1063
+ BatchUpdate(
1064
+ batch_size=1,
1065
+ removed=(),
1066
+ added=[
1067
+ (
1068
+ 0,
1069
+ SamplingParams(thinking_token_budget=5),
1070
+ None,
1071
+ [THINK_START_TOKEN_ID],
1072
+ )
1073
+ ],
1074
+ moved=(),
1075
+ )
1076
+ )
1077
+ out_lists = [[THINK_START_TOKEN_ID], [THINK_START_TOKEN_ID, 10, 11, 12, 13, 14]]
1078
+ h.update_state(
1079
+ out_lists,
1080
+ None,
1081
+ torch.tensor([0, 0], dtype=torch.long),
1082
+ )
1083
+ assert h._state[0]["output_tok_ids"] == out_lists[1]
1084
+
1085
+
1086
+def test_thinking_budget_holder_spec_mode_tensor_layout():
1087
+ h = ThinkingBudgetStateHolder(
1088
+ MockReasoningConfig(),
1089
+ 8,
1090
+ 2,
1091
+ torch.device("cpu"),
1092
+ False,
1093
+ )
1094
+ assert h.in_spec_mode
1095
+ assert h.mask.shape[0] == 8 * (2 + 1)
1096
+
1097
+
1098
+def test_thinking_budget_holder_empty_end_tokens_disables_row():
1099
+ vc = VllmConfig()
1100
+ vc.reasoning_config = MockReasoningNoEndTokens()
1101
+ h = ThinkingBudgetStateHolder(
1102
+ vc.reasoning_config,
1103
+ vc.scheduler_config.max_num_seqs,
1104
+ 0,
1105
+ torch.device("cpu"),
1106
+ False,
1107
+ )
1108
+ h.sync_batch(
1109
+ BatchUpdate(
1110
+ batch_size=1,
1111
+ removed=(),
1112
+ added=[
1113
+ (
1114
+ 0,
1115
+ SamplingParams(thinking_token_budget=5),
1116
+ None,
1117
+ [THINK_START_TOKEN_ID],
1118
+ )
1119
+ ],
1120
+ moved=(),
1121
+ )
1122
+ )
1123
+ h.update_state([[THINK_START_TOKEN_ID, 1]], None, None)
1124
+ assert h._state[0]["thinking_token_budget"] == -1
tests/v1/sample/utils.py
+23
-2
@@ -198,22 +198,43 @@ class LogitsprocsTestFakes(NamedTuple):
198
199
def fake_update_logitsprocs_state(
200
test_fakes: LogitsprocsTestFakes,
201
- batch_update: BatchUpdate,
201
+ batch_update: BatchUpdate | None,
202
) -> None:
203
"""Imitate logits processors persistent batch state update
204
in engine core"""
205
for logitproc in test_fakes.get_logitsprocs():
206
logitproc.update_state(batch_update)
207
+ holder = test_fakes.sampling_metadata.thinking_budget_state_holder
208
+ if holder is not None:
209
+ holder.sync_batch(batch_update)
210
211
212
def fake_apply_logitsprocs(
213
test_fakes: LogitsprocsTestFakes,
214
slice_indices: list[int],
215
+ slot_output_token_ids: list[list[int]] | None = None,
216
) -> torch.Tensor:
213
- """Imitate application of logits processors in engine core"""
217
+ """Imitate application of logits processors in engine core.
218
+
219
+ When ``thinking_budget_state_holder`` has tracked requests, this mirrors
220
+ :meth:`Sampler.apply_logits_processors` by refreshing per-slot
221
+ ``output_token_ids`` (if ``slot_output_token_ids`` is provided), then
222
+ ``update_state`` + ``apply_to_logits`` on the holder after built-in logits
223
+ processors.
224
+ """
225
logits = test_fakes.logits[torch.tensor(slice_indices, dtype=torch.long)].clone()
226
for processor in test_fakes.get_logitsprocs():
227
logits = processor.apply(logits)
228
+
229
+ md = test_fakes.sampling_metadata
230
+ holder = md.thinking_budget_state_holder
231
+ if holder is not None and holder.has_tracked_requests():
232
+ if slot_output_token_ids is not None:
233
+ for i, toks in enumerate(slot_output_token_ids):
234
+ if i < len(md.output_token_ids):
235
+ md.output_token_ids[i] = list(toks)
236
+ holder.update_state(md.output_token_ids, md.spec_token_ids, None)
237
+ logits = holder.apply_to_logits(logits, False, md.spec_token_ids)
238
return logits
239
240
tests/v1/streaming_input/test_gpu_model_runner_streaming.py
-1
@@ -39,7 +39,6 @@ def mock_model_runner_with_input_batch():
39
vocab_size=32000,
40
block_sizes=[16],
41
kernel_block_sizes=[16],
42
- is_spec_decode=False,
42
logitsprocs=None,
43
is_pooling_model=False,
44
)
vllm/v1/sample/logits_processor/__init__.py
-3
@@ -18,7 +18,6 @@ from vllm.v1.sample.logits_processor.builtin import (
18
LogitBiasLogitsProcessor,
19
MinPLogitsProcessor,
20
MinTokensLogitsProcessor,
21
- ThinkingTokenBudgetLogitsProcessor,
21
process_dict_updates,
22
)
23
from vllm.v1.sample.logits_processor.interface import (
@@ -51,7 +50,6 @@ BUILTIN_LOGITS_PROCESSORS: list[type[LogitsProcessor]] = [
50
MinTokensLogitsProcessor,
51
LogitBiasLogitsProcessor,
52
MinPLogitsProcessor,
54
- ThinkingTokenBudgetLogitsProcessor,
53
]
54
55
@@ -356,5 +354,4 @@ __all__ = [
354
"STR_POOLING_REJECTS_LOGITSPROCS",
355
"LOGITSPROCS_GROUP",
356
"AdapterLogitsProcessor",
359
- "ThinkingTokenBudgetLogitsProcessor",
357
]
vllm/v1/sample/logits_processor/builtin.py
+1
-262
@@ -1,7 +1,7 @@
1
# SPDX-License-Identifier: Apache-2.0
2
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
from collections.abc import Callable, Sequence
4
-from typing import TYPE_CHECKING, Any, TypeVar
4
+from typing import TYPE_CHECKING, TypeVar
5
6
import numpy as np
7
import torch
@@ -291,267 +291,6 @@ class MinTokensLogitsProcessor(LogitsProcessor):
291
return logits
292
293
294
-class ThinkingTokenBudgetLogitsProcessor(LogitsProcessor):
295
- """Limits the number of tokens allowed inside a 'thinking' section."""
296
-
297
- def __init__(
298
- self, vllm_config: "VllmConfig", device: torch.device, is_pin_memory: bool
299
- ):
300
- reasoning_config = vllm_config.reasoning_config
301
- max_num_reqs = vllm_config.scheduler_config.max_num_seqs
302
-
303
- # Check if thinking is enabled
304
- self.is_enabled = reasoning_config is not None and reasoning_config.enabled
305
-
306
- self.reasoning_start_token_ids = getattr(
307
- reasoning_config, "reasoning_start_token_ids", []
308
- )
309
- self.reasoning_end_token_ids = getattr(
310
- reasoning_config, "reasoning_end_token_ids", []
311
- )
312
-
313
- self.pin_memory = is_pin_memory
314
- self.device = device
315
- # Per-request state tracking for thinking token management
316
- # Key: request_index, Value: state dict containing:
317
- # "in_think": bool - currently in thinking mode
318
- # "in_end": bool - currently forcing end tokens output
319
- # "check_count_down": int - steps remaining until next think
320
- # start/end token parsing
321
- # "think_count": int - number of thinking tokens generated
322
- # "end_count": int - number of end tokens forced so far
323
- # "thinking_token_budget": int - max allowed thinking tokens
324
- # "output_tok_ids": list[int] - generated output tokens
325
- # "prev_output_length": int - previous output length for
326
- # incremental processing
327
- self._state: dict[int, dict[str, Any]] = {}
328
-
329
- # Preallocate reusable tensors
330
- self.mask = torch.zeros(max_num_reqs, dtype=torch.bool, device=device)
331
- self.force_token_ids = torch.full(
332
- (max_num_reqs,), -1, dtype=torch.long, device=device
333
- )
334
-
335
- @staticmethod
336
- def _find_last_sequence_index(target_list: list[int], token_ids: list[int]) -> int:
337
- """
338
- Returns the index of the last occurrence of token_ids in target_list.
339
-
340
- Args:
341
- target_list (list[int]): The list of token IDs.
342
- token_ids (list[int]): The sequence of token IDs to find.
343
- """
344
- if not token_ids:
345
- return -1
346
- for i in range(len(target_list) - len(token_ids), -1, -1):
347
- if target_list[i : i + len(token_ids)] == token_ids:
348
- return i
349
- return -1
350
-
351
- def _init_state_entry(
352
- self, prompt_tok_ids: list[int] | None, thinking_token_budget: int
353
- ) -> dict[str, Any]:
354
- """Initializes the tracking state for a given sequence index."""
355
- if prompt_tok_ids is None:
356
- last_start = -1
357
- last_end = -1
358
- in_think = False
359
- think_count = 0
360
- else:
361
- last_start = self._find_last_sequence_index(
362
- prompt_tok_ids, self.reasoning_start_token_ids
363
- )
364
- last_end = self._find_last_sequence_index(
365
- prompt_tok_ids, self.reasoning_end_token_ids
366
- )
367
- in_think = last_start > last_end
368
- if in_think:
369
- think_count = len(prompt_tok_ids) - (
370
- last_start + len(self.reasoning_start_token_ids)
371
- )
372
- else:
373
- think_count = 0
374
-
375
- return {
376
- "in_think": in_think, # Currently in thinking mode
377
- "in_end": in_think and thinking_token_budget == 0,
378
- "check_count_down": thinking_token_budget,
379
- "think_count": think_count, # Number of tokens in thinking section
380
- "end_count": 0, # Number of end tokens forced so far
381
- "prompt_tok_ids": prompt_tok_ids,
382
- "output_tok_ids": [],
383
- "thinking_token_budget": thinking_token_budget,
384
- "prev_output_length": 0,
385
- # Track previous output length for incremental updates
386
- }
387
-
388
- def _update_think_state(self, state: dict[str, Any]):
389
- """Updates the state based on newly generated output tokens."""
390
- if not state.get("in_end", False) and state.get("check_count_down", 0) > 0:
391
- state["check_count_down"] -= 1
392
- return
393
-
394
- output = state.get("output_tok_ids", [])
395
- if not output:
396
- return
397
-
398
- # Track previous output length for incremental processing
399
- prev_length = state.get("prev_output_length", 0)
400
- current_length = len(output)
401
-
402
- if current_length <= prev_length:
403
- return
404
-
405
- # Process only newly added tokens
406
- new_tokens = output[prev_length:]
407
- state["prev_output_length"] = current_length
408
-
409
- # Check if new tokens contain think start or end sequences
410
- start_len = len(self.reasoning_start_token_ids)
411
- end_len = len(self.reasoning_end_token_ids)
412
-
413
- # Look for think sequences in recent tokens (including boundary)
414
- # Check overlapping regions where sequences might span boundaries
415
- check_start_idx = max(0, prev_length - max(start_len, end_len) + 1)
416
- recent_tokens = output[check_start_idx:]
417
-
418
- # Find any think start/end sequences in recent tokens
419
- recent_start_pos = self._find_last_sequence_index(
420
- recent_tokens, self.reasoning_start_token_ids
421
- )
422
- recent_end_pos = self._find_last_sequence_index(
423
- recent_tokens, self.reasoning_end_token_ids
424
- )
425
-
426
- # Update state based on recent sequences
427
- if not state["in_end"]:
428
- if recent_start_pos >= 0 and recent_end_pos >= 0:
429
- if recent_start_pos > recent_end_pos:
430
- # Case: ...<end>...<start>... - entering think mode
431
- absolute_start_pos = check_start_idx + recent_start_pos
432
- new_think_count = current_length - (absolute_start_pos + start_len)
433
- state["in_think"] = True
434
- state["think_count"] = new_think_count
435
- else:
436
- # Case: ...<start>...<end>... - exiting think mode
437
- state["in_think"] = False
438
- state["think_count"] = 0
439
- elif recent_start_pos >= 0:
440
- # Found think start - entering think mode
441
- absolute_start_pos = check_start_idx + recent_start_pos
442
- new_think_count = current_length - (absolute_start_pos + start_len)
443
- state["in_think"] = True
444
- state["think_count"] = new_think_count
445
- elif recent_end_pos >= 0:
446
- # Found think end - exiting think mode
447
- state["in_think"] = False
448
- state["think_count"] = 0
449
- elif state["in_think"]:
450
- # Continue thinking mode, increment count by new tokens
451
- state["think_count"] += len(new_tokens)
452
-
453
- # Set countdown based on current state
454
- if state["in_think"]:
455
- remaining_budget = max(
456
- 0, state["thinking_token_budget"] - state["think_count"]
457
- )
458
- state["check_count_down"] = max(0, remaining_budget - 1)
459
- else:
460
- state["check_count_down"] = state["thinking_token_budget"]
461
-
462
- # Check if need to transition to end mode
463
- if (
464
- state["in_think"]
465
- and state["think_count"] >= state["thinking_token_budget"]
466
- ):
467
- state["in_think"] = False
468
- state["in_end"] = True
469
- state["end_count"] = 0
470
- state["check_count_down"] = state["thinking_token_budget"]
471
- else:
472
- # In end mode
473
- state["end_count"] += 1
474
- if state["end_count"] >= len(self.reasoning_end_token_ids):
475
- state.update(
476
- {
477
- "in_end": False,
478
- "end_count": 0,
479
- "check_count_down": state["thinking_token_budget"],
480
- }
481
- )
482
-
483
- def is_argmax_invariant(self) -> bool:
484
- """This logits processor can change the outcome of
485
- greedy sampling by forcing that the thinking section
486
- ends after a certain number of tokens."""
487
- return False
488
-
489
- def update_state(self, batch_update: BatchUpdate | None):
490
- if not self.is_enabled:
491
- return
492
- if batch_update:
493
- for index, params, prompt_tok_ids, output_tok_ids in batch_update.added:
494
- thinking_token_budget = params.thinking_token_budget
495
-
496
- if thinking_token_budget is not None:
497
- self._state[index] = self._init_state_entry(
498
- prompt_tok_ids, thinking_token_budget
499
- )
500
- self._state[index]["output_tok_ids"] = output_tok_ids
501
- else:
502
- # Remove state if no thinking budget
503
- self._state.pop(index, None)
504
-
505
- for index in batch_update.removed:
506
- self._state.pop(index, {})
507
-
508
- for i1, i2, direction in batch_update.moved:
509
- if direction == MoveDirectionality.SWAP:
510
- state1 = self._state.pop(i1, None)
511
- state2 = self._state.pop(i2, None)
512
- if state1 is not None:
513
- self._state[i2] = state1
514
- if state2 is not None:
515
- self._state[i1] = state2
516
- else:
517
- state = self._state.pop(i1, None)
518
- if state is not None:
519
- self._state[i2] = state
520
-
521
- for state in self._state.values():
522
- self._update_think_state(state)
523
-
524
- def apply(self, logits: torch.Tensor) -> torch.Tensor:
525
- if not self.is_enabled or not self._state:
526
- return logits
527
-
528
- batch_size = logits.size(0)
529
- self.mask[:batch_size] = False
530
-
531
- for i in range(batch_size):
532
- state = self._state.get(i)
533
- if state and state["in_end"]:
534
- self.mask[i] = True
535
- self.force_token_ids[i] = self.reasoning_end_token_ids[
536
- state["end_count"]
537
- ]
538
-
539
- # Check in CPU first not to sync with GPU
540
- has_active_thinking = any(
541
- state.get("in_end", False) for state in self._state.values()
542
- )
543
-
544
- if has_active_thinking:
545
- current_mask = self.mask[:batch_size]
546
- active_indices = current_mask.nonzero(as_tuple=False).view(-1)
547
- if len(active_indices) > 0:
548
- force_tokens = self.force_token_ids[active_indices]
549
- # Apply a large value for the end thinking token id index
550
- logits[active_indices, force_tokens] = 1e9
551
-
552
- return logits
553
-
554
-
294
def process_dict_updates(
295
req_entries: dict[int, T],
296
batch_update: BatchUpdate | None,
vllm/v1/sample/metadata.py
+6
@@ -1,11 +1,14 @@
1
# SPDX-License-Identifier: Apache-2.0
2
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4
+from __future__ import annotations
5
+
6
from dataclasses import dataclass
7
8
import torch
9
10
from vllm.v1.sample.logits_processor import LogitsProcessors
11
+from vllm.v1.sample.thinking_budget_state import ThinkingBudgetStateHolder
12
13
14
@dataclass
@@ -47,3 +50,6 @@ class SamplingMetadata:
50
51
# Speculative token ids
52
spec_token_ids: list[list[int]] | None = None
53
+ # When non-None, use ``holder.has_tracked_requests()`` to see if this batch applies
54
+ # thinking-token-budget logits (holder may exist with an empty tracking set).
55
+ thinking_budget_state_holder: ThinkingBudgetStateHolder | None = None
vllm/v1/sample/rejection_sampler.py
+16
-3
@@ -290,16 +290,24 @@ class RejectionSampler(nn.Module):
290
any_penalties_or_bad_words = (
291
sampling_metadata.bad_words_token_ids or has_penalties
292
)
293
+ holder = sampling_metadata.thinking_budget_state_holder
294
+ needs_thinking = holder is not None and holder.has_tracked_requests()
295
296
output_token_ids = sampling_metadata.output_token_ids
295
- if any_penalties_or_bad_words:
297
+ if any_penalties_or_bad_words or needs_thinking:
298
output_token_ids = self._combine_outputs_with_spec_tokens(
299
output_token_ids,
300
sampling_metadata.spec_token_ids,
301
)
302
303
# Calculate indices of target logits.
302
- if sampling_metadata.allowed_token_ids_mask is not None or has_penalties:
304
+ repeat_indices: torch.Tensor | None = None
305
+ need_repeat_indices = (
306
+ sampling_metadata.allowed_token_ids_mask is not None
307
+ or has_penalties
308
+ or needs_thinking
309
+ )
310
+ if need_repeat_indices:
311
num_requests = len(metadata.num_draft_tokens)
312
num_draft_tokens = torch.tensor(metadata.num_draft_tokens, device="cpu")
313
original_indices = torch.arange(num_requests, device="cpu")
@@ -327,7 +335,12 @@ class RejectionSampler(nn.Module):
335
logits = processor.apply_with_spec_decode(
336
logits, metadata.num_draft_tokens
337
)
330
-
338
+ if holder is not None and holder.has_tracked_requests():
339
+ logits = holder.apply_to_logits(
340
+ logits,
341
+ predict_bonus_token=False,
342
+ spec_token_ids=sampling_metadata.spec_token_ids,
343
+ )
344
return logits
345
346
@staticmethod
vllm/v1/sample/sampler.py
+16
-1
@@ -364,9 +364,13 @@ class Sampler(nn.Module):
364
any_penalties_or_bad_words = (
365
bool(bad_words_token_ids) or not sampling_metadata.no_penalties
366
)
367
+ holder = sampling_metadata.thinking_budget_state_holder
368
+ needs_thinking_combine = holder is not None and holder.has_tracked_requests()
369
370
output_token_ids = sampling_metadata.output_token_ids
369
- if predict_bonus_token and any_penalties_or_bad_words:
371
+ if predict_bonus_token and (
372
+ any_penalties_or_bad_words or needs_thinking_combine
373
+ ):
374
# Combine base outputs with spec tokens when speculative decoding
375
# is enabled.
376
output_token_ids = self._combine_outputs_with_spec_tokens(
@@ -388,6 +392,17 @@ class Sampler(nn.Module):
392
393
# Apply penalties (e.g., freq_penalties).
394
logits = self.apply_penalties(logits, sampling_metadata, output_token_ids)
395
+ if holder is not None and holder.has_tracked_requests():
396
+ holder.update_state(
397
+ output_token_ids,
398
+ sampling_metadata.spec_token_ids,
399
+ repeat_indices=None,
400
+ )
401
+ logits = holder.apply_to_logits(
402
+ logits,
403
+ predict_bonus_token,
404
+ sampling_metadata.spec_token_ids,
405
+ )
406
return logits
407
408
@staticmethod
vllm/v1/sample/thinking_budget_state.py
new
+528
@@ -0,0 +1,528 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+"""Per-batch thinking token budget state; applied after penalties at sample time."""
4
+
5
+from typing import TYPE_CHECKING, Any
6
+
7
+import torch
8
+
9
+from vllm.v1.sample.logits_processor.interface import (
10
+ BatchUpdate,
11
+ MoveDirectionality,
12
+)
13
+
14
+if TYPE_CHECKING:
15
+ from vllm.config.reasoning import ReasoningConfig
16
+
17
+
18
+def maybe_create_thinking_budget_state_holder(
19
+ reasoning_config: "ReasoningConfig | None",
20
+ max_num_seqs: int,
21
+ num_spec_tokens: int,
22
+ device: torch.device,
23
+ is_pin_memory: bool,
24
+) -> "ThinkingBudgetStateHolder | None":
25
+ if reasoning_config is None:
26
+ return None
27
+ return ThinkingBudgetStateHolder(
28
+ reasoning_config, max_num_seqs, num_spec_tokens, device, is_pin_memory
29
+ )
30
+
31
+
32
+class ThinkingBudgetStateHolder:
33
+ """Tracks thinking sections and forces end tokens when budget is exceeded."""
34
+
35
+ think_start_token_ids: list[int]
36
+ think_end_token_ids: list[int]
37
+
38
+ def __init__(
39
+ self,
40
+ reasoning_config: "ReasoningConfig | None",
41
+ max_num_seqs: int,
42
+ num_spec_tokens: int,
43
+ device: torch.device,
44
+ is_pin_memory: bool,
45
+ ):
46
+ _ = is_pin_memory # API parity with logits processors
47
+ max_num_reqs = max_num_seqs
48
+ self.in_spec_mode = num_spec_tokens > 0
49
+ self.num_spec_tokens = num_spec_tokens
50
+
51
+ # No separate enable flag: a non-``None`` ``reasoning_config`` is the switch.
52
+ self.is_enabled = reasoning_config is not None
53
+
54
+ if reasoning_config is None:
55
+ self.think_start_token_ids = []
56
+ self.think_end_token_ids = []
57
+ else:
58
+ rs = reasoning_config.reasoning_start_token_ids
59
+ re = reasoning_config.reasoning_end_token_ids
60
+ self.think_start_token_ids = rs if rs else []
61
+ self.think_end_token_ids = re if re else []
62
+
63
+ self.device = device
64
+ self._state: dict[int, dict[str, Any]] = {}
65
+ self.cu_num_tokens: dict[int, int] = {}
66
+
67
+ if self.num_spec_tokens > 0:
68
+ self.mask = torch.zeros(
69
+ max_num_reqs * (self.num_spec_tokens + 1),
70
+ dtype=torch.bool,
71
+ device=device,
72
+ )
73
+ self.force_token_ids = torch.full(
74
+ (max_num_reqs * (self.num_spec_tokens + 1),),
75
+ -1,
76
+ dtype=torch.long,
77
+ device=device,
78
+ )
79
+ else:
80
+ self.mask = torch.zeros(max_num_reqs, dtype=torch.bool, device=device)
81
+ self.force_token_ids = torch.full(
82
+ (max_num_reqs,), -1, dtype=torch.long, device=device
83
+ )
84
+
85
+ def has_tracked_requests(self) -> bool:
86
+ """True when ``sync_batch`` has state for a ``thinking_token_budget`` row.
87
+
88
+ Used to decide whether sampling needs output-token rows and spec combining;
89
+ distinct from merely having a holder instance (reasoning may be on with no
90
+ budgeted requests in this batch).
91
+ """
92
+ return bool(self._state)
93
+
94
+ def sync_batch(self, batch_update: BatchUpdate | None) -> None:
95
+ """Add/remove/move per-request state only (no _update_think_state)."""
96
+ if not self.is_enabled or not batch_update:
97
+ return
98
+ for index in batch_update.removed:
99
+ self._state.pop(index, None)
100
+
101
+ for index, params, prompt_tok_ids, output_tok_ids in batch_update.added:
102
+ thinking_token_budget = params.thinking_token_budget
103
+ if thinking_token_budget is not None:
104
+ self._state[index] = self._init_state_entry(
105
+ prompt_tok_ids, thinking_token_budget
106
+ )
107
+ self._state[index]["output_tok_ids"] = output_tok_ids
108
+ self._state[index]["spec_token_ids"] = []
109
+ else:
110
+ self._state.pop(index, None)
111
+
112
+ for i1, i2, direction in batch_update.moved:
113
+ if direction == MoveDirectionality.SWAP:
114
+ state1 = self._state.get(i1)
115
+ state2 = self._state.get(i2)
116
+ if state1 is not None:
117
+ self._state[i2] = state1
118
+ if state2 is not None:
119
+ self._state[i1] = state2
120
+ else:
121
+ state = self._state.pop(i1, None)
122
+ if state is not None:
123
+ self._state[i2] = state
124
+
125
+ def update_state(
126
+ self,
127
+ output_token_ids: list[list[int]],
128
+ spec_token_ids: list[list[int]] | None,
129
+ repeat_indices: torch.Tensor | None = None,
130
+ ) -> None:
131
+ """Refresh output/spec from sampling rows and recompute think state."""
132
+ if not self.is_enabled or not self._state:
133
+ return
134
+
135
+ spec_lists = spec_token_ids or []
136
+ last_row_for_req: dict[int, int] | None = None
137
+ if repeat_indices is not None:
138
+ last_row_for_req = {}
139
+ rpt = repeat_indices.cpu().tolist()
140
+ for batch_row, req_i in enumerate(rpt):
141
+ last_row_for_req[req_i] = batch_row
142
+
143
+ for seq_idx, state in list(self._state.items()):
144
+ if last_row_for_req is not None:
145
+ output_row: int | None = last_row_for_req.get(seq_idx)
146
+ if output_row is None or output_row >= len(output_token_ids):
147
+ continue
148
+ state["output_tok_ids"] = output_token_ids[output_row]
149
+ elif seq_idx >= len(output_token_ids):
150
+ continue
151
+ else:
152
+ state["output_tok_ids"] = output_token_ids[seq_idx]
153
+ if seq_idx < len(spec_lists):
154
+ state["spec_token_ids"] = list(spec_lists[seq_idx])
155
+ else:
156
+ state["spec_token_ids"] = []
157
+ state["in_spec_mode"] = self.in_spec_mode
158
+ state["force_index"] = []
159
+ if len(state["output_tok_ids"]) > 0:
160
+ spec_len = len(state["spec_token_ids"])
161
+ # Only strip draft suffix when there are spec tokens; ``[:-0]`` would
162
+ # clear the whole list (Python treats stop index 0 as "up to empty").
163
+ if spec_len > 0 and len(state["output_tok_ids"]) >= spec_len:
164
+ state["output_tok_ids"] = state["output_tok_ids"][:-spec_len]
165
+ self._update_think_state(state)
166
+
167
+ def apply_to_logits(
168
+ self,
169
+ logits: torch.Tensor,
170
+ predict_bonus_token: bool,
171
+ spec_token_ids: list[list[int]] | None,
172
+ ) -> torch.Tensor:
173
+ """Mask and bump logits for forced end-of-thinking tokens."""
174
+ if not self.is_enabled or not self._state:
175
+ return logits
176
+ spec_lists = spec_token_ids or []
177
+ return self._apply_forcing_to_logits(logits, predict_bonus_token, spec_lists)
178
+
179
+ @staticmethod
180
+ def _find_last_sequence_index(target_list: list[int], token_ids: list[int]) -> int:
181
+ if not token_ids:
182
+ return -1
183
+ for i in range(len(target_list) - len(token_ids), -1, -1):
184
+ if target_list[i : i + len(token_ids)] == token_ids:
185
+ return i
186
+ return -1
187
+
188
+ def _init_state_entry(
189
+ self, prompt_tok_ids: list[int] | None, thinking_token_budget: int
190
+ ) -> dict[str, Any]:
191
+ if prompt_tok_ids is None:
192
+ last_start = -1
193
+ last_end = -1
194
+ in_think = False
195
+ think_count = 0
196
+ start_thinking = -1
197
+ countdown = thinking_token_budget
198
+ continue_thinking = False
199
+ in_end = False
200
+ else:
201
+ start_thinking = -1
202
+ countdown = thinking_token_budget
203
+ continue_thinking = False
204
+ in_end = False
205
+ last_start = self._find_last_sequence_index(
206
+ prompt_tok_ids, self.think_start_token_ids
207
+ )
208
+ last_end = self._find_last_sequence_index(
209
+ prompt_tok_ids, self.think_end_token_ids
210
+ )
211
+ in_think = last_start > last_end
212
+ # load metrics such as think count, start thinking
213
+ # if request is in thinking mode, already
214
+ if in_think:
215
+ think_count = len(prompt_tok_ids) - (
216
+ last_start + len(self.think_start_token_ids)
217
+ )
218
+ start_thinking = len(prompt_tok_ids) - think_count - 1
219
+ countdown -= think_count
220
+ continue_thinking = True
221
+ # check if the token is exhausted within prompt
222
+ token_exhausted = thinking_token_budget - think_count
223
+ in_end = token_exhausted <= 0
224
+ else:
225
+ think_count = 0
226
+
227
+ return {
228
+ "in_think": in_think,
229
+ "in_end": in_end,
230
+ "check_count_down": countdown,
231
+ "think_count": think_count,
232
+ "end_count": 0,
233
+ "prompt_tok_ids": prompt_tok_ids,
234
+ "output_tok_ids": [],
235
+ "thinking_token_budget": thinking_token_budget,
236
+ "prev_output_length": 0,
237
+ "spec_token_ids": [],
238
+ "force_index": [],
239
+ "start_thinking": start_thinking,
240
+ "end_thinking": -1,
241
+ "in_spec_mode": False,
242
+ "bonus_token_forced": False,
243
+ "continue_thinking": continue_thinking,
244
+ }
245
+
246
+ def _update_think_state(self, state: dict[str, Any]) -> None:
247
+ if state.get("thinking_token_budget", -1) == -1:
248
+ return
249
+ if len(self.think_end_token_ids) == 0:
250
+ state["thinking_token_budget"] = -1
251
+ state["in_end"] = False
252
+ state["force_index"] = []
253
+ return
254
+
255
+ if state["start_thinking"] == -1:
256
+ start_thinking = self._find_last_sequence_index(
257
+ state.get("output_tok_ids", []), self.think_start_token_ids
258
+ )
259
+ state["start_thinking"] = start_thinking
260
+ if state["end_thinking"] == -1:
261
+ end_thinking = self._find_last_sequence_index(
262
+ state.get("output_tok_ids", []), self.think_end_token_ids
263
+ )
264
+ state["end_thinking"] = end_thinking
265
+
266
+ if state["start_thinking"] == -1:
267
+ return
268
+
269
+ if state["continue_thinking"]:
270
+ sampled_tokens_from_previous_step = len(
271
+ state.get("output_tok_ids", [])
272
+ ) - state.get("prev_output_length", 0)
273
+ else:
274
+ if state["prev_output_length"] == 0:
275
+ sampled_tokens_from_previous_step = len(
276
+ state.get("output_tok_ids", [])
277
+ ) - len(self.think_start_token_ids)
278
+ else:
279
+ sampled_tokens_from_previous_step = (
280
+ len(state.get("output_tok_ids", [])) - state["prev_output_length"]
281
+ )
282
+ current_step_countdown = (
283
+ state["check_count_down"] - sampled_tokens_from_previous_step
284
+ )
285
+ predicted_countdown = current_step_countdown - len(state["spec_token_ids"]) - 1
286
+ # We only proceed further if we have counted down the thinking budget
287
+ # to 0 or less and when we are in the "in think" mode.
288
+ if (
289
+ not state.get("in_end", False)
290
+ and predicted_countdown >= 0
291
+ and state["start_thinking"] > -1
292
+ ):
293
+ state["check_count_down"] = current_step_countdown
294
+ state["prev_output_length"] = len(state.get("output_tok_ids", []))
295
+ return
296
+ output = state.get("output_tok_ids", [])
297
+ if not output:
298
+ # When in_end was set at init (budget=0, prompt already in think),
299
+ # we must force the first generated token to be the end token;
300
+ # otherwise apply() sees in_end=True but force_index=[] and
301
+ # allows an extra thinking token.
302
+ if state.get("in_end", False):
303
+ state["force_index"] = [0]
304
+ return
305
+
306
+ # Track previous output length for incremental processing
307
+ prev_length = state.get("prev_output_length", 0)
308
+ current_length = len(output)
309
+
310
+ if current_length <= prev_length:
311
+ if state.get("in_end", False):
312
+ remaining_budget = state["thinking_token_budget"] - state["think_count"]
313
+ spec_len = len(state["spec_token_ids"])
314
+ if spec_len > 0:
315
+ if 0 < remaining_budget < spec_len:
316
+ state["force_index"] = [remaining_budget]
317
+ elif remaining_budget <= 0:
318
+ state["force_index"] = [0]
319
+ else:
320
+ state["force_index"] = [spec_len]
321
+ else:
322
+ state["force_index"] = [0]
323
+ return
324
+
325
+ state["prev_output_length"] = current_length
326
+
327
+ start_len = len(self.think_start_token_ids)
328
+ absolute_start_pos = state["start_thinking"]
329
+
330
+ if state["continue_thinking"] and state["end_thinking"] > -1:
331
+ absolute_end_pos = state["end_thinking"] + len(
332
+ state.get("prompt_tok_ids") or []
333
+ )
334
+ else:
335
+ absolute_end_pos = state["end_thinking"]
336
+ # Update state based on recent sequences
337
+ # This is the case where we are in end mode, but the rejection sampler
338
+ # rejected a token before the end token,
339
+ # so we need to go back to think mode and wait for the next end token
340
+ # eg with 999: [2,4,5,999] -> [3,-1,-1,-1]
341
+ if state["in_end"] and state["end_count"] == 0:
342
+ new_tokens = output[prev_length:]
343
+ stopping_thinking = (
344
+ self.think_end_token_ids[state["end_count"]] in new_tokens
345
+ )
346
+ if not stopping_thinking:
347
+ state["in_think"] = True
348
+ state["in_end"] = False
349
+ state["end_count"] = 0
350
+ state["bonus_token_forced"] = False
351
+
352
+ if not state["in_end"]:
353
+ if absolute_start_pos >= 0 and absolute_end_pos >= 0:
354
+ # Case: ...<end>...<start>... - entering think mode
355
+ if absolute_start_pos > absolute_end_pos:
356
+ new_think_count = current_length - (absolute_start_pos + start_len)
357
+ state["in_think"] = True
358
+ state["think_count"] = new_think_count
359
+ else:
360
+ # Case: ...<start>...<end>... - exiting think mode
361
+ state["in_think"] = False
362
+ state["think_count"] = 0
363
+
364
+ elif absolute_start_pos >= 0 and not state["continue_thinking"]:
365
+ # Found think start - entering think mode
366
+ new_think_count = current_length - (absolute_start_pos + start_len)
367
+ state["in_think"] = True
368
+ state["think_count"] = new_think_count
369
+
370
+ elif absolute_end_pos >= 0:
371
+ # Found think end - exiting think mode
372
+ state["in_think"] = False
373
+ state["think_count"] = 0
374
+
375
+ elif state["in_think"]:
376
+ # Continue thinking mode, increment count by new tokens
377
+ prompt_tok_ids = state.get("prompt_tok_ids") or []
378
+ think_tokens_in_prompt = len(prompt_tok_ids) - (
379
+ absolute_start_pos + start_len
380
+ )
381
+ state["think_count"] = (
382
+ len(state["output_tok_ids"]) + think_tokens_in_prompt
383
+ )
384
+ if state["in_think"]:
385
+ remaining_budget = max(
386
+ 0, state["thinking_token_budget"] - state["think_count"]
387
+ )
388
+ state["check_count_down"] = remaining_budget
389
+ else:
390
+ state["check_count_down"] = state["thinking_token_budget"]
391
+
392
+ total_thinking_tokens = (
393
+ state["think_count"] + len(state["spec_token_ids"]) + 1
394
+ )
395
+ # Check if need to transition to end mode
396
+ # If we have more thinking tokens than the budget,
397
+ # we need to transition to end mode
398
+ if (
399
+ state["in_think"]
400
+ and total_thinking_tokens > state["thinking_token_budget"]
401
+ ):
402
+ # Calculate force_index: position within spec_token_ids where
403
+ # forcing starts. If we're already over budget without spec
404
+ # tokens, force from position 0. Force from the position
405
+ # where budget is exceeded.
406
+ state["in_think"] = False
407
+ state["in_end"] = True
408
+ state["end_count"] = 0
409
+ state["check_count_down"] = state["thinking_token_budget"]
410
+ remaining_budget = state["thinking_token_budget"] - state["think_count"]
411
+ spec_len = len(state["spec_token_ids"])
412
+ if 0 < remaining_budget < spec_len:
413
+ state["force_index"] = [remaining_budget]
414
+
415
+ elif remaining_budget <= 0:
416
+ state["force_index"] = [0]
417
+
418
+ else:
419
+ # remaining_budget >= spec_len: all spec tokens are within
420
+ # budget; force the bonus token position
421
+ state["force_index"] = [len(state["spec_token_ids"])]
422
+
423
+ else:
424
+ state["force_index"] = []
425
+ if len(state["spec_token_ids"]) > 0:
426
+ for i, token_id in enumerate(state["spec_token_ids"]):
427
+ if state["end_count"] + 1 < len(self.think_end_token_ids):
428
+ if token_id == self.think_end_token_ids[state["end_count"] + 1]:
429
+ state["end_count"] += 1
430
+ else:
431
+ state["end_count"] += 1
432
+ state["force_index"] = [i]
433
+ break
434
+ else:
435
+ state["end_count"] += 1
436
+ if len(state["force_index"]) == 0:
437
+ state["end_count"] += 1
438
+ state["force_index"] = [len(state["spec_token_ids"])]
439
+ else:
440
+ state["end_count"] += 1
441
+ state["force_index"] = [0]
442
+ if state["end_count"] >= len(self.think_end_token_ids):
443
+ state.update(
444
+ {
445
+ "in_end": False,
446
+ "end_count": 0,
447
+ "check_count_down": state["thinking_token_budget"],
448
+ }
449
+ )
450
+
451
+ def _apply_forcing_to_logits(
452
+ self,
453
+ logits: torch.Tensor,
454
+ predict_bonus_token: bool,
455
+ spec_token_ids_for_layout: list[list[int]],
456
+ ) -> torch.Tensor:
457
+ self.mask[:] = False
458
+ cumulative_total = 0
459
+ self.cu_num_tokens.clear()
460
+
461
+ n_layout = len(spec_token_ids_for_layout)
462
+ if self._state:
463
+ n_layout = max(n_layout, max(self._state.keys()) + 1)
464
+
465
+ for index in range(n_layout):
466
+ self.cu_num_tokens[index] = cumulative_total
467
+ spec_tokens = (
468
+ spec_token_ids_for_layout[index]
469
+ if index < len(spec_token_ids_for_layout)
470
+ else []
471
+ )
472
+ if self.in_spec_mode:
473
+ cumulative_total += len(spec_tokens) if not predict_bonus_token else 1
474
+ else:
475
+ cumulative_total += 1
476
+
477
+ for seq_idx in sorted(self._state.keys()):
478
+ if seq_idx not in self.cu_num_tokens:
479
+ continue
480
+ state = self._state[seq_idx]
481
+ if state.get("in_end", False):
482
+ # logits processor in spec mode are called twice
483
+ # once for bonus token logits and
484
+ # second time for the target logits
485
+ # in case the force index is bonus token index
486
+ # we change the force index to 0
487
+ if predict_bonus_token:
488
+ if state.get("force_index") and state["force_index"][0] < len(
489
+ state["spec_token_ids"]
490
+ ):
491
+ continue
492
+ else:
493
+ state["force_index"] = [0]
494
+ # continue enforcing the end thinking tokens
495
+ if state["end_count"] > 0:
496
+ state["bonus_token_forced"] = False
497
+ if state and not state["bonus_token_forced"]:
498
+ force_index = state.get("force_index", [])
499
+ if len(force_index) == 0:
500
+ continue
501
+ end_count = state.get("end_count", 0)
502
+ for force_idx in force_index:
503
+ if end_count < len(self.think_end_token_ids):
504
+ mask_idx = self.cu_num_tokens[seq_idx] + force_idx
505
+ if mask_idx < len(self.mask) and mask_idx < logits.shape[0]:
506
+ self.mask[mask_idx] = True
507
+ self.force_token_ids[mask_idx] = (
508
+ self.think_end_token_ids[end_count]
509
+ )
510
+ if predict_bonus_token:
511
+ if state["end_count"] > 0:
512
+ state["bonus_token_forced"] = False
513
+ state["force_index"] = []
514
+ else:
515
+ state["bonus_token_forced"] = True
516
+
517
+ has_active_thinking = any(
518
+ state.get("in_end", False) for state in self._state.values()
519
+ )
520
+
521
+ if has_active_thinking:
522
+ active_indices = self.mask.nonzero(as_tuple=False).view(-1)
523
+
524
+ if len(active_indices) > 0:
525
+ force_tokens = self.force_token_ids[active_indices]
526
+ logits[active_indices, force_tokens] = 1e9
527
+
528
+ return logits
vllm/v1/worker/gpu_input_batch.py
+30
-2
@@ -8,6 +8,7 @@ from typing import cast
8
import numpy as np
9
import torch
10
11
+from vllm.config.reasoning import ReasoningConfig
12
from vllm.lora.request import LoRARequest
13
from vllm.multimodal.inputs import MultiModalFeatureSpec
14
from vllm.pooling_params import PoolingParams
@@ -22,6 +23,9 @@ from vllm.v1.sample.logits_processor import (
23
MoveDirectionality,
24
)
25
from vllm.v1.sample.metadata import SamplingMetadata
26
+from vllm.v1.sample.thinking_budget_state import (
27
+ maybe_create_thinking_budget_state_holder,
28
+)
29
from vllm.v1.utils import copy_slice
30
from vllm.v1.worker.block_table import MultiGroupBlockTable
31
@@ -92,12 +96,20 @@ class InputBatch:
96
max_num_blocks_per_req: list[int] | None = None,
97
logitsprocs: LogitsProcessors | None = None,
98
logitsprocs_need_output_token_ids: bool = False,
95
- is_spec_decode: bool = False,
99
+ num_spec_tokens: int = 0,
100
is_pooling_model: bool = False,
101
cp_kv_cache_interleave_size: int = 1,
102
+ reasoning_config: ReasoningConfig | None = None,
103
):
104
+ self.thinking_budget_state_holder = maybe_create_thinking_budget_state_holder(
105
+ reasoning_config,
106
+ max_num_reqs,
107
+ num_spec_tokens,
108
+ device,
109
+ pin_memory,
110
+ )
111
+ self.thinking_token_budget_reqs: set[str] = set()
112
self.is_pooling_model = is_pooling_model
100
- self.is_spec_decode = is_spec_decode
113
self.max_num_reqs = max_num_reqs
114
self.max_model_len = max_model_len
115
self.max_num_batched_tokens = max_num_batched_tokens
@@ -540,6 +552,7 @@ class InputBatch:
552
# False means we don't fill with -inf.
553
self.allowed_token_ids_mask_cpu_tensor[req_index].fill_(False)
554
self.bad_words_token_ids.pop(req_index, None)
555
+ self.thinking_token_budget_reqs.discard(req_id)
556
return req_index
557
558
def swap_states(self, i1: int, i2: int) -> None:
@@ -800,6 +813,8 @@ class InputBatch:
813
# reset batch update tracking.
814
# Update sampling metadata if batch state is changed.
815
batch_update = self.batch_update_builder.get_and_reset(self.num_reqs)
816
+ if self.thinking_budget_state_holder is not None and batch_update:
817
+ self.thinking_budget_state_holder.sync_batch(batch_update)
818
for logit_proc in self.logitsprocs.all:
819
logit_proc.update_state(batch_update)
820
if batch_update:
@@ -853,10 +868,15 @@ class InputBatch:
868
869
# Only set output_token_ids if required by the current requests'
870
# sampling parameters.
871
+ holder = self.thinking_budget_state_holder
872
+ thinking_budget_tracks_reqs = (
873
+ holder is not None and holder.has_tracked_requests()
874
+ )
875
needs_output_token_ids = (
876
not self.no_penalties
877
or bool(self.bad_words_token_ids)
878
or self.logitsprocs_need_output_token_ids
879
+ or not thinking_budget_tracks_reqs
880
)
881
output_token_ids = (
882
cast(list[list[int]], self.req_output_token_ids)
@@ -902,6 +922,7 @@ class InputBatch:
922
allowed_token_ids_mask=allowed_token_ids_mask,
923
bad_words_token_ids=self.bad_words_token_ids,
924
logitsprocs=self.logitsprocs,
925
+ thinking_budget_state_holder=self.thinking_budget_state_holder,
926
)
927
928
def get_pooling_params(self) -> list[PoolingParams]:
@@ -1076,6 +1097,13 @@ class InputBatch:
1097
and len(self.repetition_penalties_reqs) == 0
1098
)
1099
1100
+ @property
1101
+ def no_thinking_budget(self) -> bool:
1102
+ return (
1103
+ self.thinking_budget_state_holder is None
1104
+ or len(self.thinking_token_budget_reqs) == 0
1105
+ )
1106
+
1107
@property
1108
def max_num_logprobs(self) -> int | None:
1109
return max(self.num_logprobs.values()) if self.num_logprobs else None
vllm/v1/worker/gpu_model_runner.py
+4
-2
@@ -629,7 +629,7 @@ class GPUModelRunner(
629
vocab_size=self.model_config.get_vocab_size(),
630
block_sizes=[placeholder_block_size],
631
kernel_block_sizes=[placeholder_block_size],
632
- is_spec_decode=bool(self.vllm_config.speculative_config),
632
+ num_spec_tokens=self.num_spec_tokens,
633
logitsprocs=build_logitsprocs(
634
self.vllm_config,
635
self.device,
@@ -645,6 +645,7 @@ class GPUModelRunner(
645
or self.vllm_config.reasoning_config is not None,
646
is_pooling_model=self.is_pooling_model,
647
cp_kv_cache_interleave_size=self.parallel_config.cp_kv_cache_interleave_size,
648
+ reasoning_config=self.vllm_config.reasoning_config,
649
)
650
651
# Separate cuda stream for overlapping transfer of sampled token ids from
@@ -6504,10 +6505,11 @@ class GPUModelRunner(
6505
block_sizes=block_sizes,
6506
kernel_block_sizes=kernel_block_sizes,
6507
max_num_blocks_per_req=max_num_blocks,
6507
- is_spec_decode=bool(self.vllm_config.speculative_config),
6508
+ num_spec_tokens=self.num_spec_tokens,
6509
logitsprocs=self.input_batch.logitsprocs,
6510
logitsprocs_need_output_token_ids=self.input_batch.logitsprocs_need_output_token_ids,
6511
is_pooling_model=self.is_pooling_model,
6512
+ reasoning_config=self.vllm_config.reasoning_config,
6513
)
6514
6515
assert self._init_block_sizes == block_sizes, (