@mate_mattt: https://x.com/mate_mattt/status/2074313623523271010

X AI KOLs Timeline 工具

摘要

本文详细拆解了Hermes Agent的架构,包括事件总线、适配器层、GatewayRunner核心调度层等,揭示了Agent作为有状态、事件驱动运行时的设计模式。

https://t.co/8Q0c06LQdC
查看原文
查看缓存全文

缓存时间: 2026/07/07 17:34

Hermes Agent 架构详细拆解-一个工业级的 Agent 框架底层运行时揭秘

AI Agent 是具有状态的(Stateful)、事件驱动的、甚至需要长期存活在后台不断自我循环的。

这恰恰是前端和客户端写烂了的模式(管理复杂的 UI 状态机、处理高频的用户交互事件、RunLoop 守护进程)。

如果用过去传统编程领域的知识看待 Agent 架构,会发现它并没有发明任何新的技术框架,仅仅是在某些事件节点中加入了 LLM 能力。

这也就是就是那个经典公式的由来:

Agent = Harness + Model

读完全文,你会发现 Harness 是个整合了各种已经被广泛使用的技术架构的综合体,并且使用的全是大家熟悉的框架思想。

这篇是 Agent 架构系列第三篇文章,上一篇分析了 Agent 架构发展,并重点分析了 LangGraph、Pi-Agent (读完本篇,可以补充阅读,加深印象):

MateMatt@mate_mattt·Jul 5 ArticleAgent 底层状态机编排演进-让你搭建出大厂标准的 Agent 架构上一篇文章介绍了一下状态机的图表示和二维矩阵表示,反响不错:

看来大家对这类文章颇有兴趣,趁热打铁,今天继续深入 agent 底层状态编排系统内部,一探究竟,通过对比 AutoGPT、LangGraph、Pi-Agent…4114318K

如果感兴趣,记得关注我,我会持续输出该系列深度解析文章。

Hermes Agent 总览

Hermes 并不是“把一个 LLM 扔进 ReAct 循环里跑“那么简单。它是一个正经的运行时——接收外部事件、通过生命周期脚手架路由、安全地调度 agent 工作、在可能的情况下复用本地 agent 控制器、然后用重建的上下文去调用一个无状态的远端 LLM。

整体架构全景图:

架构全景图从左往右看,分别是:

  • 用户/平台入口 - 飞书、tg 等入口

  • 适配器层 - 将不同入口的信息统一为内部的 MessageEvent 结构

  • **事件总线层 Event Bus **- 管理完整的生命周期监听、事件派发

  • GatewayRunner 核心调度层 - 并发控制、线程管理调度 (这一层是核心层)

  • **AI Agent **- 大家熟悉的 ReAct 的实现运行层

  • **无状态 LLM 接口层 - **调用各个 LLM 厂商接口

接下来,根据这个架构全景图进行层层拆解。

Hermes Agent 第一层:用户 / 平台入口

最外层就是真实世界入口:用户在某个平台上发了条消息——可能是 Telegram、Discord、Slack、CLI,或者其他接入方式。

这时候消息还是平台相关的“半成品“:

Telegram update Discord message CLI input Webhook payload

这些原始的平台 payload 还不是 agent 任务。它们得先被“洗一遍“(标准化),核心运行时才能处理。

第二层:适配器层

适配器层负责把平台特定的输入转成 Hermes 内部事件。

它要回答的问题包括:

  • 谁发的?

  • 属于哪个会话 (Session)?

  • 消息文本是什么?

  • 有没有图片、文件、音频附件?

  • session_key 是啥?

  • 这个 Session 会话是不是已经在忙了?

概念上,适配器产出的东西长这样:

MessageEvent + session_key + 来源元数据

session_key 极其重要。它是本地会话的身份标识,用来把工作路由到正确的会话通道里。

session_key = 会话身份证

适配器还参与了会话级别的“门禁“:

active_sessions[session_key] = 忙状态守卫 pending_messages[session_key] = 下一轮 / 中断

这意味着 Hermes 会尽量避免同一个会话同时跑两个独立的 agent 循环——那不乱套了嘛。

第三层:事件总线 / 脚手架层

事件总线/脚手架层是整个架构里最核心的概念层

这层很容易被误解。事件总线不跑 ReAct 循环,它不“思考“,也不以 agent 的身份调用工具。它的活儿是协调事件在整个运行时中的生命周期流转。

