←新闻详情

LiveKit 产品与工程博客更新于 10月11日 07:29

LiveKit 如何诊断 Agents 中被阻塞的事件循环

Diagnosing blocked event loops in LiveKit Agents | LiveKit

AI 导读

LiveKit 发布指南,讲解如何诊断 LiveKit Agents 中阻塞事件循环这一最常见的智能体不稳定原因。Python livekit-agents 1.8.1+ 与 Node.js @livekit/agents 1.9.1+ 内置事件循环监控器,会在循环停顿 100ms 及以上时告警并给出阻塞代码行。事件循环被阻塞会导致智能体静默、忽略打断甚至被 supervisor 判定死亡并杀掉,LiveKit 约 15 秒内检测到断连并派发新智能体。

正文

当前显示原文,中文全文译文尚未就绪。

If your LiveKit agent goes silent mid-call, gets killed, ignores interruptions, or drops the call, there's a good chance it's blocking the event loop. This is one of the most common (and most misdiagnosed) causes of agent instability in production.

This guide helps you recognize the symptoms, confirm the cause, and find and fix the blocking code. It's written for developers building on LiveKit Agents in Python and Node.js.

Why blocking the event loop causes problems#

LiveKit Agents runs each job in its own subprocess, and a supervisor process health-checks it with a periodic ping/pong. That health check runs on your agent's event loop, so:

If the loop is briefly stalled, the health-check response is delayed and you get an "unresponsive" warning; If the loop stays blocked long enough, the supervisor concludes the process is dead and kills it. LiveKit detects the disconnect within about 15 seconds and dispatches a fresh agent into the room, as long as the caller is still connected

The event loop is single-threaded in both Python and Node.js, so any synchronous call that doesn't yield holds up the entire loop until it returns. While it's blocked, no audio is sent or received, the health-check response can't be sent, and interruptions can't be processed.

Common symptoms#

Blocking the event loop produces a varied set of symptoms.

Log signatures

If any of the following appear in your agent logs, it's a strong signal that your code is blocking the loop.

Python:

Node.js:

The event loop blocked warning comes from the framework's built-in event loop monitor, available in Python livekit-agents 1.8.1+ and Node.js @livekit/agents 1.9.1+. It fires for any stall of 100ms or more and, when a stack sample is available, provides the line of your code that was running.

The delay= value in these warnings is how many milliseconds the loop was stalled. A value in the hundreds or thousands of milliseconds, or one that climbs as call volume rises, means the loop is being starved.

Don't trust the Running took too long: X seconds warning to pinpoint the cause. It names whichever task was running when the stall was measured, which is often an internal framework task rather than your blocking code.

You may also see, from the underlying transport layer:

Agent behavior

A blocked event loop shows up differently depending on where in the session's lifecycle it happens.

* These behaviors have multiple possible causes; a blocked event loop is only one.

Patterns to avoid#

Prevention beats cure. The following are the most common ways code blocks the loop.

Blocking sleep calls

Make sure no blocking sleep calls are left over from testing. Use the async equivalents:

Node.js: use await new Promise(r => setTimeout(r, 2000))

Synchronous HTTP calls Libraries like requests, urllib, and most non-async SDK clients block until the response returns. Prefer async HTTP and the shared, job-scoped session:

Node.js: use fetch/undici or an async client, not synchronous request libraries.

Synchronous database drivers and RAG lookups

A blocking DB query or a synchronous RAG lookup on the loop stalls everything. Use an async driver or offload the blocking call. Watch out for synchronous auth/token flows too, where some cloud SDK credential fetches block while acquiring a token.

CPU-bound work on the loop

Watch for heavy computation that runs on the loop, such as:

Audio DSP, noise suppression, resampling, format/color conversion; Embeddings, tokenization, local model inference; Cryptography, large JSON/regex parsing, image/video frame processing

This example works because numpy's vectorized operations release the GIL during heavy C computation, freeing the loop. Pure-Python CPU work doesn't release the GIL, so a separate thread will still block the loop. Use a separate process (or a native/async library) for genuinely heavy compute. In Node.js, move CPU-bound work to a worker_thread.

Native C extensions that block

