OpenAI realtime: a long chat context sync or reconnect replay is sent in one event loop turn
メンテナーはふだん 1 日以内に返信
評価
- 難易度
- 3/5
- 見積もり時間
- 1〜2日
- 初心者へのやさしさ
- 35/100
- issue の種類
- バグ
- 明瞭さ
- 明確に書かれている
- 活発さ
- 停滞
- 技術スタック
- python
調査の方向性
Start with RealtimeSession._send_task and the reconnect replay loop in _main_task, identified in the issue; inspect how each loop sends queued events and where it can yield to the event loop. Use the provided reproduction script with 1000 chat messages to check both update_chat_ctx and reconnect replay. Done means other tasks can run between sends in both paths while event order is preserved; linked pull request #8228 indicates work is already underway.
索引モデルが issue の本文から書いたものです。
説明
Bug Description
RealtimeSession sends a long chat context, and the replay after a reconnect, in one event loop turn. No other task on the loop runs during that time.
_send_task reads the queued client events with async for msg in self._msg_ch. Chan.recv() returns without a suspension while the channel holds an item, and aiohttp send_str() waits only when the transport buffer is over its limit (writer.py#L126-L129). So the loop sends the full queue in one turn. The reconnect replay in _main_task uses the same kind of loop.
update_chat_ctx queues one conversation.item.create per chat item. So a restored long conversation goes out in one turn, and each reconnect replays it in one turn. A reconnect occurs after a connection error and after max_session_duration, 20 minutes by default. With 1000 messages, each of these sends holds the loop for 40 to 48 ms (Apple M4 Max, local server, no TLS).
Expected Behavior
Other tasks on the event loop, for example audio input and output, run between the events of a long send.
Reproduction Steps
uv run --python 3.12 --with livekit-agents==1.8.10 --with livekit-plugins-openai==1.8.10 repro.py
The script starts a local websocket server in a child process, so no request goes to OpenAI. The session syncs a chat context of 1000 messages with update_chat_ctx, then reconnects after a max_session_duration of 3 seconds. A probe task runs once per event loop turn and counts the openai_client_event_queued events since its previous turn. It exits with 1 when the problem is present.
repro.py
# /// script
# requires-python = ">=3.10"
# dependencies = ["livekit-agents", "livekit-plugins-openai"]
# ///
"""The OpenAI Realtime session sends a burst of client events in one event loop turn.
The script starts a local websocket server in a child process. The server speaks
enough of the OpenAI Realtime protocol to confirm chat items. No request goes to
OpenAI.
1. update_chat_ctx: the session syncs a chat context of 1000 messages.
2. reconnect: max_session_duration expires, and the session replays the chat
context on a new connection.
A probe task runs once per event loop turn. It counts the client events that the
session sent since its previous turn ("openai_client_event_queued" events). The
time in parentheses is the time from the first to the last send of the largest
turn. It depends on the machine.
--fixed applies the proposed change to RealtimeSession._run_ws and
RealtimeSession._main_task in the process.
"""
from __future__ import annotations
import __future__
import asyncio
import inspect
import json
import logging
import sys
import textwrap
import time
from importlib.metadata import version
from aiohttp import WSMsgType, web
from livekit.agents.llm import ChatContext
from livekit.agents.llm._realtime import openai as realtime_openai
from livekit.plugins.openai.realtime import RealtimeModel
MESSAGES = 1000
MAX_SESSION_DURATION = 3.0
TIMEOUT = 30.0
FIX = {
"_run_ws": (
(
" except Exception:\n"
' logger.exception("failed to send event")\n'
"\n"
" closing = True\n",
" except Exception:\n"
' logger.exception("failed to send event")\n'
"\n"
" # one event per loop turn, so a long queue does not block other tasks\n"
" await asyncio.sleep(0)\n"
"\n"
" closing = True\n",
),
),
"_main_task": (
(
' self.emit("openai_client_event_queued", ev)\n'
" await ws_conn.send_str(json.dumps(ev))\n",
' self.emit("openai_client_event_queued", ev)\n'
" await ws_conn.send_str(json.dumps(ev))\n"
" # one event per loop turn, so a long replay does not block other tasks\n"
" await asyncio.sleep(0)\n",
),
),
}
def apply_fix() -> None:
for name, replacements in FIX.items():
function = inspect.unwrap(getattr(realtime_openai.RealtimeSession, name))
source = textwrap.dedent(inspect.getsource(function))
for old, new in replacements:
if source.count(old) != 1:
sys.exit(f"--fixed: {name} does not contain the expected text:\n{old}")
source = source.replace(old, new)
namespace = dict(vars(realtime_openai))
code = compile(
source,
realtime_openai.__file__,
"exec",
flags=__future__.annotations.compiler_flag,
dont_inherit=True,
)
exec(code, namespace)
function.__code__ = inspect.unwrap(namespace[name]).__code__
async def realtime_endpoint(request: web.Request) -> web.WebSocketResponse:
"""Confirm the session update and every conversation.item.create."""
ws = web.WebSocketResponse()
await ws.prepare(request)
session = {"id": "sess_local", "object": "realtime.session", "model": "gpt-realtime"}
await ws.send_str(json.dumps({"type": "session.created", "event_id": "ev_c", "session": session}))
async for message in ws:
if message.type is not WSMsgType.TEXT:
continue
event = json.loads(message.data)
if event["type"] == "session.update":
await ws.send_str(
json.dumps({"type": "session.updated", "event_id": "ev_u", "session": session})
)
elif event["type"] == "conversation.item.create":
previous_item_id = event.get("previous_item_id")
await ws.send_str(
json.dumps(
{
"type": "conversation.item.added",
"event_id": f"ev_{event['item']['id']}",
"previous_item_id": None if previous_item_id == "root" else previous_item_id,
"item": event["item"],
}
)
)
return ws
async def serve() -> None:
"""Child process: serve until the parent closes stdin."""
app = web.Application()
app.router.add_get("/realtime", realtime_endpoint)
runner = web.AppRunner(app, shutdown_timeout=1.0)
await runner.setup()
site = web.TCPSite(runner, "127.0.0.1", 0)
await site.start()
print(runner.addresses[0][1], flush=True)
await asyncio.get_running_loop().run_in_executor(None, sys.stdin.read)
await runner.cleanup()
class SendProbe:
"""Count the client events that the session sends in each event loop turn."""
def __init__(self, session: realtime_openai.RealtimeSession) -> None:
self.reset()
session.on("openai_client_event_queued", self._on_event)
def reset(self) -> None:
self.sent = 0
self.most_in_one_turn = 0
self.largest_turn_ms = 0.0
self._turn: list[float] = []
def _on_event(self, _event: dict) -> None:
self.sent += 1
self._turn.append(time.perf_counter())
def end_turn(self) -> None:
if len(self._turn) > self.most_in_one_turn:
self.most_in_one_turn = len(self._turn)
self.largest_turn_ms = (self._turn[-1] - self._turn[0]) * 1000
self._turn.clear()
async def run(self) -> None:
while True:
self.end_turn()
await asyncio.sleep(0)
def report(self, phase: str) -> int:
self.end_turn()
print(
f"{phase}: the session sent {self.sent} events, at most {self.most_in_one_turn} "
f"in one event loop turn ({self.largest_turn_ms:.1f} ms)"
)
most = self.most_in_one_turn
self.reset()
return most
async def wait_for(condition, timeout: float = TIMEOUT) -> bool:
for _ in range(int(timeout / 0.01)):
if condition():
return True
await asyncio.sleep(0.01)
return False
async def sync_then_reconnect(base_url: str) -> tuple[int, int]:
model = RealtimeModel(
api_key="local",
base_url=base_url,
model="gpt-realtime",
max_session_duration=MAX_SESSION_DURATION,
)
session = model.session()
probe = SendProbe(session)
reconnected: list = []
session.on("session_reconnected", reconnected.append)
if not await wait_for(lambda: probe.sent == 1):
sys.exit("the session did not send session.update")
probe.reset()
probe_task = asyncio.create_task(probe.run())
chat_ctx = ChatContext()
for i in range(MESSAGES):
chat_ctx.add_message(role="user" if i % 2 == 0 else "assistant", content=f"message {i}")
await asyncio.wait_for(session.update_chat_ctx(chat_ctx), TIMEOUT)
if reconnected:
sys.exit("max_session_duration expired during update_chat_ctx, raise MAX_SESSION_DURATION")
sync = probe.report(f"update_chat_ctx with {MESSAGES} messages")
if not await wait_for(lambda: reconnected):
sys.exit("the session did not reconnect")
replay = probe.report("reconnect after max_session_duration")
probe_task.cancel()
await session.aclose()
await model.aclose()
return sync, replay
async def main() -> int:
server = await asyncio.create_subprocess_exec(
sys.executable,
__file__,
"--serve",
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
)
try:
port = int(await asyncio.wait_for(server.stdout.readline(), TIMEOUT))
sync, replay = await sync_then_reconnect(f"http://127.0.0.1:{port}")
finally:
server.stdin.close()
await asyncio.wait_for(server.wait(), TIMEOUT)
most = max(sync, replay)
if most > 1:
print(f"PROBLEM PRESENT: the session sent up to {most} client events in one event loop turn")
return 1
print("PROBLEM ABSENT: the session sent at most 1 client event in one event loop turn")
return 0
if __name__ == "__main__":
if "--serve" in sys.argv:
asyncio.run(serve())
sys.exit(0)
logging.getLogger("livekit").setLevel(logging.CRITICAL + 1)
if "--fixed" in sys.argv:
apply_fix()
print(
f"livekit-agents {version('livekit-agents')}, "
f"livekit-plugins-openai {version('livekit-plugins-openai')}, "
f"aiohttp {version('aiohttp')}, Python {sys.version.split()[0]}"
)
sys.exit(asyncio.run(main()))
Output with livekit-agents 1.8.10:
livekit-agents 1.8.10, livekit-plugins-openai 1.8.10, aiohttp 3.14.5, Python 3.12.12
update_chat_ctx with 1000 messages: the session sent 1000 events, at most 1000 in one event loop turn (48.2 ms)
reconnect after max_session_duration: the session sent 1001 events, at most 1001 in one event loop turn (40.5 ms)
PROBLEM PRESENT: the session sent up to 1001 client events in one event loop turn
Operating System
macOS 26
Models Used
OpenAI Realtime (local fake server)
Package Versions
livekit-agents==1.8.10
livekit-plugins-openai==1.8.10
aiohttp==3.14.5
Python 3.12.12
Session/Room/Call IDs
No response
Proposed Solution
await ws_conn.send_str(json.dumps(msg))
...
# one event per loop turn, so a long queue does not block other tasks
await asyncio.sleep(0)
The same yield goes after send_str in the reconnect replay loop. The order on the wire stays the order of the queue, and a cancellation at the yield loses no event, because the event is already sent. The cost is one event loop iteration per event.
Additional Context
No response
Screenshots and Recordings
No response
- 主要言語
- Python
- スター
- 14.6k
- フォーク
- 3.9k
- 平均マージ
- 2日 5時間
- マージ済み PR(30日)
- 232
環境構築
このプロジェクトには開発コンテナ、Dockerfile、コントリビューションガイドがありません。まず README を読み、一般的な手順ははじめてのコントリビューションガイドを参照してください。
はじめの一歩
- issue を最後まで読み、次にプロジェクトのコントリビューションガイドを読みます。
- 着手することを issue にコメントします — 二人が同じ作業をするのを防げます。
- リポジトリをフォークし、ブランチを切って変更します。
- issue 番号を参照したプルリクエストを送ります。
livekit/agents のほかの issue
-
難易度 1/5 1時間未満 初心者へのやさしさ 78/100
メンテナーはふだん 1 日以内に返信
-
mistralai: expose reasoning_effort on the LLM対応中かも @muzman123 が 1 日前に担当しました。 オープンenhancement
難易度 2/5 1〜3時間 初心者へのやさしさ 66/100
メンテナーはふだん 1 日以内に返信
-
ci: unit tests pin Python 3.12 while the package advertises 3.14 support対応中かも @huong8373tt-beep が 2 日前に担当しました。 オープン
難易度 2/5 1〜3時間 初心者へのやさしさ 74/100
livekit/agents#8231 · コメント 3 件 ·
メンテナーはふだん 1 日以内に返信
-
Assistant markup is stripped from chat history when expressive mode is off対応中かも @ShivamSharma43 が 23 日前に担当しました。 オープン
難易度 2/5 1〜3時間 初心者へのやさしさ 72/100
livekit/agents#7647 · コメント 1 件 ·
メンテナーはふだん 1 日以内に返信
-
docs: openai.LLM vs openai.responses.LLM for HTTP OpenAI-compat hosts再び着手できるかも @AbarnaaSree が 63 日前に担当しましたが、オープン中のプルリクエストはありません。 オープン
難易度 2/5 1〜3時間 初心者へのやさしさ 72/100
livekit/agents#7022 · コメント 1 件 ·
メンテナーはふだん 1 日以内に返信
似ている issue
-
bug
難易度 2/5 1〜3時間 初心者へのやさしさ 74/100
BCDA-APS/polar-bits#93 ·
-
infer_audio_type only recognises one MPEG Layer III frame header, so most MP3 files are rejected対応中かも @Steve-20-ai が今日担当しました。 オープン
難易度 2/5 1〜3時間 初心者へのやさしさ 78/100
メンテナーはふだん 1 日以内に返信
-
[BUG] - ssh_config `Match tagged` statement always applies対応中かも @AtharvRG が今日担当しました。 オープンBug
難易度 2/5 1〜3時間 初心者へのやさしさ 68/100
paramiko/paramiko#2715 · コメント 1 件 ·
メンテナーはふだん 9 日以内に返信
-
auto-rejected intake mcp-intake needs-salvage priority:high type:feature
難易度 2/5 1〜3時間 初心者へのやさしさ 65/100
Ikalus1988/MisakaNet#4221 · コメント 2 件 ·
メンテナーはふだん 1 日以内に返信
-
難易度 2/5 1〜3時間 初心者へのやさしさ 82/100
メンテナーはふだん 1 日以内に返信