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

オープン
#8,405 コメント 0 件 リアクション 0 件 担当者 0 名 GitHub で見る

メンテナーはふだん 1 日以内に返信

まだ誰も着手していません。

評価

難易度
4/5
見積もり時間
3〜5日
初心者へのやさしさ
48/100
issue の種類
バグ
明瞭さ
明確に書かれている
活発さ
活発
技術スタック
python

調査の方向性

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.

索引モデルが issue の本文から書いたものです。

説明

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"},
主要言語
Python
スター
14.6k
フォーク
3.9k
平均マージ
3日
マージ済み PR(30日)
262

環境構築

このプロジェクトには開発コンテナ、Dockerfile、コントリビューションガイドがありません。まず README を読み、一般的な手順ははじめてのコントリビューションガイドを参照してください。

はじめの一歩

  1. issue を最後まで読み、次にプロジェクトのコントリビューションガイドを読みます。
  2. 着手することを issue にコメントします — 二人が同じ作業をするのを防げます。
  3. リポジトリをフォークし、ブランチを切って変更します。
  4. issue 番号を参照したプルリクエストを送ります。

livekit/agents のほかの issue

livekit/agents の issue をすべて見る

似ている issue

Python の issue をもっと見る

新しい issue をメールで受け取る

初心者向けの GitHub issue を短くまとめたダイジェスト。