你可以把它理解成以下几样东西的缝合体:

  • 一个事件发射器。

  • 一条中间件管道。

  • 一套生命周期钩子系统。

  • 一个异步协调层。

  • 一个在主 agent 运行前后触发副作用的地方。

Event Bus 事件总线干了啥

事件总线/脚手架层负责路由和生命周期控制。它处理的概念包括:

  • publish(event) —— 发布事件

  • subscribe(handler) —— 订阅处理器

  • lifecycle hooks —— 生命周期钩子

  • async dispatch —— 异步派发

  • side effects —— 副作用

  • interrupt signals —— 中断信号

  • pending-message triggers —— 挂起消息触发

简化版的生命周期大概长这样:

on_message_received —— 收到消息 -> on_processing_start —— 开始处理 -> on_agent_run —— agent 运行 -> on_tool_event —— 工具事件 -> on_response_sent —— 回复已发送 -> on_processing_complete —— 处理完成

在源码中,这些“总线“职责可能分散在适配器、钩子、后台任务和 GatewayRunner 里,而不是实现成一个叫 EventBus 的传统单一类。

事件总线不管啥

事件总线不拥有这些东西:

  • 工作线程池。

  • AIAgent 缓存。

  • ReAct 循环。

  • 远端 LLM 会话。

  • 本地工具执行的语义。

它的角色更像是:

“有个事件发生了。该通知谁?该跑哪些生命周期钩子? 要不要触发中断?要不要开始处理?要不要发 typing 指示器、 打日志、上报指标、或者做清理?”

这里使用 Node.js 事件循环做类比

如果你熟悉 Node.js,你肯定对这个事件总线不陌生:

Node.js EventEmitter ~= 发布 / 订阅 Express 中间件 ~= 生命周期拦截 Node 事件循环 Event Loop ~= 异步调度面 libuv 工作线程池 ~= 共享执行器(跑阻塞任务) 应用处理器 ~= GatewayRunner + AIAgent

事件总线跟操作系统的事件循环不是一回事。它是在异步运行时行为之上构建的应用层事件协调模式。

为啥 Harness 要有这一层

没有这一层的话,每个平台适配器(tg feishu 等平台入口)都得自己撸一遍相同的逻辑:

  • 开关 typing 指示器。

  • 记录消息生命周期事件。

  • 触发进度更新。

  • 处理中断。

  • 处理挂起消息。

  • 跑清理逻辑。

  • 通知可观测性或指标处理器。

  • 把平台相关代码和 agent 逻辑隔离开。

事件总线/脚手架层把这些生命周期关注点挡在核心 ReAct 循环外面。

这个分离很重要:

事件总线/脚手架 = 事件生命周期 + 副作用 AIAgent ReAct 循环 = 推理 + 工具调用 + 观测 + 最终回答

事件总线中的顺序与并发

对单条消息来说,生命周期阶段大体是有序的:

收到 -> 开始处理 -> agent 运行 -> 回复 -> 完成

但跨不同会话时,事件可以并发推进

会话 A 的事件正在处理中 会话 B 的事件可以同时开始处理 会话 C 的事件在排队等工作线程

对同一个会话,Hermes 尽量保持同一时间只有一个活跃运行:

同一个 session_key -> 一个活跃运行 活跃期间来新消息 -> 挂起 / 中断 / 排队行为

这是一个关键的设计决策。系统允许跨会话并发,但保护单个会话不会同时跑多个互相打架的 agent 循环。

事件总线伪代码

pythonasync def handle_raw_platform_message(raw): event = adapter.to_message_event(raw) session_key = event.session_key

await publish("message_received", event)

if adapter.is_session_active(session_key):
    adapter.store_pending_or_interrupt(session_key, event)
    await publish("message_pending", event)
    return

await publish("processing_start", event)

try:
    response = await gateway_runner.handle_message(event)
    await publish("response_sent", response)
finally:
    await publish("processing_complete", event)

第四层:GatewayRunner

GatewayRunner 是运行时的全局调度器 (Worker)。

如果说事件总线是生命周期协调层,那 GatewayRunner 就是决定 agent 工作怎么准备、怎么执行的那一层。

GatewayRunner 管什么

GatewayRunner 拥有或协调这些主要的运行时资源:

