工业级 Agent 不只有 ReAct:Hermes 运行时架构拆解
❝
把 Agent 当成一个 ReAct 循环往里扔 LLM,这在 demo 里能跑通,丢到生产环境里三天就崩。不是模型不行,是缺了一层正经的运行时。
❞
先回答一个问题:Agent 框架到底在解决什么
过去两年我陆续搭过好几个 Agent 应用,从最早拿 LangChain 随便糊一个对话机器人,到后来需要在飞书里跑一个能读写文件、调内部 API 的”数字员工”。踩过最大的坑不是什么提示词工程,而是:「怎么让这个 Agent 在真实世界里稳定地活着」。
真实世界长什么样?用户从不同平台发消息过来——Telegram、飞书、Discord、CLI——每个平台的消息格式都不一样。同一个用户可能连续发好几条,也可能发完一条就切出去干别的了。Agent 在执行工具调用的时候,如果用户突然发了个”停”,你得能响应。如果同一个会话里两条消息同时触发两个 Agent 循环,轻则上下文错乱,重则资源全被占死。
这就是框架要解决的问题。不是”怎么调 LLM 更聪明”,而是”怎么让 LLM 驱动的程序像正经后端服务一样可靠”。
Hermes 给出的答案挺有意思:「它几乎没发明什么新技术,就是把前端的状态机、后端的线程池、操作系统的事件循环这些老东西组合起来,在每个关键节点里塞进 LLM 调用。」
下面顺着它的架构一层层拆。
第一层:入口不是消息,是”半成品”
用户发了条消息。在 Telegram 里它是一个 Update 对象,在 Discord 里是一个 Message,在 CLI 里是一行字符串,在飞书里是一个 Webhook 的 JSON payload。
这些东西有一个共同点:「平台相关的、半结构化的、还没法直接喂给 Agent 的原始数据。」
Telegram update
Discord message
CLI input
Webhook payload
这一层不干活,只负责把原始数据往里传。但它揭示了一个容易被忽略的事实:「Agent 框架的边界不是”收到消息”,而是”收到平台事件”」。如果你的框架只处理了某一种输入格式,换一个平台就得重写一半逻辑。
第二层:适配器——把五花八门的输入洗干净
适配器层干的事情可以用一句话说清楚:「把平台特定的输入转成框架内部统一的 MessageEvent。」
具体要做的事包括:
-
识别谁发的(user id) -
归属到哪个会话(chat/session id) -
提取消息文本 -
判断有没有图片、文件、音频附件 -
生成一个用于内部路由的 「session_key」
这个 session_key 是整个架构里最重要的一个概念,后面会反复提到。你可以把它理解为这个会话的”身份证”——「它决定了后续所有工作往哪里路由,也决定了并发控制的粒度。」
适配器层还参与一个关键的”门禁”动作:
active_sessions[session_key] = 忙状态守卫
pending_messages[session_key] = 下一轮 / 中断槽
这是什么意思呢?如果同一个 session_key 对应的会话已经有一个 Agent 循环在跑了,新来的消息不会直接再开一个循环,而是进入一个”挂起”状态。这个设计背后是一个很朴素的判断:「同一个会话里同时跑两个独立的 Agent 推理,除了浪费 token 和制造混乱之外没有任何好处。」
第三层:事件总线——不思考,只协调
这一层是容易被误解最多的地方。
有人看到”事件总线”就以为是消息队列,有人以为它要负责调度 Agent 执行。都不是。「事件总线的职责非常单一:协调事件在整个运行时中的生命周期流转。」
它做的事情包括:
-
发布事件(publish) -
订阅处理器(subscribe) -
触发生命周期钩子(lifecycle hooks) -
异步派发任务(async dispatch) -
处理中断信号和挂起消息
一个简化版的生命周期长这样:
on_message_received → on_processing_start → on_agent_run →
on_tool_event → on_response_sent → on_processing_complete
重点在于:「事件总线不拥有工作线程池,不缓存 AIAgent,不跑 ReAct 循环,也不维护远端 LLM 会话。」 它的角色更像一个”广播站”:有事发生了,该通知谁通知谁,该触发什么钩子触发什么钩子,typing 指示器、日志、指标、清理逻辑这些”副作用”都在这一层被挡在了核心循环外面。
如果你熟悉 Node.js,这个模式很好理解:
| Node.js | Hermes |
|---|---|
| EventEmitter | 事件总线 |
| Express 中间件 | 生命周期钩子 |
| Event Loop | 异步调度器 |
| libuv 工作线程池 | ThreadPoolExecutor |
| 路由处理器 | GatewayRunner + AIAgent |
类比不是一一对应的,但思想是通的:「事件循环负责调度,工作线程池负责跑阻塞任务,业务逻辑跑在 Handler 里。」
没有这一层的话,每个平台适配器都得自己实现一遍 typing 开关、中断处理、进度更新、清理逻辑——代码会迅速膨胀成一锅粥。「事件总线把这些”横切关注点”从核心 ReAct 循环里剥离了出去。」
关于顺序和并发,需要区分两种情况:
「单条消息内部」,生命周期阶段是有序的:收到 → 开始处理 → Agent 运行 → 回复 → 完成。
「跨不同会话」,事件可以并发推进:会话 A 在处理,会话 B 可以同时开始,会话 C 在排队等线程。
但对「同一个会话」,Hermes 维持一个硬约束:「同一时间只有一个活跃运行。」 活跃期间新消息进入挂起 / 中断 / 排队状态。
这个设计选择的本质是:「允许跨会话并发,保护单会话不出现多个互相打架的 Agent 循环。」
伪代码示意:
async 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 就是”执行层”的调度中心。
它拥有或协调这些运行时资源:
-
ThreadPoolExecutor(线程池) -
running_agents(活跃运行追踪) -
agent_cache(按 session_key 缓存的 AIAgent 对象) -
session store(会话存储) -
queued events(排队事件) -
model/provider config(模型配置) -
progress/interrupt monitoring(进度和中断监控)
线程池的用法
一个关键设计决策是:「GatewayRunner 不会为每个会话创建永久的工作线程。」 而是把阻塞的 Agent 工作提交到共享线程池里执行。
会话 A 借用工作线程 1
会话 B 借用工作线程 2
会话 C 等池子空了再跑
Agent 跑完了,线程归还给池子。「工作线程不归任何会话所有。」
为什么这么设计?核心原因在 agent.run_conversation() 这个调用——它可能会做这些事:
-
调远端 LLM API(网络 IO) -
执行本地工具(读文件、写文件) -
等子进程返回 -
流式输出
这些事情都是「阻塞型」的,不应该卡住异步消息处理的主循环。所以 GatewayRunner 的典型做法是:
await loop.run_in_executor(
shared_thread_pool,
run_sync,
)
在 run_sync 里最终调用:
agent.run_conversation(message, conversation_history=history)
这里有一个值得留意的细节:「核心 AIAgent 循环是同步函数,被扔进线程池跑的。」 周边有协程做异步调度,但脏活累活全在线程里。这么做会牺牲一些协程的高效切换,但换来了代码复杂度的显著降低。对于一个跑在用户机器上的 Agent 来说,并发压力没那么极致,这个取舍是合理的。
线程池满了怎么办
如果所有工作线程都在忙,新提交的任务就在执行器的队列里排队。这就是典型的”背压”机制:
-
池里有空闲线程 → 立即执行 -
池满了 → 等着
注意:「这不代表某个会话”拥有”一个线程,只代表这个任务在等共享资源。」
单会话活跃运行的实现
GatewayRunner 和适配器层协作,保证对单个 session_key 同一时间只有一个活跃的 Agent 运行。
活跃期间新消息来了,Hermes 的处理方式取决于配置:
-
存为挂起的下一轮 -
当作中断信号 -
当作命令路由 -
放进显式的 FIFO 队列
默认行为更偏向于”保护这个会话,记一下来了条新消息,必要时打断当前运行”,而不是”所有消息都进全局 FIFO 队列”。这个细节很重要,很多人想当然地以为 Agent 框架应该像消息队列一样先进先出,但实际场景里,用户在一个会话里连续发多条消息,更合理的做法往往是让 Agent 先处理完手头的事,再决定怎么处理排队的那条。
伪代码示意:
async 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——本地 ReAct 控制器
AIAgent 是真正干活的地方。它是一个本地运行时对象,包含:
-
用哪个模型 / 提供商 -
有哪些工具可用 -
怎么构建消息 -
怎么管理会话历史 -
怎么跑 ReAct 循环 -
怎么处理中断 -
怎么执行工具调用 -
怎么产出最终回复
ReAct 循环的内部结构
用代码表示最直观:
while 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,传入消息历史 + 工具 schema -
模型返回最终回答 → 停 -
模型返回 tool_calls → 执行工具 → 追加观测结果 → 再调 LLM
这就是 ReAct 模式在代码层面的全部:「推理 → 行动 → 观测 → 重复。」 循环是本地的,智能来自远端模型,但控制结构在本地。
远端 LLM 是无状态的
这里需要强调一个容易被误解的点:「远端 LLM 不记得你的会话。」
每次 Hermes 调模型时,都要把这次调用需要的所有上下文一股脑发过去:
-
system prompt -
conversation history -
latest user message -
tool schemas -
tool observations -
runtime instructions
从外部看,模型好像”记得”之前聊了什么——实际上是因为 「Hermes 每次都在重建完整的上下文再发过去。」 所谓的”状态”完全由本地运行时维护,远端只负责根据当前输入做一次推理。
这个设计意味着:「所有会话状态管理都在本地,远端只做无状态推理。」 好处是水平扩展的时候不需要考虑远端会话粘滞,坏处是上下文越长 token 开销越大——但这是所有 Agent 框架都要面对的取舍。
第六层:工具——LLM 的”手脚”
工具是暴露给模型的本地能力,通过结构化的 schema 描述:
-
读文件 -
写文件 -
跑终端命令 -
查记忆 -
搜上下文 -
调外部 API -
生成媒体
「LLM 不直接执行这些工具。」 它只发出结构化的工具调用请求。本地运行时负责:
-
验证调用是否合法 -
执行工具 -
把结果转成观测(observation) -
把观测塞回消息列表
流程是:
LLM 发出工具调用
→ AIAgent 验证
→ 本地运行时执行
→ 结果变成观测
→ 观测送回 LLM
这里有一个我踩过的坑:工具的 schema 描述一定要足够精确,否则模型会以各种你想象不到的方式乱填参数。”文件路径”这个参数,模型可能给你传相对路径、绝对路径、甚至带空格和换行的路径——「本地执行的容错逻辑比提示词工程更管用。」
完整的一条消息从进到出
把上面所有层串起来,一条消息的完整路径是这样的:
-
用户在某个平台发消息 -
平台适配器收到原始 payload -
适配器标准化为 MessageEvent + session_key -
事件总线发布生命周期事件( message_received) -
运行时检查该 session_key是否已有活跃运行 -
GatewayRunner 准备会话历史、配置、工具和 Agent 对象 -
GatewayRunner 把阻塞的 Agent 工作提交到共享线程池 -
某个工作线程跑 agent.run_conversation() -
AIAgent 进入 ReAct 循环 -
远端 LLM 被调用,传入消息 / 历史 / 工具 schema -
如果有工具调用,本地执行,观测结果追加 -
循环重复,直到最终回复或停止条件触发 -
GatewayRunner 持久化结果,清理运行状态 -
事件总线触发 response_sent和processing_complete -
适配器把最终回复发回给用户
这个链路里的每一步都是可观测、可干预的。这就是工业级和 demo 级的区别:「不止要能跑通,还要在每一层都能插上手。」
关键组件的所有权关系
| 组件 | 归属 | 是否共享 | 生命周期 | 职责 |
|---|---|---|---|---|
| GatewayRunner | 进程 / 运行时 | 是,进程内全局 | 长期存活 | 主调度器 |
| ThreadPoolExecutor | GatewayRunner | 是 | 长期存活 | 跑阻塞的 Agent 工作 |
| 工作线程 | 线程池 | 是 | 每次任务临时 | 执行一次提交的运行 |
| session_key | 运行时 / 会话系统 | 否 | 长期身份标识 | 路由会话工作 |
| active_sessions | 适配器 / 运行时 | 按会话 | 活跃运行期间 | 防止同一会话重复跑 |
| pending_messages | 适配器 / 运行时 | 按会话 | 直到被消费 | 暂存下一轮 / 中断 |
| running_agents | GatewayRunner | 按活跃会话 | 当前运行期间 | 追踪活跃 Agent |
| agent_cache | GatewayRunner | 按缓存会话 | 跨轮次直到失效 | 复用本地 AIAgent |
| AIAgent | 运行时创建的对象 | 绑定缓存会话 | 跨轮次复用 | 本地 ReAct 控制器 |
| 远端 LLM API | 提供商 | 共享外部服务 | 每次调用无状态 | 生成助手消息 / 工具调用 |
| 工具 | 本地运行时 | 共享定义 | 取决于工具 | 执行操作 |
一些真实判断
读到这里,你可能觉得这套架构很完善。说几个我自己的判断:
「这套架构适合什么场景?」 适合需要长期运行、多平台接入、对稳定性有要求的”数字员工”类应用。它的复杂度主要花在了”让 Agent 在真实环境里不出乱子”这件事上,而不是”让单次推理更聪明”。
「它过度设计了吗?」 如果你只是在做一个单轮对话的 chatbot,那确实过了。但如果你要做一个用户在会话中间随时可能发消息、Agent 可能需要跑几分钟长任务、还要对接多个 IM 平台的系统,这些复杂度是必需的,不是摆设。
「线程池换成纯协程会不会更好?」 在纯 Python 异步框架里,理论上全部用协程可以做到更高并发。但 Hermes 选线程池的方案有一个很实际的理由:现有的 LLM SDK 和工具库大部分是同步的,强行包成协程反而容易出各种隐性问题。用线程池跑阻塞任务,虽然线程开销大一点,但兼容性最好,代码也最容易理解。「这是一个工程取舍,不是技术上的最优解。」
「挂起消息只保留一个”槽位”够用吗?」 默认行为确实只有一个 pending 位置,不是 FIFO 队列。这意味着如果用户在一个会话里连续发三条消息,Agent 正在忙,只有最新的一条会被保留,前面的可能被覆盖或丢失。这在某些场景下是合理的(比如用户连续补充信息,只需要最新的),但在另一些场景下可能是坑(比如需要按顺序处理任务)。「要不要改成队列,取决于你的使用场景,不是框架能替你做决定的。」
实用摘要
把这篇文章浓缩成几个可以直接用的结论:
-
「Agent = Harness + Model」,不要只在模型层面下功夫,Harness 决定了 Agent 在生产环境里能不能稳定活着。
-
「session_key 是核心」,所有路由、缓存、并发控制的粒度都挂在这个键上。
-
「事件总线剥离副作用」,把日志、指标、中断、进度更新这些横切关注点挡在核心循环之外,别让它们污染 ReAct 逻辑。
-
「共享线程池跑阻塞工作」,异步调度 + 同步执行的组合在工程上比纯异步更稳妥,兼容性更好。
-
「远端 LLM 无状态,状态在本地维护」,每次调用重建上下文。这不是缺陷,是设计选择。
-
「单会话同一时间只有一个活跃运行」,这个约束避免了大量并发混乱,代价是需要处理挂起消息的逻辑。
一页速览
入口(飞书 / TG / CLI)
↓
适配器层:标准化为 MessageEvent + session_key
↓
事件总线:生命周期协调,触发钩子,处理中断
↓
GatewayRunner:调度,线程池管理,Agent 缓存
↓
AIAgent:ReAct 循环(推理 → 工具调用 → 观测 → 重复)
↓
远端 LLM:无状态推理,不维护会话
↓
本地工具:读取文件、执行命令、调 API
↓
响应返回用户
FAQ
「Q:Hermes 和 LangGraph 的核心区别是什么?」
A:LangGraph 更偏向图状态机编排,Hermes 更偏向运行时调度。一个关注”逻辑怎么组织”,一个关注”实例怎么稳定跑”。
「Q:同一个会话同时来两条消息一定会出问题吗?」
A:不一定,但 Hermes 默认不让两条同时跑。如果你需要队列语义,可以在 pending_messages 上自己实现 FIFO。
「Q:为什么不用纯协程而要用线程池?」
A:现有工具库大多是同步的,强包成协程容易出隐性问题。线程池方案工程上更稳妥,代码复杂度更低。
「Q:agent_cache 缓存的是什么?」
A:缓存的是已经初始化好的 AIAgent 实例,包括模型配置、工具注册、会话历史等。跨轮次复用避免重复初始化开销。
「Q:Hermes 适合处理多长的任务?」
A:取决于线程池的大小和任务的阻塞性质。如果单个任务跑几分钟,需要合理配置线程池上限,避免其他会话饿死。
「Q:挂起消息只有一个槽位,会丢消息吗?」
A:默认行为下,如果会话活跃期间来了多条消息,只有最新的一条会被保留。需要完整队列的话需要自己扩展。
「Q:这套架构能水平扩展吗?」
A:需要把 session store、agent_cache 等状态外移到 Redis 等共享存储。单机版直接在内存里缓存,水平扩展时需要改造这些组件。

