Repository navigation
[Bugfix][Parser] Parse closed GLM-4.7 tool arguments once while streaming - #64535
Merged
Merged
Conversation
…ming A GLM tool call that repeats closed argument pairs re-parsed every pair on each conversion, so the API server's cost per token grew with the call. A looping model saturates the event loop within minutes and fails liveness. Skip conversions for deltas without '>' and resume each conversion after the last closed pair from a per-parser cache. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Maroon Ayoub <mayoub@redhat.com>
vMaroon
requested review from
aarnphm,
chaunceyjiang and
sfeng33
as code owners
October 29, 2026 15:08
sfeng33
approved these changes
Oct 29, 2026
sfeng33
left a comment
Member
There was a problem hiding this comment.
Thanks for the work, I tested it end to end on a live server:
Setup: RedHatAI/GLM-5.3-MXFP4xFP8_BLOCK, TP4 on H200, --tool-call-parser glm47 --reasoning-parser glm47. I ran this PR (fef48163f6) and its merge-base (24e80c2614) side by side with the same flags.
GLM-5.3 reproduced the loop on its own: asked for a MultiEdit call, it repeated the closed file_path pair about 1,600 times until max_tokens.
| Looping requests | API server CPU, main | API server CPU, this PR |
|---|---|---|
| 1 | 35% of a core | 3% |
| 8 | 86% | 6% |
| 16 | 93% | 10% |
Member
|
/ci run |
|
✅ @vMaroon, CI is now available for this PR.
|
sfeng33
enabled auto-merge (squash)
October 29, 2026 18:16
|
✅ Triggered Buildkite CI #98409 for commit |
This was referenced Oct 30, 2026
bbrowning
added a commit
to bbrowning/vllm
that referenced
this pull request
Oct 30, 2026
Add ParserEngineConfig.slot_arg_converter, a factory the engine calls once per ToolCallSlot. The object it returns owns both the converter and the open-value extender for that call and only ever sees that slot's growing argument text, so it can resume from the arguments it has already parsed instead of re-converting from byte 0 on every streamed event. Adopt it for the GLM-4.7 config: Glm47ArgConverter parses each closed <arg_key>/<arg_value> pair once, and the ">" structural gate skips deltas that cannot close a tag. Ling3 shares the config and inherits both. Streamed and non-streaming arguments are unchanged against main (240 chunked scenarios, 90K fuzzed resumed parses). A 2000-pair looping call streams in 2.9 s instead of 300 s. Prototype of the engine-level alternative to vllm-project#64535. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Signed-off-by: Ben Browning <56071+bbrowning@users.noreply.github.com>
bbrowning
added a commit
to bbrowning/vllm
that referenced
this pull request
Oct 30, 2026
Profiling the looping GLM call showed the remaining cost was not in the parser: the engine re-joined the slot's whole argument text for every conversion (1.4 s of 3.6 s at 2000 pairs), which no converter can avoid while it receives raw_args. The SlotArgConverter protocol now takes feed(delta) / convert(partial) / extender(), and the stateless arg_converter and arg_value_extender config hooks are wrapped in one per-slot adapter, so the engine has a single conversion path and no fallback assert. Glm47ArgConverter keeps only the text after the last closed value. The retained inter-pair text is gone: a closed match consumes every earlier <arg_key>, so the open pair can only start after the last closed value, and the partial search no longer rescans the gaps. The stateless wrapper functions had no callers left and are removed. 2000 looping pairs at 4-char chunks: main 317 s, vllm-project#64535 3.4 s, this 1.1 s; output identical to main on 240 chunked scenarios and 183K fuzzed parses. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Signed-off-by: Ben Browning <56071+bbrowning@users.noreply.github.com>
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 GLM tool call that repeats closed argument pairs makes
Glm47MoeParserre-parse every pair 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 pair once. Follow-up to #61253, which covers a single long open value.Claims
glm47tool parser;Ling3Parsergets the>gate only (see Details).Validation
24e80c2614pytest tests/parser/engine/test_glm47_moe.py::TestStreamedToolArguments::test_closed_pairs_are_parsed_onceAssertionError: assert 1345003 == 401pytest tests/parser/engine/test_glm47_moe.pypytest tests/parser --ignore=tests/parser/mistral/test_tool_calls.py --ignore=tests/parser/test_harmony.pyTests ran on CPU in
vllm-ci-postmerge-repo:074c83a09a0b872cec92e244d91adf2b3c55e41b-arm64-cpuwith the branch tree onPYTHONPATH. The 2 parser-suite failures left are intests/parser/mistral/test_reasoning.pyand fail the same on main: the image ships mistral-common 1.11.6 and main needs 1.13.0 (#64169). The two ignored files fail collection in that image for the same reason and for a harmony download.ruff0.14.0 and the repo'smypyhook (1.20.2, Python 3.11) are clean on both files.Also checked on
glm47_moe.py:ParserManager.get_parser("glm47", "glm45"): 11 streams (edits, long writes, open values, typed and array arguments, repeated keys, two calls, plain content) at chunk sizes 1, 3, 4, 7 and 64, plus non-streaming. Content, reasoning, names, arguments and argument-delta counts are identical to the unpatched parser (vLLMd6394cf758, sameglm47_moe.pyand argument path as main) in 66 of 66 cases.Not measured: an end-to-end server run with a GLM checkpoint.
Looping stream replay
The replay feeds one GLM-5.3 output through the streaming parser on a virtual clock. The engine emits 200 tokens/s in 4-token steps (MTP at full acceptance). Each event-loop pass hands every token emitted since the previous pass to one
parse_deltacall. Measured parser time is doubled for a slower server CPU, and kubelet liveness (30 s period, 5 s timeout, 3 failures) is replayed on those passes. The output is</think><tool_call>Edit, afile_pathpair, thenold_string/new_stringpairs repeated.Once the loop saturates, each pass merges more tokens and each merged event converts again, so passes keep getting longer. The last pass on main merged 6,608 tokens, ran 7,267 conversions and took 62.9 s.
Details
Root cause.
ParserEngine._compute_arg_deltaconverts the whole raw argument text whenever the open-value extender from #61253 is inactive: between arguments, and while the open key repeats an earlier key (the extender needs the open value to be the last JSON value). GLM set noarg_structural_chars, so every argument chunk event converted, and_glm47_parse_argsre-ran_ARG_RE.finditerover every closed pair plus_ARG_RE.subover the whole text. Merging deltas under load does not bound this, because conversions scale with the events in the merged text.We hit this serving GLM-5.3 on vLLM 0.32.0, which includes #61253. Decode API servers climbed from 0.3 to 1 core in 3-4 minutes and failed liveness, five times in 24 hours. In three of them the logs show a single request left on the pod, generating at 99-100% MTP draft acceptance (the model repeating itself) while the stats line slipped from 10 s to 15-40 s apart. Stream content was not captured in production; the replay reproduces the same failure through the parser.
Change.
arg_structural_chars=frozenset(">"), as DeepSeek V3.2 and V4 use. A delta without>cannot close a key or value, so it cannot produce a streamable change: an open last value goes through the extender, and an open repeated key sits mid-JSON where the prefix check withholds it. The next conversion or the final flush converts the full text. The gate alone only delays the kill in the replay (305 s against 215 s, measured ond6394cf758), because each conversion still re-scans._glm47_parse_argstakes an optional_ClosedArgscache, andGlm47MoeParserbinds one per instance into its converter and extender, the same way DeepSeek V4 and MiniMax M3 replace_arg_converteraftersuper().__init__. A text that extends the cached text resumes after its last closed pair. Any other text parses from the start, and the cache moves forward whenever a scan closes a new pair. Appending text cannot change a pair that already closed, since each match ends at the first</arg_value>after its key, so resuming is exact.Cost. What stays linear in the text per conversion is C-level work:
startswithagainst the cached text, the slice when a pair closes, and the join of text outside the pairs for the open-key search. That is the 7% at 1.46M chars above.Compatibility and limits.
arg_converterkeeps the uncached path (identity check in__init__), so tests that wrap the converter still see every call.Ling3ParsercallsParserEngine.__init__directly, so it gets the gate from the shared config and no cache.max_tokens; this keeps the server responsive while it does.Not a duplicate. #61253 (merged) covers one long open value. #58418 (open) edits
_glm47_parse_argsfor an unclosed final value; the overlap is textual only. #51141 and #64196 address Inkling and DeepSeek DSML. No open PR covers re-parsing closed GLM pairs.AI assistance. Claude Code was used for the root-cause analysis, the fix, 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.