cover
cover
AI Agent 是具有状态的(Stateful)、事件驱动的、甚至需要长期存活在后台不断自我循环的。
这恰恰是前端和客户端写烂了的模式(管理复杂的 UI 状态机、处理高频的用户交互事件、RunLoop 守护进程)。

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

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

Agent = Harness + Model

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

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

https://x.com/i/status/2073699050671734919

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

Hermes Agent 总览

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

整体架构全景图:

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

  1. 用户/平台入口 - 飞书、tg 等入口
  2. 适配器层 - 将不同入口的信息统一为内部的 MessageEvent 结构
  3. 事件总线层 Event Bus - 管理完整的生命周期监听、事件派发
  4. GatewayRunner 核心调度层 - 并发控制、线程管理调度 (这一层是核心层)
  5. AI Agent - 大家熟悉的 ReAct 的实现运行层
  6. 无状态 LLM 接口层 - 调用各个 LLM 厂商接口

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

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

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

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

plain text
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 —— 挂起消息触发

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

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

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

事件总线不管啥

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

  • 工作线程池。
  • AIAgent 缓存。
  • ReAct 循环。
  • 远端 LLM 会话。
  • 本地工具执行的语义。

它的角色更像是:

plain text
"有个事件发生了。该通知谁?该跑哪些生命周期钩子?
要不要触发中断?要不要开始处理?要不要发 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 循环。

事件总线伪代码:

python
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 是运行时的全局调度器 (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 概念上是这么干的:

python
await loop.run_in_executor(
    shared_thread_pool,
    run_sync,
)

在 run_sync 里面,Hermes 最终调用:

python
agent.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 伪代码:

python
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

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

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

内部循环:

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

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

这就是 ReAct 模式:

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

用代码表示:

python
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 API

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

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

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

所以图上写的是:

messages/history 重建上下文

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

第七层:本地工具

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

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

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

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

完整消息流转

端到端的全路径:

plain text
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按缓存会话跨轮次直到失效复用本地 AIAgent
AIAgent运行时创建的对象通常绑定一个缓存会话跨轮次复用本地 ReAct 控制器
远端 LLM API提供商共享的外部服务每次调用无状态生成助手消息/工具调用
工具本地运行时共享定义,每次调用执行取决于工具执行操作

总结

一个简化版的处理流程:

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

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

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

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

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

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

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

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