Skip to content
105 changes: 105 additions & 0 deletions areal/api/cli_args.py
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,16 @@ class GenerationHyperparameters:
)
},
)
sampling_seed: int | None = field(
default=None,
metadata={
"help": "Per-request seed for replayable sampling. On the SGLang backend "
"this is forwarded as sampling_params['sampling_seed'], which SGLang only "
"honors when the server is launched with "
"SGLangConfig.enable_deterministic_inference=True; otherwise it is "
"silently ignored. None (default) sends no seed and changes nothing."
},
)
# NOTE: to add new parameters, please correctly handle them in the `to_openai_args_dict` method.

def new(self, **kwargs):
Expand Down Expand Up @@ -303,6 +313,7 @@ def to_openai_agents_model_settings_dict(
"lora_name", # Not supported by OpenAI
"use_beam_search", # Not supported by OpenAI
"max_tokens", # deprecated by "completions", not used in "responses", should be `max_new_tokens` in "openai-agents"
"sampling_seed", # SGLang-specific; not an OpenAI-compatible parameter

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

OpenAI's Chat Completions and Completions APIs actually support a seed parameter (introduced in late 2023) to make a best effort to sample deterministically.

Instead of marking sampling_seed as an unsupported OpenAI argument, it would be highly beneficial to map it to seed in to_openai_args_dict so that any OpenAI-compatible backends can also leverage this parameter.

You can achieve this by:

  1. Removing "sampling_seed" from _OPENAI_UNSUPPORTED_ARGS.
  2. Adding "sampling_seed": "seed" to the mapping dictionary inside to_openai_args_dict.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

already replied above why this is not a good idea.

}
Comment on lines 313 to 317

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

OpenAI's Chat Completions and Completions APIs actually support a seed parameter for deterministic sampling. Instead of treating sampling_seed as unsupported by OpenAI, we can map it to seed in to_openai_args_dict so that OpenAI-compatible backends can also benefit from deterministic sampling.

To do this:

  1. Remove "sampling_seed" from _OPENAI_UNSUPPORTED_ARGS (as suggested below).
  2. Map "sampling_seed" to "seed" in to_openai_args_dict's mapping dictionary (around line 303):
        mapping = {"n_samples": "n", "sampling_seed": "seed"}
        "lora_name",  # Not supported by OpenAI
        "use_beam_search",  # Not supported by OpenAI
        "max_tokens",  # deprecated by "completions", not used in "responses", should be max_new_tokens in "openai-agents"
    }

@SushantDaga SushantDaga Jul 13, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the suggestion, but I don't think this is safe as proposed. to_openai_args_dict/to_openai_agents_model_settings_dict are outbound-only converters used to hand GenerationHyperparameters to the external openai-agents SDK, they're not part of AReaL's own request-building path. ArealOpenAI.create() builds a fresh GenerationHyperparameters from its own explicit kwargs and never reads sampling_seed/seed back in, so mapping it here wouldn't actually make AReaL's own rollouts seeded.

More importantly, if a mapped seed value ever reached a real OpenAI-compatible endpoint via the openai-agents SDK backed by vLLM, it would hit exactly the problem called out under "Not the vLLM path" in the PR description: vLLM's own per-request seed is stream-based and doesn't survive AReaL's interruption/resumption re-submits, so it wouldn't give the reproducibility guarantee the name implies. sampling_seed here is deliberately scoped to SGLang's own sampling_params['sampling_seed'] + --enable-deterministic-inference mechanism, which is why it's excluded rather than remapped. Happy to revisit once there's a validated vLLM-seed design.


