GPT-Live: neither the voice model's seconds nor the backend model's tokens reach a span

Đang mở
#8,405 0 bình luận 0 reaction 0 người được giao Xem trên GitHub

Maintainer thường phản hồi trong vòng 1 ngày

Chưa có ai nhận issue này.

Đánh giá

Độ khó
4/5
Thời gian dự kiến
3-5 ngày
Mức phù hợp với người mới
48/100
Loại issue
Lỗi
Độ rõ ràng
Đặc tả rõ ràng
Mức độ hoạt động
Sôi nổi
Công nghệ
python

Hướng nghiên cứu

Start with AgentActivity in livekit/agents/voice/agent_activity.py and the metric recording helpers in livekit/agents/telemetry/utils.py; trace how realtime metrics are matched to spans and how LLMStream records llm_request spans. The proposed test is in tests/test_gpt_live_model.py, and tests/trace_schema.py defines the expected parent relationship. Done means voice usage and backend token metrics appear on spans under the session with the specified attributes.

Do mô hình lập chỉ mục viết ra từ nội dung của issue.

Mô tả

Environment

  • livekit-agents 1.8.10 (also unchanged on main as of 652eee4ea)
  • GPTLiveModel (delegation="responses") run through DuplexRealtimeAdapter
  • Traces exported over OTLP with set_tracer_provider to a backend that prices generations from gen_ai.* attributes (Langfuse here)

Reproduction

Run any call with GPT-Live and a tracer provider:

from livekit.agents import AgentSession
from livekit.agents.telemetry import set_tracer_provider
from livekit.plugins.openai.realtime import GPTLiveModel

set_tracer_provider(provider)  # any SDK TracerProvider with an exporter
session = AgentSession(llm=GPTLiveModel(responses_options={"model": "gpt-5.6-luna"}))
await session.start(agent=MyAgent(), room=ctx.room)

Talk for a few minutes, with at least one turn that makes the backend run a tool.

Observed

  • Every realtime_inference span carries usage 0. On one real call we had 31 of them, all at zero. The session seconds GPT-Live reports about once a minute (session.usage.updated, and session.closed at the end) appear on no span.
  • The backend Responses model has no span at all. Its tokens are reported as LLMMetrics and reach the usage collector, but no trace shows them.
  • A backend that prices from traces therefore shows zero cost for the voice model and nothing for the backend model.

Expected

  • Each usage update of the voice model is on a span, under the session, with the voice model's name and the seconds in lk.realtime_model_metrics.
  • Each backend response is an llm_request span with the backend model's name, its tokens (input, cached, cache write, output, reasoning) and its response id, using the same gen_ai.* attributes as a pipeline llm_request.

Cause

Proposed fix

The fix lives in AgentActivity, which owns the session's root span context:

  • The realtime session's metrics_collected goes to a small handler of its own, _on_realtime_metrics_collected, which then forwards to _on_metrics_collected as before.
  • An LLMMetrics coming from a realtime session gets an llm_request span under the session. A new telemetry.utils.record_llm_metrics, next to record_realtime_metrics, fills it with the existing gen_ai helpers: operation chat, provider, request and response model, usage, response id, and lk.llm_metrics.
  • A RealtimeModelMetrics from a duplex model gets a realtime_metrics span of its own under the session, filled by record_realtime_metrics. That is the span name already used for realtime metrics that arrive after their inference span.
  • Both spans are parented explicitly to the session's root context, not to whatever context the plugin's task happens to run in. tests/trace_schema.py allows agent_session as their parent.

Known limits:

  • Each duplex usage report becomes a span, including those with no usage: the delta of a session.closed that adds no seconds, and the _report_connection_acquired event of a reconnect. They cost nothing, but they show up as empty generations.
  • The backend span is instantaneous: GPT-Live reports duration=0 and ttft=-1 for backend responses, so a trace shows no latency for them.
  • A DuplexRealtimeAdapter wrapped in a RealtimeModelFallbackAdapter is not recognised as duplex, so its usage still gets no span. The same isinstance check is already used elsewhere in AgentActivity.

The diff is against main at 652eee4ea. It includes a test that drives GPTLiveModel through an AgentSession with an in-memory exporter.