Some libraries look like normal async SDK calls but drop into a synchronous C extension under the hood (audio enhancement models and certain streaming STT clients are common offenders). If you find one, isolate it in a thread or process, or switch to a pure-async implementation.

Blocking work in the entrypoint before the agent is listening

Slow work in the entrypoint delays the agent from joining and publishing its first audio.

Best practice:

Pass user/session data via job metadata, room metadata, or participant attributes instead of fetching in the entrypoint; If you must make a network call in the entrypoint, do it before ctx.connect() and make it async, so the frontend doesn't show the agent before it's actually listening

Re-initializing expensive resources on every job

Because a new process (and event loop) is created per job, anything you build in the entrypoint is rebuilt on every call: loading models, opening DB pools, building clients, validating config.

The prewarm function runs once when a worker process starts, before it picks up any job. Load a resource there and stash it on proc.userdata, and every job that worker handles can reuse it instead of rebuilding it. Here, a set of company FAQs is read from disk once and reused by every session that process handles:

Note that an async client (e.g. an async Redis or DB client) created at import time or in prewarm is bound to a different event loop than the per-job loop and will fail when used. Create such clients inside the entrypoint/session, or lazily on first use within the job's loop.

Blocking or unbounded work in shutdown callbacks

If post-call work in a shutdown callback (transcript uploads, DB writes, API calls) exceeds the shutdown timeout, the process is force-killed and you lose that work.

If your shutdown work is legitimately long, raise shutdown_process_timeout on your AgentServer, but still bound each step with asyncio.wait_for so a hang can't block drain forever.

Slow tool calls with no feedback

Tool calls and MCP servers are often not built for realtime and can take a long time to respond. For how to keep the agent talking while a slow tool runs, see Async tools for voice agents.

Blocking inside callbacks and pipeline hooks

Event handlers (@room.on(...), @session.on(...)), the on_user_turn_completed hook, and pipeline nodes (STT/LLM/TTS) all run on the loop, so keep them non-blocking.

Blocking imports and module-level work

Heavy top-level imports or model initialization at import time delay worker startup and registration. Keep module load light; defer heavy initialization to prewarm.

If the immediate cause isn't obvious#

If your agent is showing signs of a blocked loop, the following techniques can help you pin down the root cause.

Check the built-in event loop monitor

LiveKit Agents watches its own event loop and reports every stall of 100ms or more, with no code changes needed. It's on by default in Python livekit-agents 1.8.1+ and Node.js @livekit/agents 1.9.1+. A blocking call like this one:

produces the following:

A warning log naming the innermost line of your code that was running, as described earlier: event loop blocked for 288ms at "/app/src/agent.py", line 102, in lookup_order; An event_loop_blocked span in your agent's trace

In Agent Observability, the stall appears inside the tool call that caused it:

Note that nesting the stall under the operation that caused it requires Python 3.12 or later. On earlier versions, event_loop_blocked spans appear directly under agent_session instead.

Select the span to see its attributes, including duration and where the stall happened:

The stall lasted 288ms but only used 10ms of CPU time, so the loop was waiting, which is the behavior you would expect from a time.sleep.

For specific details on how the monitor works, see the Python and Node.js PRs.

Use py-spy on a running process (Python)

py-spy attaches to a live process without modifying it, and can inspect C extensions that are invisible to Python-frame profilers.

If the stack repeatedly lands in the same synchronous call, that's your block.

Enable asyncio debug mode (Python)

Or in code, loop.set_debug(True) and tune loop.slow_callback_duration. This surfaces slow callbacks and the coroutines responsible.

Measure event-loop lag in Node.js

Node.js has good built-in tools for measuring event-loop lag:

Also useful: node --cpu-prof / --prof (V8 profiler) and node --inspect with Chrome DevTools.

In summary#

During development, code defensively and avoid synchronous calls where possible; Learn the symptoms of a blocked loop and how your agent misbehaves when it happens; Watch for event loop blocked warnings and event_loop_blocked spans; Many symptoms that suggest a blocked loop have several possible causes, so keep an open mind and consult your agent logs when debugging

Related

Retell vs. Vapi vs. LiveKit for voice AI agents: A comparison for developers

The best open source frameworks for building realtime voice and video AI agents

What does "inference is slower than realtime" mean?

来源

LiveKit 产品与工程博客