# Workflow-layer flags, not generation arguments. Exclude silently from
Expand Down Expand Up @@ -2140,6 +2151,10 @@ class SGLangConfig:
enable_memory_saver: bool = False
allow_auto_truncate: bool = False
attention_backend: str | None = "fa3"
# Required for per-request GenerationHyperparameters.sampling_seed to be honored
# (SGLang gates sampling_seed on this flag). Also enables SGLang's batch-invariant
# kernels, at their documented throughput cost, which is why this is opt-in.
enable_deterministic_inference: bool = False
enable_multimodal: bool = False
sampling_backend: str | None = None
context_length: int | None = 32768
Expand Down Expand Up @@ -3364,6 +3379,96 @@ def __post_init__(self):
# the engine config. Single source of truth: gconfig.lora_name.
if self.rollout.use_lora and not self.rollout.lora_name:
self.rollout.lora_name = self.gconfig.lora_name
# vLLM has no sampling_seed support (both VLLMBackend and VLLMBridgeBackend
# raise NotImplementedError on the first request); fail here instead, before
# any server launch or model load wastes time on an unsupported config.
# rollout.backend is the current per-engine field, e.g. "vllm:d2t4" or the
# named form "vllm[name]:d2t4" -- confirmed authoritative via
# RolloutController.__init__ parsing config.backend through
# ModelAllocation.from_str, whose grammar allows that optional "[name]", so
# strip it before comparing rather than only splitting on ":".
# Unlike self.sglang below, self.rollout is never None in practice here (the
# `self.rollout.use_lora` check above already dereferences it unconditionally),
# so this ternary is defense-in-depth, not a live guard.
rollout_backend = self.rollout.backend if self.rollout is not None else None
if (
isinstance(rollout_backend, str)
and rollout_backend.split(":")[0].split("[")[0] == "vllm"
and (
self.gconfig.sampling_seed is not None
or self.eval_gconfig.sampling_seed is not None
)
):
raise ValueError(
"gconfig.sampling_seed or eval_gconfig.sampling_seed is set, but "
"rollout.backend is vLLM, which does not support sampling_seed."
)
# NOTE: eval_gconfig is currently only consumed for its n_samples field
# (areal/trainer/rl_trainer.py); no stock eval workflow builds a request from
# eval_gconfig's other fields, so eval_gconfig.sampling_seed does not reach
# SGLang either way. Checked here anyway so this warning stays correct if that
# changes, but do not read "no warning" as "eval_gconfig.sampling_seed works."
# self.sglang is not Optional and the YAML/CLI config loader (OmegaConf
# structured-config validation) rejects `sglang: null`, but direct Python
# construction (PPOConfig(sglang=None, ...), bypassing that loader) is not
# type-checked at runtime, so guard rather than assume non-None here.
sglang_deterministic = (
self.sglang.enable_deterministic_inference
if self.sglang is not None
else False
)
if (

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think there should be a check here: when the flag is enabled, no seed is set, and gconfig.n_samples > 1, all requests fall back to SGLang's default seed 42, sharing the same noise across prompts, leading to diversity collapse within the group.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right, thanks. That confirms the open question at the end of the PR description. I've added the guard in PPOConfig.__post_init__ at this spot.

I made it a ValueError rather than a warning. My reasoning, and the alternative I weighed:

  • This combination (enable_deterministic_inference on, n_samples > 1, no sampling_seed) has no working use. The group's same-prompt rollouts collapse to identical completions and GRPO's advantage goes to zero, so it quietly defeats the flag's own purpose.
  • enable_deterministic_inference is new in this PR and defaults to off, so failing fast can't break any existing config. It's the same fail-fast this PR already does for sampling_seed on the vLLM backend.
  • The alternative was a warning, which would match the existing mean_level='group' + group_size=1 check in cli_args.py (that one warns for a similar group-quantity degeneracy). I went with raising because that check guards a pre-existing surface where a warning avoids breaking someone, whereas this flag doesn't exist until the PR merges, so there's nobody to break. Happy to switch it to a warning if you'd rather keep the two consistent.
  • It only fires for stochastic sampling. Under greedy or temperature == 0 the request builders decode at temperature 0.0, so the group collapses regardless of any seed, and the guard skips that case to avoid a misleading error. It also skips the vLLM backend, since enable_deterministic_inference is SGLang-only and inert on vLLM, so an SGLang-worded error there would just point someone at the wrong subsystem (it reuses the same rollout.backend parse the vLLM guard above already does).

Tests are in tests/test_sampling_seed_deterministic_inference_warning.py: it raises for grouped + deterministic + unseeded, and does not raise with a seed set, at n_samples == 1, under greedy or temperature == 0, or on a vLLM backend.

self.gconfig.sampling_seed is not None
or self.eval_gconfig.sampling_seed is not None
) and not sglang_deterministic:
warnings.warn(
"gconfig.sampling_seed or eval_gconfig.sampling_seed is set but "
"sglang.enable_deterministic_inference is False: SGLang silently "
"ignores per-request sampling_seed unless the server is launched "
"with --enable-deterministic-inference. Rollouts will not be seeded "
"as expected. (Not applicable if you're launching SGLang servers "
"yourself outside this config -- the vLLM case is already rejected "
"above, before this warning can be reached.)",
UserWarning,
stacklevel=2,
)
Comment on lines +3420 to +3434

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The warning only checks self.gconfig.sampling_seed, but self.eval_gconfig can also have sampling_seed set. If self.eval_gconfig.sampling_seed is set without sglang.enable_deterministic_inference being enabled, the evaluation rollouts will silently run without deterministic sampling. We should check both self.gconfig.sampling_seed and self.eval_gconfig.sampling_seed.

