patch-structured-output.py 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451
  1. #!/usr/bin/env python3
  2. """Patch vLLM structured-output reasoning-boundary bugs.
  3. Ports upstream vLLM PRs #44297 and #44993 to the b12x fork's vLLM source.
  4. Background
  5. ----------
  6. When a reasoning parser (e.g. glm45 -> Glm47MoeParser) emits the reasoning-end
  7. marker (</think>) under speculative decoding (MTP, num_speculative_tokens=4),
  8. the grammar FSM (used by response_format / guided decoding) fails to advance
  9. across the reasoning->content boundary. This is vLLM bug #34650, fixed
  10. upstream by:
  11. #44297 - Constrain bitmask and trim grammar advance at the reasoning boundary
  12. #44993 - Advance grammar across reasoning boundary (supersedes #44993's
  13. earlier conservative version; final merged form)
  14. Without this patch, models emit prose or markdown-fence prefixes before JSON
  15. in the content channel (e.g. ```{..." or "Here{..."), causing structured
  16. output validation failures. The tool-eval-bench Structured Output category
  17. scores 50% (6/12) instead of 100%.
  18. The fix
  19. -------
  20. 1. should_advance() accepts an optional new_token_ids argument and uses it as
  21. the authoritative step delta (instead of reconstructing the window from
  22. num_computed_tokens - num_output_placeholders, which skips the marker
  23. under async scheduling + spec-decode draft rejection).
  24. 2. At the boundary, should_advance() records reasoning_end_token_index and
  25. returns True for ALL constraint types (not just STRUCTURAL_TAG under spec
  26. decode). The scheduler then trims reasoning content before accept_tokens.
  27. 3. grammar_bitmask() detects a mid-window reasoning-end marker and constrains
  28. subsequent draft positions via the bitmask, skipping grammar advance
  29. through the marker itself.
  30. 4. The scheduler's update_from_output() passes new_token_ids to should_advance
  31. and calls trim_reasoning_for_advance() before accept_tokens.
  32. Fixes: #34650, #43388, #48228.
  33. """
  34. import sys
  35. from pathlib import Path
  36. VLLM_DIR = Path("/opt/venv/lib/python3.12/site-packages/vllm")
  37. def patch_request():
  38. """Add reasoning_end_token_index field to StructuredOutputRequest."""
  39. path = VLLM_DIR / "v1" / "structured_output" / "request.py"
  40. content = path.read_text()
  41. if "reasoning_end_token_index" in content:
  42. print(f" {path.name}: already patched, skipping")
  43. return
  44. old = " reasoning_ended: bool | None = None\n"
  45. new = (
  46. " reasoning_ended: bool | None = None\n"
  47. " # Absolute index into the request's all_token_ids of the last\n"
  48. " # reasoning token (the reasoning-end marker). Tokens at or before\n"
  49. " # this index are reasoning content and must never be fed to the\n"
  50. " # grammar. Only set when reasoning ends in a step whose tokens\n"
  51. " # the scheduler advances immediately (structural tags +\n"
  52. " # speculative decoding, see #42452).\n"
  53. " reasoning_end_token_index: int | None = None\n"
  54. )
  55. if old not in content:
  56. print(f" {path.name}: ERROR - could not find reasoning_ended field",
  57. file=sys.stderr)
  58. sys.exit(1)
  59. content = content.replace(old, new, 1)
  60. path.write_text(content)
  61. print(f" {path.name}: patched")
  62. def patch_init():
  63. """Patch __init__.py: imports, grammar_bitmask, should_advance, helpers."""
  64. path = VLLM_DIR / "v1" / "structured_output" / "__init__.py"
  65. content = path.read_text()
  66. if "trim_reasoning_for_advance" in content:
  67. print(f" {path.name}: already patched, skipping")
  68. return
  69. # 1. Add Sequence to collections.abc import
  70. old_import = "from collections.abc import Iterable\n"
  71. new_import = "from collections.abc import Iterable, Sequence\n"
  72. if old_import not in content:
  73. print(f" {path.name}: ERROR - could not find Iterable import",
  74. file=sys.stderr)
  75. sys.exit(1)
  76. content = content.replace(old_import, new_import, 1)
  77. # 2. Remove StructuredOutputOptions from backend_types import
  78. old_bo = (
  79. "from vllm.v1.structured_output.backend_types import (\n"
  80. " StructuredOutputBackend,\n"
  81. " StructuredOutputGrammar,\n"
  82. " StructuredOutputOptions,\n"
  83. ")"
  84. )
  85. new_bo = (
  86. "from vllm.v1.structured_output.backend_types import (\n"
  87. " StructuredOutputBackend,\n"
  88. " StructuredOutputGrammar,\n"
  89. ")"
  90. )
  91. if old_bo not in content:
  92. print(f" {path.name}: ERROR - could not find backend_types import",
  93. file=sys.stderr)
  94. sys.exit(1)
  95. content = content.replace(old_bo, new_bo, 1)
  96. # 3. Remove logger import and initialization (unused after should_advance
  97. # no longer references StructuredOutputOptions)
  98. old_logger_import = "from vllm.logger import init_logger\n"
  99. if old_logger_import in content:
  100. content = content.replace(old_logger_import, "", 1)
  101. old_logger_init = "logger = init_logger(__name__)\n\n\n"
  102. if old_logger_init in content:
  103. content = content.replace(old_logger_init, "", 1)
  104. # 4. Replace grammar_bitmask serial-filling loop
  105. old_bitmask = ''' state_advancements = 0
  106. req_tokens = scheduled_spec_decode_tokens.get(req_id, ())
  107. if self.vllm_config.model_config.is_diffusion and req_tokens:
  108. # Diffusion LLMs don't sample a bonus token after the
  109. # scheduled positions, so don't append the -1 placeholder.
  110. token_iter: Iterable[int] = req_tokens
  111. else:
  112. token_iter = itertools.chain(req_tokens, (-1,))
  113. for token in token_iter:
  114. self._fill_bitmasks(((grammar, cumulative_index, apply_bitmask),))
  115. if token == -1:
  116. # Stop advancing the grammar once we hit a padding token.
  117. apply_bitmask = False
  118. if apply_bitmask and not grammar.is_terminated():
  119. accepted = grammar.accept_tokens(req_id, [token])
  120. assert accepted, (token, req_id, scheduled_spec_decode_tokens)
  121. state_advancements += 1
  122. cumulative_index += 1
  123. if state_advancements > 0:
  124. grammar.rollback(state_advancements)'''
  125. new_bitmask = ''' reasoner = self._get_reasoner(request)
  126. detect_reasoning_end = (
  127. not apply_bitmask
  128. and reasoner is not None
  129. and not self.enable_in_reasoning
  130. )
  131. simulated_buf: list[int] | None = None
  132. history_len = 0
  133. state_advancements = 0
  134. post_reasoning_end_in_window = False
  135. req_tokens = scheduled_spec_decode_tokens.get(req_id, ())
  136. for i, token in enumerate(req_tokens):
  137. self._fill_bitmasks(((grammar, cumulative_index, apply_bitmask),))
  138. advance_grammar = apply_bitmask
  139. if token == -1:
  140. apply_bitmask = False
  141. advance_grammar = False
  142. elif (
  143. detect_reasoning_end
  144. and reasoner is not None
  145. and not apply_bitmask
  146. ):
  147. if simulated_buf is None:
  148. history = list(request.all_token_ids)
  149. history_len = len(history)
  150. simulated_buf = history + list(req_tokens)
  151. simulated = simulated_buf[: history_len + i + 1]
  152. if reasoner.is_reasoning_end_streaming(simulated, [token]):
  153. # Reasoning ended mid-window. Constrain the rest
  154. # of the window via bitmask. Skip grammar advance
  155. # through the marker (it is reasoning content);
  156. # try to advance through subsequent drafts so the
  157. # next bitmask row reflects the post-advance state,
  158. # but tolerate rejection since those drafts predate
  159. # the bitmask and are not guaranteed valid.
  160. apply_bitmask = True
  161. advance_grammar = False
  162. post_reasoning_end_in_window = True
  163. if advance_grammar and not grammar.is_terminated():
  164. accepted = grammar.accept_tokens(req_id, [token])
  165. if accepted:
  166. state_advancements += 1
  167. elif not post_reasoning_end_in_window:
  168. raise AssertionError(
  169. (token, req_id, scheduled_spec_decode_tokens)
  170. )
  171. cumulative_index += 1
  172. # Diffusion LLMs don't sample a bonus token after the
  173. # scheduled positions, so skip its bitmask in that case.
  174. if not (self.vllm_config.model_config.is_diffusion and req_tokens):
  175. # bonus_apply must be True when the bonus-row position
  176. # should be grammar-constrained. Two triggers:
  177. # - should_fill_bitmask(request): reasoning was already
  178. # over at step start (or no reasoner /
  179. # enable_in_reasoning).
  180. # - apply_bitmask: reasoning ended mid-window in this
  181. # call and was flipped True after the marker;
  182. # should_fill_bitmask still returns False here because
  183. # reasoning_ended is only persisted later by
  184. # should_advance.
  185. bonus_apply = self.should_fill_bitmask(request) or apply_bitmask
  186. self._fill_bitmasks(((grammar, cumulative_index, bonus_apply),))
  187. cumulative_index += 1
  188. if state_advancements > 0:
  189. grammar.rollback(state_advancements)'''
  190. if old_bitmask not in content:
  191. print(f" {path.name}: ERROR - could not find grammar_bitmask serial loop",
  192. file=sys.stderr)
  193. sys.exit(1)
  194. content = content.replace(old_bitmask, new_bitmask, 1)
  195. # 5. Replace should_advance and add helper methods
  196. old_should_advance = ''' def should_advance(self, request: "Request") -> bool:
  197. if not request.use_structured_output:
  198. return False
  199. # To determine whether we can advance the FSM.
  200. # Supports thinking usage where we skip the reasoning components.
  201. if TYPE_CHECKING:
  202. assert request.structured_output_request is not None
  203. assert request.structured_output_request.grammar is not None
  204. # by default, we should always advance
  205. # for cases that don't use thinking mode.
  206. reasoner = self._get_reasoner(request)
  207. if reasoner is None:
  208. return True
  209. # if the model needs structured in reasoning, we should advance
  210. if self.enable_in_reasoning:
  211. return True
  212. structured_req = request.structured_output_request
  213. if structured_req.reasoning_ended:
  214. return True
  215. # Check if reasoning ends in *this* step
  216. delta_from = request.num_computed_tokens - request.num_output_placeholders
  217. all_token_ids = request.all_token_ids
  218. start = (
  219. delta_from if delta_from >= 0 else max(len(all_token_ids) + delta_from, 0)
  220. )
  221. if reasoner.is_reasoning_end_streaming(
  222. all_token_ids, itertools.islice(all_token_ids, start, None)
  223. ):
  224. structured_req.reasoning_ended = True
  225. # Reasoning just ended this step. Defer FSM advance until the next
  226. # pass (see reasoning_ended check above) for JSON/regex/choice/grammar:
  227. # advancing on the closing boundary token can accept tokens that still
  228. # belong to the reasoning stream. Structural tags are the only safe
  229. # same-step exception: they model phased output (e.g. thinking tag ->
  230. # answer tag), and speculative decoding must run grammar.validate_tokens
  231. # on draft tokens produced immediately after that transition.
  232. if (
  233. self.vllm_config.speculative_config is not None
  234. and structured_req.structured_output_key[0]
  235. == StructuredOutputOptions.STRUCTURAL_TAG
  236. ):
  237. return True
  238. return False'''
  239. new_should_advance = ''' def should_advance(
  240. self,
  241. request: "Request",
  242. new_token_ids: list[int] | None = None,
  243. ) -> bool:
  244. if not request.use_structured_output:
  245. return False
  246. # To determine whether we can advance the FSM.
  247. # Supports thinking usage where we skip the reasoning components.
  248. if TYPE_CHECKING:
  249. assert request.structured_output_request is not None
  250. assert request.structured_output_request.grammar is not None
  251. # by default, we should always advance
  252. # for cases that don't use thinking mode.
  253. reasoner = self._get_reasoner(request)
  254. if reasoner is None:
  255. return True
  256. # if the model needs structured in reasoning, we should advance
  257. if self.enable_in_reasoning:
  258. return True
  259. structured_req = request.structured_output_request
  260. if structured_req.reasoning_ended:
  261. return True
  262. # Check if reasoning ends in *this* step.
  263. # When the caller passes new_token_ids (the tokens that were just
  264. # appended this step), use it directly as the delta window. The
  265. # placeholder-derived fallback assumes num_output_placeholders ==
  266. # len(new_token_ids), which breaks under async scheduling + spec
  267. # decode when some drafts are rejected (#43388): the placeholder
  268. # count remains > 0 after the step and the computed delta window
  269. # starts past the reasoning-end marker.
  270. all_token_ids = request.all_token_ids
  271. if new_token_ids:
  272. # The tokens were already appended this step, so the step window
  273. # starts exactly len(new_token_ids) from the end.
  274. start = len(all_token_ids) - len(new_token_ids)
  275. delta_ids: Iterable[int] = new_token_ids
  276. else:
  277. delta_from = request.num_computed_tokens - request.num_output_placeholders
  278. start = (
  279. delta_from
  280. if delta_from >= 0
  281. else max(len(all_token_ids) + delta_from, 0)
  282. )
  283. delta_ids = itertools.islice(all_token_ids, start, None)
  284. if reasoner.is_reasoning_end_streaming(all_token_ids, delta_ids):
  285. structured_req.reasoning_ended = True
  286. # Record the boundary so the scheduler can exclude reasoning tokens.
  287. end_index = self._find_reasoning_end_index(reasoner, all_token_ids, start)
  288. structured_req.reasoning_end_token_index = end_index
  289. return True
  290. return False
  291. @staticmethod
  292. def _find_reasoning_end_index(
  293. reasoner: "ReasoningParser", all_token_ids: Sequence[int], start: int
  294. ) -> int:
  295. """Locates the last reasoning token within all_token_ids[start:].
  296. Returns the absolute index of the token at which
  297. is_reasoning_end_streaming first fires. Falls back to the final
  298. index when no single token triggers the detection (e.g. a
  299. multi-token marker only recognized on the full delta), which
  300. conservatively treats the whole step as reasoning content.
  301. """
  302. prefix = list(itertools.islice(all_token_ids, start))
  303. for idx in range(start, len(all_token_ids)):
  304. token = all_token_ids[idx]
  305. prefix.append(token)
  306. if reasoner.is_reasoning_end_streaming(prefix, [token]):
  307. return idx
  308. return len(all_token_ids) - 1
  309. def trim_reasoning_for_advance(
  310. self, request: "Request", new_token_ids: list[int]
  311. ) -> list[int]:
  312. """Drops reasoning content from tokens about to advance the grammar.
  313. When reasoning ends mid-step (see should_advance), the step's output
  314. still contains reasoning tokens up to and including the end marker.
  315. Those are not grammar content: feeding them to accept_tokens makes
  316. the grammar reject the marker and kills the request (#44006).
  317. Returns the suffix of new_token_ids that follows the reasoning-end
  318. marker. Steps fully after the boundary are returned unchanged.
  319. """
  320. structured_req = request.structured_output_request
  321. if structured_req is None:
  322. return new_token_ids
  323. end_idx = structured_req.reasoning_end_token_index
  324. if end_idx is None:
  325. return new_token_ids
  326. first_idx = len(request.all_token_ids) - len(new_token_ids)
  327. num_reasoning = end_idx + 1 - first_idx
  328. if num_reasoning <= 0:
  329. return new_token_ids
  330. return new_token_ids[num_reasoning:]'''
  331. if old_should_advance not in content:
  332. print(f" {path.name}: ERROR - could not find should_advance method",
  333. file=sys.stderr)
  334. sys.exit(1)
  335. content = content.replace(old_should_advance, new_should_advance, 1)
  336. path.write_text(content)
  337. print(f" {path.name}: patched")
  338. def patch_scheduler():
  339. """Patch update_from_output to pass new_token_ids and trim before accept."""
  340. path = VLLM_DIR / "v1" / "core" / "sched" / "scheduler.py"
  341. content = path.read_text()
  342. if "trim_reasoning_for_advance" in content:
  343. print(f" {path.name}: already patched, skipping")
  344. return
  345. old = ''' if new_token_ids and self.structured_output_manager.should_advance(request):
  346. struct_output_request = request.structured_output_request
  347. assert struct_output_request is not None
  348. assert struct_output_request.grammar is not None
  349. if not struct_output_request.grammar.accept_tokens( # type: ignore[union-attr]
  350. req_id, new_token_ids
  351. ):
  352. logger.error(
  353. "Unexpected: grammar rejected tokens %s for request %s. "
  354. "Terminating request.",
  355. new_token_ids,
  356. req_id,
  357. )
  358. request.status = RequestStatus.FINISHED_ERROR
  359. request.resumable = False
  360. stopped = True'''
  361. new = ''' if new_token_ids and self.structured_output_manager.should_advance(
  362. request, new_token_ids=new_token_ids
  363. ):
  364. struct_output_request = request.structured_output_request
  365. assert struct_output_request is not None
  366. grammar = struct_output_request.grammar
  367. assert grammar is not None
  368. # new_token_ids can be a mixed block of reasoning content, then
  369. # the reasoning end marker, then the start of the grammar content.
  370. # Trim the reasoning content so the grammar only sees grammar content.
  371. advance_token_ids = (
  372. self.structured_output_manager.trim_reasoning_for_advance(
  373. request, new_token_ids
  374. )
  375. )
  376. if advance_token_ids and not grammar.accept_tokens(
  377. req_id, advance_token_ids
  378. ):
  379. logger.error(
  380. "Unexpected: grammar rejected tokens %s for request %s. "
  381. "Terminating request.",
  382. advance_token_ids,
  383. req_id,
  384. )
  385. request.status = RequestStatus.FINISHED_ERROR
  386. request.resumable = False
  387. stopped = True'''
  388. if old not in content:
  389. print(f" {path.name}: ERROR - could not find should_advance block",
  390. file=sys.stderr)
  391. sys.exit(1)
  392. content = content.replace(old, new, 1)
  393. path.write_text(content)
  394. print(f" {path.name}: patched")
  395. if __name__ == "__main__":
  396. print("Applying structured output reasoning-boundary patch...")
  397. print(f" vLLM dir: {VLLM_DIR}")
  398. patch_request()
  399. patch_init()
  400. patch_scheduler()
  401. print("Done.")