sglang backend: pass through thinking_budget + require_reasoning - #12193
Open
pos-ei-don wants to merge 2 commits into
Open
pos-ei-don wants to merge 2 commits into
pos-ei-don wants to merge 2 commits into
Conversation
sglang's raw Engine.async_generate() API (which this backend calls directly, bypassing sglang's own OpenAI server) supports a precise, tokenizer-derived reasoning-length budget via sampling_params["custom_params"]["thinking_budget"] plus require_reasoning=True, gated behind --enable-strict-thinking. Neither was reachable through LocalAI: this backend built sampling_params only from a fixed field mapping (temperature, top_p, ...) with no custom_params key, and never passed require_reasoning to async_generate at all. - LoadModel now reads a model-level "thinking_budget" option (same mechanism as the existing tool_parser/reasoning_parser options), and _build_sampling_params adds it as custom_params.thinking_budget on every request when configured. - _new_reasoning_parser already derives, from the rendered prompt, whether the model's chat template pre-opened a reasoning block (Qwen3-style templates append <think> to the prompt instead of letting the model emit it) -- the same signal sglang's own OpenAI server computes from per-template config to decide require_reasoning. This backend has no template manager, so it now returns that signal too and _predict forwards it to async_generate(require_reasoning=...). Verified against production (NVFP4, sm_121, Qwen3.6-35B-A3B) via a raw Engine.async_generate() call bypassing this backend: 301 reasoning tokens against a 300-token budget, clean completion, ~27s. Not yet verified through this backend's own gRPC path end-to-end (no local CUDA/sglang environment available here) -- existing + new unit tests in test.py cover the pure-Python merge/passthrough logic only. Scope note: require_reasoning is derived only from the existing prompt-suffix heuristic, not sglang's full per-template _get_reasoning_from_request decision tree (minimax-m3/hunyuan special cases etc.) -- this backend has no template manager to evaluate that tree against, and the prompt-suffix check is the one heuristic already validated in this file (test_reasoning_parser_forced_when_template_prefills_think_tag). Signed-off-by: pos-ei-don <1822533+pos-ei-don@users.noreply.github.com>
A model YAML can already carry "parameters: reasoning_effort:", but that
value only reaches this backend when a *caller* sets it per request (the Go
side turns it into Metadata["enable_thinking"]). As a model-level default it
is silently dropped: a config reading "reasoning_effort: none" still produces
full reasoning on every request, so the config says one thing and the model
does another.
That gap is expensive in practice. On a self-hosted Qwen3.6-35B-A3B the
reasoning phase consumed the entire max_tokens budget before any content was
produced - 90% of code completions came back empty at max_tokens=768, and the
server log filled with "backend produced only reasoning, retrying". The
config looked like reasoning was off the whole time.
This adds "reasoning_default:off" (or ":on") on the same model-level
options: mechanism as thinking_budget. A per-request value always wins; the
default only fills in when the request is silent.
Measured on the stack above (sglang 0.5.20, NVFP4, GB10/sm_121) after
applying it:
default (nothing set) -> 0 chars reasoning, 27 tokens
"reasoning_effort": "none" -> 0 chars reasoning, 27 tokens
metadata enable_thinking=true -> capped at the 512-token thinking_budget,
541 tokens total, finish_reason stop
Tests: three cases added to backend/python/sglang/test.py covering the
default, per-request override in both directions, and the unconfigured case
(which must leave the template untouched).
Signed-off-by: pos-ei-don <1822533+pos-ei-don@users.noreply.github.com>
pos-ei-don
force-pushed
the
feat/sglang-thinking-budget-passthrough
branch
from
September 22, 2026 06:05
c211767 to
a0fc6fb
Compare
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Motivation
sglang's raw
Engine.async_generate()API — whichbackend/python/sglang/backend.pycallsdirectly, bypassing sglang's own OpenAI-compatible HTTP server — supports a precise,
tokenizer-derived reasoning-length budget via
sampling_params["custom_params"]["thinking_budget"]plusrequire_reasoning=True, gatedbehind sglang's
--enable-strict-thinkingengine arg (already passable today via thisbackend's existing generic
engine_args:passthrough). Neither of the two request-side pieceswas reachable through LocalAI:
_build_sampling_params()only builds from a fixed proto-field mapping (temperature, top_p,…) — there was no way to add a
custom_paramskey at all._predict()never passedrequire_reasoningtoasync_generate(), so even with a budgetset, sglang's strict-thinking grammar backend wouldn't know a request is inside a reasoning
block.
This came up wiring a self-hosted Qwen3.6-35B-A3B onto sglang: without this, the only way to
bound reasoning length is
reasoning_effort(a blunt LocalAI-side workaround, does not reachthe backend at all today) or an unbounded/hardcoded-token-ID mechanism
(
Qwen3ThinkingBudgetLogitProcessor) that's wrong for current Qwen3.x vocabularies.Change
LoadModelreads a new model-levelthinking_budgetoption, using the exact same mechanismalready used for
tool_parser/reasoning_parser(per-model YAMLoptions:, not a new protofield).
_build_sampling_params()adds it ascustom_params.thinking_budgeton every requestwhen configured — mirroring how sglang's own
--preferred-sampling-paramsis a server-widedefault rather than a per-request choice.
_new_reasoning_parser()already inspects the rendered prompt to detect whether the chattemplate pre-opened a reasoning block (Qwen3-style templates append
<think>to the promptinstead of the model emitting it) — that's exactly the signal sglang's own OpenAI server
derives per-template to set
require_reasoning. This backend has no template manager, so itnow returns that signal alongside the parser, and
_predictforwards it toasync_generate(require_reasoning=...).Scope note
require_reasoningis derived only from the existing prompt-suffix heuristic, not sglang'sfull per-template
_get_reasoning_from_requestdecision tree (there are model-family specialcases there, e.g. minimax-m3, hunyuan). This backend has no template-manager equivalent to
evaluate that tree against, and the prompt-suffix check is the one heuristic already validated
in this file's own tests. Happy to extend if that gap matters for a concrete model.
Verification
Qwen3.6-35B-A3B,
--enable-strict-thinking --grammar-backend xgrammar) via a rawEngine.async_generate()call bypassing this backend entirely:custom_params: {"thinking_budget": 300}+require_reasoning=True→ 301 reasoning tokens, cleanfinish_reason: stop, ~27s.environment available here to load a real model through LocalAI itself.
backend/python/sglang/test.py: fixed the two existing call sites for_new_reasoning_parser()'s now-tuple return, and added two new tests(
test_thinking_budget_added_to_sampling_params_as_custom_params,test_no_thinking_budget_means_no_custom_params_key) covering the new_build_sampling_params()behavior. Could not run the suite locally (needs sglang/torch);happy to address CI feedback.
Related
Complements sgl-project/sglang#40634 (a
preferred_sampling_params/custom_paramsmerge bugfound while investigating this — fixed upstream in sglang itself, independent of this change).
Dependency note (added 2026-09-22)
This backend does not load at all against sglang ≥ 0.5.20 without #12155
(
ServerArgsmoved from adataclassto amsgspec.Structthere; thedataclasses.fields(ServerArgs)validation inbackend/python/sglang/backend.pyraises
TypeErroron model load, unconditionally). This PR was developed and testedagainst sglang 0.5.20, so it implicitly depends on #12155 (or an equivalent local patch)
being applied first. Rebasing onto #12155 once that merges, or noting the dependency
explicitly, would avoid a maintainer hitting a load failure unrelated to this change.
End-to-end verification (added 2026-09-22)
Verified through this backend's own gRPC path this time (not bypassed): a real LocalAI +
sglang 0.5.20 stack on the same production hardware (NVFP4, GB10/sm_121, Qwen3.6-35B-A3B),
model YAML option
thinking_budget:N, request-levelreasoning_effort. Confirmed thesampling_params passthrough works and the budget is honored (e.g.
thinking_budget:256→~245 reasoning tokens;
thinking_budget:128→ ~123). One operational caveat found whiledoing this:
options:(this backend's model-level config, includingthinking_budget) isread once when the backend process loads a model and is not re-read by LocalAI's
/models/reload(config-only reload) or even a per-model backend restart — only a fullLocalAI process restart re-reads the model YAML and picks up a new
options:value. Not abug in this PR, but worth a doc note for anyone tuning
thinking_budgetiteratively: avalue change needs a full container/process restart to take effect, not just a model
reload.