Suggested change
if (
self.gconfig.sampling_seed is not None
and not self.sglang.enable_deterministic_inference
):
warnings.warn(
"gconfig.sampling_seed is set but sglang.enable_deterministic_inference "
"is False: SGLang silently ignores per-request sampling_seed unless the "
"server is launched with --enable-deterministic-inference. Rollouts will "
"not be seeded as expected. (Not applicable if you're launching SGLang "
"servers yourself outside this config, or using the vLLM backend, which "
"rejects sampling_seed outright.)",
UserWarning,
stacklevel=2,
)
if (
(self.gconfig.sampling_seed is not None or self.eval_gconfig.sampling_seed is not None)
and not self.sglang.enable_deterministic_inference
):
warnings.warn(
"gconfig.sampling_seed or eval_gconfig.sampling_seed is set but sglang.enable_deterministic_inference "
"is False: SGLang silently ignores per-request sampling_seed unless the "
"server is launched with --enable-deterministic-inference. Rollouts will "
"not be seeded as expected. (Not applicable if you're launching SGLang "
"servers yourself outside this config, or using the vLLM backend, which "
"rejects sampling_seed outright.)",
UserWarning,
stacklevel=2,
)

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed as suggested. Also left a comment noting eval_gconfig is currently only read for .n_samples elsewhere in the codebase, so eval_gconfig.sampling_seed doesn't reach SGLang yet either way, this check is honest about the flag mismatch, not evidence the field does anything.

Comment on lines +3420 to +3434

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Instead of waiting for a runtime NotImplementedError when the first generation request is built, we should validate that sampling_seed is not used with the vllm backend during configuration initialization in PPOConfig.__post_init__. This prevents wasting time and resources (such as starting up servers and loading models) on an unsupported configuration.

        rollout_backend = getattr(self.rollout, "backend", None) if self.rollout is not None else None
        if rollout_backend is not None and rollout_backend is not MISSING and "vllm" in rollout_backend:
            if (
                self.gconfig.sampling_seed is not None
                or self.eval_gconfig.sampling_seed is not None
            ):
                raise ValueError(
                    "gconfig.sampling_seed or eval_gconfig.sampling_seed is set, "
                    "but the vLLM backend does not support sampling_seed."
                )
        if (
            self.gconfig.sampling_seed is not None
            or self.eval_gconfig.sampling_seed is not None
        ) and not sglang_deterministic:
            warnings.warn(
                "gconfig.sampling_seed or eval_gconfig.sampling_seed is set but "
                "sglang.enable_deterministic_inference is False: SGLang silently "
                "ignores per-request sampling_seed unless the server is launched "
                "with --enable-deterministic-inference. Rollouts will not be seeded "
                "as expected. (Not applicable if you're launching SGLang servers "
                "yourself outside this config.)",
                UserWarning,
                stacklevel=2,
            )

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is already handled — PPOConfig.post_init now raises a ValueError when rollout.backend is vLLM and sampling_seed is set (see the check immediately above this warning). This comment was made against an earlier revision of the diff, before that check was added. No further action needed here.

Comment on lines +3424 to +3434

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Since the vLLM backend check above raises a ValueError and terminates execution immediately if sampling_seed is set, the warning's mention of or using the vLLM backend, which rejects sampling_seed outright is unreachable dead code. Removing this part makes the warning message cleaner and more accurate.

            warnings.warn(
                "gconfig.sampling_seed or eval_gconfig.sampling_seed is set but "
                "sglang.enable_deterministic_inference is False: SGLang silently "
                "ignores per-request sampling_seed unless the server is launched "
                "with --enable-deterministic-inference. Rollouts will not be seeded "
                "as expected. (Not applicable if you're launching SGLang servers "
                "yourself outside this config.)",
                UserWarning,
                stacklevel=2,
            )