ThreadPoolExecutor —— 线程池 running_agents —— 活跃运行追踪 agent_cache —— 按 session_key 缓存的 AIAgent 对象 session store —— 会话存储 queued events —— 排队事件 model / provider config —— 模型/提供商配置 progress and interrupt monitoring —— 进度和中断监控 adapters —— 适配器

当任务到来时,GatewayRunner 并不会为 Session 会话创建一个永久的工作线程。它把阻塞的 agent 工作提交到共享执行器中。

会话 A 借用工作线程 1 会话 B 借用工作线程 2 会话 C 如果池满了就等着

当 agent 跑完,工作线程还回池里。

工作线程不归会话所有

共享线程池

Hermes 用一个共享线程池来处理阻塞的 agent 工作。

原因很实际:agent.run_conversation() 可能会调远端 API、跑工具、跟文件交互、等子进程、流式输出——这些活儿不应该阻塞异步消息处理循环。

所以 GatewayRunner 概念上是这么干的:

pythonawait loop.run_in_executor( shared_thread_pool, run_sync, )

run_sync 里面,Hermes 最终调用:

pythonagent.run_conversation(message, conversation_history=history)

关键思想:

异步事件处理保持响应灵敏 阻塞的 agent 运行扔到工作线程里搞

目前调研下来,GatewayRunner 里有用协程,_handle_message、_run_agent_inner 都是 async def,周边也会用 asyncio.create_task() 跑 progress、stream、interrupt monitor 等任务。但核心 AIAgent loop 不是协程,是同步函数,放进线程池跑。

参考源代码:gateway/run.py

所以调用流程大致如下:

某个 worker thread 被 Session A 的 agent run 占用 -> agent 正在等 LLM / 读文件 / 跑工具 -> 这个 worker thread 不会让出来给 Session B -> 只有等这个 run 返回,线程才归还线程池

可以看到,agent 读取文件的时候,明显是个 IO 阻塞。但是线程依然等待着,不会让出;这并不符合现代协程的高效特点。我猜是为了降低代码复杂度,毕竟这是一个跑在用户机器上的 Agent,并发要求很低。(这里比较复杂,有不对的地方,还请指正)

线程池满了咋办

如果所有工作线程都在忙,新的执行器任务就在执行器队列里排队等着。

这就是正常的背压:

池里有空闲线程 -> 立刻开跑 池满了 -> 等着

这不代表会话拥有某个工作线程。只代表任务在等共享资源空出来。

每个会话一个活跃运行

GatewayRunner 和适配器层一起协作,保护会话不会被重复运行。

对单个 session_key

同一时间只有一个活跃的 agent 运行

如果会话活跃期间又来了新消息,Hermes 可能会:

  • 把它存为挂起的下一轮。

  • 把它当作中断信号处理。

  • 把它当命令路由。

  • 如果用户用了队列语义,就放进显式的 FIFO 队列里。

重要的区别:

默认挂起消息 = 通常是一个下一轮/中断槽位 显式队列 = FIFO 语义

所以别想当然地以为每条普通消息都会进一个大 FIFO 队列。默认行为更像是“保护这个会话,记下接下来来了啥,可能的话打断它。

GatewayRunner 伪代码:

pythonasync def gateway_handle_message(event): session_key = event.session_key

mark_running_or_pending(session_key)

def run_sync():
    history = load_history(session_key)
    config = resolve_model_and_tools(event)

    agent = agent_cache.get(session_key, config)
    if agent is None:
        agent = AIAgent(config=config, session_key=session_key)
        agent_cache[session_key] = agent

    return agent.run_conversation(
        event.text,
        conversation_history=history,
    )

result = await run_in_executor(shared_thread_pool, run_sync)

persist_result(session_key, result)
clear_running(session_key)
drain_pending_if_any(session_key)

return result.final_response

这里展示了核心的调度关系:

GatewayRunner 负责调度。 ThreadPoolExecutor 提供临时执行线程。 AIAgent 执行 ReAct 循环。

第五层:AIAgent

AIAgent 是本地 agent 控制器。它是一个本地运行时对象:

    • 用哪个模型/提供商。
    • 有哪些工具可用。
    • 怎么构建消息。
    • 怎么管理会话历史。
    • 怎么跑 ReAct 循环。
    • 怎么处理中断。
    • 怎么执行工具调用。
    • 怎么产出最终回复。

内部循环:

调 LLM -> 模型返回最终回答 -> 停

