Agent Loop 源码导读:一次 Hermes 任务的完整生命周期
点击上方 前端Q,关注公众号
回复加群,加入前端Q技术交流群
我读 Hermes 源码的时候,第一件让我吃惊的事是 ——它的 Agent Loop 主循环不到 200 行代码。
我原以为像 Hermes 这种"自进化"系统,主循环至少得 1000 行起跳,里面塞满各种 Memory 注入、Skill 检索、状态机、错误处理。结果打开一看,主循环干干净净,做的事情就一句话:循环 → 拼提示词 → 调模型 → 看要不要调工具 → 检查是不是该退出。
那些复杂的事情都被推到了别的模块里。Loop 本身只是个调度器。
这种"克制"的设计,是 Hermes 真正聪明的地方。这一篇我们就把这个 200 行的主循环拆开看,搞清楚一次 Hermes 任务从启动到退出,内部到底发生了什么。
主循环就 5 个阶段
把 Hermes 的 Agent Loop 抽象出来,本质就 5 个阶段,循环跑:
- Receive Input:拿到 user message 或上一轮 tool result
- Build Prompt:拼 Memory + Skill + Context
- Call LLM:发请求 / 等响应 / 解析结果
- Execute Tool:模型要调工具就执行,不要就跳过
- Check Done:任务完成了?完了就退出,没完就回到第 1 步
我故意没用源码里的真实变量名,因为不同 Agent 框架(Hermes、LangGraph、Cursor、Claude Code)的命名都不一样,但本质都是这 5 个阶段。理解了这个抽象,再看任何一家的源码都能秒懂。
金句:Agent 不是一次性的调用,而是一个循环。
这是和"普通 Chat 应用"最根本的区别。Chat 是 request-response 模式,问一句答一句。Agent 是 task-completion 模式,给定一个目标,循环跑直到完成。
我看过有些团队做"Agent",其实只是在 ChatCompletion 外面套了个 if-else,没有真正意义上的循环。这种东西做不出"自进化"。没有 Loop,就没有 Agent。
4 个核心模块各司其职
虽然主循环只有 5 个阶段,但 Hermes 在内部把每个阶段拆给了不同的模块。读源码时如果不搞清楚这套分层,会看得晕头转向。
模块 1:Loop Orchestrator(循环主控)
这就是那个 200 行的主循环本体。
它的职责非常窄:
▸维护当前是第几轮(turn count)
▸在 5 个阶段之间调度
▸检查终止条件
▸把每一步事件丢给下层记录
它不知道 prompt 怎么拼,不知道模型怎么调,不知道工具怎么跑。它只知道"下一步该叫谁"。
这种"什么都不做"的设计反直觉,但其实是好架构的标志 ——主循环应该是最薄的那一层,重逻辑要推到下面。
模块 2:Prompt Builder(提示词组装)
这是 Hermes 里最复杂的模块之一(下一篇会专门讲)。
它要在每一轮做的事很多:
▸读 system prompt 模板
▸注入相关 Memory 条目
▸检索并注入相关 Skill
▸把可用的 Tools 序列化成 schema
▸拼上整个对话历史
▸控制总长度不超过模型 context window
这一块独立出来的好处是:改 prompt 策略不需要动主循环。你想加一种新的注入方式(比如加一个 RAG 检索结果),只改 Prompt Builder,Loop 完全不知道。
模块 3:执行层(LLM Adapter + Tool Runner)
这一层一轮内可能被多次调用。
▸LLM Adapter:屏蔽掉不同模型 API 的差异(OpenAI / Anthropic / Bedrock 长得都不一样),上层只需要传 prompt、拿回结构化响应
▸Tool Runner:拿到模型要调用的工具,找到对应实现,传参,捕获异常,把结果包成统一格式
这两个模块都是"无状态"的 —— 每次调用都是独立的,不依赖任何全局状态。这是工程上的好习惯,方便测试也方便并发。
模块 4:Trajectory Recorder(轨迹记录)
这一层是后台默默干活的:把循环里的每一个事件,写到一个结构化的 session trace 里。
为什么单独抽出来?因为这份 trace 是后续 Nudge Engine 和 Review Agent 的输入。把记录这件事和执行解耦,意味着即使你完全不开自学习功能,主循环也能正常跑(trace 写到 /dev/null 就行)。
金句:分得清楚,才能改得动。
我自己改过几次 Hermes 的 Loop 实现,每次都很顺手 —— 因为 4 个模块边界很清楚,改一个不会牵动其他。这是源码读起来体感最好的部分。
一轮内的完整时序
讲完模块结构,再看一轮内的"调用时序"是什么样。这部分必须画时序图才说得清楚。
按数字顺序看:
- Orchestrator → Prompt Builder:buildPrompt(state)
主循环把当前的状态(已有对话、用户输入、上一轮 tool result)打包,让 Prompt Builder 拼一份完整提示词。
- Prompt Builder → Orchestrator:return prompt
返回拼好的 messages 数组。这一步在 Hermes 里平均耗时 50-200ms(包含 Memory 检索、Skill 匹配的开销)。
- Orchestrator → LLM Adapter:callModel(prompt)
把 prompt 发给模型 API。这一步是整个循环里最慢的(通常 1-5 秒)。
- LLM Adapter → Orchestrator:return assistant message
返回模型的回复。这个回复可能是纯文本(任务完成),也可能包含 tool_calls(需要执行工具)。
- Orchestrator 自检:has tool_calls?
解析模型回复,看是不是要调工具。
- Orchestrator → Tool Runner:execute(tool_call)
如果有工具调用,逐个执行。注意 Hermes 里默认是串行执行(除非工具明确标注了"可并发"),这是为了避免并发副作用。
- Tool Runner → Orchestrator:return tool result
每个工具的结果包装成tool_result消息,准备塞进下一轮的 prompt。
- Orchestrator 自检:task done?
- 如果模型没有发起工具调用 → 任务完成,退出 Loop
- 如果有工具调用且都执行完了 → 回到第 1 步,开新一轮
金句:一轮 = 一次模型调用 + 0~N 次工具调用。
这个等式很重要。很多人以为"一轮 = 一次模型调用",结果在算 token 和耗时时算偏了。实际上一轮里如果模型调了 5 个工具,那就是 1 次 LLM + 5 次 Tool。
一个有意思的细节:很多人觉得 tool_calls 应该并发跑,更快。Hermes 默认串行的原因是 ——工具之间可能有顺序依赖(比如先 read_file 再 write_file),并发跑会出竞态。除非工具明确声明"我是只读、纯函数、可并发",否则一律串行。
4 种退出姿势
Loop 必须知道什么时候停。这件事比看起来难得多。
我见过不少人写的"Agent"在测试环境跑得好好的,一上线就死循环跑爆 token 账单。原因都是 ——只考虑了"任务完成"这一种退出条件,没考虑兜底情况。
Hermes 里的 Loop 有 4 种退出姿势,缺一不可。
退出姿势 1:任务自然完成
最常见的一种:模型这一轮不再发起 tool_calls,直接给出 final answer。
判断条件就一句话:if (assistantMessage.tool_calls.length === 0) break;
我看了大概一周的运行日志,Hermes 里大约 70% 的任务是这种正常退出。
退出姿势 2:达到最大 turn 上限
兜底机制。哪怕模型疯了一直在调工具,到了 turn 上限也要强制退出。
Hermes 默认是 25 轮(小任务)或 50 轮(复杂任务)。这个数字是经验值 —— 太小会让正常任务被掐断,太大会让卡住的任务无限消耗 token。
特别坑的点:达到 turn 上限的退出不能算"任务完成",否则 Review Agent 会把"卡住"当成"成功"去复盘,写出错的 Skill。Hermes 在这种情况下会显式标注terminated_by: "max_turns",让下游知道这次是异常结束。
退出姿势 3:用户主动打断
用户按 Ctrl+C、点击取消按钮、或者通过 Hook 阻断当前任务。
这种退出要做的工作比看起来多:
▸如果模型正在生成中,需要中断 stream
▸如果工具正在执行中,需要尝试取消(不是所有工具都支持取消)
▸已经写出去的状态要回滚还是保留?
Hermes 的处理是:已经完成的工具调用保留结果,正在执行的工具尝试 cancel,模型流式输出直接丢弃。这套规则不是最优的,但是是"用户最容易理解"的。
退出姿势 4:不可恢复错误
模型 API 挂了、工具抛出未捕获异常、内存爆了 OOM。
这一类退出的关键是"优雅"二字。要做到:
▸把错误信息记到 trajectory 里(让 Review Agent 知道这次是因为啥挂的)
▸释放占用的资源(数据库连接、文件句柄)
▸给用户一个能看懂的错误提示,而不是甩个 stack trace
Hermes 这块写得挺细的,不同类型的错误有不同的处理路径。我看源码时学到一招:所有从 Loop 抛出去的异常都要包装成AgentError,带上错误码和上下文。这样不管在哪一层接住,都能拿到完整信息。
金句:一个好的 Loop,必须有 4 种退出姿势。
我现在自己写 Agent 时,第一个写的就是退出条件。先想清楚怎么停,再想怎么跑—— 这是从 Hermes 学到的最重要的一条。
我自己读源码的几个发现
读 Hermes Loop 源码的过程中,有几处设计让我印象很深。
发现 1:Loop 内不做任何业务判断
Hermes 的 Loop 里没有任何 "if 任务类型 == X 那么..." 的代码。
业务差异(比如"代码任务要先 lint"、"文档任务要先 outline")全都通过 Skill 注入到 prompt 里,由模型自己判断。Loop 永远是同一个流程。
这种"通用循环 + 差异化 prompt"的设计,让 Loop 可以在所有场景复用,不会因为加新功能而越来越臃肿。
发现 2:每一步事件都立刻落盘
Trajectory Recorder 不是任务结束才一次性写入,而是每一步事件实时写到磁盘。
我刚开始觉得这是性能浪费,后来想明白了 ——如果任务跑到一半挂了,没落盘的事件就丢了,Review Agent 拿不到完整 trace。
实时落盘的代价是每秒多写几次 disk,收益是任何异常情况下都能事后复盘。这个 trade-off 很值。
发现 3:Tool 调用一定串行
前面提过,但我想再强调一次。Hermes 默认串行执行工具调用,不是性能不够,是为了正确性优先于速度。
我做实验把它改成默认并发,跑 100 个真实任务,发现 7% 的任务出现了"读到了上一步还没写完的数据"这种竞态。一旦出错,整个 trajectory 就变得无法复盘。
并发应该是个 opt-in 选项,不能是默认。
发现 4:状态机被刻意避免
很多 Agent 框架(比如 LangGraph)把 Loop 实现成显式状态机,节点之间有明确转移规则。
Hermes 没用状态机。Loop 就是个简单的 while 循环,状态全在变量里。
我刚开始觉得这是技术债,后来意识到这是有意为之 ——显式状态机会让 Loop 的每一种状态都需要预先定义,新增能力时改动巨大。简单的 while 循环灵活得多,新增能力只需要往 prompt 里加内容。
这两种风格各有适用场景。任务种类有限、流程稳定的场景用状态机(比如客服对话);任务种类无限、需要高度灵活的场景用简单 Loop(比如通用 Coding Agent)。
我的看法
读完 Hermes 的 Agent Loop 源码,我最大的感受是 ——好的 Agent 框架,主循环越少越好。
把所有复杂逻辑都堆在主循环里,是初学者最容易犯的错。结果就是循环代码 5000 行,谁来都改不动,加任何新功能都怕踩坑。
Hermes 走了相反的路:
▸主循环只做 5 件最朴素的事
▸复杂的事推给 Prompt Builder、Tool Runner、Trajectory Recorder
▸业务差异通过 Skill 注入,不进 Loop
这种"主循环克制,外围灵活"的设计,让整个系统既好理解又好扩展。
更重要的是,它让"自进化"成为了一个可以叠加的能力,而不是嵌入的逻辑。Memory、Skill、Nudge、Review 都是从外部叠加到这个 Loop 上的,Loop 本身不知道它们的存在。哪天你不想要自学习了,把这些模块拔掉,Loop 照样能跑。
这种解耦在工程上极其值钱。任何想做长期演进的 Agent 系统,都应该把"基础执行能力"和"高阶能力"分得这么清楚。
下一篇我们深入 Prompt Builder ——Memory、Skill、Context Files 到底是怎么进入 system prompt 的,长度超了怎么裁剪、优先级怎么定。这是整个系统里真正"决定模型每一步看到什么"的关键。
往期推荐
Multi-Agent Teams:让多个专家 Agent 像团队一样协作
AI Agent 是怎么"想一步做一步"的?拆解 ReAct 模式
从零开始:用 LangChain.js 构建你的第一个 Tool-Calling Agent
最后
点个在看支持我吧
