Skip to content

Commit a96a3d9

Browse files
author
Philipp Wacker
committed
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.
1 parent e92b0e9 commit a96a3d9

2 files changed

Lines changed: 211 additions & 0 deletions

File tree

examples/vllm-bench/README.md

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
# vLLM streaming + tool-parser benchmark
2+
3+
A small, self-contained Python script (stdlib only) that measures
4+
time-to-first-token (TTFT) for the vLLM backend's streaming path with
5+
a tool parser configured.
6+
7+
## Why this exists
8+
9+
When a vLLM tool parser is active and a streaming chat completion is requested,
10+
LocalAI used to buffer the full generation to prevent raw tool-call markup
11+
(e.g. `<tool_call>...`) from leaking as `delta.content`. That was correct
12+
for tool-call responses, but it turned plain-text responses into effectively
13+
non-streaming — the client received nothing until the model finished.
14+
15+
With native parser-side streaming (`parser.extract_tool_calls_streaming`,
16+
implemented by every concrete vLLM 0.23+ tool parser), each delta can be
17+
classified per-token: emit as content, emit as a structured tool_call, or
18+
suppress. This benchmark shows the difference.
19+
20+
## Two scenarios
21+
22+
| Scenario | Request | Expected outcome |
23+
|---|---|---|
24+
| `tool_call` | "What is the weather in Paris? Please use the tool." | Model calls `get_weather`. `delta.tool_calls` chunks; no content leak. |
25+
| `plain_text` | "Explain in 3 short sentences what a hash table is. Do NOT call any tool." | Model writes prose. With the streaming parser, content streams progressively; without it, the entire response arrives in one chunk. |
26+
27+
## What the script reports
28+
29+
For each scenario, across N runs:
30+
31+
- `ttf_content_s` — time until the first `delta.content` chunk
32+
- `ttf_tool_s` — time until the first `delta.tool_calls` chunk
33+
- `n_content_chunks` — total number of distinct content deltas (1 = bundled, >1 = streamed)
34+
- `n_tool_chunks` — total tool_call deltas
35+
- `total_s` — total wall-clock until `[DONE]`
36+
- `finish_reason``tool_calls` / `stop` / `length`
37+
38+
## Usage
39+
40+
```bash
41+
python ttft_streaming_tool_parser.py --url http://localhost:8080 --model my-coder --runs 3
42+
```
43+
44+
JSON results are written to `ttft_bench_<label>.json` (default label: `run`).
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
#!/usr/bin/env python3
2+
"""
3+
TTFT benchmark for the vLLM backend's streaming + tool-parser path.
4+
5+
Measures time-to-first-token (TTFT) for two scenarios against a running
6+
LocalAI instance with a vLLM-backed chat model:
7+
8+
1. tool_call — request mentions a tool; model is expected to call it
9+
2. plain_text — request offers a tool but explicitly asks for prose
10+
11+
Useful to validate the difference between:
12+
- the buffer-all path (#10346): plain_text TTFT ≈ total response time
13+
- the native-streaming path (this PR): plain_text TTFT ≈ true first-token time
14+
15+
Usage:
16+
python ttft_streaming_tool_parser.py \\
17+
--url http://localhost:8080 --model my-coder --runs 3
18+
19+
The script is self-contained (stdlib only — urllib, json, time, argparse).
20+
"""
21+
import argparse
22+
import json
23+
import sys
24+
import time
25+
import urllib.request
26+
27+
DEFAULT_TOOLS = [{
28+
"type": "function",
29+
"function": {
30+
"name": "get_weather",
31+
"description": "Get current weather for a city",
32+
"parameters": {
33+
"type": "object",
34+
"properties": {"city": {"type": "string"}},
35+
"required": ["city"],
36+
},
37+
},
38+
}]
39+
40+
SCENARIOS = [
41+
{
42+
"label": "tool_call",
43+
"messages": [{"role": "user",
44+
"content": "What is the weather in Paris? Please use the tool."}],
45+
"max_tokens": 80,
46+
},
47+
{
48+
"label": "plain_text",
49+
"messages": [{"role": "user",
50+
"content": "Explain in 3 short sentences what a hash table is. "
51+
"Do NOT call any tool."}],
52+
"max_tokens": 200,
53+
},
54+
]
55+
56+
57+
def bench_one(url, model, messages, tools, max_tokens, timeout):
58+
body = json.dumps({
59+
"model": model,
60+
"stream": True,
61+
"tools": tools,
62+
"messages": messages,
63+
"max_tokens": max_tokens,
64+
}).encode()
65+
req = urllib.request.Request(
66+
f"{url.rstrip('/')}/v1/chat/completions",
67+
data=body, headers={"Content-Type": "application/json"},
68+
)
69+
70+
t0 = time.perf_counter()
71+
first_content = None
72+
first_tool = None
73+
n_content = 0
74+
n_tool = 0
75+
last = None
76+
finish = None
77+
with urllib.request.urlopen(req, timeout=timeout) as resp:
78+
for line in resp:
79+
line = line.decode("utf-8", "replace").strip()
80+
if not line.startswith("data: "):
81+
continue
82+
payload = line[6:]
83+
if payload == "[DONE]":
84+
break
85+
try:
86+
chunk = json.loads(payload)
87+
except Exception:
88+
continue
89+
if not chunk.get("choices"):
90+
continue
91+
ch = chunk["choices"][0]
92+
delta = ch.get("delta") or {}
93+
now = time.perf_counter() - t0
94+
if delta.get("content"):
95+
if first_content is None:
96+
first_content = now
97+
n_content += 1
98+
if delta.get("tool_calls"):
99+
if first_tool is None:
100+
first_tool = now
101+
n_tool += 1
102+
if ch.get("finish_reason"):
103+
finish = ch["finish_reason"]
104+
last = now
105+
return {
106+
"ttf_content_s": first_content,
107+
"ttf_tool_s": first_tool,
108+
"n_content_chunks": n_content,
109+
"n_tool_chunks": n_tool,
110+
"total_s": last,
111+
"finish_reason": finish,
112+
}
113+
114+
115+
def stats(values):
116+
values = [v for v in values if v is not None]
117+
if not values:
118+
return "n/a"
119+
return f"min={min(values):.3f} avg={sum(values)/len(values):.3f} max={max(values):.3f}"
120+
121+
122+
def main():
123+
p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
124+
p.add_argument("--url", default="http://localhost:8080",
125+
help="LocalAI base URL (default: %(default)s)")
126+
p.add_argument("--model", default="coder", help="Model name (default: %(default)s)")
127+
p.add_argument("--runs", type=int, default=3, help="Repetitions per scenario (default: %(default)s)")
128+
p.add_argument("--timeout", type=int, default=120, help="Per-request timeout in seconds")
129+
p.add_argument("--label", default="run",
130+
help="Tag for the JSON output file (default: %(default)s)")
131+
args = p.parse_args()
132+
133+
print(f"=== TTFT Bench — {args.url} model={args.model} runs={args.runs} ===")
134+
summary = {}
135+
for sc in SCENARIOS:
136+
print(f"\nScenario: {sc['label']}")
137+
rows = []
138+
for run in range(args.runs):
139+
r = bench_one(args.url, args.model,
140+
sc["messages"], DEFAULT_TOOLS, sc["max_tokens"], args.timeout)
141+
rows.append(r)
142+
ttf_c = f"{r['ttf_content_s']:.3f}" if r["ttf_content_s"] is not None else "—"
143+
ttf_t = f"{r['ttf_tool_s']:.3f}" if r["ttf_tool_s"] is not None else "—"
144+
print(f" run {run+1}/{args.runs}: "
145+
f"ttf_content={ttf_c}s ttf_tool={ttf_t}s "
146+
f"n_content={r['n_content_chunks']} n_tool={r['n_tool_chunks']} "
147+
f"total={r['total_s']:.2f}s finish={r['finish_reason']}")
148+
summary[sc["label"]] = rows
149+
150+
print("\n=== Summary (per scenario) ===")
151+
for label, rows in summary.items():
152+
print(f"[{label}]")
153+
print(f" ttf_content_s: {stats(r['ttf_content_s'] for r in rows)}")
154+
print(f" ttf_tool_s: {stats(r['ttf_tool_s'] for r in rows)}")
155+
print(f" n_content_chunks: {stats(r['n_content_chunks'] for r in rows)}")
156+
print(f" n_tool_chunks: {stats(r['n_tool_chunks'] for r in rows)}")
157+
print(f" total_s: {stats(r['total_s'] for r in rows)}")
158+
159+
out = f"ttft_bench_{args.label}.json"
160+
with open(out, "w") as f:
161+
json.dump(summary, f, indent=2)
162+
print(f"\nSaved to {out}")
163+
return 0
164+
165+
166+
if __name__ == "__main__":
167+
sys.exit(main())

0 commit comments

Comments
 (0)