Skip to content

Commit bddff60

Browse files
Philipp Wackerpos-ei-don
authored andcommitted
feat(vllm): progressive streaming via parser.extract_tool_calls_streaming
When a tool parser is active for a tool-enabled streaming request, mudler#10346 buffers the entire generation and surfaces it on the final chunk to prevent raw tool-call markup from leaking as delta.content. This is correct but turns the request into effectively non-streaming for plain-text responses — the client sees nothing until the model stops. Every concrete tool parser shipped with vLLM 0.23+ already implements extract_tool_calls_streaming (Granite4, Qwen3Coder, DeepSeekV31, Jamba, Ernie45, Hermes2Pro, llama3_json, mistral, …). Use it: instantiate the parser before the streaming loop and call its streaming method per delta, emitting DeltaMessage(content=…) or DeltaMessage(tool_calls=[…]) when the parser is ready. Falls back to the existing mudler#10346 buffer path when: - the parser does not have extract_tool_calls_streaming, OR - extract_tool_calls_streaming raises mid-stream (logged, the rest of the request finishes via post-loop extract_tool_calls). Tests (TestStreamingToolParser): 1. Buffer path: no markup leaked, no content duplication 2. Native streaming: plain-text response streams progressively 3. Native streaming: tool_call structured, no markup leaked 4. Native streaming exception → graceful fallback, no markup, no crash 5. No tool parser → unchanged per-delta content stream E2E verified against qwen3_coder on vLLM 0.23.0 (NVIDIA GB10 / arm64 / CUDA 13). Signed-off-by: pos-ei-don <1822533+pos-ei-don@users.noreply.github.com>
1 parent 4383419 commit bddff60

2 files changed

Lines changed: 327 additions & 263 deletions

File tree

backend/python/vllm/backend.py

Lines changed: 145 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -596,29 +596,124 @@ async def _predict(self, request, context, streaming=False):
596596

597597
# Stream the results
598598
generated_text = ""
599+
generated_token_ids: list[int] = []
599600
last_output = None
600-
# When a tool parser is active for a tool-enabled request, the model's
601-
# raw tool-call markup (e.g. <tool_call>...) must not be streamed as
602-
# delta.content — clients would otherwise see the unparsed syntax. Buffer
603-
# the text; the final chat_delta below carries the parsed tool_calls (or
604-
# the cleaned content), which the Go side converts to SSE deltas.
601+
602+
# Tool-parsing strategy decision (made once, before the loop):
603+
#
604+
# When a tool parser is active, the model's raw tool-call markup
605+
# (e.g. <tool_call>...) must not be streamed verbatim as delta.content
606+
# — clients would see the unparsed syntax. Two paths:
607+
#
608+
# (A) native streaming via parser.extract_tool_calls_streaming. All
609+
# concrete tool parsers shipped with vLLM 0.23+ implement this
610+
# (Granite4, Qwen3Coder, DeepSeekV31, Jamba, Ernie45, Hermes,
611+
# llama3_json, mistral, …). The parser decides per-delta whether
612+
# to emit content or suppress tool-call markup, and emits a
613+
# structured DeltaMessage(tool_calls=[...]) when a call is ready.
614+
# (B) buffer fallback — used only when the parser surprisingly lacks
615+
# the streaming method or it raises mid-stream. The post-loop
616+
# extract_tool_calls assembles the final chat_delta. Same correctness
617+
# guarantee as a non-streaming response, at the cost of a delayed
618+
# final chunk.
605619
has_tool_parser = bool(self.tool_parser_cls and request.Tools)
620+
tp_instance = None
621+
tp_request = None
622+
native_streaming = False
623+
native_streaming_error = False
624+
if has_tool_parser:
625+
try:
626+
tools_for_parser = json.loads(request.Tools)
627+
except json.JSONDecodeError:
628+
tools_for_parser = []
629+
try:
630+
tp_instance = self.tool_parser_cls(self.tokenizer, tools=tools_for_parser)
631+
except TypeError:
632+
tp_instance = self.tool_parser_cls(self.tokenizer)
633+
# Build a minimal ChatCompletionRequest so the streaming method
634+
# sees the tools list. We do not need any other request fields —
635+
# parsers only read .tools (and sometimes .tool_choice, which we
636+
# leave at default).
637+
try:
638+
from vllm.entrypoints.openai.chat_completion.protocol import (
639+
ChatCompletionRequest as _CCR,
640+
)
641+
tp_request = _CCR(
642+
model="local",
643+
messages=[{"role": "user", "content": ""}],
644+
tools=tools_for_parser or None,
645+
)
646+
except Exception as e:
647+
print(f"Could not build ChatCompletionRequest for streaming parser: {e}",
648+
file=sys.stderr)
649+
tp_request = None
650+
native_streaming = (
651+
tp_request is not None
652+
and hasattr(tp_instance, "extract_tool_calls_streaming")
653+
)
654+
606655
try:
607656
async for request_output in outputs:
608657
iteration_text = request_output.outputs[0].text
609658
last_output = request_output
610659

