A high-throughput and memory-efficient inference and serving engine for LLMs
Mirrored from https://github.com/tduncan-lum/Nvidia-Optimization-Stack---VLLM.git
Synced Nov 14, 05:30 UTC
[frontend][gptoss] Add per turn stats into Harmony Context (#25061)
Signed-off-by: lacora <hyelacora@gmail.com> Co-authored-by: Ye Hu <yehu@fb.com>
Ye Hu committed
Oct 14, 2025 at 16:48 UTC
0512c04aee408367a068b5960e7857c722ed204d
4 files changed
+188
-62
tests/entrypoints/test_context.py
+84
-9
@@ -6,7 +6,11 @@ from unittest.mock import MagicMock, patch
6
import pytest
7
from openai_harmony import Author, Message, Role, StreamState, TextContent
8
9
-from vllm.entrypoints.context import HarmonyContext, StreamingHarmonyContext
9
+from vllm.entrypoints.context import (
10
+ HarmonyContext,
11
+ StreamingHarmonyContext,
12
+ TurnMetrics,
13
+)
14
from vllm.outputs import CompletionOutput, RequestOutput
15
16
@@ -101,8 +105,12 @@ def test_single_turn_token_counting():
105
106
# Verify internal state tracking
107
assert not context.is_first_turn
104
- assert context.previous_turn.input_tokens == 5
105
- assert context.previous_turn.output_tokens == 3
108
+ assert len(context.all_turn_metrics) == 1
109
+ previous_turn = context.all_turn_metrics[0]
110
+ assert previous_turn.input_tokens == 5
111
+ assert previous_turn.output_tokens == 3
112
+ assert previous_turn.cached_input_tokens == 2
113
+ assert previous_turn.tool_output_tokens == 0
114
115
116
@pytest.mark.asyncio
@@ -156,6 +164,15 @@ async def test_multi_turn_token_counting():
164
assert context.num_tool_output_tokens == expected_tool_output
165
assert context.num_cached_tokens == 5 + 15
166
167
+ # Validate all turn metrics
168
+ assert len(context.all_turn_metrics) == 3
169
+ for i, turn in enumerate(context.all_turn_metrics):
170
+ assert turn.input_tokens == prompt_token_counts[i]
171
+ assert turn.output_tokens == output_token_counts[i]
172
+ assert turn.cached_input_tokens == cached_token_counts[i]
173
+ assert context.all_turn_metrics[1].tool_output_tokens == 7
174
+ assert context.all_turn_metrics[2].tool_output_tokens == 1
175
+
176
177
def test_empty_output_tokens():
178
"""Test behavior when RequestOutput has empty output tokens."""
@@ -314,6 +331,10 @@ async def test_streaming_multi_turn_token_counting(mock_parser):
331
# Create a streaming context
332
context = StreamingHarmonyContext(messages=[], available_tools=["browser"])
333
334
+ num_prompt_tokens = [3, 8, 13]
335
+ num_output_tokens = [3, 3, 2]
336
+ num_cached_tokens = [0, 3, 8]
337
+
338
# Simulate three turns of conversation:
339
# Turn 1: stream tokens one by one, then finish the message
340
# Turn 2: new prompt, stream more tokens with a reasoning segment
@@ -325,7 +346,7 @@ async def test_streaming_multi_turn_token_counting(mock_parser):
346
create_mock_request_output(
347
prompt_token_ids=[1, 2, 3], # 3 prompt tokens
348
output_token_ids=[101], # Single token
328
- num_cached_tokens=0,
349
+ num_cached_tokens=num_cached_tokens[0],
350
finished=False, # Not end of message yet
351
)
352
)
@@ -370,7 +391,7 @@ async def test_streaming_multi_turn_token_counting(mock_parser):
391
5,
392
], # 8 tokens (includes previous)
393
output_token_ids=[201],
373
- num_cached_tokens=3, # Some tokens cached
394
+ num_cached_tokens=num_cached_tokens[1], # Some tokens cached
395
finished=False,
396
)
397
)
@@ -422,7 +443,7 @@ async def test_streaming_multi_turn_token_counting(mock_parser):
443
7,
444
], # 13 tokens
445
output_token_ids=[301],
425
- num_cached_tokens=8, # More cached tokens
446
+ num_cached_tokens=num_cached_tokens[2], # More cached tokens
447
finished=False,
448
)
449
)
@@ -435,10 +456,12 @@ async def test_streaming_multi_turn_token_counting(mock_parser):
456
)
457
458
# Final token counts check
438
- assert context.num_prompt_tokens == 3 + 8 + 13 # All prompts
439
- assert context.num_output_tokens == 3 + 3 + 2 # All outputs
459
+ assert context.num_prompt_tokens == sum(num_prompt_tokens) # All prompts
460
+ assert context.num_output_tokens == sum(num_output_tokens) # All outputs
461
assert context.num_reasoning_tokens == 3 # Unchanged from second turn
441
- assert context.num_cached_tokens == 3 + 8 # Accumulated cached tokens
462
+ assert context.num_cached_tokens == sum(
463
+ num_cached_tokens
464
+ ) # Accumulated cached tokens
465
466
# Additional tool tokens from third turn
467
# Formula: this turn prompt - last turn prompt - last turn output
@@ -447,6 +470,15 @@ async def test_streaming_multi_turn_token_counting(mock_parser):
470
context.num_tool_output_tokens == expected_tool_tokens + additional_tool_tokens
471
)
472
473
+ # Validate all turn metrics
474
+ assert len(context.all_turn_metrics) == 3
475
+ for i, turn in enumerate(context.all_turn_metrics):
476
+ assert turn.input_tokens == num_prompt_tokens[i]
477
+ assert turn.output_tokens == num_output_tokens[i]
478
+ assert turn.cached_input_tokens == num_cached_tokens[i]
479
+ assert context.all_turn_metrics[1].tool_output_tokens == 2
480
+ assert context.all_turn_metrics[2].tool_output_tokens == 2
481
+
482
483
@pytest.mark.asyncio
484
async def test_streaming_message_synchronization(mock_parser):
@@ -522,3 +554,46 @@ async def test_streaming_message_synchronization(mock_parser):
554
assert len(context._messages) == 3
555
assert context.num_init_messages == 1
556
assert context._messages[2].content[0].text == "Response 4"
557
+
558
+
559
+def test_turn_metrics_copy_and_reset():
560
+ """Test TurnMetrics copy and reset methods work correctly."""
561
+ # Create a TurnMetrics with specific values
562
+ original_metrics = TurnMetrics(
563
+ input_tokens=10,
564
+ output_tokens=20,
565
+ cached_input_tokens=5,
566
+ tool_output_tokens=3,
567
+ )
568
+
569
+ # Test copy functionality
570
+ copied_metrics = original_metrics.copy()
571
+
572
+ # Verify copy has same values
573
+ assert copied_metrics.input_tokens == 10
574
+ assert copied_metrics.output_tokens == 20
575
+ assert copied_metrics.cached_input_tokens == 5
576
+ assert copied_metrics.tool_output_tokens == 3
577
+
578
+ # Verify they are separate objects
579
+ assert copied_metrics is not original_metrics
580
+
581
+ # Modify copy to ensure independence
582
+ copied_metrics.input_tokens = 999
583
+ assert original_metrics.input_tokens == 10 # Original unchanged
584
+ assert copied_metrics.input_tokens == 999
585
+
586
+ # Test reset functionality
587
+ original_metrics.reset()
588
+
589
+ # Verify all fields are reset to zero
590
+ assert original_metrics.input_tokens == 0
591
+ assert original_metrics.output_tokens == 0
592
+ assert original_metrics.cached_input_tokens == 0
593
+ assert original_metrics.tool_output_tokens == 0
594
+
595
+ # Verify copied metrics are unaffected by reset
596
+ assert copied_metrics.input_tokens == 999
597
+ assert copied_metrics.output_tokens == 20
598
+ assert copied_metrics.cached_input_tokens == 5
599
+ assert copied_metrics.tool_output_tokens == 3
vllm/entrypoints/context.py
+43
-22
@@ -45,21 +45,36 @@ def _map_tool_name_to_tool_type(tool_name: str) -> str:
45
return _TOOL_NAME_TO_TYPE_MAP[tool_name]
46
47
48
-class TurnTokens:
49
- """Tracks token counts for a single conversation turn."""
48
+class TurnMetrics:
49
+ """Tracks token and toolcall details for a single conversation turn."""
50
51
- def __init__(self, input_tokens=0, output_tokens=0):
51
+ def __init__(
52
+ self,
53
+ input_tokens=0,
54
+ output_tokens=0,
55
+ cached_input_tokens=0,
56
+ tool_output_tokens=0,
57
+ ):
58
self.input_tokens = input_tokens
59
self.output_tokens = output_tokens
60
+ self.cached_input_tokens = cached_input_tokens
61
+ self.tool_output_tokens = tool_output_tokens
62
63
def reset(self):
64
"""Reset counters for a new turn."""
65
self.input_tokens = 0
66
self.output_tokens = 0
67
+ self.cached_input_tokens = 0
68
+ self.tool_output_tokens = 0
69
70
def copy(self):
71
"""Create a copy of this turn's token counts."""
62
- return TurnTokens(self.input_tokens, self.output_tokens)
72
+ return TurnMetrics(
73
+ self.input_tokens,
74
+ self.output_tokens,
75
+ self.cached_input_tokens,
76
+ self.tool_output_tokens,
77
+ )
78
79
80
class ConversationContext(ABC):
@@ -102,6 +117,8 @@ class SimpleContext(ConversationContext):
117
self.num_cached_tokens = 0
118
# todo num_reasoning_tokens is not implemented yet.
119
self.num_reasoning_tokens = 0
120
+ # not implemented yet for SimpleContext
121
+ self.all_turn_metrics = []
122
123
def append_output(self, output) -> None:
124
self.last_output = output
@@ -154,8 +171,9 @@ class HarmonyContext(ConversationContext):
171
self.num_tool_output_tokens = 0
172
173
# Turn tracking - replaces multiple individual tracking variables
157
- self.current_turn = TurnTokens()
158
- self.previous_turn = TurnTokens()
174
+ self.current_turn_metrics = TurnMetrics()
175
+ # Track metrics for all turns
176
+ self.all_turn_metrics: list[TurnMetrics] = []
177
self.is_first_turn = True
178
self.first_tok_of_message = True # For streaming support
179
@@ -173,11 +191,10 @@ class HarmonyContext(ConversationContext):
191
# Check if the current token is part of reasoning content
192
self._update_num_reasoning_tokens()
193
self._update_prefill_token_usage(output)
176
- # Reset current turn output tokens for this turn
177
- self.current_turn.output_tokens = 0
194
self._update_decode_token_usage(output)
179
- # Move current turn to previous turn for next turn's calculations
180
- self.previous_turn = self.current_turn.copy()
195
+ # Append current turn to all turn list for next turn's calculations
196
+ self.all_turn_metrics.append(self.current_turn_metrics.copy())
197
+ self.current_turn_metrics.reset()
198
# append_output is called only once before tool calling
199
# in non-streaming case
200
# so we can append all the parser messages to _messages
@@ -213,20 +230,21 @@ class HarmonyContext(ConversationContext):
230
logger.error("RequestOutput appended contains no prompt_token_ids.")
231
232
# Update current turn input tokens
216
- self.current_turn.input_tokens = this_turn_input_tokens
233
+ self.current_turn_metrics.input_tokens = this_turn_input_tokens
234
self.num_prompt_tokens += this_turn_input_tokens
235
236
# Calculate tool tokens (except on first turn)
237
if self.is_first_turn:
238
self.is_first_turn = False
239
else:
240
+ previous_turn = self.all_turn_metrics[-1]
241
# start counting tool after first turn
242
# tool tokens = this turn prefill - last turn prefill -
243
# last turn decode
244
this_turn_tool_tokens = (
227
- self.current_turn.input_tokens
228
- - self.previous_turn.input_tokens
229
- - self.previous_turn.output_tokens
245
+ self.current_turn_metrics.input_tokens
246
+ - previous_turn.input_tokens
247
+ - previous_turn.output_tokens
248
)
249
250
# Handle negative tool token counts (shouldn't happen in normal
@@ -237,17 +255,20 @@ class HarmonyContext(ConversationContext):
255
"(current_input=%d, previous_input=%d, "
256
"previous_output=%d). Setting to 0.",
257
this_turn_tool_tokens,
240
- self.current_turn.input_tokens,
241
- self.previous_turn.input_tokens,
242
- self.previous_turn.output_tokens,
258
+ self.current_turn_metrics.input_tokens,
259
+ previous_turn.input_tokens,
260
+ previous_turn.output_tokens,
261
)
262
this_turn_tool_tokens = 0
263
264
self.num_tool_output_tokens += this_turn_tool_tokens
265
+ self.current_turn_metrics.tool_output_tokens = this_turn_tool_tokens
266
267
# Update cached tokens
249
- if output.num_cached_tokens is not None:
250
- self.num_cached_tokens += output.num_cached_tokens
268
+ num_cached_token = output.num_cached_tokens
269
+ if num_cached_token is not None:
270
+ self.num_cached_tokens += num_cached_token
271
+ self.current_turn_metrics.cached_input_tokens = num_cached_token
272
273
def _update_decode_token_usage(self, output: RequestOutput) -> int:
274
"""Update token usage statistics for the decode phase of generation.
@@ -272,7 +293,7 @@ class HarmonyContext(ConversationContext):
293
# only keep last round
294
updated_output_token_count += len(completion_output.token_ids)
295
self.num_output_tokens += updated_output_token_count
275
- self.current_turn.output_tokens += updated_output_token_count
296
+ self.current_turn_metrics.output_tokens += updated_output_token_count
297
return updated_output_token_count
298
299
@property
@@ -452,7 +473,6 @@ class StreamingHarmonyContext(HarmonyContext):
473
# so we only want to add the prompt tokens once for each message.
474
if self.first_tok_of_message:
475
self._update_prefill_token_usage(output)
455
- self.current_turn.output_tokens = 0
476
# Reset self.first_tok_of_message if needed:
477
# if the current token is the last one of the current message
478
# (finished=True), then the next token processed will mark the
@@ -464,7 +484,8 @@ class StreamingHarmonyContext(HarmonyContext):
484
485
# For streaming, update previous turn when message is complete
486
if output.finished:
467
- self.previous_turn = self.current_turn.copy()
487
+ self.all_turn_metrics.append(self.current_turn_metrics.copy())
488
+ self.current_turn_metrics.reset()
489
# Check if the current token is part of reasoning content
490
self._update_num_reasoning_tokens()
491
self.last_tok = tok
vllm/entrypoints/openai/protocol.py
+4
@@ -2103,11 +2103,15 @@ class TranscriptionStreamResponse(OpenAIBaseModel):
2103
2104
class InputTokensDetails(OpenAIBaseModel):
2105
cached_tokens: int
2106
+ input_tokens_per_turn: list[int] = Field(default_factory=list)
2107
+ cached_tokens_per_turn: list[int] = Field(default_factory=list)
2108
2109
2110
class OutputTokensDetails(OpenAIBaseModel):
2111
reasoning_tokens: int = 0
2112
tool_output_tokens: int = 0
2113
+ output_tokens_per_turn: list[int] = Field(default_factory=list)
2114
+ tool_output_tokens_per_turn: list[int] = Field(default_factory=list)
2115
2116
2117
class ResponseUsage(OpenAIBaseModel):
vllm/entrypoints/openai/serving_responses.py
+57
-31
@@ -589,10 +589,24 @@ class OpenAIServingResponses(OpenAIServing):
589
input_tokens=num_prompt_tokens,
590
output_tokens=num_generated_tokens,
591
total_tokens=num_prompt_tokens + num_generated_tokens,
592
- input_tokens_details=InputTokensDetails(cached_tokens=num_cached_tokens),
592
+ input_tokens_details=InputTokensDetails(
593
+ cached_tokens=num_cached_tokens,
594
+ input_tokens_per_turn=[
595
+ turn.input_tokens for turn in context.all_turn_metrics
596
+ ],
597
+ cached_tokens_per_turn=[
598
+ turn.cached_input_tokens for turn in context.all_turn_metrics
599
+ ],
600
+ ),
601
output_tokens_details=OutputTokensDetails(
602
reasoning_tokens=num_reasoning_tokens,
603
tool_output_tokens=num_tool_output_tokens,
604
+ output_tokens_per_turn=[
605
+ turn.output_tokens for turn in context.all_turn_metrics
606
+ ],
607
+ tool_output_tokens_per_turn=[
608
+ turn.tool_output_tokens for turn in context.all_turn_metrics
609
+ ],
610
),
611
)
612
response = ResponsesResponse.from_request(
@@ -665,11 +679,13 @@ class OpenAIServingResponses(OpenAIServing):
679
token=text,
680
logprob=max(token_logprob.logprob, -9999.0),
681
bytes=list(text.encode("utf-8", errors="replace")),
668
- top_logprobs=self._topk_logprobs(
669
- logprob, top_logprobs=top_logprobs, tokenizer=tokenizer
670
- )
671
- if top_logprobs
672
- else [],
682
+ top_logprobs=(
683
+ self._topk_logprobs(
684
+ logprob, top_logprobs=top_logprobs, tokenizer=tokenizer
685
+ )
686
+ if top_logprobs
687
+ else []
688
+ ),
689
)
690
)
691
return out
@@ -758,14 +774,16 @@ class OpenAIServingResponses(OpenAIServing):
774
text=content,
775
annotations=[], # TODO
776
type="output_text",
761
- logprobs=self._create_response_logprobs(
762
- token_ids=final_output.token_ids,
763
- logprobs=final_output.logprobs,
764
- tokenizer=tokenizer,
765
- top_logprobs=request.top_logprobs,
766
- )
767
- if request.is_include_output_logprobs()
768
- else None,
777
+ logprobs=(
778
+ self._create_response_logprobs(
779
+ token_ids=final_output.token_ids,
780
+ logprobs=final_output.logprobs,
781
+ tokenizer=tokenizer,
782
+ top_logprobs=request.top_logprobs,
783
+ )
784
+ if request.is_include_output_logprobs()
785
+ else None
786
+ ),
787
)
788
message = ResponseOutputMessage(
789
id=f"msg_{random_uuid()}",
@@ -870,15 +888,21 @@ class OpenAIServingResponses(OpenAIServing):
888
with_custom_tools = has_custom_tools(tool_types)
889
sys_msg = get_system_message(
890
reasoning_effort=reasoning_effort,
873
- browser_description=self.tool_server.get_tool_description("browser")
874
- if enable_browser and self.tool_server is not None
875
- else None,
876
- python_description=self.tool_server.get_tool_description("python")
877
- if enable_code_interpreter and self.tool_server is not None
878
- else None,
879
- container_description=self.tool_server.get_tool_description("container")
880
- if enable_container and self.tool_server is not None
881
- else None,
891
+ browser_description=(
892
+ self.tool_server.get_tool_description("browser")
893
+ if enable_browser and self.tool_server is not None
894
+ else None
895
+ ),
896
+ python_description=(
897
+ self.tool_server.get_tool_description("python")
898
+ if enable_code_interpreter and self.tool_server is not None
899
+ else None
900
+ ),
901
+ container_description=(
902
+ self.tool_server.get_tool_description("container")
903
+ if enable_container and self.tool_server is not None
904
+ else None
905
+ ),
906
instructions=request.instructions,
907
with_custom_tools=with_custom_tools,
908
)
@@ -1283,14 +1307,16 @@ class OpenAIServingResponses(OpenAIServing):
1307
output_index=current_output_index,
1308
item_id=current_item_id,
1309
delta=delta_message.content,
1286
- logprobs=self._create_stream_response_logprobs(
1287
- token_ids=output.token_ids,
1288
- logprobs=output.logprobs,
1289
- tokenizer=tokenizer,
1290
- top_logprobs=request.top_logprobs,
1291
- )
1292
- if request.is_include_output_logprobs()
1293
- else [],
1310
+ logprobs=(
1311
+ self._create_stream_response_logprobs(
1312
+ token_ids=output.token_ids,
1313
+ logprobs=output.logprobs,
1314
+ tokenizer=tokenizer,
1315
+ top_logprobs=request.top_logprobs,
1316
+ )
1317
+ if request.is_include_output_logprobs()
1318
+ else []
1319
+ ),
1320
)
1321
)
1322
current_content_index += 1