乐于分享
好东西不私藏

从源码看 Hermes 事件循环策略设计:主线程、工作线程、异步上下文怎么玩?

从源码看 Hermes 事件循环策略设计:主线程、工作线程、异步上下文怎么玩?

1. 主线程,无运行中的循环 → 持久事件循环 (_tool_loop)

原理: 当主线程运行时,如果没有现成的事件循环,就创建一个持久的循环并长期持有。所有缓存的异步 HTTP 客户端(httpx.AsyncClient / AsyncOpenAI)都会绑定到这个循环,避免每次调用都重新创建。

典型场景: CLI 模式下,用户在终端进行对话。每次需要调用辅助任务(如 vision 分析、web extraction)时,使用的是同一个循环,避免重复初始化开销。

# _get_cached_client 中的关键逻辑
if async_mode:
    current_loop = _aio.get_event_loop()
    loop_id = id(current_loop)  # 循环身份作为缓存键的一部分

如果循环不存在(RuntimeError),_get_event_loop() 会抛异常,此时可以创建一个持久循环(例如 _tool_loop)并缓存起来。

缓存键中包含 loop_id:

cache_key = (provider, async_mode, base_url or"", api_key or"", loop_id)

这保证了不同循环不会共享客户端,防止跨循环使用导致的崩溃。


2. 工作线程(例如 delegate_task 池)→ 每线程持久循环 (_worker_loop)

原理: 子 Agent 在独立线程中运行(通过 delegate_tool 的线程池),每个线程创建并持有自己的持久事件循环。这样避免与主线程竞争,并且当该线程结束时,即使主线程的循环还活着,也不会影响该线程的客户端清理。

典型场景: 主 Agent 使用 delegate_task 工具调用子 Agent。子 Agent 的运行过程完全独立,有自己的 loop 和客户端缓存。

def_run_single_child(task_index: int, goal: str, child=None, parent_agent=None, **_kwargs) -> Dict[strAny]:
"""
    Run a pre-built child agent. Called from within a thread.
    Returns a structured result dict.
    """

# 线程内部运行子 Agent
    result = child.run_conversation(user_message=goal)
# ...

子 Agent 在自己的线程中执行,线程池管理生命周期。当线程结束时,它绑定的所有异步客户端也会随之清理,避免主线程 GC 时误以为循环还活着。

为什么要这样做?

  • • 主线程的 _tool_loop 可能还活着,但工作线程已经结束。
  • • 如果工作线程使用了主线程的循环,会导致循环引用和清理顺序问题。
  • • 每线程独立循环保证了线程隔离和生命周期清晰。

3. 在异步上下文内部(网关、RL 环境)→ 带 asyncio.run() 的一次性线程

原理: 当代码已经在一个运行中的事件循环内(例如 Gateway 的 asyncio 服务器),需要调用同步或阻塞操作时,创建一个新线程并在该线程内启动一个新的事件循环(使用 asyncio.run())。这样避免了与已有循环冲突。

典型场景: Gateway 处理多条消息,所有消息在同一个事件循环中处理。如果某步需要调用阻塞的工具(如同步的 file_operations),可以将其放入新线程,并在该线程中启动新循环,避免阻塞主循环。

# 伪代码示意
asyncdefhandle_message(message):
# 当前已经在 Gateway 的事件循环中

# 需要调用一个阻塞操作
    result = await asyncio.to_thread(some_blocking_operation)

# some_blocking_operation 可能在其内部创建新循环(例如调用需要 asyncio 的库)

另一种直接做法是:

defrun_in_fresh_loop():
    asyncio.run(blocking_async_task())

# 从已有循环中调用
await asyncio.to_thread(run_in_fresh_loop)

为什么需要一次性循环?

  • • 避免在已有循环中再创建子循环(asyncio 不允许嵌套循环)
  • • 阻塞操作不会影响主循环的性能
  • • 生命周期简单,用完即销毁

总结对比

场景
循环策略
原因
主线程无循环
持久循环 (_tool_loop)
避免重复创建,保持客户端绑定
工作线程池
每线程持久循环 (_worker_loop)
隔离生命周期,避免与主线程竞争
已有异步上下文
一次性线程 + asyncio.run()
避免嵌套循环,隔离阻塞操作

这种分层策略确保了不同执行环境下的异步安全性,同时最大程度地复用了客户端实例,减少开销。