Repository navigation
[Bugfix][Parser] Parse closed Qwen3 tool parameters once while streaming - #64675
Merged
sfeng33 merged 2 commits intoOct 31, 2026
Merged
Conversation
Every streaming conversion of Qwen3 XML tool arguments re-scanned the whole argument text, twice: once for the closed parameters and once to strip them before finding the open one. The value extender scanned it twice more. A model looping on closed parameters inside one call made the API server's cost per delta grow with the call until the event loop starved and liveness probes failed. Cache the closed parameters per parser instance, as Glm47MoeParser does, so each conversion and extender call resumes after the last closed parameter. Qwen3Parser and the parsers that inherit its converter (Nemotron-3, Step-3.5, Step5, Seed-OSS, MiniCPM-V, Granite thinking) share the change. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Maroon Ayoub <mayoub@redhat.com>
vMaroon
requested review from
aarnphm,
bbrowning,
chaunceyjiang and
sfeng33
as code owners
October 30, 2026 06:26
smiyamoto3
approved these changes
Oct 30, 2026
smiyamoto3
left a comment
There was a problem hiding this comment.
LGTM. Checked that it mirrors the already merged #64535 for GLM, and the output looks fine.
Member
|
/ci run |
sfeng33
enabled auto-merge (squash)
October 30, 2026 22:20
|
✅ Triggered Buildkite CI #98828 for commit |
he-yufeng
added a commit
to he-yufeng/vllm
that referenced
this pull request
Nov 1, 2026
…n the post-vllm-project#64675 parser The model can close the call without closing the last parameter. The final conversion (partial False) must still capture that trailing value; the old inline capture was removed by the vllm-project#64675 refactor into _qwen3_parse_params, where the open trailing parameter was only gathered when partial was true. Drop that gate so the trailing parameter survives both the streamed partial conversions and the final one. Red-green on tests/parser/engine/test_qwen3.py: the three TestUnclosedLastParameter cases fail against the un-fixed parser and pass with this change; the full file is 89 passed (CPU, no GPU). This change was prepared with an AI assistant and reviewed line by line by he-yufeng before pushing. Signed-off-by: Yufeng He <40085740+he-yufeng@users.noreply.github.com>
1 task done
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.
Overview
A Qwen3 XML tool call that repeats closed parameters makes
_qwen3_arg_converterre-scan every parameter on each streaming conversion, so the API server's cost per token grows with the call until the event loop saturates and liveness fails. This parses each closed parameter once. It is the Qwen3 counterpart of #64535 (GLM) and covers every parser on the Qwen3 converter:qwen3_coder, Nemotron-3, Step-3.5, Step5, Seed-OSS, MiniCPM-V and Granite thinking.Claims
/healthanswers no probe within 10 s for the last 150 s of the stream. With this PR the API server is 7% busy and/healthp99 is 0.9 ms.MiMoParserkeeps its own converter and is unchanged.Validation
8084f0624dpytest tests/parser/engine/test_qwen3.py::TestArgValueExtender::test_closed_params_are_parsed_onceAssertionError: assert 2051331 == 401pytest tests/parser/engine/test_qwen3.pypytest tests/parser --ignore=tests/parser/mistral/test_tool_calls.py --ignore=tests/parser/test_harmony.pypytest tests/tool_parsers/test_qwen3coder_tool_parser.py tests/tool_parsers/test_step3p5_tool_parser.py tests/tool_parsers/test_structural_tag_registry.py tests/parser/test_parse.py tests/parser/test_streaming.py tests/parser/test_include_reasoning.pyqwen3_coder+nemotron_v3,parse_deltaat chunk sizes 1, 3, 4, 7, 64, plusparseOutputProcessorandchat_completion_stream_generator/healthtimeouts of 10 s/healthp99 0.90 ms, no timeoutUnit tests, equivalence and replays ran on CPU in
vllm-ci-postmerge-repo:074c83a09a0b872cec92e244d91adf2b3c55e41b-arm64-cpuwith the tree onPYTHONPATH. The 93 parser-suite errors are the same set on both: 70 in the three files that need Hugging Face access (the fifth row, run with access), and 23 intests/parser/mistral/test_reasoning.py, which fails the same on main with the image's mistral-common.ruff, the repo'smypyhook and the CI-wiring hook pass.CI coverage:
tests/parserjobs are disabled since #63609, so CI will run the new tests once that job is back.Resumed parse against main
Each case builds two calls' raw arguments from fragments: whole and split
<parameter=...>openers,</parameter>and< / parameter >closers with prefixes of both, repeated names, newlines,>,=, non-ASCII and value text. Each call grows in random 1-9 char pieces through one shared cache. After every piece, the converter with the cache must return main's_qwen3_arg_converteroutput forpartial=Trueandpartial=False, and the extender must beNoneexactly when main's is and then stream the same text for every later piece. Seeds 1-3, 4,000 cases each.Streaming equivalence
Each output is tokenized with the Qwen3 tokenizer and streamed as token-id chunks through
ParserManager.get_parser("qwen3_coder", "nemotron_v3"). Every delta's content, reasoning, tool-call name and arguments are recorded, plus one non-streamingparse. The outputs: content, CJK content, reasoning, an open Python, HTML and CJK value, a parameter loop, a call loop, an open JSON array, a long value under a repeated name, and 9 repeated-name cases (a different, identical, extending or prefix value, a repeat that is not last, a third occurrence restoring the first value, another name after the repeat, an unclosed repeat, a second call).Cost per step and looping stream replay
The stream is
<tool_call>\n<function=Edit>\nfollowed by<parameter=old_string>\ntotal = 0\n</parameter>\n<parameter=new_string>\ntotal = 1\n</parameter>\nrepeated, with the Qwen3-0.6B tokenizer. Cost is the endpoint's time per 4-token engine step (chat_completion_stream_generatorwithqwen3_coderandnemotron_v3, prompt with reasoning closed).The replay feeds the same stream through the streaming parser on a virtual clock: 200 tokens/s in 4-token steps, one
parse_deltaper event-loop pass for every token emitted since the previous pass, measured parser time doubled for a slower server CPU, and kubelet liveness (30 s period, 5 s timeout, 3 failures) replayed on those passes.Server run
vllm serve Qwen/Qwen3-Coder-30B-A3B-Instruct --tool-call-parser qwen3_coder --enable-auto-tool-choicefrom the CUDA CI image of main8084f0624d, one H200 per server, Model Runner V2, no speculative decoding. The PR server runs the same image withvllm/parser/qwen3.pyfirst onPYTHONPATH. A test logits processor forces the token ids of the cost-per-step stream, closed with</function></tool_call>, at temperature 0, so both servers generate the same output; weights, engine, detokenizer, parser and HTTP server are real. The client streams/v1/chat/completionswith anEdittool, probes/healthevery 0.5 s with a 10 s timeout and samples the API server process's CPU time./healthp99, timeouts, stream timeWriteof a Python module/healthtakes over 1 s from 216 s, and from 287 s until the stream ends at 437 s no probe answers within 10 s. A kubelet liveness probe with a 30 s period, 5 s timeout and 3 failures would restart the container by about 380 s, before the call finishes. With 8 streams at 64K chars, no probe answers from 68 s to the end at 193 s.Details
Root cause.
ParserEngine._compute_arg_deltaconverts the whole raw argument text whenever the open-value extender is inactive: between parameters, and while the open parameter repeats an earlier name (the extender needs the open value to be the last JSON value). Qwen3 sets noarg_structural_chars, so every argument delta in those states converts._qwen3_arg_converterruns_PARAM_RE.finditerover every closed parameter, then_PARAM_RE.subover the whole text to find the open one. After each conversion that extends the streamed JSON,_param_value_extenderruns both again.Change.
_qwen3_parse_paramstakes an optional_ClosedParamscache, andQwen3Parserbinds one per instance into its converter and extender aftersuper().__init__, asGlm47MoeParserdoes since [Bugfix][Parser] Parse closed GLM-4.7 tool arguments once while streaming #64535. A text that starts with the cached text resumes after the last closed parameter. Any other text parses from the start, and the cache moves forward whenever a scan closes a new parameter.<parameter=begins (the lookahead in_PARAM_RE), and holds only while that opener follows. So the cached text runs through that opener, and the scan resumes at the end of the value. Appending text cannot change a parameter closed within the cached text, so resuming is exact.test_value_closed_by_next_parameter_needs_that_parametercovers a text that shares the prefix but not the opener.finditer.arg_converterkeeps the uncached path (identity check in__init__), as in [Bugfix][Parser] Parse closed GLM-4.7 tool arguments once while streaming #64535.Cost. What stays linear in the text per conversion is C-level work:
startswithagainst the cached text, the join of text outside the parameters for the open-parameter search, and the engine's own argument concatenation. That is the 10% busy at 818K chars in the replay.Not in this PR. #64535 also set
arg_structural_chars=frozenset(">")for GLM, on the argument that a delta without>cannot close a key or value. That does not hold for Qwen3, where a value closes at<parameter=before any>, and the cache alone passes the replay. Measured with the gate added: 39 us per step at 64K chars for the parameter loop, and 385 us against 1247 us for many distinct parameters.Unchanged here and the same on main.
_param_value_extenderrefuses any repeated name, so each delta converts the whole value (11.7 ms per step at 64K chars, against 17.6 ms on main). Letting it accept a repeated last name, as GLM's extender does, is a separate change.MiMoParserkeeps its own converter with the full scan.Not a duplicate. #55497 (open) narrows the parameter-name group of
_PARAM_REand_PARTIAL_PARAM_RE; the cache works with either pattern and the overlap is textual. #59821 (open) editsQwen3Parser.__init__for tool markup in reasoning; whichever lands second rebases. #63277 (open) changes zero-argument calls in the tool adapter. No open PR covers re-parsing closed Qwen3 parameters.AI assistance. Claude Code was used for the analysis, the change, the tests and this description.
Pull Request Checklist
I used vLLM's
/pr-checklistskill. (Mandatory for agents, optional for humans).AI assistance was used during the creation of this PR.
Design Fit: Minimizes impact on core components, reuses existing functionality, and justifies added complexity.
Testing and Validation: Validates the change and ensures any added tests are meaningful and reliable, with CI coverage or documented CI resource constraints and validation performed outside CI.
Code Quality and Style: Keeps code and comments clear and concise, and updates relevant documentation and examples.
Pull Request Contents: Includes a brief summary and relevant links, supports claims with evidence, explains root causes and implementation trade-offs, and follows the contributing guide.