@mate_mattt: https://x.com/mate_mattt/status/2074313623523271010
摘要
本文详细拆解了Hermes Agent的架构,包括事件总线、适配器层、GatewayRunner核心调度层等,揭示了Agent作为有状态、事件驱动运行时的设计模式。
查看缓存全文
缓存时间: 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
完整消息流转
端到端的全路径:
- 用户发消息。
- 平台适配器收到原始平台载荷。
- 适配器把它标准化成 MessageEvent + session_key。
- 事件总线/脚手架发布生命周期事件。
- 适配器/运行时检查该会话是否已在活跃中。
- GatewayRunner 准备会话历史、配置、工具和 agent 对象。
- GatewayRunner 把阻塞的 agent 工作提交到共享 ThreadPoolExecutor。
- 某个工作线程跑 agent.run_conversation()。
- AIAgent 进入 ReAct 循环。
- 远端 LLM 被调用,传入消息/历史/工具 schema。
- 如果返回了工具调用,本地工具执行,观测结果被追加。
- 循环重复,直到最终回复或停止条件触发。
- GatewayRunner 持久化并清理运行状态。
- 事件总线/脚手架触发回复和完成的钩子。
- 适配器把最终回复发回给用户。
所有权与共享关系表
归谁管共享?生命周期干啥用的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 架构系列文章。
相似文章
@dotey: Hermes Agent 架构的文档推荐直接看官方的文档,写的还算清楚。 然后用 Codex 或者 Claude Code 打开项目代码库,直接让 Agent 给你解释代码库,如果不清楚的你随时追问,这样比较好就是你可以问任何你想知道的问…
推荐直接查看 Hermes Agent 官方架构文档,并使用 Codex 或 Claude Code 打开项目代码库,让 Agent 解释代码,以便深入理解。
@libapi_: hermes agent + hermes-web-ui 安装教程 模型配置 面板安装 多agent群聊协作 定时任务设置 agent 性格/记忆设定
教程介绍了hermes agent和hermes-web-ui的安装、模型配置、面板安装、多agent群聊协作、定时任务设置以及agent性格/记忆设定。
@QingQ77: 为 Hermes Agent 提供一套开箱即用的技能和工作流层,覆盖从想法到部署再到运维的完整应用生命周期。 https://github.com/Salomondiei08/oh-my-hermes… Oh My Hermes 就是给 …
Oh My Hermes是一个开源的工作流层,为Hermes Agent提供开箱即用的技能和Agent团队,覆盖从需求到部署运维的完整应用生命周期,使Hermes成为一个自主的运维和开发搭档。
@qc777qc: https://x.com/qc777qc/status/2055938260103548970
本文介绍了如何通过Cron、Gateway和Heartbeat机制让Hermes Agent实现24小时持续工作,关键在于使用状态文件而非聊天上下文来维持连续性。
@NFTCPS: Hermes 这波直接把 Agent 天花板给掀了! 全网程序员已经开始用它搭桌面神器、跑创意流水线、省 token 省到飞起——你还没跟上? 先说 hermes-desktop,CLI 小玩具直接进化成 Mac/Windows 原生桌面…
本文介绍了围绕Hermes Agent构建的五个开源工具项目,包括桌面应用、创意工作流、共享记忆层、token压缩工具和监控仪表板,旨在扩展Agent的生态系统。