Skip to content

Commit b4c0dc6

Browse files
pos-ei-donPhilipp Wacker
andauthored
feat(vllm): progressive streaming via parser.extract_tool_calls_streaming (follow-up to #10346) (#10351)
* fix(vllm): don't stream raw tool-call markup as content when a tool parser is active When a tool_parser is configured and the request carries tools, the streaming loop emitted every text delta as delta.content — including the model's raw tool-call markup (e.g. <tool_call>...) — because extract_tool_calls only runs on the full output after the stream. Clients streaming a tool call therefore saw the unparsed tool-call syntax as assistant content. Buffer the text while a tool parser is active for the request; the existing end-of-stream chat_delta already carries the parsed tool_calls (or the cleaned content), which the Go side converts to SSE deltas. Non-tool-parser streaming is unchanged. Add a server-less regression test covering both the tool-call case (no raw markup leaked as content) and the plain-text case (content delivered exactly once — guards against double-emitting the buffered content). Signed-off-by: pos-ei-don <1822533+pos-ei-don@users.noreply.github.com> * test(vllm): add expectedFailure test for progressive streaming with tool parser (Case 3, #582) Signed-off-by: pos-ei-don <1822533+pos-ei-don@users.noreply.github.com> * test(vllm): add Cases 4+5 — marker split across chunks + false-positive prefix (TDD, Option B state machine, #582) Signed-off-by: pos-ei-don <1822533+pos-ei-don@users.noreply.github.com> * feat(vllm): progressive streaming via parser.extract_tool_calls_streaming When a tool parser is active for a tool-enabled streaming request, #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 #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> * docs(vllm): add server-side TTFT benchmark for the streaming tool-parser path Self-contained stdlib-only script that measures time-to-first-token (TTFT) for the vLLM backend's two streaming scenarios: - tool_call: request mentions a tool; model is expected to call it - plain_text: request offers a tool but explicitly asks for prose Use this to compare: - the buffer-all path (#10346) → plain_text TTFT ≈ total response time - the native-streaming path (this PR) → plain_text TTFT ≈ true first-token time python examples/vllm-bench/ttft_streaming_tool_parser.py \\ --url http://localhost:8080 --model my-coder --runs 3 Lives under examples/ so it does not interfere with the test suite. Signed-off-by: pos-ei-don <1822533+pos-ei-don@users.noreply.github.com> * examples/vllm-bench: add long-text scenario (8 paragraphs, 1500 tokens) The long-text scenario shows the buffering vs streaming difference most dramatically: with the buffer-all path, the client receives nothing for 20+ seconds and then the entire 1500-token response at once. With native streaming, the first token arrives in tens of milliseconds and the response flows progressively. Signed-off-by: pos-ei-don <1822533+pos-ei-don@users.noreply.github.com> --------- Signed-off-by: pos-ei-don <1822533+pos-ei-don@users.noreply.github.com> Co-authored-by: Philipp Wacker <philipp.wacker@ibf-solutions.com>
1 parent 01fa12e commit b4c0dc6

4 files changed

Lines changed: 632 additions & 19 deletions

File tree

backend/python/vllm/backend.py

Lines changed: 145 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -598,23 +598,124 @@ async def _predict(self, request, context, streaming=False):
598598

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

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

@@ -639,16 +740,19 @@ async def _predict(self, request, context, streaming=False):
639740
except Exception as e:
640741
print(f"Reasoning parser error: {e}", file=sys.stderr)
641742

642-
if self.tool_parser_cls and request.Tools:
743+
# When (A) native streaming ran cleanly, per-delta yields above already
744+
# delivered everything — do NOT extract again on the full text or we'd
745+
# duplicate content/tool_calls into the final chunk.
746+
if has_tool_parser and not (native_streaming and not native_streaming_error):
643747
try:
644-
tools = json.loads(request.Tools)
645-
# Some concrete parsers only accept the tokenizer; only the
646-
# abstract base declares the tools kwarg. Try with tools first,
647-
# fall back to tokenizer-only.
648-
try:
649-
tp = self.tool_parser_cls(self.tokenizer, tools=tools)
650-
except TypeError:
651-
tp = self.tool_parser_cls(self.tokenizer)
748+
tp = tp_instance
749+
if tp is None:
750+
# Defensive: tp_instance build failed earlier; reconstruct.
751+
tools = json.loads(request.Tools)
752+
try:
753+
tp = self.tool_parser_cls(self.tokenizer, tools=tools)
754+
except TypeError:
755+
tp = self.tool_parser_cls(self.tokenizer)
652756
info = tp.extract_tool_calls(content, request=None)
653757
if info.tools_called:
654758
content = info.content or ""
@@ -661,6 +765,10 @@ async def _predict(self, request, context, streaming=False):
661765
))
662766
except Exception as e:
663767
print(f"Tool parser error: {e}", file=sys.stderr)
768+
elif native_streaming and not native_streaming_error:
769+
# Per-delta path already emitted content + tool_calls; the final
770+
# chat_delta should carry only metadata (token counts, logprobs).
771+
content = ""
664772

665773
# Extract token counts
666774
prompt_tokens = 0
@@ -700,7 +808,26 @@ async def _predict(self, request, context, streaming=False):
700808
)
701809

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

0 commit comments

Comments
 (0)