A tool call emitted inside the think block never reaches the tool parser, so vLLM returns HTTP 200 with a populated reasoning field, empty content, and an empty tool_calls array. The mechanism is three lines: the qwen3 reasoning parser splits on the closing think tag and hands everything before it to reasoning, and vLLM's own docs state that tool calling parses functions only from the content field. vLLM issue #39056, filed 2026-04-06 against v0.19.0 with Qwen3.5-35B-A3B-FP8, is still open on 2026-08-06 with 24 comments. The most repeated fix online, switching --tool-call-parser from qwen3_coder to qwen3_xml, is a no-op from v0.24.0 onward because PR #45413 collapsed both names onto one class. The second most repeated fix, upgrading to v0.20.0 for PR #35687, misses the case in the issue: that patch explicitly skips a paired tool_call block. The version floor where the code path was rewritten is v0.24.0 (2026-06-29), and v0.26.0 (2026-07-27) is current. Separately, vLLM removed reasoning_content from the response body in PR #33402 and it has been absent from v0.16.0 onward, so a client reading reasoning_content on a modern server gets nothing even when reasoning is full.
{
"choices": [{
"finish_reason": "stop",
"message": {
"content": "",
"reasoning": "...<tool_call>\n<function=Finish>\n<parameter=answer>\n204\n</parameter>\n</function>\n</tool_call>",
"tool_calls": []
}
}]
}HTTP 200. No exception, no warning line, no stack trace. The model decided to call a tool, the text of that call is sitting right there in the response, and tool_calls is an empty array. Your agent loop reads finish_reason: "stop" with zero tool calls, concludes the model is finished, and halts mid task.
That is the failure behind the query "tool calls not correctly parsed from the reasoning_content field", and it is one of the most under-documented serving-layer bugs in local LLM deployments right now. vLLM issue #39056, filed 2026-04-06 against v0.19.0 with Qwen3.5-35B-A3B-FP8, is still open on 2026-08-06 with 24 comments and 15 reactions. The two fixes repeated most often in forums and blog posts are both wrong on current releases. This post covers the actual mechanism, the version floors, and the smoke test that turns a silent swallow into a failed build.
The mechanism is three lines, and vLLM documents it
The issue body states the root cause in three numbered steps, and they are worth reading literally:
qwen3_reasoning_parser extracts everything before </think> into reasoning.content.<tool_call>...</tool_call> remains inside reasoning, it never reaches qwen3_coder.Step one is one line of Python. In vllm/reasoning/qwen3_reasoning_parser.py at tag v0.19.0:
# extract_reasoning(): everything before </think> is reasoning, full stop. reasoning, _, content = model_output.partition(self.end_token) # end_token == "</think>"
Step two is not a bug report, it is vLLM's own documentation. From docs/features/reasoning_outputs.md on main: "tool calling only parses functions from the content field, not from the reasoning." The two behaviours are individually correct and jointly catastrophic. There is no code path that inspects the reasoning channel for tool markup, so a tool call emitted before the closing think tag is dropped with no diagnostic anywhere.
There is a second, nastier variant in the same parser. With thinking enabled and no closing think tag anywhere in the generation, extract_reasoning() returns the entire generation as reasoning and None as content. The parser default leans toward this: at v0.19.0 the qwen3 parser reads chat_kwargs.get("enable_thinking", True), and the rewritten parser on main declares thinking: bool = True with the initial state set to reasoning. That is a serving-layer default, not a statement about what any vendor's chat template ships.
The same class of failure has open issue threads across the major open-weight serving stacks, which is what makes it worth a full write-up rather than a forum reply. If you are choosing between engines, our SGLang against vLLM inference engine comparison covers the wider tradeoff.
Contradiction 1: switching qwen3_coder to qwen3_xml is a no-op on current releases
This is the most repeated fix on forums, model-hub discussions and GPU vendor developer forums. The April 2026 reports that spread it describe pairing --tool-call-parser qwen3_xml with the updated chat template and getting a six-hour agent session that ran to completion, where the same template under qwen3_coder had gone silent on tool calls after about two hours.
It was true. It is now a no-op. At v0.19.0 those two flag values loaded two genuinely different implementations from two files, vllm/tool_parsers/qwen3coder_tool_parser.py and vllm/tool_parsers/qwen3xml_tool_parser.py. PR #45413, merged 2026-06-15 and first shipped in v0.24.0, replaced both with a single implementation. The registry in vllm/tool_parsers/__init__.py on current main reads:
"qwen3_coder": ("qwen3_engine_tool_parser", "Qwen3EngineToolParser"),
"qwen3_xml": ("qwen3_engine_tool_parser", "Qwen3EngineToolParser"),Same module, same class. From v0.24.0 onward, flipping that flag changes nothing whatsoever. Anyone on a recent release who "fixed" an outage by flipping it and watched the problem go away has been fooled by intermittency: the reporter in vLLM #50889 measured roughly 5 failures across thousands of requests over two days, which is exactly the frequency at which any config change looks like a cure. The advice failed to replicate even in its own thread, where one participant reported the opposite preference and another reported the patched parser was still buggy with both parser names.
Use qwen3_xml anyway, for one reason only: current docs head the section "Qwen3-Coder Models (qwen3_xml)" and give that as the flag. qwen3_coder survives as a registry alias.
Contradiction 2: PR #35687 and v0.20.0 do not cover the reported case
The second most repeated fix in the thread is "upgrade to v0.20.0, PR #35687 is the right upstream fix." That PR is real, merged 2026-04-24, and first released in v0.20.0 on 2026-04-27. It fixes a different shape. Its own description scopes it: Qwen3.5 models sometimes emit a tool_call inside the think block without closing the think tag first, at which point the whole output is classified as reasoning and the tool call is silently dropped.
Its implementation then explicitly excludes the paired case:
if tool_call_token_id is not None and token_id == tool_call_token_id:
# Only treat as implicit reasoning end if this <tool_call>
# is NOT followed by </tool_call>. Paired occurrences are
# template examples in the prompt, not model output.
if tool_call_end_token_id is not None and any(
input_ids[j] == tool_call_end_token_id
for j in range(i + 1, len(input_ids))
):
continue
return TrueIssue #39056's reproduction is a fully paired <tool_call>...</tool_call> sitting inside a properly closed think block. It hits continue and is skipped. That is why the issue is still open on 2026-08-06, and why PR #39055, the fix its own reporter wrote, was closed without merge on 2026-06-23.
The code path that does handle it is the parser engine from PR #45413, whose state machine in vllm/parser/qwen3.py carries an unconditional transition from the reasoning state on a tool start token, emitting a reasoning-end event followed by a tool-call-start event. The qwen3_engine_tool_parser module that PR introduced is absent at tags v0.20.0 through v0.23.0 and present at v0.24.0, v0.25.0 and v0.26.0. So v0.24.0 (2026-06-29) is the version floor where the code path was rewritten. It is not confirmed as the version that closes the issue: no reporter in the thread has verified the fix on a released v0.24.0 or later, and the issue remains open.
One unverified hypothesis is worth knowing about before you conclude a patched server is still broken. The v0.20.0 fix resolves its token via self.vocab.get("<tool_call>") and guards every implicit-end branch on that lookup not being None. If a given checkpoint or quantization tokenizes the tool_call marker as several ordinary tokens rather than one special token, the entire branch is inert and no error is raised. That would explain the "I applied the patch and it is still broken" reports, but it was not verified against any tokenizer config, so treat it as a hypothesis to test rather than a finding.
Contradiction 3: on a modern vLLM there is no reasoning_content field to read
The primary search query for this problem literally names reasoning_content, and most client wrappers still read message.reasoning_content. On a current vLLM that field does not exist in the response.
The break is one-directional and silent. Input still accepts the old name, output does not produce it, and nothing raises. vLLM's docs now say so outright: your client code could silently read an empty reasoning_content even when reasoning is populated. PR #50624 proved it against a live server running a Qwen3.6 NVFP4 checkpoint with --reasoning-parser qwen3: 310 streamed chunks, 299 carried reasoning, zero carried reasoning_content.
The practical consequence: a meaningful share of people reporting "empty tool_calls and empty reasoning_content" on a modern release have two independent problems stacked, and one of them is a renamed field rather than a parser bug. Diagnose the field name first. It makes the parser bug look worse than it is.
| Change | PR or RFC | Merged | Effect | Release boundary |
|---|---|---|---|---|
| Rename reasoning_content to reasoning | RFC #27755, PR #27752 | 2025-11-08 | Both fields emitted, one copied to the other for compatibility | v0.11.2 era |
| Remove reasoning_content from the output | #33402, part two #37480 | 2026-01-30 | Response JSON no longer carries reasoning_content at all | absent from v0.16.0 (2026-02-25) onward |
| Keep accepting reasoning_content on input | #42664 | 2026-05-21 | Incoming assistant messages are normalized to reasoning | v0.22.0 (2026-05-29) |
| Document the asymmetry as breaking | #50624 | 2026-08-03 | Docs now warn that clients reading reasoning_content get nothing | main |
The parser matrix across vLLM, SGLang and llama.cpp
Two rows deserve a caveat that the headlines get wrong.
The Gemma 4 row runs the opposite direction from the Qwen row. Its verbatim actual behaviour is "reasoning_content": null with the thinking text sitting in content. The cause is different too: vLLM decodes text with skip_special_tokens=True before the reasoning parser runs, so the channel markers the parser matches on are already gone, and the parser matched on text rather than token IDs. Its unit tests passed the whole time because they injected the markers as literal text, which serving never does. Fixed in PR #39027, shipped in v0.19.1.
The SGLang row is one reporter's measurement with zero maintainer comments and zero reactions on the issue. It is internally rigorous, with Fisher exact p-values and a zero-delta identity adapter as a kernel-path control, and it is unreplicated. The runtime LoRA path measured 51 of 1056 turns defective against 2 of 901 on the base route. The same weights merged and served on vLLM gave 2 of 107 with zero dead turns, so merging reduced the defect and changed its character rather than removing it.
And the speculative decoding row is the reminder that the parser config is not always the culprit. In vLLM #34650, MTP speculative decoding pre-incremented num_computed_tokens before the model ran, so the structured-output path's delta slice was always empty, the reasoning-end check never saw the closing tag, and the grammar was never enforced. That is a scheduler bug wearing a parser bug's costume. Related constraint mechanics are in our guide to constrained decoding instead of regex-parsing JSON.
| Engine and version | Reasoning parser | Tool parser | Model | Observed failure | Issue | State on 2026-08-06 |
|---|---|---|---|---|---|---|
| vLLM 0.19.0 | qwen3 | qwen3_coder | Qwen3.5-35B-A3B-FP8 | 200 OK, reasoning full, tool_calls empty, non-streaming | vllm #39056 | open |
| vLLM nightly, 0.19 to 0.20 era | qwen3 | qwen3_coder | Qwen3.6-27B INT4 | Streaming only: XML arrives as raw delta.content, no delta.tool_calls, finish_reason stop | vllm #39056 comment | open |
| vLLM 0.19.0 | qwen3 | qwen3_xml | Qwen3.5-27B AWQ 4-bit | tool_choice required returns an empty array, auto works | vllm #39056 comment | addressed by #39870 and #42292 |
| vLLM 0.18.2 dev build | gemma4 | none | Gemma 4 26B A4B | Inverse direction: reasoning_content null, thinking leaks into content | vllm #38855 | closed 2026-06-15, fix PR #39027 |
| vLLM 0.26.0, TP8 | gemma4 | gemma4 | Gemma 4 31B QAT W4A16 | finish_reason tool_calls with an empty tool_calls array, roughly 5 hits in 2 days | vllm #50889 | open |
| vLLM, 2x H100 NVL | deepseek_v4 | deepseek_v4 | DeepSeek V4 | No closing think tag, whole answer routed to reasoning_content, content empty, trailing EOS not stripped, 11 of 24 requests | vllm #48645 | open |
| vLLM 0.24.0 with MTP speculative decoding | deepseek_r1 or qwen3 | not applicable | Qwen3.5-397B-A17B and Qwen3.6 checkpoints | Closing think tag never detected, json_schema unenforced after thinking | vllm #34650 | closed 2026-07-23, PRs #44297 and #44993 |
| SGLang 0.5.14, runtime LoRA rank 32 | qwen3 | qwen3_coder | Qwen3.5-9B plus SFT adapter | 4.83% defective turns, call stranded in reasoning_content | sglang #30744 | open |
| SGLang | GLM detector | none | GLM with n above 1 plus JSON schema | content None, content misrouted to reasoning_content | sglang #22042 | closed 2026-08-02 |
| llama.cpp b8461 | reasoning-format auto | built-in jinja parser | Qwen3.5 9B Q4_K_XL GGUF | Prints tool calls as raw XML in content and stops, call sits inside the thinking block | llama.cpp #20837 | open, 57 comments |
| llama.cpp autoparser | auto | built-in | Step 3.7 Flash | Stuck in reasoning while trying to call a tool | llama.cpp #24181 | closed 2026-07-03 |
Contradiction 4: tool_choice "required" is the flag that guarantees an empty array
When tool_calls comes back empty, the reflex is to set tool_choice: "required" and force the issue. On vLLM with any XML-format parser below v0.21.0, that reflex is inverted.
The required branch of _parse_tool_calls_from_content() validated the content as JSON with TypeAdapter(list[FunctionDefinition]).validate_json(content), wrapped in contextlib.suppress(ValidationError). Qwen's XML-shaped tool output always raises that error, the suppression swallows it, and the function returns an empty list. Only the auto branch routes through tool_parser.extract_tool_calls(). So the flag people reach for to force a tool call is the flag that guarantees none.
PR #35936, which proposed the direct fix, was closed without merge. What landed was PR #39870 on 2026-04-17, then PR #41876, then PR #42292 whose title begins with "Restore supports_required_and_named for required tool_choice". That word "restore" means required-mode support regressed in between. Safe floor: v0.21.0 (2026-05-15). Below it, use tool_choice: "auto".
Two other flags matter while you are in here. VLLM_ENFORCE_STRICT_TOOL_CALLING defaults to True, and the docs note that with tool_choice: "auto", schema-level constraint requires both that default and at least one tool declared with strict: true. Otherwise vLLM extracts tool calls from raw text and arguments may occasionally be malformed. VLLM_TOOL_PARSE_REGEX_TIMEOUT_SECONDS defaults to 1.
Parser names are exact strings. A mistyped value raises KeyError: Tool parser 'qwen3xml' not found. from the tool parser registry, or KeyError: Reasoning parser '<name>' not found. Available parsers: ... from the reasoning registry. SGLang raises ValueError: Unsupported model type: <name> instead, and its family names are hyphenated (deepseek-r1, deepseek-v4, gpt-oss) where vLLM's are underscored. One user reported the mistyped vLLM name manifesting as a silent container exit in about 1.7 seconds with nothing useful in the logs, which is most likely a log-capture artifact rather than a different failure, since the source raises a plain KeyError.
Detect it before production, because nothing else will
In the on-premise deployments we audit at Particula Tech, this is among the classes of failure that tend to survive longest undetected: every dashboard stays green because every request returned 200. The only defence is asserting on the shape of the response.
def assert_no_swallow(msg: dict, finish_reason: str) -> None:
calls = msg.get("tool_calls") or []
# vLLM >= 0.16.0 emits "reasoning". Read both so this works across versions.
reasoning = msg.get("reasoning") or msg.get("reasoning_content") or ""
content = msg.get("content") or ""
if finish_reason == "tool_calls" and not calls:
raise AssertionError("INCONSISTENT: finish_reason=tool_calls with empty array")
if not calls:
blob = reasoning + content
if "<tool_call>" in blob or "<function=" in blob:
raise AssertionError(
f"PARSER SWALLOW: tool markup in reasoning/content but tool_calls "
f"is empty. reasoning={len(reasoning)}B content={len(content)}B "
f"finish_reason={finish_reason}"
)Run it in CI against the exact image and flags you deploy, non-streaming and streaming, because the streaming extractor fails independently of the non-streaming one. For the streaming path, count chunks by field, which is exactly how PR #50624 proved the field rename:
n_chunks = n_reasoning = n_reasoning_content = n_tool_deltas = 0
for chunk in stream:
d = chunk.choices[0].delta
n_chunks += 1
n_reasoning += bool(getattr(d, "reasoning", None))
n_reasoning_content += bool(getattr(d, "reasoning_content", None))
n_tool_deltas += len(getattr(d, "tool_calls", None) or [])
# n_reasoning_content == 0 on vLLM >= 0.16.0 is expected, not the bug.
# n_tool_deltas == 0 with finish_reason "stop" and XML in content IS the bug.In production, log reasoning length, content length and tool-call count on every turn, and raise an error-level line when the count is zero and the reasoning field contains tool markup. Note one wire-format change while you write the assertion: PR #44105, merged 2026-06-23 and shipped in v0.24.0, omits empty tool_calls arrays from chat responses entirely, so on v0.24.0 and later the field may be absent rather than an empty list. Assert on both. The broader question of what to measure for agent runs rather than for models is in agent reliability against accuracy, and the failure where the tool exists but the agent misuses it is in making AI agents use tools correctly.
What to do
Use vLLM v0.26.0 with --tool-call-parser qwen3_xml, and stop debating parser names.
docker run --gpus all --ipc=host -p 8000:8000 --shm-size 16g \ -v vllm-hf-cache:/root/.cache/huggingface \ vllm/vllm-openai:v0.26.0 \ Qwen/Qwen3.5-35B-A3B-FP8 \ --served-model-name qwen35 \ --reasoning-parser qwen3 \ --enable-auto-tool-choice \ --tool-call-parser qwen3_xml
reasoning, not reasoning_content.** Fix this before anything else. On v0.16.0 and later the old field is gone from the response and reading it returns nothing while the new one is full.--reasoning-parser entirely** and strip the think block client-side. Thinking then stays in content where the tool parser can see it. Disabling thinking on tool turns with --default-chat-template-kwargs is the third choice, not the first.tool_choice: "required" below v0.21.0** with an XML-format parser. Send "auto".--reasoning-format none and use -rea off rather than the deprecated --chat-template-kwargs form for enable_thinking. Note that the reasoning-format workaround is a commenter's report on an open issue with no maintainer confirmation and no merged fix, so validate it against your own build.The version floor is the closest thing to a fix, and it is still unconfirmed by anyone in the thread. Everything else on this list is a way to survive until you can move, and a smoke test is how you find out either way. Related serving-layer debugging is in our walkthrough of vLLM KV cache OOM failures, the offline path is in vLLM air-gapped deployment, and the wider model selection context sits in our open-weight LLM pillar guide.
FAQ
Quick answers to the questions this post tends to raise.