调 LLM -> 模型返回 tool_calls -> 执行工具 -> 追加观测结果 -> 再调 LLM

这就是 ReAct 模式:

推理(Reason) -> 行动(Action) -> 观测(Observation) -> 重复

用代码表示:

pythonwhile not done and iteration_count < max_iterations: assistant_message = call_llm(messages, tools=tool_schemas)

if assistant_message.tool_calls:
    tool_results = execute_tools(assistant_message.tool_calls)
    messages.append(tool_results)
    continue

return assistant_message.final_text

循环是本地的。智能来自远端模型,但控制结构在本地。

第六层:远端 LLM API

远端 LLM API 是无状态的,远端模型不记得你的会话。

每次 Hermes 调模型时,都会把这次调用需要的上下文一股脑发过去:

system prompt —— 系统提示词 conversation history —— 对话历史 latest user message —— 最新用户消息 tool schemas —— 工具 schema tool observations —— 工具观测结果 runtime instructions —— 运行时指令

所以图上写的是:

messages/history 重建上下文

远端 LLM 看起来好像“记得“——那是因为 Hermes 把会话状态重新拼好又发了一遍。

第七层:本地工具

工具是暴露给模型的本地能力,通过结构化的工具 schema 来描述。例如:

读文件 写文件 跑终端命令 查记忆 搜上下文 调 API 生成媒体

LLM 不直接执行这些工具。它只是发出结构化的工具调用。本地运行时负责验证和执行,然后把观测结果塞回消息列表里。

LLM 发出工具调用 -> AIAgent 验证它 -> 本地运行时执行它 -> 结果变成观测 -> 观测送回 LLM

完整消息流转

端到端的全路径:

  1. 用户发消息。
  2. 平台适配器收到原始平台载荷。
  3. 适配器把它标准化成 MessageEvent + session_key。
  4. 事件总线/脚手架发布生命周期事件。
  5. 适配器/运行时检查该会话是否已在活跃中。
  6. GatewayRunner 准备会话历史、配置、工具和 agent 对象。
  7. GatewayRunner 把阻塞的 agent 工作提交到共享 ThreadPoolExecutor。
  8. 某个工作线程跑 agent.run_conversation()。
  9. AIAgent 进入 ReAct 循环。
  10. 远端 LLM 被调用,传入消息/历史/工具 schema。
  11. 如果返回了工具调用,本地工具执行,观测结果被追加。
  12. 循环重复,直到最终回复或停止条件触发。
  13. GatewayRunner 持久化并清理运行状态。
  14. 事件总线/脚手架触发回复和完成的钩子。
  15. 适配器把最终回复发回给用户。

所有权与共享关系表

归谁管共享?生命周期干啥用的GatewayRunner进程/运行时是,进程内全局长期存活主调度器ThreadPoolExecutorGatewayRunner是长期存活跑阻塞的 agent 工作工作线程线程池是每次任务临时用执行一次提交的运行session_key运行时/会话系统否长期身份标识路由会话工作active_sessions[session_key]适配器/运行时按会话活跃运行期间防止同一会话重复跑pending_messages[session_key]适配器/运行时按会话直到被消费暂存下一轮/中断running_agents[session_key]GatewayRunner按活跃会话当前运行期间追踪活跃 agent 用于中断/状态agent_cache[session_key]GatewayRunner按缓存会话跨轮次直到失效复用本地 AIAgentAIAgent运行时创建的对象通常绑定一个缓存会话跨轮次复用本地 ReAct 控制器远端 LLM API提供商共享的外部服务每次调用无状态生成助手消息/工具调用工具本地运行时共享定义,每次调用执行取决于工具执行操作

总结

一个简化版的处理流程:

事件总线 / 脚手架: 处理生命周期、路由、钩子、副作用、中断

GatewayRunner: 调度工作、拥有共享执行器、追踪活跃 agent、管理缓存

ThreadPoolExecutor: 共享的工作线程池;会话临时借用线程

session_key: 会话身份证;路由状态、挂起消息、缓存的 agent

AIAgent: 本地 ReAct 控制器;有效时按 session_key 缓存

远端 LLM: 无状态 API;上下文通过消息/历史重建

这篇文章整体较长,原因是完整的 Hermes 运行时框架本身复杂度较高。

如果觉得有帮助,记得帮忙点赞,关注后续我会持续更新 Agent 架构系列文章。

相似文章