# Complementary to the warning above (the two never both fire: that one needs
# the flag off, this one needs it on). With deterministic inference ON but no
# per-request seed and n_samples > 1, SGLang gives every seedless request the
# same default seed (42, see sampling_batch_info.py in sglang); its noise is a
# pure function of (seed, position, vocab-index), so a group's same-prompt
# rollouts collapse to identical completions and zero out GRPO's advantage.
# Raise rather than warn (unlike the seed-ignored case above, which is
# harmless): the combination has no working use, and enable_deterministic_
# inference is new here and default-off, so failing fast breaks no existing
# config -- the same fail-fast this class already applies to sampling_seed on
# the vLLM backend. Scoped to stochastic sampling: under greedy or
# temperature == 0 the request builders decode with temperature 0.0 (see
# "0.0 if gconfig.greedy else gconfig.temperature" in
# SGLangBackend.build_generation_request), so the group collapses regardless of
# any seed and a per-request seed would not change it. Also scoped to a non-vLLM
# backend: enable_deterministic_inference is an SGLang-only flag, so on a vLLM
# run it is inert and this SGLang-worded error would misdirect debugging. The
# vLLM guard above only fires when a seed is set, so it never covers this
# unset-seed case; scope it out here, reusing the rollout_backend parsed above.
if (
sglang_deterministic
and self.gconfig.sampling_seed is None
and self.gconfig.n_samples > 1
and not self.gconfig.greedy
and self.gconfig.temperature > 0
and not (
isinstance(rollout_backend, str)
and rollout_backend.split(":")[0].split("[")[0] == "vllm"
)
):
raise ValueError(
"sglang.enable_deterministic_inference is True with gconfig.n_samples "
"> 1 but no gconfig.sampling_seed: SGLang gives every seedless request "
"the same default seed, so the group's same-prompt rollouts collapse to "
"identical completions. Set distinct per-rollout seeds, use "
"n_samples=1, or disable enable_deterministic_inference."
)
super().__post_init__()


Expand Down
3 changes: 3 additions & 0 deletions areal/engine/sglang_remote.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,9 @@ def build_generation_request(
}
if stop:
sample_params["stop"] = stop
if gconfig.sampling_seed is not None:
# Gating relationship: see GenerationHyperparameters.sampling_seed docstring.
sample_params["sampling_seed"] = gconfig.sampling_seed

payload = {
"input_ids": req.input_ids.copy(),
Expand Down
17 changes: 17 additions & 0 deletions areal/engine/vllm_remote.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,23 @@ def build_generation_request(
gconfig = req.gconfig
stop_token_ids = gconfig.stop_token_ids

if gconfig.sampling_seed is not None:
# No native vLLM equivalent is wired here (vLLM's own per-request `seed`
# is stream-based and would misalign under this engine's
# interruption/resumption re-submits). Fail loudly rather than silently
# dropping the seed and producing non-reproducible rollouts the caller
# believes are seeded; mirrors how SGLangBackend rejects its own
# unsupported use_beam_search (areal/engine/sglang_remote.py).
# Note: PPOConfig.__post_init__ validates this combination once at
# config-construction time. A seed set dynamically per-request after
# that (e.g. by a future per-rollout seed-minting workflow) still raises
# here, but on the async rollout path this exception is caught
# generically further up the call stack and surfaces as a rejected
# rollout, not a hard crash.
raise NotImplementedError(
"sampling_seed is not yet supported on the vLLM backend."
)

# NOTE: vLLM uses flat payload structure, not nested sampling_params
payload = {
"top_p": gconfig.top_p,
Expand Down
3 changes: 3 additions & 0 deletions areal/v2/inference_service/sglang/bridge.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,9 @@ def build_generation_request(
}
if gconfig.stop:
sampling_params["stop"] = gconfig.stop
if gconfig.sampling_seed is not None:
# Gating relationship: see GenerationHyperparameters.sampling_seed docstring.
sampling_params["sampling_seed"] = gconfig.sampling_seed

payload: dict[str, Any] = {
"input_ids": list(req.input_ids),
Expand Down
15 changes: 15 additions & 0 deletions areal/v2/inference_service/vllm/bridge.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,21 @@ def build_generation_request(
"""Build a ``/v1/completions`` or ``/v1/chat/completions`` request."""
gconfig = req.gconfig

if gconfig.sampling_seed is not None:
# No native vLLM equivalent is wired here; mirrors
# areal.engine.vllm_remote.VLLMBackend's rejection -- fail loudly rather
# than silently drop the seed and produce non-reproducible rollouts the
# caller believes are seeded.
# Note: PPOConfig.__post_init__ validates this combination once at
# config-construction time. A seed set dynamically per-request after
# that (e.g. by a future per-rollout seed-minting workflow) still raises
# here, but on the async rollout path this exception is caught
# generically further up the call stack and surfaces as a rejected
# rollout, not a hard crash.
raise NotImplementedError(
"sampling_seed is not yet supported on the vLLM backend."
)

# Compute effective max_new_tokens (cap by remaining context window)
max_new_tokens = min(
gconfig.max_tokens - len(req.input_ids),
Expand Down
Loading