611-
if streaming and not has_tool_parser:
612-
# Remove text already sent as vllm concatenates the text from previous yields
660+
if streaming:
613661
delta_iteration_text = iteration_text.removeprefix(generated_text)
614-
# Send the partial result
615-
yield backend_pb2.Reply(
616-
message=bytes(delta_iteration_text, encoding='utf-8'),
617-
chat_deltas=[backend_pb2.ChatDelta(content=delta_iteration_text)],
618-
)
619-
620-
# Keep track of text generated
662+
new_token_ids = list(request_output.outputs[0].token_ids)
663+
delta_token_ids = new_token_ids[len(generated_token_ids):]
664+
665+
if not has_tool_parser:
666+
# Plain streaming — unchanged from pre-tool-parser path.
667+
yield backend_pb2.Reply(
668+
message=bytes(delta_iteration_text, encoding='utf-8'),
669+
chat_deltas=[backend_pb2.ChatDelta(content=delta_iteration_text)],
670+
)
671+
elif native_streaming and not native_streaming_error:
672+
# (A) Native vLLM extract_tool_calls_streaming.
673+
try:
674+
msg = tp_instance.extract_tool_calls_streaming(
675+
previous_text=generated_text,
676+
current_text=iteration_text,
677+
delta_text=delta_iteration_text,
678+
previous_token_ids=generated_token_ids,
679+
current_token_ids=new_token_ids,
680+
delta_token_ids=delta_token_ids,
681+
request=tp_request,
682+
)
683+
except Exception as e:
684+
print(f"Streaming tool parser error (falling back to "
685+
f"buffer for the rest of the stream): {e}",
686+
file=sys.stderr)
687+
native_streaming_error = True
688+
msg = None
689+
if msg is not None:
690+
tc_protos = []
691+
for tc in (msg.tool_calls or []):
692+
fn = tc.function or None
693+
tc_protos.append(backend_pb2.ToolCallDelta(
694+
index=tc.index,
695+
id=tc.id or "",
696+
name=(fn.name if fn and fn.name else "") or "",
697+
arguments=(fn.arguments if fn and fn.arguments else "") or "",
698+
))
699+
cd_kwargs = {}
700+
if msg.content:
701+
cd_kwargs["content"] = msg.content
702+
if msg.reasoning:
703+
cd_kwargs["reasoning_content"] = msg.reasoning
704+
if tc_protos:
705+
cd_kwargs["tool_calls"] = tc_protos
706+
if cd_kwargs:
707+
yield backend_pb2.Reply(
708+
message=bytes(msg.content or "", encoding='utf-8'),
709+
chat_deltas=[backend_pb2.ChatDelta(**cd_kwargs)],
710+
)
711+
# (B) buffer fallback — emit nothing during the stream.
712+
# The post-loop extract_tool_calls block builds the final chunk.
713+
714+
# Keep track of text + token_ids generated
621715
generated_text = iteration_text
716+
generated_token_ids = list(request_output.outputs[0].token_ids)
622717
finally:
623718
await outputs.aclose()
624719

@@ -643,16 +738,19 @@ async def _predict(self, request, context, streaming=False):
643738
except Exception as e:
644739
print(f"Reasoning parser error: {e}", file=sys.stderr)
645740

646-
if self.tool_parser_cls and request.Tools:
741+
# When (A) native streaming ran cleanly, per-delta yields above already
742+
# delivered everything — do NOT extract again on the full text or we'd
743+
# duplicate content/tool_calls into the final chunk.
744+
if has_tool_parser and not (native_streaming and not native_streaming_error):
647745
try:
648-
tools = json.loads(request.Tools)
649-
# Some concrete parsers only accept the tokenizer; only the
650-
# abstract base declares the tools kwarg. Try with tools first,
651-
# fall back to tokenizer-only.
652-
try:
653-
tp = self.tool_parser_cls(self.tokenizer, tools=tools)
654-
except TypeError:
655-
tp = self.tool_parser_cls(self.tokenizer)
746+
tp = tp_instance
747+
if tp is None:
748+
# Defensive: tp_instance build failed earlier; reconstruct.
749+
tools = json.loads(request.Tools)
750+
try:
751+
tp = self.tool_parser_cls(self.tokenizer, tools=tools)
752+
except TypeError:
753+
tp = self.tool_parser_cls(self.tokenizer)
656754
info = tp.extract_tool_calls(content, request=None)
657755
if info.tools_called:
658756
content = info.content or ""
@@ -665,6 +763,10 @@ async def _predict(self, request, context, streaming=False):
665763
))
666764
except Exception as e:
667765
print(f"Tool parser error: {e}", file=sys.stderr)
766+
elif native_streaming and not native_streaming_error:
767+
# Per-delta path already emitted content + tool_calls; the final
768+
# chat_delta should carry only metadata (token counts, logprobs).
769+
content = ""
668770

669771
# Extract token counts
670772
prompt_tokens = 0
@@ -704,7 +806,26 @@ async def _predict(self, request, context, streaming=False):
704806
)
705807

706808
if streaming:
707-
# Final chunk with structured data
809+
# Final chunk with structured data.
810+
#
811+
# If we used the buffer fallback (has_tool_parser=True AND native
812+
# streaming did NOT run cleanly) and the parser found no tool call,
813+
# flush the buffered content as ONE content delta — and clear the
814+
# final chat_delta's content so the metadata chunk does not repeat
815+
# what we just sent. This is the plain-text-with-tool-parser path.
816+
buffered_fallback = (
817+
has_tool_parser
818+
and not (native_streaming and not native_streaming_error)
819+
)
820+
if buffered_fallback and not tool_calls_proto and content:
821+
yield backend_pb2.Reply(
822+
message=bytes(content, encoding='utf-8'),
823+
chat_deltas=[backend_pb2.ChatDelta(content=content)],
824+
)
825+
chat_delta = backend_pb2.ChatDelta(
826+
reasoning_content=reasoning_content,
827+
tool_calls=tool_calls_proto,
828+
)
708829
yield backend_pb2.Reply(
709830
message=b"",
710831
prompt_tokens=prompt_tokens,

0 commit comments

Comments
 (0)