Skip to content

Commit 3ae349c

Browse files
committed
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>
1 parent 9ba8521 commit 3ae349c

2 files changed

Lines changed: 82 additions & 2 deletions

File tree

backend/python/vllm/backend.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -597,12 +597,18 @@ async def _predict(self, request, context, streaming=False):
597597
# Stream the results
598598
generated_text = ""
599599
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.
605+
has_tool_parser = bool(self.tool_parser_cls and request.Tools)
600606
try:
601607
async for request_output in outputs:
602608
iteration_text = request_output.outputs[0].text
603609
last_output = request_output
604610

605-
if streaming:
611+
if streaming and not has_tool_parser:
606612
# Remove text already sent as vllm concatenates the text from previous yields
607613
delta_iteration_text = iteration_text.removeprefix(generated_text)
608614
# Send the partial result

backend/python/vllm/test.py

Lines changed: 75 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -278,4 +278,78 @@ def test_embedding(self):
278278
print(err)
279279
self.fail("Embedding service failed")
280280
finally:
281-
self.tearDown()
281+
self.tearDown()
282+
283+
def test_streaming_tool_parser_buffering(self):
284+
"""
285+
When a tool parser is active and the request carries tools, streaming
286+
must NOT emit the model's raw tool-call markup as content, and must NOT
287+
duplicate the buffered content. Exercises _predict(streaming=True) with a
288+
mocked engine + tool parser (no server / GPU).
289+
"""
290+
import sys, os, asyncio
291+
from types import SimpleNamespace
292+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
293+
from backend import BackendServicer
294+
295+
def make_generate(chunks):
296+
async def gen(*a, **k):
297+
for t in chunks:
298+
yield SimpleNamespace(
299+
outputs=[SimpleNamespace(text=t, token_ids=[1], logprobs=None)],
300+
prompt_token_ids=[0],
301+
)
302+
return lambda *a, **k: gen()
303+
304+
def parser_cls(called, content, calls):
305+
class _P:
306+
def __init__(self, tokenizer, tools=None):
307+
pass
308+
def extract_tool_calls(self, c, request=None):
309+
return SimpleNamespace(tools_called=called, content=content, tool_calls=calls)
310+
return _P
311+
312+
def collect(servicer, req):
313+
async def run():
314+
return [r async for r in servicer._predict(req, None, streaming=True)]
315+
return asyncio.run(run())
316+
317+
def contents(replies):
318+
return [cd.content for r in replies for cd in r.chat_deltas if cd.content]
319+
320+
tools_json = '[{"type":"function","function":{"name":"calc"}}]'
321+
322+
# Case 1: model emits a tool call -> no raw markup as content, tool_call present.
323+
s = BackendServicer()
324+
s.reasoning_parser_cls = None
325+
s.tokenizer = None
326+
s.llm = SimpleNamespace(generate=make_generate([
327+
'<tool_call>\n{"name": "calc"',
328+
'<tool_call>\n{"name": "calc", "arguments": {"x": 1}}\n</tool_call>',
329+
]))
330+
call = SimpleNamespace(id="call_1", function=SimpleNamespace(name="calc", arguments='{"x": 1}'))
331+
s.tool_parser_cls = parser_cls(True, "", [call])
332+
req = backend_pb2.PredictOptions(Prompt="x", Tools=tools_json)
333+
replies = collect(s, req)
334+
self.assertFalse(
335+
any("<tool_call" in c or "<function" in c for c in contents(replies)),
336+
"raw tool-call markup leaked as streamed content",
337+
)
338+
names = [tc.name for r in replies for cd in r.chat_deltas for tc in cd.tool_calls]
339+
self.assertIn("calc", names, "structured tool_call was not emitted")
340+
341+
# Case 2: tools offered but model answers in plain text -> content once.
342+
s2 = BackendServicer()
343+
s2.reasoning_parser_cls = None
344+
s2.tokenizer = None
345+
s2.llm = SimpleNamespace(generate=make_generate([
346+
"The capital ",
347+
"The capital of France is Paris.",
348+
]))
349+
s2.tool_parser_cls = parser_cls(False, "", [])
350+
req2 = backend_pb2.PredictOptions(Prompt="x", Tools=tools_json)
351+
joined = "".join(contents(collect(s2, req2)))
352+
self.assertEqual(
353+
joined.count("The capital of France is Paris."), 1,
354+
f"buffered content was duplicated: {joined!r}",
355+
)

0 commit comments

Comments
 (0)