Hacktoberfest 2026: những issue maintainer đã đánh dấu cho tháng Mười, đang mở và phù hợp người mới. Xem issue Hacktoberfest

OpenAI realtime: a long chat context sync or reconnect replay is sent in one event loop turn

Đang mở
#8,227 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

@zek đang làm issue này rồi.

Từ ngày 28/10/2026.

  • #8228 của @zek — đang mở

Đánh giá

Độ khó
3/5
Thời gian dự kiến
1-2 ngày
Mức phù hợp với người mới
35/100
Loại issue
Lỗi
Độ rõ ràng
Đặc tả rõ ràng
Mức độ hoạt động
Đình trệ
Công nghệ
python
Lĩnh vực
audio-video-rtc, backend

Hướng nghiên cứu

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.

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

Mô tả

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

Ngôn ngữ chính
Python
Star
14.6k
Fork
3.9k
Merge trung bình
2 ngày 6 giờ
Pull request đã merge (30 ngày)
227

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.