[Frontend] Expose per-request spec decode metrics in generate API (#43310)
Signed-off-by: aoshen02 <aoshen@inferact.ai> Co-authored-by: aoshen02 <aoshen@inferact.ai> Co-authored-by: Codex <codex@openai.com>
Hongxin Xu committed
Sep 20, 2026 at 20:21 UTC
10e6a7f21094b76c65efb029b39b3f2227516418
12 files changed
+516
-9
rust/src/engine-core-client/src/protocol/output.rs
+29
-3
@@ -130,10 +130,36 @@ pub struct EngineCoreOutput {
130
#[serde(default)]
131
pub new_sampling_mask: Option<MaybeWireSamplingMask>,
132
/// Per-request speculative-decoding acceptance metrics, set on the final
133
- /// output when `--per-request-spec-decode-metrics` is enabled. Opaque here;
134
- /// the Rust frontend does not yet surface it in responses.
133
+ /// output when `--per-request-spec-decode-metrics` is enabled.
134
#[serde(default)]
136
- pub spec_decode_metrics: Option<OpaqueValue>,
135
+ pub spec_decode_metrics: Option<RequestSpecDecodeMetrics>,
136
+}
137
+
138
+/// Raw per-sequence speculative-decoding accumulator.
139
+///
140
+/// Python models this as a plain `@dataclass`, so it is serialized by msgspec
141
+/// as a map (named fields). Derived values such as acceptance rates are
142
+/// computed by the frontend, not carried on the wire.
143
+///
144
+/// Original Python definition: `RequestSpecDecodeMetrics` in
145
+/// `vllm/v1/metrics/stats.py`.
146
+#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
147
+pub struct RequestSpecDecodeMetrics {
148
+ /// Configured `num_speculative_tokens`.
149
+ #[serde(default)]
150
+ pub num_spec_tokens: u64,
151
+ /// Verify-step counts indexed by accepted draft-token count.
152
+ #[serde(default)]
153
+ pub histogram: Vec<u64>,
154
+ /// Total proposed draft tokens.
155
+ #[serde(default)]
156
+ pub num_draft_tokens: u64,
157
+ /// Accepted draft count per verify step; empty unless `detailed`.
158
+ #[serde(default)]
159
+ pub per_step_accepted: Vec<u64>,
160
+ /// Proposed draft count per verify step; empty unless `detailed`.
161
+ #[serde(default)]
162
+ pub per_step_drafted: Vec<u64>,
163
}
164
165
impl EngineCoreOutput {
rust/src/llm/src/output.rs
+11
-1
@@ -11,7 +11,9 @@ use futures::stream::FusedStream;
11
use futures::{Stream, StreamExt as _, pin_mut};
12
use serde::{Deserialize, Serialize};
13
use vllm_engine_core_client::protocol::logprobs::Logprobs;
14
-use vllm_engine_core_client::protocol::output::{EngineCoreFinishReason, StopReason};
14
+use vllm_engine_core_client::protocol::output::{
15
+ EngineCoreFinishReason, RequestSpecDecodeMetrics, StopReason,
16
+};
17
use vllm_engine_core_client::protocol::sampling_mask::SamplingMask;
18
use vllm_engine_core_client::{AbortCause, EngineCoreOutputStream};
19
@@ -47,6 +49,8 @@ pub struct CollectedGenerateOutput {
49
pub ec_transfer_params: Option<serde_json::Value>,
50
/// Sampling support sets aligned one-to-one with generated token positions.
51
pub sampling_mask: Option<SamplingMask>,
52
+ /// Per-request speculative-decoding metrics from the terminal output.
53
+ pub spec_decode_metrics: Option<RequestSpecDecodeMetrics>,
54
}
55
56
/// Prompt-scoped metadata emitted only once on the first [`GenerateOutput`] for
@@ -160,6 +164,8 @@ pub struct GenerateOutput {
164
pub ec_transfer_params: Option<serde_json::Value>,
165
/// Sampling support sets aligned one-to-one with `token_ids`.
166
pub sampling_mask: Option<SamplingMask>,
167
+ /// Per-request speculative-decoding metrics, present on terminal outputs.
168
+ pub spec_decode_metrics: Option<RequestSpecDecodeMetrics>,
169
}
170
171
impl GenerateOutput {
@@ -208,6 +214,7 @@ impl GenerateOutput {
214
kv_transfer_params: None,
215
ec_transfer_params: None,
216
sampling_mask: None,
217
+ spec_decode_metrics: None,
218
}
219
}
220
}
@@ -308,6 +315,7 @@ impl Stream for GenerateOutputStream {
315
kv_transfer_params: raw.kv_transfer_params,
316
ec_transfer_params: raw.ec_transfer_params,
317
sampling_mask,
318
+ spec_decode_metrics: raw.spec_decode_metrics,
319
};
320
321
Poll::Ready(Some(Ok(output)))
@@ -396,6 +404,7 @@ impl<T: Stream<Item = Result<GenerateOutput>> + Send> T {
404
kv_transfer_params: None,
405
ec_transfer_params: None,
406
sampling_mask,
407
+ spec_decode_metrics: None,
408
});
409
}
410
@@ -409,6 +418,7 @@ impl<T: Stream<Item = Result<GenerateOutput>> + Send> T {
418
};
419
collected.kv_transfer_params = output.kv_transfer_params;
420
collected.ec_transfer_params = output.ec_transfer_params;
421
+ collected.spec_decode_metrics = output.spec_decode_metrics;
422
if let Some(mask) = collected.sampling_mask.as_ref()
423
&& mask.rows.len() != collected.token_ids.len()
424
{
rust/src/llm/tests/generate.rs
+1
@@ -413,6 +413,7 @@ async fn collect_output_rejects_partial_sampling_mask() {
413
sampling_mask: Some(SamplingMask {
414
rows: vec![vec![1, 33, 99]],
415
}),
416
+ spec_decode_metrics: None,
417
};
418
419
let error = futures::stream::iter([Ok(output)]).collect_output().await.unwrap_err();
rust/src/server/src/routes/inference/generate.rs
+140
-1
@@ -29,7 +29,8 @@ use vllm_llm::{
29
use self::convert::{ResponseOptions, prepare_generate_request};
30
use self::types::{
31
GenerateLogprob, GenerateResponse, GenerateResponseChoice, GenerateResponseStreamChoice,
32
- GenerateStreamResponse, MultiModalPlaceholders, PlaceholderRangeInfo,
32
+ GenerateStreamResponse, MultiModalPlaceholders, PerRequestMetrics, PlaceholderRangeInfo,
33
+ SpeculativeDecodingMetrics, StreamingSpeculativeDecodingMetrics,
34
};
35
pub(crate) use self::types::{GenerateRequest, GenerateSamplingParams};
36
pub(crate) use self::validate::validate_request_compat;
@@ -160,10 +161,14 @@ async fn generate_chunk_stream(
161
let mut prompt_tokens = None;
162
let mut prompt_token_ids = None;
163
let mut usage = TokenUsage::default();
164
+ let mut spec_decode_metrics = None;
165
166
while let Some(next) = stream.next().await {
167
match next {
168
Ok(output) => {
169
+ if let Some(metrics) = output.spec_decode_metrics {
170
+ spec_decode_metrics = Some(SpeculativeDecodingMetrics::from(metrics));
171
+ }
172
if prompt_tokens.is_none()
173
&& let Some(info) = output.prompt_info.as_ref()
174
{
@@ -223,6 +228,7 @@ async fn generate_chunk_stream(
228
.then(|| Usage::from_token_usage(usage, enable_prompt_tokens_details)),
229
mm_placeholders: prompt_token_ids.as_ref().and_then(|_| mm_placeholders.take()),
230
prompt_token_ids,
231
+ metrics: None,
232
})
233
.await;
234
}
@@ -243,6 +249,11 @@ async fn generate_chunk_stream(
249
usage: Some(Usage::from_token_usage(usage, enable_prompt_tokens_details)),
250
prompt_token_ids: None,
251
mm_placeholders: None,
252
+ metrics: spec_decode_metrics.map(|speculative_decoding| PerRequestMetrics {
253
+ speculative_decoding: StreamingSpeculativeDecodingMetrics::from(
254
+ speculative_decoding,
255
+ ),
256
+ }),
257
})
258
.await;
259
}
@@ -318,6 +329,9 @@ fn collect_generate(
329
mm_placeholders: return_token_ids.then_some(mm_placeholders).flatten(),
330
kv_transfer_params: collected.kv_transfer_params,
331
ec_transfer_params: collected.ec_transfer_params,
332
+ metrics: collected.spec_decode_metrics.map(|metrics| PerRequestMetrics {
333
+ speculative_decoding: SpeculativeDecodingMetrics::from(metrics),
334
+ }),
335
})
336
}
337
@@ -465,6 +479,7 @@ mod tests {
479
480
use futures::{TryStreamExt as _, stream};
481
use vllm_engine_core_client::protocol::multimodal::{MmModality, PlaceholderRange};
482
+ use vllm_engine_core_client::protocol::output::RequestSpecDecodeMetrics;
483
use vllm_llm::GeneratePromptInfo;
484
485
use super::*;
@@ -482,6 +497,7 @@ mod tests {
497
kv_transfer_params: None,
498
ec_transfer_params: None,
499
sampling_mask: None,
500
+ spec_decode_metrics: None,
501
}),
502
Ok(GenerateOutput {
503
request_id: String::new(),
@@ -496,6 +512,7 @@ mod tests {
512
kv_transfer_params: None,
513
ec_transfer_params: None,
514
sampling_mask: None,
515
+ spec_decode_metrics: None,
516
}),
517
]);
518
@@ -578,6 +595,7 @@ mod tests {
595
kv_transfer_params: None,
596
ec_transfer_params: None,
597
sampling_mask: None,
598
+ spec_decode_metrics: None,
599
}
600
}
601
@@ -645,6 +663,80 @@ mod tests {
663
assert!(chunks[1].mm_placeholders.is_none());
664
}
665
666
+ #[tokio::test]
667
+ async fn generate_chunk_stream_returns_terminal_spec_decode_metrics() {
668
+ let metrics = RequestSpecDecodeMetrics {
669
+ num_spec_tokens: 3,
670
+ histogram: vec![0, 1, 1, 0],
671
+ num_draft_tokens: 6,
672
+ ..Default::default()
673
+ };
674
+ let mut output = stream_output(None, Vec::new(), Some(FinishReason::stop_eos()));
675
+ output.spec_decode_metrics = Some(metrics.clone());
676
+
677
+ for include_usage in [false, true] {
678
+ let chunks: Vec<_> = generate_chunk_stream(
679
+ stream::iter(vec![Ok(output.clone())]),
680
+ "raw-stream".to_string(),
681
+ ApiServerOptions::default(),
682
+ ResponseOptions {
683
+ include_usage,
684
+ ..Default::default()
685
+ },
686
+ None,
687
+ )
688
+ .try_collect()
689
+ .await
690
+ .expect("collect chunks");
691
+ assert_eq!(chunks.len(), 1 + usize::from(include_usage));
692
+ assert!(chunks[0].metrics.is_none());
693
+ if include_usage {
694
+ assert!(chunks[1].choices.is_empty());
695
+ assert_eq!(
696
+ chunks[1].metrics,
697
+ Some(PerRequestMetrics {
698
+ speculative_decoding: StreamingSpeculativeDecodingMetrics::from(
699
+ SpeculativeDecodingMetrics::from(metrics.clone())
700
+ )
701
+ })
702
+ );
703
+ let json = serde_json::to_value(&chunks[1]).expect("serialize chunk");
704
+ let stats = &json["metrics"]["speculative_decoding"];
705
+ assert_eq!(stats.as_object().expect("metrics object").len(), 7);
706
+ assert_eq!(stats["num_draft_tokens"], 6);
707
+ assert_eq!(stats["num_accepted_draft_tokens"], 3);
708
+ assert_eq!(stats["mean_acceptance_length"], 2.5);
709
+ assert_eq!(
710
+ stats["acceptance_histogram"],
711
+ serde_json::json!([0, 1, 1, 0])
712
+ );
713
+ assert!(stats.get("per_step_accepted").is_none());
714
+ }
715
+ }
716
+ }
717
+
718
+ #[test]
719
+ fn spec_decode_metrics_zero_steps_and_detailed() {
720
+ let empty = SpeculativeDecodingMetrics::from(RequestSpecDecodeMetrics {
721
+ num_spec_tokens: 2,
722
+ histogram: vec![0, 0, 0],
723
+ ..Default::default()
724
+ });
725
+ assert_eq!(empty.mean_acceptance_length, 1.0);
726
+ assert_eq!(empty.draft_acceptance_rate, 0.0);
727
+ assert_eq!(empty.per_step_accepted, None);
728
+
729
+ let detailed = SpeculativeDecodingMetrics::from(RequestSpecDecodeMetrics {
730
+ num_spec_tokens: 2,
731
+ histogram: vec![0, 1, 1],
732
+ num_draft_tokens: 4,
733
+ per_step_accepted: vec![1, 2],
734
+ per_step_drafted: vec![2, 2],
735
+ });
736
+ assert_eq!(detailed.per_step_accepted, Some(vec![1, 2]));
737
+ assert_eq!(detailed.per_step_drafted, Some(vec![2, 2]));
738
+ }
739
+
740
#[test]
741
fn collect_generate_omits_prompt_metadata_by_default() {
742
let output = CollectedGenerateOutput {
@@ -662,6 +754,7 @@ mod tests {
754
ec_transfer_params: None,
755
prompt_token_ids: vec![10, 20],
756
sampling_mask: None,
757
+ spec_decode_metrics: None,
758
};
759
760
let response = collect_generate(
@@ -683,6 +776,50 @@ mod tests {
776
assert!(response.mm_placeholders.is_none());
777
}
778
779
+ #[test]
780
+ fn collect_generate_returns_spec_decode_metrics() {
781
+ let metrics = RequestSpecDecodeMetrics {
782
+ num_spec_tokens: 3,
783
+ histogram: vec![0, 1, 1, 0],
784
+ num_draft_tokens: 6,
785
+ ..Default::default()
786
+ };
787
+ let output = CollectedGenerateOutput {
788
+ request_id: "raw-metrics".to_string(),
789
+ prompt_logprobs: None,
790
+ token_ids: vec![30],
791
+ logprobs: None,
792
+ finish_reason: FinishReason::stop_eos(),
793
+ usage: TokenUsage::default(),
794
+ kv_transfer_params: None,
795
+ ec_transfer_params: None,
796
+ prompt_token_ids: vec![10],
797
+ sampling_mask: None,
798
+ spec_decode_metrics: Some(metrics.clone()),
799
+ };
800
+
801
+ let response = collect_generate(
802
+ output,
803
+ "raw-metrics".to_string(),
804
+ ApiServerOptions::default(),
805
+ ResponseOptions::default(),
806
+ None,
807
+ )
808
+ .expect("response");
809
+
810
+ assert_eq!(
811
+ response.metrics,
812
+ Some(PerRequestMetrics {
813
+ speculative_decoding: SpeculativeDecodingMetrics::from(metrics)
814
+ })
815
+ );
816
+ let json = serde_json::to_value(&response).expect("serialize response");
817
+ let stats = &json["metrics"]["speculative_decoding"];
818
+ assert_eq!(stats.as_object().expect("metrics object").len(), 9);
819
+ assert!(stats["per_step_accepted"].is_null());
820
+ assert!(stats["per_step_drafted"].is_null());
821
+ }
822
+
823
#[test]
824
fn extract_mm_placeholders_returns_none_without_features() {
825
assert!(extract_mm_placeholders(None).is_none());
@@ -749,6 +886,7 @@ mod tests {
886
ec_transfer_params: None,
887
prompt_token_ids: vec![10, 20],
888
sampling_mask: None,
889
+ spec_decode_metrics: None,
890
};
891
892
let response = collect_generate(
@@ -782,6 +920,7 @@ mod tests {
920
kv_transfer_params: None,
921
ec_transfer_params: None,
922
sampling_mask: None,
923
+ spec_decode_metrics: None,
924
prompt_token_ids,
925
};
926
rust/src/server/src/routes/inference/generate/types.rs
+87
@@ -7,6 +7,7 @@ use llm_multimodal::MediaContentPart;
7
use serde::{Deserialize, Serialize};
8
use serde_json::{Map, Value};
9
use validator::Validate;
10
+use vllm_engine_core_client::protocol::output::RequestSpecDecodeMetrics;
11
use vllm_text::SamplingParams;
12
13
use crate::routes::openai::utils::types::{ChatLogProbs, Normalizable, StreamOptions, Usage};
@@ -84,6 +85,7 @@ pub(super) struct GenerateStreamResponse {
85
pub usage: Option<Usage>,
86
pub prompt_token_ids: Option<Vec<u32>>,
87
pub mm_placeholders: Option<MultiModalPlaceholders>,
88
+ pub metrics: Option<PerRequestMetrics<StreamingSpeculativeDecodingMetrics>>,
89
}
90
91
/// Mirrors the Python vLLM `GenerateResponse` class.
@@ -96,6 +98,91 @@ pub(super) struct GenerateResponse {
98
pub mm_placeholders: Option<MultiModalPlaceholders>,
99
pub kv_transfer_params: Option<Value>,
100
pub ec_transfer_params: Option<Value>,
101
+ pub metrics: Option<PerRequestMetrics<SpeculativeDecodingMetrics>>,
102
+}
103
+
104
+#[derive(Debug, Clone, PartialEq, Serialize)]
105
+pub(super) struct PerRequestMetrics<T> {
106
+ pub speculative_decoding: T,
107
+}
108
+
109
+/// Mirrors the Python vLLM `SpeculativeDecodingMetrics` class.
110
+///
111
+/// Derived from the raw engine accumulator the same way as Python
112
+/// `RequestSpecDecodeMetrics.to_dict`.
113
+#[derive(Debug, Clone, PartialEq, Serialize)]
114
+pub(super) struct SpeculativeDecodingMetrics {
115
+ pub mean_acceptance_length: f64,
116
+ pub draft_acceptance_rate: f64,
117
+ pub acceptance_histogram: Vec<u64>,
118
+ pub num_spec_steps: u64,
119
+ pub num_accepted_draft_tokens: u64,
120
+ pub num_draft_tokens: u64,
121
+ pub num_spec_tokens: u64,
122
+ pub per_step_accepted: Option<Vec<u64>>,
123
+ pub per_step_drafted: Option<Vec<u64>>,
124
+}
125
+
126
+impl From<RequestSpecDecodeMetrics> for SpeculativeDecodingMetrics {
127
+ fn from(raw: RequestSpecDecodeMetrics) -> Self {
128
+ let num_spec_steps: u64 = raw.histogram.iter().sum();
129
+ let num_accepted_draft_tokens: u64 =
130
+ (0u64..).zip(&raw.histogram).map(|(accepted, count)| accepted * count).sum();
131
+ let ratio = |num: u64, den: u64| {
132
+ if den == 0 {
133
+ 0.0
134
+ } else {
135
+ num as f64 / den as f64
136
+ }
137
+ };
138
+ let detailed = !raw.per_step_accepted.is_empty();
139
+ Self {
140
+ mean_acceptance_length: if num_spec_steps == 0 {
141
+ 1.0
142
+ } else {
143
+ 1.0 + ratio(num_accepted_draft_tokens, num_spec_steps)
144
+ },
145
+ draft_acceptance_rate: ratio(num_accepted_draft_tokens, raw.num_draft_tokens),
146
+ acceptance_histogram: raw.histogram,
147
+ num_spec_steps,
148
+ num_accepted_draft_tokens,
149
+ num_draft_tokens: raw.num_draft_tokens,
150
+ num_spec_tokens: raw.num_spec_tokens,
151
+ per_step_accepted: detailed.then_some(raw.per_step_accepted),
152
+ per_step_drafted: detailed.then_some(raw.per_step_drafted),
153
+ }
154
+ }
155
+}
156
+
157
+/// Streaming form omits detailed fields when summary metrics are requested.
158
+#[serde_with::skip_serializing_none]
159
+#[derive(Debug, Clone, PartialEq, Serialize)]
160
+pub(super) struct StreamingSpeculativeDecodingMetrics {
161
+ pub mean_acceptance_length: f64,
162
+ pub draft_acceptance_rate: f64,
163
+ pub acceptance_histogram: Vec<u64>,
164
+ pub num_spec_steps: u64,
165
+ pub num_accepted_draft_tokens: u64,
166
+ pub num_draft_tokens: u64,
167
+ pub num_spec_tokens: u64,
168
+ pub per_step_accepted: Option<Vec<u64>>,
169
+ pub per_step_drafted: Option<Vec<u64>>,
170
+}
171
+
172
+impl From<SpeculativeDecodingMetrics> for StreamingSpeculativeDecodingMetrics {
173
+ fn from(metrics: SpeculativeDecodingMetrics) -> Self {
174
+ Self {
175
+ mean_acceptance_length: metrics.mean_acceptance_length,
176
+ draft_acceptance_rate: metrics.draft_acceptance_rate,
177
+ acceptance_histogram: metrics.acceptance_histogram,
178
+ num_spec_steps: metrics.num_spec_steps,
179
+ num_accepted_draft_tokens: metrics.num_accepted_draft_tokens,
180
+ num_draft_tokens: metrics.num_draft_tokens,
181
+ num_spec_tokens: metrics.num_spec_tokens,
182
+ per_step_accepted: metrics.per_step_accepted,
183
+ per_step_drafted: metrics.per_step_drafted,
184
+ }
185
+ }
186
}
187
188
pub(super) type MultiModalPlaceholders = HashMap<String, Vec<PlaceholderRangeInfo>>;
tests/entrypoints/scale_out/derender/test_derender.py
+45
@@ -52,6 +52,7 @@ def _make_generate_response(
52
logprobs: dict | None = None,
53
prompt_logprobs: list | None = None,
54
kv_transfer_params: dict | None = None,
55
+ metrics: dict | None = None,
56
) -> dict:
57
choice: dict = {
58
"index": 0,
@@ -64,6 +65,7 @@ def _make_generate_response(
65
"choices": [choice],
66
"prompt_logprobs": prompt_logprobs,
67
"kv_transfer_params": kv_transfer_params,
68
+ "metrics": metrics,
69
}
70
71
@@ -266,6 +268,49 @@ async def test_derender_chat_kv_transfer_params_passthrough(client):
268
assert response.json()["kv_transfer_params"] == kv
269
270
271
+@pytest.mark.asyncio
272
+async def test_derender_chat_metrics_passthrough(client):
273
+ gen_req = await _render_chat(client)
274
+ metrics = {
275
+ "speculative_decoding": {
276
+ "mean_acceptance_length": 2.0,
277
+ "draft_acceptance_rate": 0.5,
278
+ "acceptance_histogram": [0, 1],
279
+ "num_spec_steps": 1,
280
+ "num_accepted_draft_tokens": 1,
281
+ "num_draft_tokens": 2,
282
+ "num_spec_tokens": 1,
283
+ }
284
+ }
285
+ response = await client.post(
286
+ "/v1/chat/completions/derender",
287
+ json={
288
+ "model": MODEL_NAME,
289
+ "generate_response": _make_generate_response(
290
+ gen_req["token_ids"][:3], metrics=metrics
291
+ ),
292
+ },
293
+ )
294
+ assert response.status_code == 200
295
+ actual = response.json()["metrics"]
296
+ assert set(actual) == {
297
+ "time_to_first_token_ms",
298
+ "generation_time_ms",
299
+ "queue_time_ms",
300
+ "mean_itl_ms",
301
+ "tokens_per_second",
302
+ "speculative_decoding",
303
+ }
304
+ assert all(
305
+ actual[name] is None for name in actual if name != "speculative_decoding"
306
+ )
307
+ assert actual["speculative_decoding"] == {
308
+ **metrics["speculative_decoding"],
309
+ "per_step_accepted": None,
310
+ "per_step_drafted": None,
311
+ }
312
+
313
+
314
@pytest.mark.asyncio
315
async def test_derender_chat_empty_token_ids(client):
316
"""Empty token_ids list returns 400."""
tests/entrypoints/scale_out/derender/test_derender_stream.py
+24
@@ -27,6 +27,8 @@ from vllm.entrypoints.generate.base.protocol import (
27
DeltaFunctionCall,
28
DeltaMessage,
29
DeltaToolCall,
30
+ PerRequestMetrics,
31
+ SpeculativeDecodingMetrics,
32
)
33
from vllm.entrypoints.scale_out.token_in_token_out.protocol import (
34
DerenderStreamState,
@@ -164,6 +166,7 @@ def _make_stream_chunk(
166
finish_reason: str | None = None,
167
request_id: str = "test-req",
168
usage: dict | None = None,
169
+ metrics: PerRequestMetrics | None = None,
170
) -> GenerateStreamResponse:
171
"""Build a GenerateStreamResponse SSE chunk."""
172
return GenerateStreamResponse(
@@ -176,6 +179,7 @@ def _make_stream_chunk(
179
)
180
],
181
usage=UsageInfo(**usage) if usage else None,
182
+ metrics=metrics,
183
)
184
185
@@ -512,6 +516,26 @@ class TestDerenderChatStream:
516
assert chunk1.choices[0].delta.role == "assistant"
517
assert chunk2.choices[0].delta.role is None
518
519
+ @pytest.mark.asyncio
520
+ async def test_metrics_passthrough(self, derenderer):
521
+ metrics = PerRequestMetrics(
522
+ speculative_decoding=SpeculativeDecodingMetrics(
523
+ mean_acceptance_length=2.0,
524
+ draft_acceptance_rate=0.5,
525
+ acceptance_histogram=[0, 1],
526
+ num_spec_steps=1,
527
+ num_accepted_draft_tokens=1,
528
+ num_draft_tokens=2,
529
+ num_spec_tokens=1,
530
+ )
531
+ )
532
+ chunk, _ = await derenderer.derender_chat_stream(
533
+ model=MODEL_NAME,
534
+ generate_chunk=_make_stream_chunk([], metrics=metrics),
535
+ state=DerenderStreamState(),
536
+ )
537
+ assert chunk.metrics == metrics
538
+
539
@pytest.mark.asyncio
540
async def test_chunked_equals_oneshot(self, derenderer, tokenizer):
541
"""Sum of streaming content deltas == one shot decode."""
tests/entrypoints/scale_out/token_in_token_out/test_generate_stream.py
+142
@@ -28,6 +28,7 @@ from vllm.renderers import renderer_from_config
28
from vllm.renderers.online_renderer import OnlineRenderer
29
from vllm.sampling_params import SamplingParams
30
from vllm.v1.engine.async_llm import AsyncLLM
31
+from vllm.v1.metrics.stats import RequestSpecDecodeMetrics
32
33
MODEL_NAME = "openai-community/gpt2"
34
BASE_MODEL_PATHS = [
@@ -130,6 +131,7 @@ def _make_request_output(
131
logprobs: list[dict[int, Any] | None] | None = None,
132
num_cached_tokens: int | None = None,
133
index: int = 0,
134
+ spec_decode_metrics: RequestSpecDecodeMetrics | None = None,
135
) -> RequestOutput:
136
return RequestOutput(
137
request_id=request_id,
@@ -144,6 +146,7 @@ def _make_request_output(
146
cumulative_logprob=None,
147
logprobs=logprobs,
148
finish_reason=finish_reason,
149
+ spec_decode_metrics=spec_decode_metrics,
150
)
151
],
152
finished=finished,
@@ -321,6 +324,145 @@ async def test_stream_basic():
324
assert data_chunks[2]["choices"][0]["finish_reason"] == "stop"
325
326
327
+@pytest.mark.asyncio
328
+@pytest.mark.parametrize("stream", [False, True])
329
+async def test_serve_tokens_returns_spec_decode_metrics(stream: bool):
330
+ engine = _mock_engine()
331
+ metrics = RequestSpecDecodeMetrics.new(num_spec_tokens=3)
332
+ metrics.observe(num_draft_tokens=3, num_accepted=2)
333
+
334
+ async def mock_generate(*args, **kwargs):
335
+ yield _make_request_output(
336
+ "req-1",
337
+ token_ids=[10],
338
+ finish_reason="stop",
339
+ finished=True,
340
+ spec_decode_metrics=metrics,
341
+ )
342
+
343
+ engine.generate = MagicMock(side_effect=mock_generate)
344
+ serving = _build_serving_tokens(engine)
345
+ request = GenerateRequest(
346
+ token_ids=[1, 2, 3],
347
+ sampling_params=SamplingParams(max_tokens=1),
348
+ model=MODEL_NAME,
349
+ stream=stream,
350
+ stream_options=StreamOptions(include_usage=True) if stream else None,
351
+ )
352
+
353
+ response = await serving.serve_tokens(request)
354
+ if stream:
355
+ chunks = [chunk async for chunk in response]
356
+ payload = next(
357
+ chunk
358
+ for chunk in _parse_sse_chunks(chunks)
359
+ if isinstance(chunk, dict) and chunk.get("metrics")
360
+ )["metrics"]["speculative_decoding"]
361
+ else:
362
+ assert isinstance(response, GenerateResponse)
363
+ assert response.metrics is not None
364
+ assert response.metrics.speculative_decoding is not None
365
+ payload = response.metrics.speculative_decoding.model_dump()
366
+
367
+ assert payload["num_draft_tokens"] == 3
368
+ assert payload["num_accepted_draft_tokens"] == 2
369
+ assert payload["num_spec_steps"] == 1
370
+
371
+
372
+@pytest.mark.asyncio
373
+@pytest.mark.parametrize("include_usage", [False, True])
374
+async def test_stream_returns_spec_decode_metrics_on_empty_terminal_output(
375
+ include_usage,
376
+):
377
+ engine = _mock_engine()
378
+ metrics = RequestSpecDecodeMetrics.new(num_spec_tokens=3)
379
+ metrics.observe(num_draft_tokens=3, num_accepted=2)
380
+
381
+ async def mock_generate(*args, **kwargs):
382
+ yield _make_request_output("req-1", token_ids=[10])
383
+ yield _make_request_output(
384
+ "req-1",
385
+ token_ids=[],
386
+ finish_reason="stop",
387
+ finished=True,
388
+ spec_decode_metrics=metrics,
389
+ )
390
+
391
+ engine.generate = MagicMock(side_effect=mock_generate)
392
+ serving = _build_serving_tokens(engine)
393
+ request = GenerateRequest(
394
+ token_ids=[1, 2, 3],
395
+ sampling_params=SamplingParams(max_tokens=1),
396
+ model=MODEL_NAME,
397
+ stream=True,
398
+ stream_options=StreamOptions(include_usage=include_usage),
399
+ )
400
+
401
+ chunks = [chunk async for chunk in await serving.serve_tokens(request)]
402
+ data_chunks = [chunk for chunk in _parse_sse_chunks(chunks) if chunk != "[DONE]"]
403
+
404
+ assert "metrics" not in data_chunks[0]
405
+ assert len(data_chunks) == 1 + int(include_usage)
406
+ if include_usage:
407
+ assert data_chunks[1]["choices"] == []
408
+ assert (
409
+ data_chunks[1]["metrics"]["speculative_decoding"]["num_draft_tokens"] == 3
410
+ )
411
+
412
+
413
+@pytest.mark.asyncio
414
+@pytest.mark.parametrize("stream", [False, True])
415
+async def test_serve_tokens_omits_spec_decode_metrics_for_parallel_sampling(
416
+ stream: bool,
417
+):
418
+ engine = _mock_engine()
419
+ metrics = RequestSpecDecodeMetrics.new(num_spec_tokens=3)
420
+ metrics.observe(num_draft_tokens=3, num_accepted=2)
421
+ result = _make_request_output(
422
+ "req-1",
423
+ token_ids=[10],
424
+ finish_reason="stop",
425
+ finished=True,
426
+ spec_decode_metrics=metrics,
427
+ )
428
+ result.outputs.append(
429
+ CompletionOutput(
430
+ index=1,
431
+ text="",
432
+ token_ids=[20],
433
+ cumulative_logprob=None,
434
+ logprobs=None,
435
+ finish_reason="stop",
436
+ spec_decode_metrics=metrics,
437
+ )
438
+ )
439
+
440
+ async def mock_generate(*args, **kwargs):
441
+ yield result
442
+
443
+ engine.generate = MagicMock(side_effect=mock_generate)
444
+ serving = _build_serving_tokens(engine)
445
+ request = GenerateRequest(
446
+ token_ids=[1, 2, 3],
447
+ sampling_params=SamplingParams(max_tokens=1, n=2),
448
+ model=MODEL_NAME,
449
+ stream=stream,
450
+ stream_options=StreamOptions(include_usage=True) if stream else None,
451
+ )
452
+
453
+ response = await serving.serve_tokens(request)
454
+ if stream:
455
+ chunks = [chunk async for chunk in response]
456
+ assert all(
457
+ "metrics" not in chunk
458
+ for chunk in _parse_sse_chunks(chunks)
459
+ if isinstance(chunk, dict)
460
+ )
461
+ else:
462
+ assert isinstance(response, GenerateResponse)
463
+ assert response.metrics is None
464
+
465
+
466
@pytest.mark.asyncio
467
async def test_non_stream_returns_final_prompt_token_ids_when_requested():
468
engine = _mock_engine()
vllm/entrypoints/scale_out/derender/serving.py
+4
@@ -179,6 +179,7 @@ class ServingDerender(BaseServing):
179
usage=usage,
180
prompt_logprobs=gen.prompt_logprobs,
181
kv_transfer_params=gen.kv_transfer_params,
182
+ metrics=gen.metrics,
183
)
184
185
async def derender_completion_response(
@@ -245,6 +246,9 @@ class ServingDerender(BaseServing):
246
choices=choices,
247
usage=usage,
248
kv_transfer_params=kv_params,
249
+ # Metrics describe one prompt. Parallel samples are already
250
+ # suppressed by /generate; multi-prompt responses cannot be merged.
251
+ metrics=first.metrics if len(request.generate_responses) == 1 else None,
252
)
253
254
async def derender_chat_stream_response(
vllm/entrypoints/scale_out/token_in_token_out/protocol.py
+7
-1
@@ -11,7 +11,11 @@ from pydantic import (
11
)
12
13
from vllm.config import ModelConfig
14
-from vllm.entrypoints.generate.base.protocol import StreamOptions, validate_cache_salt
14
+from vllm.entrypoints.generate.base.protocol import (
15
+ PerRequestMetrics,
16
+ StreamOptions,
17
+ validate_cache_salt,
18
+)
19
from vllm.entrypoints.openai.chat_completion.protocol import (
20
ChatCompletionLogProbs,
21
ChatCompletionRequest,
@@ -360,6 +364,7 @@ class GenerateStreamResponse(BaseModel):
364
usage: UsageInfo | None = Field(default=None)
365
prompt_token_ids: list[int] | None = None
366
mm_placeholders: dict[str, list[PlaceholderRangeInfo]] | None = None
367
+ metrics: PerRequestMetrics | None = None
368
369
370
class GenerateResponse(BaseModel):
@@ -378,6 +383,7 @@ class GenerateResponse(BaseModel):
383
prompt_logprobs: list[dict[int, Logprob] | None] | None = None
384
prompt_token_ids: list[int] | None = None
385
mm_placeholders: dict[str, list[PlaceholderRangeInfo]] | None = None
386
+ metrics: PerRequestMetrics | None = None
387
388
kv_transfer_params: dict[str, Any] | None = Field(
389
default=None,
vllm/entrypoints/scale_out/token_in_token_out/serving.py
+23
-3
@@ -12,9 +12,13 @@ from fastapi import Request
12
13
from vllm.engine.protocol import EngineClient
14
from vllm.entrypoints.chat_utils import AsyncMultiModalItemTracker
15
-from vllm.entrypoints.generate.base.protocol import RequestResponseMetadata
15
+from vllm.entrypoints.generate.base.protocol import (
16
+ PerRequestMetrics,
17
+ RequestResponseMetadata,
18
+)
19
from vllm.entrypoints.generate.base.serving import (
20
GenerateBaseServing,
21
+ build_spec_decoding_metrics,
22
clamp_prompt_logprobs,
23
)
24
from vllm.entrypoints.openai.chat_completion.protocol import (
@@ -373,6 +377,11 @@ class ServingTokens(GenerateBaseServing):
377
378
request_metadata.final_usage_info = usage
379
380
+ per_request_metrics = None
381
+ if request.sampling_params.n == 1:
382
+ spec_stats = build_spec_decoding_metrics(final_res)
383
+ if spec_stats is not None:
384
+ per_request_metrics = PerRequestMetrics(speculative_decoding=spec_stats)
385
response = GenerateResponse(
386
request_id=request_id,
387
created=created_time,
@@ -384,6 +393,7 @@ class ServingTokens(GenerateBaseServing):
393
final_res.prompt_token_ids if request.return_token_ids else None
394
),
395
mm_placeholders=request._response_mm_placeholders,
396
+ metrics=per_request_metrics,
397
kv_transfer_params=final_res.kv_transfer_params,
398
ec_transfer_params=final_res.ec_transfer_params,
399
)
@@ -423,6 +433,7 @@ class ServingTokens(GenerateBaseServing):
433
prompt_token_ids: list[int] | None = None
434
num_cached_tokens = None
435
sampling_params: SamplingParams = request.sampling_params
436
+ last_res: RequestOutput | None = None
437
438
include_usage, include_continuous_usage = should_include_usage(
439
request.stream_options, False
@@ -430,6 +441,7 @@ class ServingTokens(GenerateBaseServing):
441
442
try:
443
async for res in result_generator:
444
+ last_res = res
445
if first_iteration:
446
if res.prompt_token_ids is not None:
447
num_prompt_tokens = len(res.prompt_token_ids)
@@ -496,10 +508,10 @@ class ServingTokens(GenerateBaseServing):
508
total_tokens=(num_prompt_tokens + num_generated_tokens[i]),
509
)
510
499
- # Omit absent prompt metadata (Rust skips None fields).
511
+ # Omit fields that are absent from token-bearing chunks.
512
exclude = {
513
name
502
- for name in ("prompt_token_ids", "mm_placeholders")
514
+ for name in ("prompt_token_ids", "mm_placeholders", "metrics")
515
if getattr(chunk, name) is None
516
}
517
yield f"data: {chunk.model_dump_json(exclude=exclude)}\n\n"
@@ -517,10 +529,18 @@ class ServingTokens(GenerateBaseServing):
529
)
530
531
if include_usage:
532
+ per_request_metrics = None
533
+ if sampling_params.n == 1:
534
+ spec_stats = build_spec_decoding_metrics(last_res)
535
+ if spec_stats is not None:
536
+ per_request_metrics = PerRequestMetrics(
537
+ speculative_decoding=spec_stats
538
+ )
539
final_chunk = GenerateStreamResponse(
540
request_id=request_id,
541
choices=[],
542
usage=final_usage_info,
543
+ metrics=per_request_metrics,
544
)
545
yield f"data: {final_chunk.model_dump_json(exclude_none=True)}\n\n"
546
vllm/renderers/online_derenderer.py
+3
@@ -398,6 +398,7 @@ class OnlineDerenderer:
398
model=model,
399
choices=stream_choices,
400
usage=usage,
401
+ metrics=generate_chunk.metrics,
402
)
403
return chunk, updated_state
404
@@ -595,6 +596,7 @@ class OnlineDerenderer:
596
model=model,
597
choices=stream_choices,
598
usage=usage,
599
+ metrics=generate_chunk.metrics,
600
)
601
return chunk, updated_state
602
@@ -741,6 +743,7 @@ class OnlineDerenderer:
743
model=model,
744
choices=stream_choices,
745
usage=usage,
746
+ metrics=generate_chunk.metrics,
747
)
748
return chunk, updated_state
749