Add function call latency tracking to LatencyBreakdown
This commit is contained in:
@@ -1 +1 @@
|
|||||||
- Added `on_latency_breakdown` event to `UserBotLatencyObserver` providing per-service TTFB, text aggregation, and user turn duration metrics for each user-to-bot response cycle.
|
- Added `on_latency_breakdown` event to `UserBotLatencyObserver` providing per-service TTFB, text aggregation, user turn duration, and function call latency metrics for each user-to-bot response cycle.
|
||||||
|
|||||||
@@ -5,11 +5,14 @@
|
|||||||
#
|
#
|
||||||
|
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import os
|
import os
|
||||||
|
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
|
from pipecat.adapters.schemas.function_schema import FunctionSchema
|
||||||
|
from pipecat.adapters.schemas.tools_schema import ToolsSchema
|
||||||
from pipecat.audio.vad.silero import SileroVADAnalyzer
|
from pipecat.audio.vad.silero import SileroVADAnalyzer
|
||||||
from pipecat.frames.frames import LLMRunFrame
|
from pipecat.frames.frames import LLMRunFrame
|
||||||
from pipecat.observers.startup_timing_observer import StartupTimingObserver
|
from pipecat.observers.startup_timing_observer import StartupTimingObserver
|
||||||
@@ -26,6 +29,7 @@ from pipecat.runner.types import RunnerArguments
|
|||||||
from pipecat.runner.utils import create_transport
|
from pipecat.runner.utils import create_transport
|
||||||
from pipecat.services.cartesia.tts import CartesiaTTSService
|
from pipecat.services.cartesia.tts import CartesiaTTSService
|
||||||
from pipecat.services.deepgram.stt import DeepgramSTTService
|
from pipecat.services.deepgram.stt import DeepgramSTTService
|
||||||
|
from pipecat.services.llm_service import FunctionCallParams
|
||||||
from pipecat.services.openai.llm import OpenAILLMService
|
from pipecat.services.openai.llm import OpenAILLMService
|
||||||
from pipecat.transports.base_transport import BaseTransport, TransportParams
|
from pipecat.transports.base_transport import BaseTransport, TransportParams
|
||||||
from pipecat.transports.daily.transport import DailyParams
|
from pipecat.transports.daily.transport import DailyParams
|
||||||
@@ -33,6 +37,17 @@ from pipecat.transports.websocket.fastapi import FastAPIWebsocketParams
|
|||||||
|
|
||||||
load_dotenv(override=True)
|
load_dotenv(override=True)
|
||||||
|
|
||||||
|
|
||||||
|
async def fetch_weather_from_api(params: FunctionCallParams):
|
||||||
|
await asyncio.sleep(0.25)
|
||||||
|
await params.result_callback({"conditions": "nice", "temperature": "75"})
|
||||||
|
|
||||||
|
|
||||||
|
async def fetch_restaurant_recommendation(params: FunctionCallParams):
|
||||||
|
await asyncio.sleep(0.1)
|
||||||
|
await params.result_callback({"name": "The Golden Dragon"})
|
||||||
|
|
||||||
|
|
||||||
# We use lambdas to defer transport parameter creation until the transport
|
# We use lambdas to defer transport parameter creation until the transport
|
||||||
# type is selected at runtime.
|
# type is selected at runtime.
|
||||||
transport_params = {
|
transport_params = {
|
||||||
@@ -63,6 +78,38 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments):
|
|||||||
|
|
||||||
llm = OpenAILLMService(api_key=os.getenv("OPENAI_API_KEY"))
|
llm = OpenAILLMService(api_key=os.getenv("OPENAI_API_KEY"))
|
||||||
|
|
||||||
|
llm.register_function("get_current_weather", fetch_weather_from_api)
|
||||||
|
llm.register_function("get_restaurant_recommendation", fetch_restaurant_recommendation)
|
||||||
|
|
||||||
|
weather_function = FunctionSchema(
|
||||||
|
name="get_current_weather",
|
||||||
|
description="Get the current weather",
|
||||||
|
properties={
|
||||||
|
"location": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The city and state, e.g. San Francisco, CA",
|
||||||
|
},
|
||||||
|
"format": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["celsius", "fahrenheit"],
|
||||||
|
"description": "The temperature unit to use. Infer this from the user's location.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required=["location", "format"],
|
||||||
|
)
|
||||||
|
restaurant_function = FunctionSchema(
|
||||||
|
name="get_restaurant_recommendation",
|
||||||
|
description="Get a restaurant recommendation",
|
||||||
|
properties={
|
||||||
|
"location": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The city and state, e.g. San Francisco, CA",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required=["location"],
|
||||||
|
)
|
||||||
|
tools = ToolsSchema(standard_tools=[weather_function, restaurant_function])
|
||||||
|
|
||||||
messages = [
|
messages = [
|
||||||
{
|
{
|
||||||
"role": "system",
|
"role": "system",
|
||||||
@@ -70,7 +117,7 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments):
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
context = LLMContext(messages)
|
context = LLMContext(messages, tools)
|
||||||
user_aggregator, assistant_aggregator = LLMContextAggregatorPair(
|
user_aggregator, assistant_aggregator = LLMContextAggregatorPair(
|
||||||
context,
|
context,
|
||||||
user_params=LLMUserAggregatorParams(vad_analyzer=SileroVADAnalyzer()),
|
user_params=LLMUserAggregatorParams(vad_analyzer=SileroVADAnalyzer()),
|
||||||
@@ -147,9 +194,16 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments):
|
|||||||
stt_note = f" (STT: {stt_ttfb.value:.3f}s)" if stt_ttfb else ""
|
stt_note = f" (STT: {stt_ttfb.value:.3f}s)" if stt_ttfb else ""
|
||||||
logger.info(f" User turn: {breakdown.user_turn_secs:.3f}s{stt_note}")
|
logger.info(f" User turn: {breakdown.user_turn_secs:.3f}s{stt_note}")
|
||||||
|
|
||||||
for ttfb in breakdown.ttfb:
|
# Show non-STT TTFBs, inserting function calls after the first
|
||||||
if ttfb is not stt_ttfb:
|
# LLM TTFB (which triggered the calls) for a chronological waterfall.
|
||||||
logger.info(f" {ttfb.processor}: TTFB {ttfb.value:.3f}s")
|
non_stt = [t for t in breakdown.ttfb if t is not stt_ttfb]
|
||||||
|
fc_shown = False
|
||||||
|
for ttfb in non_stt:
|
||||||
|
logger.info(f" {ttfb.processor}: TTFB {ttfb.value:.3f}s")
|
||||||
|
if not fc_shown and breakdown.function_calls:
|
||||||
|
for fc in breakdown.function_calls:
|
||||||
|
logger.info(f" {fc.function_name}: {fc.duration_secs:.3f}s")
|
||||||
|
fc_shown = True
|
||||||
|
|
||||||
if breakdown.text_aggregation:
|
if breakdown.text_aggregation:
|
||||||
ta = breakdown.text_aggregation
|
ta = breakdown.text_aggregation
|
||||||
|
|||||||
@@ -15,11 +15,13 @@ is measured. Optionally collects per-service latency breakdown metrics
|
|||||||
import time
|
import time
|
||||||
from collections import deque
|
from collections import deque
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import List, Optional
|
from typing import Dict, List, Optional
|
||||||
|
|
||||||
from pipecat.frames.frames import (
|
from pipecat.frames.frames import (
|
||||||
BotStartedSpeakingFrame,
|
BotStartedSpeakingFrame,
|
||||||
ClientConnectedFrame,
|
ClientConnectedFrame,
|
||||||
|
FunctionCallInProgressFrame,
|
||||||
|
FunctionCallResultFrame,
|
||||||
InterruptionFrame,
|
InterruptionFrame,
|
||||||
MetricsFrame,
|
MetricsFrame,
|
||||||
UserStoppedSpeakingFrame,
|
UserStoppedSpeakingFrame,
|
||||||
@@ -34,6 +36,19 @@ from pipecat.observers.base_observer import BaseObserver, FramePushed
|
|||||||
from pipecat.processors.frame_processor import FrameDirection
|
from pipecat.processors.frame_processor import FrameDirection
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class FunctionCallMetrics:
|
||||||
|
"""Latency for a single function call execution.
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
function_name: Name of the function that was called.
|
||||||
|
duration_secs: Time in seconds from execution start to result.
|
||||||
|
"""
|
||||||
|
|
||||||
|
function_name: str
|
||||||
|
duration_secs: float
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class LatencyBreakdown:
|
class LatencyBreakdown:
|
||||||
"""Per-service latency breakdown for a single user-to-bot cycle.
|
"""Per-service latency breakdown for a single user-to-bot cycle.
|
||||||
@@ -52,11 +67,14 @@ class LatencyBreakdown:
|
|||||||
VAD silence detection, STT finalization, and any turn analyzer
|
VAD silence detection, STT finalization, and any turn analyzer
|
||||||
wait. ``None`` if no ``UserStoppedSpeakingFrame`` was observed
|
wait. ``None`` if no ``UserStoppedSpeakingFrame`` was observed
|
||||||
(e.g. no turn analyzer configured).
|
(e.g. no turn analyzer configured).
|
||||||
|
function_calls: Latency for each function call executed during
|
||||||
|
this cycle. Empty if no function calls occurred.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
ttfb: List[TTFBMetricsData] = field(default_factory=list)
|
ttfb: List[TTFBMetricsData] = field(default_factory=list)
|
||||||
text_aggregation: Optional[TextAggregationMetricsData] = None
|
text_aggregation: Optional[TextAggregationMetricsData] = None
|
||||||
user_turn_secs: Optional[float] = None
|
user_turn_secs: Optional[float] = None
|
||||||
|
function_calls: List[FunctionCallMetrics] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
class UserBotLatencyObserver(BaseObserver):
|
class UserBotLatencyObserver(BaseObserver):
|
||||||
@@ -113,6 +131,8 @@ class UserBotLatencyObserver(BaseObserver):
|
|||||||
# Per-cycle metric accumulators
|
# Per-cycle metric accumulators
|
||||||
self._ttfb: List[TTFBMetricsData] = []
|
self._ttfb: List[TTFBMetricsData] = []
|
||||||
self._text_aggregation: Optional[TextAggregationMetricsData] = None
|
self._text_aggregation: Optional[TextAggregationMetricsData] = None
|
||||||
|
self._function_call_starts: Dict[str, tuple[str, float]] = {}
|
||||||
|
self._function_call_metrics: List[FunctionCallMetrics] = []
|
||||||
|
|
||||||
self._register_event_handler("on_latency_measured")
|
self._register_event_handler("on_latency_measured")
|
||||||
self._register_event_handler("on_latency_breakdown")
|
self._register_event_handler("on_latency_breakdown")
|
||||||
@@ -171,6 +191,21 @@ class UserBotLatencyObserver(BaseObserver):
|
|||||||
elif isinstance(data.frame, InterruptionFrame):
|
elif isinstance(data.frame, InterruptionFrame):
|
||||||
# Discard stale metrics from cancelled LLM/TTS cycles
|
# Discard stale metrics from cancelled LLM/TTS cycles
|
||||||
self._reset_accumulators()
|
self._reset_accumulators()
|
||||||
|
elif isinstance(data.frame, FunctionCallInProgressFrame):
|
||||||
|
self._function_call_starts[data.frame.tool_call_id] = (
|
||||||
|
data.frame.function_name,
|
||||||
|
time.time(),
|
||||||
|
)
|
||||||
|
elif isinstance(data.frame, FunctionCallResultFrame):
|
||||||
|
start = self._function_call_starts.pop(data.frame.tool_call_id, None)
|
||||||
|
if start is not None:
|
||||||
|
function_name, start_time = start
|
||||||
|
self._function_call_metrics.append(
|
||||||
|
FunctionCallMetrics(
|
||||||
|
function_name=function_name,
|
||||||
|
duration_secs=time.time() - start_time,
|
||||||
|
)
|
||||||
|
)
|
||||||
elif isinstance(data.frame, MetricsFrame):
|
elif isinstance(data.frame, MetricsFrame):
|
||||||
self._handle_metrics_frame(data.frame)
|
self._handle_metrics_frame(data.frame)
|
||||||
elif isinstance(data.frame, BotStartedSpeakingFrame):
|
elif isinstance(data.frame, BotStartedSpeakingFrame):
|
||||||
@@ -198,6 +233,7 @@ class UserBotLatencyObserver(BaseObserver):
|
|||||||
ttfb=list(self._ttfb),
|
ttfb=list(self._ttfb),
|
||||||
text_aggregation=self._text_aggregation,
|
text_aggregation=self._text_aggregation,
|
||||||
user_turn_secs=self._user_turn,
|
user_turn_secs=self._user_turn,
|
||||||
|
function_calls=list(self._function_call_metrics),
|
||||||
)
|
)
|
||||||
await self._call_event_handler("on_latency_breakdown", breakdown)
|
await self._call_event_handler("on_latency_breakdown", breakdown)
|
||||||
self._reset_accumulators()
|
self._reset_accumulators()
|
||||||
@@ -229,3 +265,5 @@ class UserBotLatencyObserver(BaseObserver):
|
|||||||
self._ttfb = []
|
self._ttfb = []
|
||||||
self._text_aggregation = None
|
self._text_aggregation = None
|
||||||
self._user_turn = None
|
self._user_turn = None
|
||||||
|
self._function_call_starts = {}
|
||||||
|
self._function_call_metrics = []
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import unittest
|
|||||||
from pipecat.frames.frames import (
|
from pipecat.frames.frames import (
|
||||||
BotStartedSpeakingFrame,
|
BotStartedSpeakingFrame,
|
||||||
ClientConnectedFrame,
|
ClientConnectedFrame,
|
||||||
|
FunctionCallInProgressFrame,
|
||||||
|
FunctionCallResultFrame,
|
||||||
InterruptionFrame,
|
InterruptionFrame,
|
||||||
MetricsFrame,
|
MetricsFrame,
|
||||||
UserStoppedSpeakingFrame,
|
UserStoppedSpeakingFrame,
|
||||||
@@ -463,6 +465,85 @@ class TestUserBotLatencyObserver(unittest.IsolatedAsyncioTestCase):
|
|||||||
|
|
||||||
self.assertEqual(len(first_speech_latencies), 0)
|
self.assertEqual(len(first_speech_latencies), 0)
|
||||||
|
|
||||||
|
async def test_function_call_latency_in_breakdown(self):
|
||||||
|
"""Test that function call duration appears in the latency breakdown."""
|
||||||
|
observer = UserBotLatencyObserver()
|
||||||
|
processor = IdentityFilter()
|
||||||
|
|
||||||
|
breakdowns = []
|
||||||
|
|
||||||
|
@observer.event_handler("on_latency_breakdown")
|
||||||
|
async def on_breakdown(obs, breakdown):
|
||||||
|
breakdowns.append(breakdown)
|
||||||
|
|
||||||
|
tool_call_id = "call_abc123"
|
||||||
|
|
||||||
|
frames_to_send = [
|
||||||
|
VADUserStoppedSpeakingFrame(),
|
||||||
|
FunctionCallInProgressFrame(
|
||||||
|
function_name="get_weather",
|
||||||
|
tool_call_id=tool_call_id,
|
||||||
|
arguments={"location": "Atlanta"},
|
||||||
|
),
|
||||||
|
SleepFrame(sleep=0.1),
|
||||||
|
FunctionCallResultFrame(
|
||||||
|
function_name="get_weather",
|
||||||
|
tool_call_id=tool_call_id,
|
||||||
|
arguments={"location": "Atlanta"},
|
||||||
|
result={"temperature": "75"},
|
||||||
|
),
|
||||||
|
BotStartedSpeakingFrame(),
|
||||||
|
]
|
||||||
|
|
||||||
|
await run_test(
|
||||||
|
processor,
|
||||||
|
frames_to_send=frames_to_send,
|
||||||
|
observers=[observer],
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(len(breakdowns), 1)
|
||||||
|
self.assertEqual(len(breakdowns[0].function_calls), 1)
|
||||||
|
fc = breakdowns[0].function_calls[0]
|
||||||
|
self.assertEqual(fc.function_name, "get_weather")
|
||||||
|
self.assertGreaterEqual(fc.duration_secs, 0.1)
|
||||||
|
|
||||||
|
async def test_function_call_reset_on_interruption(self):
|
||||||
|
"""Test that function call metrics are cleared on interruption."""
|
||||||
|
observer = UserBotLatencyObserver()
|
||||||
|
processor = IdentityFilter()
|
||||||
|
|
||||||
|
breakdowns = []
|
||||||
|
|
||||||
|
@observer.event_handler("on_latency_breakdown")
|
||||||
|
async def on_breakdown(obs, breakdown):
|
||||||
|
breakdowns.append(breakdown)
|
||||||
|
|
||||||
|
frames_to_send = [
|
||||||
|
VADUserStoppedSpeakingFrame(),
|
||||||
|
FunctionCallInProgressFrame(
|
||||||
|
function_name="get_weather",
|
||||||
|
tool_call_id="call_1",
|
||||||
|
arguments={},
|
||||||
|
),
|
||||||
|
FunctionCallResultFrame(
|
||||||
|
function_name="get_weather",
|
||||||
|
tool_call_id="call_1",
|
||||||
|
arguments={},
|
||||||
|
result={},
|
||||||
|
),
|
||||||
|
InterruptionFrame(),
|
||||||
|
BotStartedSpeakingFrame(),
|
||||||
|
]
|
||||||
|
|
||||||
|
await run_test(
|
||||||
|
processor,
|
||||||
|
frames_to_send=frames_to_send,
|
||||||
|
observers=[observer],
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(len(breakdowns), 1)
|
||||||
|
self.assertEqual(len(breakdowns[0].function_calls), 0)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
Reference in New Issue
Block a user