OpenAI realtime: a long chat context sync or reconnect replay is sent in one event loop turn
Maintainer thường phản hồi trong vòng 1 ngày
Đá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
- Đọc hết issue, rồi đọc hướng dẫn đóng góp của dự án.
- 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.
- Fork repository và làm thay đổi trên một nhánh.
- Mở pull request có tham chiếu số hiệu của issue.
Issue khác của livekit/agents
-
Độ khó 1/5 Dưới một giờ Mức phù hợp với người mới 78/100
Maintainer thường phản hồi trong vòng 1 ngày
-
enhancement
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 66/100
Maintainer thường phản hồi trong vòng 1 ngày
-
ci: unit tests pin Python 3.12 while the package advertises 3.14 supportCó thể đã có người làm @huong8373tt-beep đã nhận hôm nay. Đang mở
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 74/100
livekit/agents#8231 · 3 bình luận ·
Maintainer thường phản hồi trong vòng 1 ngày
-
Assistant markup is stripped from chat history when expressive mode is offCó thể đã có người làm @ShivamSharma43 đã nhận 22 ngày trước. Đang mở
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 72/100
livekit/agents#7647 · 1 bình luận ·
Maintainer thường phản hồi trong vòng 1 ngày
-
docs: openai.LLM vs openai.responses.LLM for HTTP OpenAI-compat hostsCó thể làm lại được @AbarnaaSree đã nhận 61 ngày trước và không có pull request nào đang mở. Đang mở
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 72/100
livekit/agents#7022 · 1 bình luận ·
Maintainer thường phản hồi trong vòng 1 ngày
Tất cả issue của livekit/agents
Issue tương tự
-
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 86/100
-
Validate finite output clamping limits in StepPredictor.prepare_clamping_params to prevent silent NaN/Inf propagationCó thể đã có người làm @muskan-creates352 đã nhận hôm nay. Đang mở
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 84/100
mllam/neural-lam#848 ·
Maintainer thường phản hồi trong vòng 2 ngày
-
infra
Độ khó 1/5 Dưới một giờ Mức phù hợp với người mới 88/100
fab-geocommuns/RNB-coeur#1166 ·
Maintainer thường phản hồi trong vòng 1 ngày
-
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 78/100
deepspeedai/DeepSpeed#8951 ·
Maintainer thường phản hồi trong vòng 2 ngày
-
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 76/100
aws/context-ontology-accelerator#343 ·
Maintainer thường phản hồi trong vòng 1 ngày