工业级 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

实际流程是:

  1. 调 LLM,传入消息历史 + 工具 schema
  2. 模型返回最终回答 → 停
  3. 模型返回 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 不直接执行这些工具。」 它只发出结构化的工具调用请求。本地运行时负责:

  1. 验证调用是否合法
  2. 执行工具
  3. 把结果转成观测(observation)
  4. 把观测塞回消息列表

流程是:

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

这里有一个我踩过的坑:工具的 schema 描述一定要足够精确,否则模型会以各种你想象不到的方式乱填参数。”文件路径”这个参数,模型可能给你传相对路径、绝对路径、甚至带空格和换行的路径——「本地执行的容错逻辑比提示词工程更管用。」

完整的一条消息从进到出

把上面所有层串起来,一条消息的完整路径是这样的:

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

这个链路里的每一步都是可观测、可干预的。这就是工业级和 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 正在忙,只有最新的一条会被保留,前面的可能被覆盖或丢失。这在某些场景下是合理的(比如用户连续补充信息,只需要最新的),但在另一些场景下可能是坑(比如需要按顺序处理任务)。「要不要改成队列,取决于你的使用场景,不是框架能替你做决定的。」

实用摘要

把这篇文章浓缩成几个可以直接用的结论:

  1. 「Agent = Harness + Model」,不要只在模型层面下功夫,Harness 决定了 Agent 在生产环境里能不能稳定活着。

  2. 「session_key 是核心」,所有路由、缓存、并发控制的粒度都挂在这个键上。

  3. 「事件总线剥离副作用」,把日志、指标、中断、进度更新这些横切关注点挡在核心循环之外,别让它们污染 ReAct 逻辑。

  4. 「共享线程池跑阻塞工作」,异步调度 + 同步执行的组合在工程上比纯异步更稳妥,兼容性更好。

  5. 「远端 LLM 无状态,状态在本地维护」,每次调用重建上下文。这不是缺陷,是设计选择。

  6. 「单会话同一时间只有一个活跃运行」,这个约束避免了大量并发混乱,代价是需要处理挂起消息的逻辑。

一页速览

入口(飞书 / 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 等共享存储。单机版直接在内存里缓存,水平扩展时需要改造这些组件。