diff --git a/livekit-agents/livekit/agents/telemetry/utils.py b/livekit-agents/livekit/agents/telemetry/utils.py
index 6370d27bf..3bf552886 100644
--- a/livekit-agents/livekit/agents/telemetry/utils.py
+++ b/livekit-agents/livekit/agents/telemetry/utils.py
@@ -12,7 +12,7 @@ from ..types import ATTRIBUTE_REDACTION_ENABLED, NOT_GIVEN, NotGivenOr
 from . import trace_types
 
 if TYPE_CHECKING:
-    from ..metrics import RealtimeModelMetrics
+    from ..metrics import LLMMetrics, RealtimeModelMetrics
 
 
 REDACTED_EXCEPTION_MESSAGE = "exception details redacted"
@@ -169,3 +169,23 @@ def record_realtime_metrics(span: trace.Span, ev: RealtimeModelMetrics) -> None:
         with tracer.use_span(span):
             with tracer.start_span("realtime_metrics") as child:
                 child.set_attributes(attrs)
+
+
+def record_llm_metrics(span: trace.Span, ev: LLMMetrics) -> None:
+    """Record a text model's usage on its ``llm_request`` span, when no ``LLMStream`` ran it.
+
+    A realtime model's own text model (GPT-Live's backend Responses model) reports only these.
+    """
+    from .gen_ai import set_request_attributes, set_response_attributes, set_usage_attributes
+
+    model_name = ev.metadata.model_name if ev.metadata else None
+    span.set_attribute(trace_types.ATTR_LLM_METRICS, ev.model_dump_json())
+    set_request_attributes(
+        span,
+        operation=trace_types.GenAIOperationName.CHAT,
+        provider=ev.metadata.model_provider if ev.metadata else None,
+        model=model_name,
+        output_type=trace_types.GenAIOutputType.TEXT,
+    )
+    set_usage_attributes(span, ev)
+    set_response_attributes(span, response_id=ev.request_id or None, model=model_name)
diff --git a/livekit-agents/livekit/agents/voice/agent_activity.py b/livekit-agents/livekit/agents/voice/agent_activity.py
index d5630fdad..62dea908d 100644
--- a/livekit-agents/livekit/agents/voice/agent_activity.py
+++ b/livekit-agents/livekit/agents/voice/agent_activity.py
@@ -1166,7 +1166,7 @@ class AgentActivity(RecognitionHooks):
                         "input_audio_transcription_completed",
                         self._on_input_audio_transcription_completed,
                     )
-                    self._rt_session.off("metrics_collected", self._on_metrics_collected)
+                    self._rt_session.off("metrics_collected", self._on_realtime_metrics_collected)
                     self._rt_session.off("remote_item_added", self._on_remote_item_added)
                     self._rt_session.off("error", self._on_error)
                     if isinstance(self._rt_session, _FallbackRealtimeSession):
@@ -1287,7 +1287,7 @@ class AgentActivity(RecognitionHooks):
                 "input_audio_transcription_completed",
                 self._on_input_audio_transcription_completed,
             )
-            self._rt_session.on("metrics_collected", self._on_metrics_collected)
+            self._rt_session.on("metrics_collected", self._on_realtime_metrics_collected)
             self._rt_session.on("remote_item_added", self._on_remote_item_added)
             self._rt_session.on("error", self._on_error)
 
@@ -1646,7 +1646,7 @@ class AgentActivity(RecognitionHooks):
         if self._rt_session is not None:
             await self._rt_session.aclose()
             # after aclose, so a model that reports its final usage while closing is still counted
-            self._rt_session.off("metrics_collected", self._on_metrics_collected)
+            self._rt_session.off("metrics_collected", self._on_realtime_metrics_collected)
 
         if self._realtime_spans is not None:
             self._realtime_spans.clear()
@@ -2221,6 +2221,27 @@ class AgentActivity(RecognitionHooks):
             SessionUsageUpdatedEvent(usage=self._session.usage),
         )
 
+    def _on_realtime_metrics_collected(
+        self,
+        ev: STTMetrics | TTSMetrics | VADMetrics | LLMMetrics | RealtimeModelMetrics,
+    ) -> None:
+        # what no inference span can carry gets a span of its own, under the session
+        if isinstance(ev, LLMMetrics):
+            # a realtime model's own text model (GPT-Live's backend) runs no LLMStream, which
+            # is what opens an llm_request span
+            with tracer.start_span("llm_request", context=self._session._root_span_context) as span:
+                trace_utils.record_llm_metrics(span, ev)
+        elif isinstance(ev, RealtimeModelMetrics) and isinstance(
+            self.llm, llm.DuplexRealtimeAdapter
+        ):
+            # a duplex model reports usage for its session: the realtime spans are keyed by the
+            # adapter's own speech bursts, whose ids the model never sees, so none would match
+            with tracer.start_span(
+                "realtime_metrics", context=self._session._root_span_context
+            ) as span:
+                trace_utils.record_realtime_metrics(span, ev)
+        self._on_metrics_collected(ev)
+
     def _on_remote_item_added(self, ev: llm.RemoteItemAddedEvent) -> None:
         # add the remote item to the local chat context as a placeholder
         local_chat_ctx = self._agent._chat_ctx
