Просмотр исходного кода

fix(glm52): patch vLLM structured-output reasoning-boundary bug under MTP

Alexander Korolev 2 месяцев назад
Родитель
Сommit
240002c125

+ 2 - 0
.gitignore

@@ -1,3 +1,5 @@
 /target
 **/*.rs.bk
 config.toml
+__pycache__/
+*.pyc

+ 25 - 0
AGENTS.md

@@ -184,6 +184,31 @@ VLLM_COMMIT = "00907fc9b982f973808a0193ad0f8c61fbb62854"  # bump fork commit for
 TORCH_VERSION = "2.11.0"
 ```
 
+### Structured output under speculative decoding (glm52)
+
+The glm52 image patches vLLM's structured-output reasoning-boundary logic
+(upstream PRs #44297 + #44993) to fix guided decoding (`response_format`)
+under MTP + reasoning parser. Without the patch, when the reasoning parser
+emits the reasoning-end marker (`</think>`) under speculative decoding, the
+grammar FSM fails to advance, so the content channel is unconstrained —
+models emit prose or markdown-fence prefixes before JSON (tool-eval-bench
+Structured Output: 50% instead of 100%). The patch is applied at image
+build time by `docker-images/glm52/patch-structured-output.py`, which
+modifies three files in the installed vLLM package:
+
+1. `v1/structured_output/request.py` — adds `reasoning_end_token_index` field
+2. `v1/structured_output/__init__.py` — `should_advance()` accepts
+   `new_token_ids` (authoritative step delta), returns `True` for all
+   constraint types at the boundary, records the index; `grammar_bitmask()`
+   detects mid-window reasoning end and constrains subsequent drafts;
+   adds `_find_reasoning_end_index()` and `trim_reasoning_for_advance()`
+3. `v1/core/sched/scheduler.py` — `update_from_output()` passes
+   `new_token_ids` to `should_advance()` and trims reasoning content before
+   `accept_tokens()`
+
+Fixes: vLLM #34650, #43388, #48228. The b12x fork predates the upstream
+merge (Jul 23, 2026); this patch is a forward-port until the fork rebases.
+
 ## Composed model directories
 
 A recipe may declare `[composed_model]` to combine multiple HF repos into a

+ 19 - 0
docker-images/glm52/Dockerfile

@@ -17,6 +17,25 @@ FROM aidendle94/sparkrun-vllm-ds4-gb10:production-hybrid-1.4
 RUN sed -i '/from . import hybrid_modelopt_ct/d' \
     /opt/venv/lib/python3.12/site-packages/vllm/model_executor/layers/quantization/__init__.py
 
+# Patch vLLM structured-output reasoning-boundary bug under speculative
+# decoding (MTP). When the reasoning parser emits the reasoning-end marker
+# () under MTP, the grammar FSM (used by response_format / guided decoding)
+# failed to advance, so the content channel was unconstrained — models
+# emitted prose or markdown-fence prefixes before JSON (tool-eval-bench
+# Structured Output: 50% instead of 100%).
+#
+# Ports upstream vLLM PRs:
+#   #44297 — Constrain bitmask and trim grammar advance at the boundary
+#   #44993 — Advance grammar across reasoning boundary (fixes #34650,
+#            #43388, #48228)
+#
+# Changes: should_advance() accepts new_token_ids (authoritative step delta),
+# records reasoning_end_token_index, returns True for all constraint types;
+# scheduler trims reasoning content before accept_tokens; grammar_bitmask
+# constrains draft tokens after the marker mid-window.
+COPY patch-structured-output.py /tmp/patch-structured-output.py
+RUN python3 /tmp/patch-structured-output.py && rm /tmp/patch-structured-output.py
+
 RUN pip install --no-cache-dir --no-deps --force-reinstall nvidia-nccl-cu13==2.30.4 \
  && pip install --no-cache-dir --no-deps --force-reinstall instanttensor==0.1.9
 

+ 451 - 0
docker-images/glm52/patch-structured-output.py

@@ -0,0 +1,451 @@
+#!/usr/bin/env python3
+"""Patch vLLM structured-output reasoning-boundary bugs.
+
+Ports upstream vLLM PRs #44297 and #44993 to the b12x fork's vLLM source.
+
+Background
+----------
+When a reasoning parser (e.g. glm45 -> Glm47MoeParser) emits the reasoning-end
+marker (</think>) under speculative decoding (MTP, num_speculative_tokens=4),
+the grammar FSM (used by response_format / guided decoding) fails to advance
+across the reasoning->content boundary. This is vLLM bug #34650, fixed
+upstream by:
+
+  #44297 - Constrain bitmask and trim grammar advance at the reasoning boundary
+  #44993 - Advance grammar across reasoning boundary (supersedes #44993's
+           earlier conservative version; final merged form)
+
+Without this patch, models emit prose or markdown-fence prefixes before JSON
+in the content channel (e.g. ```{..." or "Here{..."), causing structured
+output validation failures. The tool-eval-bench Structured Output category
+scores 50% (6/12) instead of 100%.
+
+The fix
+-------
+1. should_advance() accepts an optional new_token_ids argument and uses it as
+   the authoritative step delta (instead of reconstructing the window from
+   num_computed_tokens - num_output_placeholders, which skips the marker
+   under async scheduling + spec-decode draft rejection).
+
+2. At the boundary, should_advance() records reasoning_end_token_index and
+   returns True for ALL constraint types (not just STRUCTURAL_TAG under spec
+   decode). The scheduler then trims reasoning content before accept_tokens.
+
+3. grammar_bitmask() detects a mid-window reasoning-end marker and constrains
+   subsequent draft positions via the bitmask, skipping grammar advance
+   through the marker itself.
+
+4. The scheduler's update_from_output() passes new_token_ids to should_advance
+   and calls trim_reasoning_for_advance() before accept_tokens.
+
+Fixes: #34650, #43388, #48228.
+"""
+import sys
+from pathlib import Path
+
+VLLM_DIR = Path("/opt/venv/lib/python3.12/site-packages/vllm")
+
+
+def patch_request():
+    """Add reasoning_end_token_index field to StructuredOutputRequest."""
+    path = VLLM_DIR / "v1" / "structured_output" / "request.py"
+    content = path.read_text()
+    if "reasoning_end_token_index" in content:
+        print(f"  {path.name}: already patched, skipping")
+        return
+    old = "    reasoning_ended: bool | None = None\n"
+    new = (
+        "    reasoning_ended: bool | None = None\n"
+        "    # Absolute index into the request's all_token_ids of the last\n"
+        "    # reasoning token (the reasoning-end marker). Tokens at or before\n"
+        "    # this index are reasoning content and must never be fed to the\n"
+        "    # grammar. Only set when reasoning ends in a step whose tokens\n"
+        "    # the scheduler advances immediately (structural tags +\n"
+        "    # speculative decoding, see #42452).\n"
+        "    reasoning_end_token_index: int | None = None\n"
+    )
+    if old not in content:
+        print(f"  {path.name}: ERROR - could not find reasoning_ended field",
+              file=sys.stderr)
+        sys.exit(1)
+    content = content.replace(old, new, 1)
+    path.write_text(content)
+    print(f"  {path.name}: patched")
+
+
+def patch_init():
+    """Patch __init__.py: imports, grammar_bitmask, should_advance, helpers."""
+    path = VLLM_DIR / "v1" / "structured_output" / "__init__.py"
+    content = path.read_text()
+    if "trim_reasoning_for_advance" in content:
+        print(f"  {path.name}: already patched, skipping")
+        return
+
+    # 1. Add Sequence to collections.abc import
+    old_import = "from collections.abc import Iterable\n"
+    new_import = "from collections.abc import Iterable, Sequence\n"
+    if old_import not in content:
+        print(f"  {path.name}: ERROR - could not find Iterable import",
+              file=sys.stderr)
+        sys.exit(1)
+    content = content.replace(old_import, new_import, 1)
+
+    # 2. Remove StructuredOutputOptions from backend_types import
+    old_bo = (
+        "from vllm.v1.structured_output.backend_types import (\n"
+        "    StructuredOutputBackend,\n"
+        "    StructuredOutputGrammar,\n"
+        "    StructuredOutputOptions,\n"
+        ")"
+    )
+    new_bo = (
+        "from vllm.v1.structured_output.backend_types import (\n"
+        "    StructuredOutputBackend,\n"
+        "    StructuredOutputGrammar,\n"
+        ")"
+    )
+    if old_bo not in content:
+        print(f"  {path.name}: ERROR - could not find backend_types import",
+              file=sys.stderr)
+        sys.exit(1)
+    content = content.replace(old_bo, new_bo, 1)
+
+    # 3. Remove logger import and initialization (unused after should_advance
+    #    no longer references StructuredOutputOptions)
+    old_logger_import = "from vllm.logger import init_logger\n"
+    if old_logger_import in content:
+        content = content.replace(old_logger_import, "", 1)
+    old_logger_init = "logger = init_logger(__name__)\n\n\n"
+    if old_logger_init in content:
+        content = content.replace(old_logger_init, "", 1)
+
+    # 4. Replace grammar_bitmask serial-filling loop
+    old_bitmask = '''                state_advancements = 0
+                req_tokens = scheduled_spec_decode_tokens.get(req_id, ())
+                if self.vllm_config.model_config.is_diffusion and req_tokens:
+                    # Diffusion LLMs don't sample a bonus token after the
+                    # scheduled positions, so don't append the -1 placeholder.
+                    token_iter: Iterable[int] = req_tokens
+                else:
+                    token_iter = itertools.chain(req_tokens, (-1,))
+                for token in token_iter:
+                    self._fill_bitmasks(((grammar, cumulative_index, apply_bitmask),))
+                    if token == -1:
+                        # Stop advancing the grammar once we hit a padding token.
+                        apply_bitmask = False
+                    if apply_bitmask and not grammar.is_terminated():
+                        accepted = grammar.accept_tokens(req_id, [token])
+                        assert accepted, (token, req_id, scheduled_spec_decode_tokens)
+                        state_advancements += 1
+                    cumulative_index += 1
+                if state_advancements > 0:
+                    grammar.rollback(state_advancements)'''
+
+    new_bitmask = '''                reasoner = self._get_reasoner(request)
+                detect_reasoning_end = (
+                    not apply_bitmask
+                    and reasoner is not None
+                    and not self.enable_in_reasoning
+                )
+                simulated_buf: list[int] | None = None
+                history_len = 0
+
+                state_advancements = 0
+                post_reasoning_end_in_window = False
+                req_tokens = scheduled_spec_decode_tokens.get(req_id, ())
+                for i, token in enumerate(req_tokens):
+                    self._fill_bitmasks(((grammar, cumulative_index, apply_bitmask),))
+                    advance_grammar = apply_bitmask
+                    if token == -1:
+                        apply_bitmask = False
+                        advance_grammar = False
+                    elif (
+                        detect_reasoning_end
+                        and reasoner is not None
+                        and not apply_bitmask
+                    ):
+                        if simulated_buf is None:
+                            history = list(request.all_token_ids)
+                            history_len = len(history)
+                            simulated_buf = history + list(req_tokens)
+                        simulated = simulated_buf[: history_len + i + 1]
+                        if reasoner.is_reasoning_end_streaming(simulated, [token]):
+                            # Reasoning ended mid-window. Constrain the rest
+                            # of the window via bitmask. Skip grammar advance
+                            # through the marker (it is reasoning content);
+                            # try to advance through subsequent drafts so the
+                            # next bitmask row reflects the post-advance state,
+                            # but tolerate rejection since those drafts predate
+                            # the bitmask and are not guaranteed valid.
+                            apply_bitmask = True
+                            advance_grammar = False
+                            post_reasoning_end_in_window = True
+                    if advance_grammar and not grammar.is_terminated():
+                        accepted = grammar.accept_tokens(req_id, [token])
+                        if accepted:
+                            state_advancements += 1
+                        elif not post_reasoning_end_in_window:
+                            raise AssertionError(
+                                (token, req_id, scheduled_spec_decode_tokens)
+                            )
+                    cumulative_index += 1
+                # Diffusion LLMs don't sample a bonus token after the
+                # scheduled positions, so skip its bitmask in that case.
+                if not (self.vllm_config.model_config.is_diffusion and req_tokens):
+                    # bonus_apply must be True when the bonus-row position
+                    # should be grammar-constrained. Two triggers:
+                    # - should_fill_bitmask(request): reasoning was already
+                    #   over at step start (or no reasoner /
+                    #   enable_in_reasoning).
+                    # - apply_bitmask: reasoning ended mid-window in this
+                    #   call and was flipped True after the marker;
+                    #   should_fill_bitmask still returns False here because
+                    #   reasoning_ended is only persisted later by
+                    #   should_advance.
+                    bonus_apply = self.should_fill_bitmask(request) or apply_bitmask
+                    self._fill_bitmasks(((grammar, cumulative_index, bonus_apply),))
+                    cumulative_index += 1
+                if state_advancements > 0:
+                    grammar.rollback(state_advancements)'''
+
+    if old_bitmask not in content:
+        print(f"  {path.name}: ERROR - could not find grammar_bitmask serial loop",
+              file=sys.stderr)
+        sys.exit(1)
+    content = content.replace(old_bitmask, new_bitmask, 1)
+
+    # 5. Replace should_advance and add helper methods
+    old_should_advance = '''    def should_advance(self, request: "Request") -> bool:
+        if not request.use_structured_output:
+            return False
+
+        # To determine whether we can advance the FSM.
+        # Supports thinking usage where we skip the reasoning components.
+        if TYPE_CHECKING:
+            assert request.structured_output_request is not None
+            assert request.structured_output_request.grammar is not None
+        # by default, we should always advance
+        # for cases that don't use thinking mode.
+        reasoner = self._get_reasoner(request)
+        if reasoner is None:
+            return True
+
+        # if the model needs structured in reasoning, we should advance
+        if self.enable_in_reasoning:
+            return True
+
+        structured_req = request.structured_output_request
+        if structured_req.reasoning_ended:
+            return True
+
+        # Check if reasoning ends in *this* step
+        delta_from = request.num_computed_tokens - request.num_output_placeholders
+        all_token_ids = request.all_token_ids
+        start = (
+            delta_from if delta_from >= 0 else max(len(all_token_ids) + delta_from, 0)
+        )
+        if reasoner.is_reasoning_end_streaming(
+            all_token_ids, itertools.islice(all_token_ids, start, None)
+        ):
+            structured_req.reasoning_ended = True
+
+            # Reasoning just ended this step. Defer FSM advance until the next
+            # pass (see reasoning_ended check above) for JSON/regex/choice/grammar:
+            # advancing on the closing boundary token can accept tokens that still
+            # belong to the reasoning stream. Structural tags are the only safe
+            # same-step exception: they model phased output (e.g. thinking tag ->
+            # answer tag), and speculative decoding must run grammar.validate_tokens
+            # on draft tokens produced immediately after that transition.
+            if (
+                self.vllm_config.speculative_config is not None
+                and structured_req.structured_output_key[0]
+                == StructuredOutputOptions.STRUCTURAL_TAG
+            ):
+                return True
+
+        return False'''
+
+    new_should_advance = '''    def should_advance(
+        self,
+        request: "Request",
+        new_token_ids: list[int] | None = None,
+    ) -> bool:
+        if not request.use_structured_output:
+            return False
+
+        # To determine whether we can advance the FSM.
+        # Supports thinking usage where we skip the reasoning components.
+        if TYPE_CHECKING:
+            assert request.structured_output_request is not None
+            assert request.structured_output_request.grammar is not None
+        # by default, we should always advance
+        # for cases that don't use thinking mode.
+        reasoner = self._get_reasoner(request)
+        if reasoner is None:
+            return True
+
+        # if the model needs structured in reasoning, we should advance
+        if self.enable_in_reasoning:
+            return True
+
+        structured_req = request.structured_output_request
+        if structured_req.reasoning_ended:
+            return True
+
+        # Check if reasoning ends in *this* step.
+        # When the caller passes new_token_ids (the tokens that were just
+        # appended this step), use it directly as the delta window. The
+        # placeholder-derived fallback assumes num_output_placeholders ==
+        # len(new_token_ids), which breaks under async scheduling + spec
+        # decode when some drafts are rejected (#43388): the placeholder
+        # count remains > 0 after the step and the computed delta window
+        # starts past the reasoning-end marker.
+        all_token_ids = request.all_token_ids
+        if new_token_ids:
+            # The tokens were already appended this step, so the step window
+            # starts exactly len(new_token_ids) from the end.
+            start = len(all_token_ids) - len(new_token_ids)
+            delta_ids: Iterable[int] = new_token_ids
+        else:
+            delta_from = request.num_computed_tokens - request.num_output_placeholders
+            start = (
+                delta_from
+                if delta_from >= 0
+                else max(len(all_token_ids) + delta_from, 0)
+            )
+            delta_ids = itertools.islice(all_token_ids, start, None)
+        if reasoner.is_reasoning_end_streaming(all_token_ids, delta_ids):
+            structured_req.reasoning_ended = True
+
+            # Record the boundary so the scheduler can exclude reasoning tokens.
+            end_index = self._find_reasoning_end_index(reasoner, all_token_ids, start)
+
+            structured_req.reasoning_end_token_index = end_index
+            return True
+
+        return False
+
+    @staticmethod
+    def _find_reasoning_end_index(
+        reasoner: "ReasoningParser", all_token_ids: Sequence[int], start: int
+    ) -> int:
+        """Locates the last reasoning token within all_token_ids[start:].
+
+        Returns the absolute index of the token at which
+        is_reasoning_end_streaming first fires. Falls back to the final
+        index when no single token triggers the detection (e.g. a
+        multi-token marker only recognized on the full delta), which
+        conservatively treats the whole step as reasoning content.
+        """
+        prefix = list(itertools.islice(all_token_ids, start))
+        for idx in range(start, len(all_token_ids)):
+            token = all_token_ids[idx]
+            prefix.append(token)
+            if reasoner.is_reasoning_end_streaming(prefix, [token]):
+                return idx
+        return len(all_token_ids) - 1
+
+    def trim_reasoning_for_advance(
+        self, request: "Request", new_token_ids: list[int]
+    ) -> list[int]:
+        """Drops reasoning content from tokens about to advance the grammar.
+
+        When reasoning ends mid-step (see should_advance), the step's output
+        still contains reasoning tokens up to and including the end marker.
+        Those are not grammar content: feeding them to accept_tokens makes
+        the grammar reject the marker and kills the request (#44006).
+
+        Returns the suffix of new_token_ids that follows the reasoning-end
+        marker. Steps fully after the boundary are returned unchanged.
+        """
+        structured_req = request.structured_output_request
+        if structured_req is None:
+            return new_token_ids
+        end_idx = structured_req.reasoning_end_token_index
+        if end_idx is None:
+            return new_token_ids
+        first_idx = len(request.all_token_ids) - len(new_token_ids)
+        num_reasoning = end_idx + 1 - first_idx
+        if num_reasoning <= 0:
+            return new_token_ids
+        return new_token_ids[num_reasoning:]'''
+
+    if old_should_advance not in content:
+        print(f"  {path.name}: ERROR - could not find should_advance method",
+              file=sys.stderr)
+        sys.exit(1)
+    content = content.replace(old_should_advance, new_should_advance, 1)
+
+    path.write_text(content)
+    print(f"  {path.name}: patched")
+
+
+def patch_scheduler():
+    """Patch update_from_output to pass new_token_ids and trim before accept."""
+    path = VLLM_DIR / "v1" / "core" / "sched" / "scheduler.py"
+    content = path.read_text()
+    if "trim_reasoning_for_advance" in content:
+        print(f"  {path.name}: already patched, skipping")
+        return
+
+    old = '''            if new_token_ids and self.structured_output_manager.should_advance(request):
+                struct_output_request = request.structured_output_request
+                assert struct_output_request is not None
+                assert struct_output_request.grammar is not None
+                if not struct_output_request.grammar.accept_tokens(  # type: ignore[union-attr]
+                    req_id, new_token_ids
+                ):
+                    logger.error(
+                        "Unexpected: grammar rejected tokens %s for request %s. "
+                        "Terminating request.",
+                        new_token_ids,
+                        req_id,
+                    )
+                    request.status = RequestStatus.FINISHED_ERROR
+                    request.resumable = False
+                    stopped = True'''
+
+    new = '''            if new_token_ids and self.structured_output_manager.should_advance(
+                request, new_token_ids=new_token_ids
+            ):
+                struct_output_request = request.structured_output_request
+                assert struct_output_request is not None
+                grammar = struct_output_request.grammar
+                assert grammar is not None
+                # new_token_ids can be a mixed block of reasoning content, then
+                # the reasoning end marker, then the start of the grammar content.
+                # Trim the reasoning content so the grammar only sees grammar content.
+                advance_token_ids = (
+                    self.structured_output_manager.trim_reasoning_for_advance(
+                        request, new_token_ids
+                    )
+                )
+                if advance_token_ids and not grammar.accept_tokens(
+                    req_id, advance_token_ids
+                ):
+                    logger.error(
+                        "Unexpected: grammar rejected tokens %s for request %s. "
+                        "Terminating request.",
+                        advance_token_ids,
+                        req_id,
+                    )
+                    request.status = RequestStatus.FINISHED_ERROR
+                    request.resumable = False
+                    stopped = True'''
+
+    if old not in content:
+        print(f"  {path.name}: ERROR - could not find should_advance block",
+              file=sys.stderr)
+        sys.exit(1)
+    content = content.replace(old, new, 1)
+    path.write_text(content)
+    print(f"  {path.name}: patched")
+
+
+if __name__ == "__main__":
+    print("Applying structured output reasoning-boundary patch...")
+    print(f"  vLLM dir: {VLLM_DIR}")
+    patch_request()
+    patch_init()
+    patch_scheduler()
+    print("Done.")

+ 8 - 0
recipes/glm52-nf4-lloyd-experts-gptq/recipe.toml

@@ -81,6 +81,14 @@ args = [
     # Speculative decode — MTP k=4 with adaptive depths
     # /root/model/mtp-draft is symlinked from the MXFP4 base checkpoint
     # (the composed dir includes it via source #1).
+    #
+    # NOTE: MTP + reasoning parser (glm45) + response_format (guided decoding)
+    # requires the vLLM structured-output reasoning-boundary fix (upstream
+    # PRs #44297 + #44993, forward-ported in docker-images/glm52/). Without
+    # it, the grammar FSM fails to advance across the reasoning-end marker
+    # (|◊|) under MTP, so the content channel is unconstrained and the model
+    # emits prose/markdown-fence prefixes before JSON. The fix is baked into
+    # the sparkrun-vllm-ds4-gb10:glm52 image.
     "--speculative-config", """
 '{"model":"/root/model/mtp-draft","method":"mtp","num_speculative_tokens":4,"draft_attention_backend":"B12X_MLA_SPARSE","adaptive_speculative_tokens_window":32}'""",
     # Compilation — FULL_AND_PIECEWISE with all custom ops, 8 capture sizes