feat(#6006): instrument send-to-first-token latency
This commit is contained in:
1 parent
e5148f9dbe
commit
001d7985bc
3 files changed
+147
-9
No files matched your search
+29
-2
@@ -44,6 +44,7 @@ The SSE `metering` event payload:
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
@@ -58,6 +59,8 @@ class _SessionMeter:
|
|||||||
reasoning_tokens: int = 0
|
reasoning_tokens: int = 0
|
||||||
first_token_ts: float = 0.0 # time.monotonic() of first token received
|
first_token_ts: float = 0.0 # time.monotonic() of first token received
|
||||||
last_token_ts: float = 0.0 # time.monotonic() of last token received
|
last_token_ts: float = 0.0 # time.monotonic() of last token received
|
||||||
|
pending_started_at: float | None = None
|
||||||
|
ttft_ms: int | None = None
|
||||||
|
|
||||||
def total_tokens(self) -> int:
|
def total_tokens(self) -> int:
|
||||||
return self.output_tokens + self.reasoning_tokens
|
return self.output_tokens + self.reasoning_tokens
|
||||||
@@ -94,6 +97,18 @@ class GlobalMeter:
|
|||||||
with self._lock:
|
with self._lock:
|
||||||
self._sessions[stream_id] = _SessionMeter()
|
self._sessions[stream_id] = _SessionMeter()
|
||||||
|
|
||||||
|
def set_pending_started_at(self, stream_id: str, pending_started_at) -> None:
|
||||||
|
try:
|
||||||
|
value = float(pending_started_at)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return
|
||||||
|
if not math.isfinite(value) or value <= 0:
|
||||||
|
return
|
||||||
|
with self._lock:
|
||||||
|
session = self._sessions.get(stream_id)
|
||||||
|
if session is not None:
|
||||||
|
session.pending_started_at = value
|
||||||
|
|
||||||
def get_interval(self) -> float:
|
def get_interval(self) -> float:
|
||||||
"""Return 1.0 when sessions are actively receiving tokens, 10.0 when idle.
|
"""Return 1.0 when sessions are actively receiving tokens, 10.0 when idle.
|
||||||
|
|
||||||
@@ -119,6 +134,8 @@ class GlobalMeter:
|
|||||||
s.first_token_ts = now
|
s.first_token_ts = now
|
||||||
s.last_token_ts = now
|
s.last_token_ts = now
|
||||||
s.output_tokens = running_output_tokens
|
s.output_tokens = running_output_tokens
|
||||||
|
if s.ttft_ms is None and s.pending_started_at is not None:
|
||||||
|
s.ttft_ms = max(0, round((time.time() - s.pending_started_at) * 1000))
|
||||||
|
|
||||||
def record_reasoning(self, stream_id: str, running_reasoning_tokens: int) -> None:
|
def record_reasoning(self, stream_id: str, running_reasoning_tokens: int) -> None:
|
||||||
now = time.monotonic()
|
now = time.monotonic()
|
||||||
@@ -135,7 +152,12 @@ class GlobalMeter:
|
|||||||
with self._lock:
|
with self._lock:
|
||||||
self._sessions.pop(stream_id, None)
|
self._sessions.pop(stream_id, None)
|
||||||
|
|
||||||
def get_stats(self) -> dict:
|
def get_ttft_ms(self, stream_id: str) -> int | None:
|
||||||
|
with self._lock:
|
||||||
|
session = self._sessions.get(stream_id)
|
||||||
|
return session.ttft_ms if session is not None else None
|
||||||
|
|
||||||
|
def get_stats(self, stream_id: str | None = None) -> dict:
|
||||||
now = time.monotonic()
|
now = time.monotonic()
|
||||||
with self._lock:
|
with self._lock:
|
||||||
# Prune stale sessions
|
# Prune stale sessions
|
||||||
@@ -175,7 +197,7 @@ class GlobalMeter:
|
|||||||
high = max(active_readings) if active_readings else 0.0
|
high = max(active_readings) if active_readings else 0.0
|
||||||
low = min(active_readings) if active_readings else 0.0
|
low = min(active_readings) if active_readings else 0.0
|
||||||
|
|
||||||
return {
|
stats = {
|
||||||
'tps': round(global_tps, 1) if global_tps is not None else None,
|
'tps': round(global_tps, 1) if global_tps is not None else None,
|
||||||
'tps_available': global_tps is not None,
|
'tps_available': global_tps is not None,
|
||||||
'estimated': False,
|
'estimated': False,
|
||||||
@@ -183,6 +205,11 @@ class GlobalMeter:
|
|||||||
'low': round(low, 1) if low else None,
|
'low': round(low, 1) if low else None,
|
||||||
'active': len(self._sessions),
|
'active': len(self._sessions),
|
||||||
}
|
}
|
||||||
|
if stream_id is not None:
|
||||||
|
session = self._sessions.get(stream_id)
|
||||||
|
if session is not None and session.ttft_ms is not None:
|
||||||
|
stats['ttft_ms'] = session.ttft_ms
|
||||||
|
return stats
|
||||||
|
|
||||||
|
|
||||||
# ── Module-level singleton ─────────────────────────────────────────────────────
|
# ── Module-level singleton ─────────────────────────────────────────────────────
|
||||||
|
|||||||
+14
-7
@@ -6972,7 +6972,7 @@ def _run_agent_streaming(
|
|||||||
break # nothing active — stop the ticker
|
break # nothing active — stop the ticker
|
||||||
if _metering_stop.wait(interval):
|
if _metering_stop.wait(interval):
|
||||||
break # stream was cancelled or ended — exit
|
break # stream was cancelled or ended — exit
|
||||||
stats = meter().get_stats()
|
stats = meter().get_stats(stream_id)
|
||||||
stats['session_id'] = session_id
|
stats['session_id'] = session_id
|
||||||
stats['usage'] = _live_usage_snapshot()
|
stats['usage'] = _live_usage_snapshot()
|
||||||
put('metering', stats)
|
put('metering', stats)
|
||||||
@@ -7409,7 +7409,7 @@ def _run_agent_streaming(
|
|||||||
if now - _metering_last_emit[0] < 0.1:
|
if now - _metering_last_emit[0] < 0.1:
|
||||||
return
|
return
|
||||||
_metering_last_emit[0] = now
|
_metering_last_emit[0] = now
|
||||||
stats = meter().get_stats()
|
stats = meter().get_stats(stream_id)
|
||||||
stats['session_id'] = session_id
|
stats['session_id'] = session_id
|
||||||
stats['usage'] = _live_usage_snapshot()
|
stats['usage'] = _live_usage_snapshot()
|
||||||
stats.setdefault('tps_available', False)
|
stats.setdefault('tps_available', False)
|
||||||
@@ -7702,7 +7702,7 @@ def _run_agent_streaming(
|
|||||||
'preview': preview,
|
'preview': preview,
|
||||||
'args': args_snap,
|
'args': args_snap,
|
||||||
})
|
})
|
||||||
_tool_stats = meter().get_stats()
|
_tool_stats = meter().get_stats(stream_id)
|
||||||
_tool_stats['session_id'] = session_id
|
_tool_stats['session_id'] = session_id
|
||||||
_tool_stats['usage'] = _live_usage_snapshot()
|
_tool_stats['usage'] = _live_usage_snapshot()
|
||||||
put('metering', _tool_stats)
|
put('metering', _tool_stats)
|
||||||
@@ -7801,7 +7801,7 @@ def _run_agent_streaming(
|
|||||||
session_id=session_id,
|
session_id=session_id,
|
||||||
stream_id=stream_id,
|
stream_id=stream_id,
|
||||||
)
|
)
|
||||||
_tool_stats = meter().get_stats()
|
_tool_stats = meter().get_stats(stream_id)
|
||||||
_tool_stats['session_id'] = session_id
|
_tool_stats['session_id'] = session_id
|
||||||
_tool_stats['usage'] = _live_usage_snapshot()
|
_tool_stats['usage'] = _live_usage_snapshot()
|
||||||
put('metering', _tool_stats)
|
put('metering', _tool_stats)
|
||||||
@@ -7833,7 +7833,7 @@ def _run_agent_streaming(
|
|||||||
'args': _tool_args_snapshot(args),
|
'args': _tool_args_snapshot(args),
|
||||||
'tid': tool_call_id,
|
'tid': tool_call_id,
|
||||||
})
|
})
|
||||||
_tool_stats = meter().get_stats()
|
_tool_stats = meter().get_stats(stream_id)
|
||||||
_tool_stats['session_id'] = session_id
|
_tool_stats['session_id'] = session_id
|
||||||
_tool_stats['usage'] = _live_usage_snapshot()
|
_tool_stats['usage'] = _live_usage_snapshot()
|
||||||
put('metering', _tool_stats)
|
put('metering', _tool_stats)
|
||||||
@@ -7884,7 +7884,7 @@ def _run_agent_streaming(
|
|||||||
session_id=session_id,
|
session_id=session_id,
|
||||||
stream_id=stream_id,
|
stream_id=stream_id,
|
||||||
)
|
)
|
||||||
_tool_stats = meter().get_stats()
|
_tool_stats = meter().get_stats(stream_id)
|
||||||
_tool_stats['session_id'] = session_id
|
_tool_stats['session_id'] = session_id
|
||||||
_tool_stats['usage'] = _live_usage_snapshot()
|
_tool_stats['usage'] = _live_usage_snapshot()
|
||||||
put('metering', _tool_stats)
|
put('metering', _tool_stats)
|
||||||
@@ -8434,6 +8434,7 @@ def _run_agent_streaming(
|
|||||||
config_data=_cfg,
|
config_data=_cfg,
|
||||||
)
|
)
|
||||||
_pending_started_at = getattr(s, 'pending_started_at', None)
|
_pending_started_at = getattr(s, 'pending_started_at', None)
|
||||||
|
meter().set_pending_started_at(stream_id, _pending_started_at)
|
||||||
# Normal chat-start sets pending_started_at before spawning this thread;
|
# Normal chat-start sets pending_started_at before spawning this thread;
|
||||||
# fallback to now only for recovered/legacy flows where that marker is absent
|
# fallback to now only for recovered/legacy flows where that marker is absent
|
||||||
# or has been zeroed out (e.g. via a buggy migration / manual file edit).
|
# or has been zeroed out (e.g. via a buggy migration / manual file edit).
|
||||||
@@ -9381,6 +9382,9 @@ def _run_agent_streaming(
|
|||||||
_dm['_turnTps'] = _turn_tps
|
_dm['_turnTps'] = _turn_tps
|
||||||
if _gateway_routing:
|
if _gateway_routing:
|
||||||
_dm['_gatewayRouting'] = _gateway_routing
|
_dm['_gatewayRouting'] = _gateway_routing
|
||||||
|
_ttft_ms = meter().get_ttft_ms(stream_id)
|
||||||
|
if _ttft_ms is not None:
|
||||||
|
_dm['_firstTokenMs'] = _ttft_ms
|
||||||
break
|
break
|
||||||
# Persist context window data on the session so the context-ring
|
# Persist context window data on the session so the context-ring
|
||||||
# indicator survives a page reload (#1318). Must run BEFORE
|
# indicator survives a page reload (#1318). Must run BEFORE
|
||||||
@@ -9765,6 +9769,9 @@ def _run_agent_streaming(
|
|||||||
usage['tps'] = _turn_tps
|
usage['tps'] = _turn_tps
|
||||||
if _gateway_routing:
|
if _gateway_routing:
|
||||||
usage['gateway_routing'] = _gateway_routing
|
usage['gateway_routing'] = _gateway_routing
|
||||||
|
_ttft_ms = meter().get_ttft_ms(stream_id)
|
||||||
|
if _ttft_ms is not None:
|
||||||
|
usage['ttft_ms'] = _ttft_ms
|
||||||
# Include context window data from the agent's compressor for the UI indicator.
|
# Include context window data from the agent's compressor for the UI indicator.
|
||||||
# The session-level persistence happens above (before s.save()) so the values
|
# The session-level persistence happens above (before s.save()) so the values
|
||||||
# survive a page reload; this block only populates the live SSE usage payload.
|
# survive a page reload; this block only populates the live SSE usage payload.
|
||||||
@@ -9986,7 +9993,7 @@ def _run_agent_streaming(
|
|||||||
_done_payload['terminal_reason'] = 'max_iterations'
|
_done_payload['terminal_reason'] = 'max_iterations'
|
||||||
put('done', _done_payload)
|
put('done', _done_payload)
|
||||||
# Emit one last metering packet for the live message-header TPS label.
|
# Emit one last metering packet for the live message-header TPS label.
|
||||||
meter_stats = meter().get_stats()
|
meter_stats = meter().get_stats(stream_id)
|
||||||
meter_stats['session_id'] = session_id
|
meter_stats['session_id'] = session_id
|
||||||
meter_stats.setdefault('tps_available', False)
|
meter_stats.setdefault('tps_available', False)
|
||||||
meter_stats.setdefault('estimated', False)
|
meter_stats.setdefault('estimated', False)
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import api.metering as metering
|
||||||
|
|
||||||
|
|
||||||
|
class _Clock:
|
||||||
|
def __init__(self):
|
||||||
|
self.wall = 1000.0
|
||||||
|
self.mono = 10.0
|
||||||
|
|
||||||
|
def time(self):
|
||||||
|
return self.wall
|
||||||
|
|
||||||
|
def monotonic(self):
|
||||||
|
return self.mono
|
||||||
|
|
||||||
|
|
||||||
|
def _meter(monkeypatch):
|
||||||
|
clock = _Clock()
|
||||||
|
monkeypatch.setattr(metering.time, 'time', clock.time)
|
||||||
|
monkeypatch.setattr(metering.time, 'monotonic', clock.monotonic)
|
||||||
|
return metering.GlobalMeter(), clock
|
||||||
|
|
||||||
|
|
||||||
|
def test_first_output_records_stream_ttft_ms(monkeypatch):
|
||||||
|
meter, clock = _meter(monkeypatch)
|
||||||
|
meter.begin_session('one')
|
||||||
|
meter.set_pending_started_at('one', 1000.0)
|
||||||
|
|
||||||
|
clock.wall += 0.250
|
||||||
|
meter.record_token('one', 1)
|
||||||
|
|
||||||
|
assert meter.get_stats('one')['ttft_ms'] == 250
|
||||||
|
|
||||||
|
|
||||||
|
def test_reasoning_before_output_does_not_start_ttft(monkeypatch):
|
||||||
|
meter, clock = _meter(monkeypatch)
|
||||||
|
meter.begin_session('one')
|
||||||
|
meter.set_pending_started_at('one', 1000.0)
|
||||||
|
|
||||||
|
clock.wall += 0.100
|
||||||
|
meter.record_reasoning('one', 1)
|
||||||
|
assert 'ttft_ms' not in meter.get_stats('one')
|
||||||
|
|
||||||
|
clock.wall += 0.150
|
||||||
|
meter.record_token('one', 1)
|
||||||
|
assert meter.get_stats('one')['ttft_ms'] == 250
|
||||||
|
|
||||||
|
clock.wall += 1.0
|
||||||
|
meter.record_token('one', 2)
|
||||||
|
assert meter.get_stats('one')['ttft_ms'] == 250
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_start_and_zero_token_omit_ttft(monkeypatch):
|
||||||
|
meter, clock = _meter(monkeypatch)
|
||||||
|
meter.begin_session('missing')
|
||||||
|
meter.set_pending_started_at('missing', None)
|
||||||
|
meter.record_token('missing', 1)
|
||||||
|
assert 'ttft_ms' not in meter.get_stats('missing')
|
||||||
|
|
||||||
|
meter.begin_session('invalid')
|
||||||
|
meter.set_pending_started_at('invalid', 0)
|
||||||
|
meter.record_token('invalid', 1)
|
||||||
|
assert 'ttft_ms' not in meter.get_stats('invalid')
|
||||||
|
|
||||||
|
meter.begin_session('zero')
|
||||||
|
meter.set_pending_started_at('zero', clock.wall)
|
||||||
|
assert 'ttft_ms' not in meter.get_stats('zero')
|
||||||
|
meter.end_session('zero', 0)
|
||||||
|
assert meter.get_stats('zero')['active'] == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_concurrent_stream_ttft_isolation(monkeypatch):
|
||||||
|
meter, clock = _meter(monkeypatch)
|
||||||
|
meter.begin_session('one')
|
||||||
|
meter.begin_session('two')
|
||||||
|
meter.set_pending_started_at('one', 1000.0)
|
||||||
|
meter.set_pending_started_at('two', 1000.0)
|
||||||
|
|
||||||
|
clock.wall += 0.125
|
||||||
|
meter.record_token('one', 1)
|
||||||
|
clock.wall += 0.375
|
||||||
|
meter.record_token('two', 1)
|
||||||
|
|
||||||
|
assert meter.get_stats('one')['ttft_ms'] == 125
|
||||||
|
assert meter.get_stats('two')['ttft_ms'] == 500
|
||||||
|
|
||||||
|
|
||||||
|
def test_streaming_propagates_ttft_to_journal_done_and_message(monkeypatch):
|
||||||
|
meter, clock = _meter(monkeypatch)
|
||||||
|
stream_id = 'propagation'
|
||||||
|
meter.begin_session(stream_id)
|
||||||
|
meter.set_pending_started_at(stream_id, 1000.0)
|
||||||
|
clock.wall += 0.250
|
||||||
|
meter.record_token(stream_id, 1)
|
||||||
|
|
||||||
|
metering_event = meter.get_stats(stream_id)
|
||||||
|
journal = [metering_event]
|
||||||
|
usage = {'ttft_ms': metering_event['ttft_ms']}
|
||||||
|
assistant = {'role': 'assistant', '_firstTokenMs': meter.get_ttft_ms(stream_id)}
|
||||||
|
final_session = {'messages': [assistant]}
|
||||||
|
|
||||||
|
assert journal[0]['ttft_ms'] == 250
|
||||||
|
assert usage['ttft_ms'] == 250
|
||||||
|
assert assistant['_firstTokenMs'] == 250
|
||||||
|
assert final_session['messages'][-1]['_firstTokenMs'] == 250
|
||||||
Reference in new issue
Block a user