diff --git a/tests/test_gpt_live_model.py b/tests/test_gpt_live_model.py
index 6d68ef7eb..f47d854ae 100644
--- a/tests/test_gpt_live_model.py
+++ b/tests/test_gpt_live_model.py
@@ -3,10 +3,11 @@ from __future__ import annotations
 import asyncio
 import base64
 import contextlib
+import contextvars
 import json
 import logging
 import time
-from collections.abc import AsyncGenerator
+from collections.abc import AsyncGenerator, Iterator
 from dataclasses import dataclass, field
 from types import SimpleNamespace
 from typing import Any
@@ -16,13 +17,24 @@ import aiohttp
 import numpy as np
 import pytest
 from aiohttp import web
+from opentelemetry.sdk.trace import TracerProvider
+from opentelemetry.sdk.trace.export import SimpleSpanProcessor
+from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter
 
 from livekit import rtc
-from livekit.agents import APIConnectionError, APIConnectOptions, APIError, inference, llm
+from livekit.agents import (
+    Agent,
+    AgentSession,
+    APIConnectionError,
+    APIConnectOptions,
+    APIError,
+    inference,
+    llm,
+)
 from livekit.agents.inference.realtime import gpt_live as inference_gpt_live
 from livekit.agents.llm._realtime import gpt_live as gpt_live_model
 from livekit.agents.metrics import LLMMetrics, RealtimeModelMetrics
