系列第一篇。本文深入解析 Hermes Agent 对话循环背后的工程判断:一个同步 while 循环如何承载复杂的 Agent 行为,双轨预算如何防止多层委托失控,以及这些看似朴素的选择如何形成一条设计链条。
打开 run_agent.py,你会在 run_conversation() 方法里找到 Hermes Agent 的心脏:一个 while 循环,加上几十行状态管理代码。如果你熟悉当代 AI Agent 框架的设计风格,第一眼可能会感到困惑——这也太素了吧?
2024 到 2026 年间,围绕 LLM Agent 的框架竞赛如火如荼。主流方案几乎无一例外地走向了同一个方向:有向无环图(DAG)建模工作流,异步状态机管理状态转换,事件总线传递执行结果。这类设计有清晰的工程动机:并行执行多个工具、状态转换可记录可回放、每个节点独立可测试。
Hermes 为什么没有走这条路?
答案需要从一个具体的调试场景说起。
假设有一个 Agent 任务:克隆一个仓库,运行测试,找出失败的测试用例,然后修复它们。任务进行到一半,Agent 卡住了——它一直在重复调用同一个工具,没有进展。
在一个基于异步状态机的框架里,调试过程是这样的:
你打开事件日志,看到一串类似下面的条目:
[14:32:01.234] TRANSITION: analyze_error -> generate_fix
[14:32:01.891] EMIT: tool_call(patch, {"file": "test_foo.py", ...})
[14:32:02.103] TRANSITION: generate_fix -> await_tool_result
[14:32:02.441] EMIT: tool_result(patch, {"success": true})
[14:32:02.442] TRANSITION: await_tool_result -> analyze_error ← 重新进入了?
[14:32:02.889] EMIT: tool_call(patch, {"file": "test_foo.py", ...}) ← 同一个文件!
你能确认循环在发生,但为什么进入循环?"analyze_error -> generate_fix" 的转换逻辑在哪里?那是一个注册在状态机框架深处的回调,它接收了什么输入,LLM 的推理过程产生了什么上下文,这些信息在状态转换日志里统统不见了。要还原完整的执行路径,你需要把事件日志、LLM 请求日志、工具调用日志三者拼接起来,而拼接的依据往往是脆弱的时间戳。
异步上下文还有一个根本性的问题:Python 的 traceback 在 await 边界处断裂。当你的工具抛出异常,你得到的是一个在 asyncio 事件循环内部某处截断的调用栈,而不是从"用户发出这条消息"一直到"工具爆炸"的完整路径。
在 Hermes 的同步循环里,同样的场景是这样的:
你在 run_conversation() 里任意一行代码上设置断点,直接得到完整调用栈。messages 列表在栈上,你直接打印出来就是整个对话历史。api_call_count 就在那里,iteration_budget.remaining 就在那里。整个 Agent 状态是一个可以被 pdb 直接检查的 Python 对象图,不需要从分布式日志系统里拼接。
更关键的是,LLM 调用本身就是一个天然的串行瓶颈。每次工具调用的结果都必须返回给 LLM,再由 LLM 决定下一步做什么。这个"LLM 思考 → 决策 → 工具执行 → 返回结果"的循环是本质串行的。异步化主循环,并不能让 LLM 变快——它只是把状态管理的复杂度从"几个局部变量"提升到了"一个需要序列化的状态机"。
当然,同步主循环不是免费的午餐。它最显著的代价是:在单个 Agent 等待 LLM 响应期间,进程在阻塞 IO 上空转。这在 CLI 模式下完全无所谓,但在 gateway 模式下(同时服务多个用户的消息平台),多个并发 Agent 会争夺线程资源。
Hermes 的解决方案是在进程外部引入并发,而不是在 Agent 内部异步化。Gateway 为每个 session 启动独立的 Agent 实例,进程间隔离比线程内的异步状态机更容易推理。但这也意味着 gateway 的吞吐上限受操作系统进程/线程数限制,在极高并发场景下会是瓶颈。如果项目规模增长 10 倍,gateway 的进程模型可能需要改成异步长连接池——但那时候,主循环的同步结构依然可以保持不变,改的只是调度层。