Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
117 changes: 117 additions & 0 deletions tests/v1/sample/test_reasoning_temperature.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Unit tests for the reasoning/answer temperature split on SamplingParams.

These lock in the contract the InputBatch split gate relies on: an unset
``reasoning_temperature`` must mean "mirror temperature" (no split, no
overhead), while a set, differing value opts the request into phase-dependent
sampling.
"""

from unittest.mock import Mock

import pytest

from vllm.sampling_params import _MAX_TEMP, SamplingParams, SamplingType


def test_default_is_unset():
"""An unspecified reasoning_temperature stays None (the no-split sentinel),
so an ordinary greedy request is not pushed onto the split path."""
params = SamplingParams(temperature=0.0)
assert params.reasoning_temperature is None
# Plain greedy request remains greedy and gets the greedy top_p/k reset.
assert params.sampling_type == SamplingType.GREEDY
assert params.top_p == 1.0
assert params.top_k == 0
assert params.min_p == 0.0


def test_from_optional_passes_none_through():
"""from_optional must not coerce an unset value to a concrete float;
coercing to 1.0 was what made the split fire for nearly every request."""
params = SamplingParams.from_optional(temperature=0.7)
assert params.reasoning_temperature is None


@pytest.mark.parametrize("reasoning_temperature", [0.0, 0.7, 1.5])
def test_split_is_set_when_specified(reasoning_temperature):
params = SamplingParams.from_optional(
temperature=0.0, reasoning_temperature=reasoning_temperature
)
assert params.reasoning_temperature == reasoning_temperature


def test_greedy_reset_skipped_when_reasoning_is_random():
"""temperature=0 with a stochastic reasoning_temperature must NOT collapse
top_p/top_k/min_p, since the reasoning phase still samples randomly."""
params = SamplingParams(
temperature=0.0,
reasoning_temperature=0.7,
top_p=0.5,
top_k=20,
min_p=0.1,
)
assert params.top_p == 0.5
assert params.top_k == 20
assert params.min_p == 0.1


def test_sampling_type_uses_reasoning_temperature():
params = SamplingParams(
temperature=0.0,
reasoning_temperature=0.7,
seed=123,
)
assert params.sampling_type == SamplingType.RANDOM_SEED


def test_spec_decode_disables_reasoning_temperature_split():
params = SamplingParams(
temperature=0.0,
reasoning_temperature=0.7,
top_p=0.5,
top_k=20,
min_p=0.1,
)
assert params.sampling_type == SamplingType.RANDOM

params._validate_spec_decode(Mock())

assert params.reasoning_temperature is None
assert params.sampling_type == SamplingType.GREEDY
assert params.top_p == 1.0
assert params.top_k == 0
assert params.min_p == 0.0


def test_greedy_reset_applies_when_both_greedy():
"""temperature=0 and reasoning_temperature=0 is fully greedy and should
reset the nucleus parameters just like a plain greedy request."""
params = SamplingParams(
temperature=0.0,
reasoning_temperature=0.0,
top_p=0.5,
top_k=20,
min_p=0.1,
)
assert params.top_p == 1.0
assert params.top_k == 0
assert params.min_p == 0.0


def test_negative_reasoning_temperature_rejected():
with pytest.raises(ValueError):
SamplingParams(reasoning_temperature=-0.1)


def test_tiny_positive_reasoning_temperature_clamped():
params = SamplingParams(reasoning_temperature=_MAX_TEMP / 2)
assert params.reasoning_temperature == _MAX_TEMP


def test_unset_reasoning_temperature_not_clamped():
"""None must survive __post_init__ untouched (no clamp, no validation
error)."""
params = SamplingParams(temperature=0.0)
assert params.reasoning_temperature is None
12 changes: 11 additions & 1 deletion vllm/entrypoints/openai/chat_completion/protocol.py
Original file line number Diff line number Diff line change
Expand Up @@ -608,16 +608,26 @@ def to_sampling_params(
else replace(self.structured_outputs, **structured_outputs_kwargs)
)

extra_args: dict[str, Any] = self.vllm_xargs if self.vllm_xargs else {}
# Copy so popping known extension params below does not mutate the
# request's ``vllm_xargs`` in place.
extra_args: dict[str, Any] = dict(self.vllm_xargs) if self.vllm_xargs else {}
if self.kv_transfer_params:
# Pass in kv_transfer_params via extra_args
extra_args["kv_transfer_params"] = self.kv_transfer_params

# Extract known extension parameters from extra_args before passing
# rest downstream. Pop them so they don't end up in extra_args.
reasoning_temperature: float | None = extra_args.pop(
"reasoning_temperature", None
)

return SamplingParams.from_optional(
n=self.n,
presence_penalty=self.presence_penalty,
frequency_penalty=self.frequency_penalty,
repetition_penalty=repetition_penalty,
temperature=temperature,
reasoning_temperature=reasoning_temperature,
top_p=top_p,
top_k=top_k,
min_p=min_p,
Expand Down
10 changes: 9 additions & 1 deletion vllm/entrypoints/openai/completion/protocol.py
Original file line number Diff line number Diff line change
Expand Up @@ -308,16 +308,24 @@ def to_sampling_params(
else replace(self.structured_outputs, **structured_outputs_kwargs)
)

extra_args: dict[str, Any] = self.vllm_xargs if self.vllm_xargs else {}
# Copy so popping known extension params below does not mutate the
# request's ``vllm_xargs`` in place.
extra_args: dict[str, Any] = dict(self.vllm_xargs) if self.vllm_xargs else {}
if self.kv_transfer_params:
# Pass in kv_transfer_params via extra_args
extra_args["kv_transfer_params"] = self.kv_transfer_params

reasoning_temperature: float | None = extra_args.pop(
"reasoning_temperature", None
)

return SamplingParams.from_optional(
n=self.n,
presence_penalty=self.presence_penalty,
frequency_penalty=self.frequency_penalty,
repetition_penalty=repetition_penalty,
temperature=temperature,
reasoning_temperature=reasoning_temperature,
top_p=top_p,
top_k=top_k,
min_p=min_p,
Expand Down
9 changes: 8 additions & 1 deletion vllm/entrypoints/openai/responses/protocol.py
Original file line number Diff line number Diff line change
Expand Up @@ -397,12 +397,19 @@ def to_sampling_params(
if isinstance(stop, str):
stop = [stop]

extra_args: dict[str, Any] = self.vllm_xargs if self.vllm_xargs else {}
# Copy so popping known extension params below does not mutate the
# request's ``vllm_xargs`` in place.
extra_args: dict[str, Any] = dict(self.vllm_xargs) if self.vllm_xargs else {}
if self.kv_transfer_params:
extra_args["kv_transfer_params"] = self.kv_transfer_params

reasoning_temperature: float | None = extra_args.pop(
"reasoning_temperature", None
)

return SamplingParams.from_optional(
temperature=temperature,
reasoning_temperature=reasoning_temperature,
top_p=top_p,
top_k=top_k,
max_tokens=max_tokens,
Expand Down
70 changes: 67 additions & 3 deletions vllm/sampling_params.py
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,14 @@ class SamplingParams(
"""Controls the randomness of the sampling. Lower values make the model
more deterministic, while higher values make the model more random. Zero
means greedy sampling."""
reasoning_temperature: float | None = None
"""Temperature used during the reasoning/thinking phase.
When set and different from ``temperature``, reasoning/thinking tokens are
sampled at ``reasoning_temperature`` while the answer is sampled at
``temperature``. For example ``temperature=0.0, reasoning_temperature=0.7``
gives a greedy answer with stochastic reasoning. Leave unset (``None``,
the default) to use ``temperature`` for both phases — no split, no
overhead."""
top_p: float = 1.0
"""Controls the cumulative probability of the top tokens to consider. Must
be in (0, 1]. Set to 1 to consider all tokens."""
Expand Down Expand Up @@ -358,6 +366,7 @@ def from_optional(
frequency_penalty: float | None = 0.0,
repetition_penalty: float | None = 1.0,
temperature: float | None = 1.0,
reasoning_temperature: float | None = None,
top_p: float | None = 1.0,
top_k: int = 0,
min_p: float = 0.0,
Expand Down Expand Up @@ -399,6 +408,7 @@ def from_optional(
if repetition_penalty is None
else repetition_penalty,
temperature=1.0 if temperature is None else temperature,
reasoning_temperature=reasoning_temperature,
top_p=1.0 if top_p is None else top_p,
top_k=top_k,
min_p=min_p,
Expand Down Expand Up @@ -436,6 +446,19 @@ def __post_init__(self) -> None:
)
self.temperature = max(self.temperature, _MAX_TEMP)

if self.reasoning_temperature is not None and (
0 < self.reasoning_temperature < _MAX_TEMP
):
logger.warning(
"reasoning_temperature %s is less than %s, which may cause "
"numerical errors nan or inf in tensors. We have maxed it "
"out to %s.",
self.reasoning_temperature,
_MAX_TEMP,
_MAX_TEMP,
)
self.reasoning_temperature = max(self.reasoning_temperature, _MAX_TEMP)

if self.seed == -1:
self.seed = None

Expand Down Expand Up @@ -467,8 +490,16 @@ def __post_init__(self) -> None:

self._verify_args()

if self.temperature < _SAMPLING_EPS:
# Zero temperature means greedy sampling.
# ``reasoning_temperature is None`` means the reasoning phase mirrors
# ``temperature``, so the request is fully greedy iff ``temperature``
# is. A set, non-greedy reasoning_temperature keeps top_p/top_k/min_p
# alive for the (random) reasoning phase even when the answer is greedy.
reasoning_temp_greedy = (
self.reasoning_temperature is None
or self.reasoning_temperature < _SAMPLING_EPS
)
if self.temperature < _SAMPLING_EPS and reasoning_temp_greedy:
# Both phases are effectively zero -> fully greedy sampling.
self.top_p = 1.0
self.top_k = 0
self.min_p = 0.0
Expand Down Expand Up @@ -514,6 +545,13 @@ def _verify_args(self) -> None:
parameter="temperature",
value=self.temperature,
)
if self.reasoning_temperature is not None and self.reasoning_temperature < 0.0:
raise VLLMValidationError(
f"reasoning_temperature must be non-negative, got "
f"{self.reasoning_temperature}.",
parameter="reasoning_temperature",
value=self.reasoning_temperature,
)
if not 0.0 < self.top_p <= 1.0:
raise VLLMValidationError(
f"top_p must be in (0, 1], got {self.top_p}.",
Expand Down Expand Up @@ -660,7 +698,15 @@ def update_from_tokenizer(self, tokenizer: TokenizerLike) -> None:

@cached_property
def sampling_type(self) -> SamplingType:
if self.temperature < _SAMPLING_EPS:
reasoning_temperature = (
self.temperature
if self.reasoning_temperature is None
else self.reasoning_temperature
)
if (
self.temperature < _SAMPLING_EPS
and reasoning_temperature < _SAMPLING_EPS
):
return SamplingType.GREEDY
if self.seed is not None:
return SamplingType.RANDOM_SEED
Expand Down Expand Up @@ -818,6 +864,23 @@ def _validate_spec_decode(
if speculative_config is None:
return

if (
self.reasoning_temperature is not None
and self.reasoning_temperature != self.temperature
):
logger.warning_once(
"reasoning_temperature is not supported with speculative "
"decoding yet. Ignoring reasoning_temperature and using "
"temperature for all generated tokens."
)
self.reasoning_temperature = None
self.__dict__.pop("sampling_type", None)
if self.temperature < _SAMPLING_EPS:
self.top_p = 1.0
self.top_k = 0
self.min_p = 0.0
self._verify_greedy_sampling()

# Some sampling parameters are not yet compatible with spec decoding.
if self.min_p > _SAMPLING_EPS or self.logit_bias:
raise ValueError(
Expand Down Expand Up @@ -968,6 +1031,7 @@ def __repr__(self) -> str:
f"frequency_penalty={self.frequency_penalty}, "
f"repetition_penalty={self.repetition_penalty}, "
f"temperature={self.temperature}, "
f"reasoning_temperature={self.reasoning_temperature}, "
f"top_p={self.top_p}, "
f"top_k={self.top_k}, "
f"min_p={self.min_p}, "
Expand Down
Loading