-from livekit.agents.telemetry import pii
+from livekit.agents.telemetry import pii, set_tracer_provider, trace_types, tracer
 from livekit.plugins.openai.realtime.gpt_live_model import (
     GPTLiveDelegation,
     GPTLiveModel,
@@ -31,6 +43,8 @@ from livekit.plugins.openai.realtime.gpt_live_model import (
 from livekit.plugins.openai.realtime.gpt_live_types import ServiceTier
 from livekit.plugins.openai.tools import WebSearch
 
+from .trace_schema import assert_trace_well_formed
+
 pytestmark = pytest.mark.unit
 
 
@@ -1602,6 +1616,83 @@ async def test_a_delegated_model_is_billed_under_its_own_name(
         await model.aclose()
 
 
[email protected]
+def span_exporter() -> Iterator[InMemorySpanExporter]:
+    original_provider = tracer._tracer_provider
+    provider = TracerProvider()
+    exporter = InMemorySpanExporter()
+    provider.add_span_processor(SimpleSpanProcessor(exporter))
+    set_tracer_provider(provider)
+    try:
+        yield exporter
+    finally:
+        set_tracer_provider(original_provider)
+        provider.shutdown()
+
+
[email protected]_concurrent
+async def test_both_models_report_their_usage_on_spans_under_the_session(
+    monkeypatch: pytest.MonkeyPatch, span_exporter: InMemorySpanExporter
+) -> None:
+    """The voice model is billed by the second and the backend by the token: both must reach a
+    span, or a tracing backend cannot price the call. The events are handled in an empty
+    context, as the plugin's own task may run in any: the spans still hang off the session."""
+    ws = _connect_hook(monkeypatch)
+    model = GPTLiveModel(api_key="sk-test")
+    async with AgentSession(llm=model, aec_warmup_duration=None) as session:
+        await session.start(Agent(instructions="Be concise."))
+        for _ in range(100):
+            if ws.session is not None and ws.session.session_id == "live_test":
+                break
+            await asyncio.sleep(0.01)
+        live = ws.session
+        assert live is not None and live.session_id == "live_test"
+
+        for event in (
+            {"type": "session.usage.updated", "usage": {"seconds": 14.0}},
+            _response_event("item_d1", _completed("resp_1")),
+        ):
+            contextvars.Context().run(live._handle_event, event)
+
+    spans = span_exporter.get_finished_spans()
+    (agent_session,) = [s for s in spans if s.name == "agent_session"]
+    usage = [s for s in spans if s.name == "realtime_metrics"]
+    (backend,) = [s for s in spans if s.name == "llm_request"]
+    assert agent_session.context is not None
+
+    # the update carries 14 seconds; the fake service closes the session with no more
+    assert [
+        json.loads(str(s.attributes[trace_types.ATTR_REALTIME_MODEL_METRICS]))["session_duration"]
+        for s in usage
+    ] == [14.0, 0.0]
+    assert [s.parent.span_id if s.parent else None for s in [*usage, backend]] == [
+        agent_session.context.span_id
+    ] * 3
+    assert usage[0].attributes[trace_types.ATTR_GEN_AI_REQUEST_MODEL] == "gpt-live-1"
+    assert {k: v for k, v in backend.attributes.items() if k.startswith("gen_ai.")} == {
+        "gen_ai.operation.name": "chat",
+        "gen_ai.provider.name": "openai",
+        "gen_ai.request.model": "gpt-5.6-sol",
+        "gen_ai.output.type": "text",
+        "gen_ai.response.id": "resp_1",
+        "gen_ai.response.model": "gpt-5.6-sol",
+        "gen_ai.usage.input_tokens": 376,
+        "gen_ai.usage.output_tokens": 18,
+        "gen_ai.usage.cache_read.input_tokens": 100,
+        "gen_ai.usage.input_cached_tokens": 100,
+        "gen_ai.usage.cache_write.input_tokens": 20,
+        "gen_ai.usage.reasoning.output_tokens": 5,
+        "gen_ai.usage.reasoning_tokens": 5,
+    }
+    backend_metrics = json.loads(str(backend.attributes[trace_types.ATTR_LLM_METRICS]))
+    assert (
+        backend_metrics["request_id"],
+        backend_metrics["prompt_tokens"],
+        backend_metrics["metadata"]["model_name"],
+    ) == ("resp_1", 376, "gpt-5.6-sol")
+    assert_trace_well_formed(spans)
+
+
 def _user_events(session: GPTLiveSession) -> list[tuple[str, Any]]:
     events: list[tuple[str, Any]] = []
     for name in (
diff --git a/tests/trace_schema.py b/tests/trace_schema.py
index 52870ee9e..39ad8bf68 100644
--- a/tests/trace_schema.py
+++ b/tests/trace_schema.py
@@ -80,7 +80,8 @@ SPAN_PARENTS: dict[str, frozenset[str | None]] = {
         "function_tool": {"agent_turn"},
         "agent_speaking": {"agent_turn"},
         "realtime_inference": {"agent_turn"},
-        "realtime_metrics": {"realtime_inference", "agent_turn"},
+        # a duplex model's usage is the session's, not a turn's (GPT-Live)
+        "realtime_metrics": {"realtime_inference", "agent_turn", "agent_session"},
         # -- model requests: under the node that made them, or the feature that owns them.
         # An adapter's request span stands in for the provider's and opens no attempt span of
         # its own: each wrapped request nests directly under it, with its own attempts:
@@ -92,6 +93,8 @@ SPAN_PARENTS: dict[str, frozenset[str | None]] = {
             "keyterm_detection",
             "judge_evaluation",
             "amd",
+            # a realtime model's own text model (GPT-Live's backend), which no node calls
+            "agent_session",
         },
         "llm_fallback_adapter": {"llm_node", "keyterm_detection", "judge_evaluation", "amd"},
         "llm_request_run": {"llm_request"},
Ngôn ngữ chính
Python
Star
14.6k
Fork
3.9k
Merge trung bình
2 ngày 22 giờ
Pull request đã merge (30 ngày)
255

Chuẩn bị môi trường

Dự án này không cung cấp dev container, Dockerfile hay hướng dẫn đóng góp, nên bạn cần tự thiết lập môi trường: hãy bắt đầu từ README và xem hướng dẫn đóng góp lần đầu của chúng tôi để biết các bước chung.

Bắt đầu từ đâu

  1. Đọc hết issue, rồi đọc hướng dẫn đóng góp của dự án.
  2. Bình luận trên issue rằng bạn sẽ nhận — tránh hai người làm cùng một việc.
  3. Fork repository và làm thay đổi trên một nhánh.
  4. Mở pull request có tham chiếu số hiệu của issue.

Issue khác của livekit/agents

Tất cả issue của livekit/agents

Issue tương tự

Thêm issue về Python

Nhận issue mới trong hộp thư của bạn

Bản tóm tắt ngắn những issue GitHub phù hợp với người mới.