乐于分享
好东西不私藏

Agent循环死在这9步:一次源码深潜,扒开Hermes的循环引擎

Agent循环死在这9步:一次源码深潜,扒开Hermes的循环引擎

Agent循环的命门在哪?9步turn lifecycle里的设计逻辑

你有没有遇到过这种情况——
Claude Code跑起来了,工具也调了,然后……就没然后了。
Agent”死循环”了。
不是真正卡死,是它在某个步骤里反复重试,不知道什么时候该停。或者是上下文爆了,或者是某个API调用超时了,或者是消息格式不符合模型要求,被模型拒绝。
大多数人对Agent循环的理解是:发消息→等回复→执行工具→再发消息。这个理解漏掉了太多细节。
今天,我们从源码层面拆开Hermes Agent的循环引擎——run_agent.py(11,603行),看看一次完整的对话turn到底经历了什么。

9步Turn Lifecycle:一次完整的Agent循环

当你对Hermes Agent发出一条消息,从你敲回车到看到最终响应,背后走了9个步骤:

第1步:生成task_id,隔离执行上下文

每个对话轮次有唯一的task_id。这个ID用于在并发场景下隔离不同任务的状态——当你同时运行多个Agent实例时,相同的上下文不能混淆。task_id还用于日志追踪(hermes logs –session id)。
关键细节:这不是线程ID,是业务层面的任务ID。线程由Python的threading.current_thread().ident单独管理(后文会讲中断机制)。

第2步:重置迭代预算

每次turn开始,所有重试计数器清零:
_invalid_tool_retries(工具调用失败重试)
_invalid_json_retries(JSON解析失败重试)
_empty_content_retries(空响应重试)
_thinking_prefill_retries(thinking预填失败重试)
这个设计很关键:上一轮的重试次数不会泄漏到下一轮,每次turn都有完整的”新鲜预算”。
迭代预算默认是90轮(max_iterations=90),主Agent和子Agent共享同一个IterationBudget对象。子Agent有自己独立的IterationBudget,上限50轮(源码第174行)。

第3步:构建系统提示词(缓存机制)

系统提示词缓存在内存里,每个session只构建一次。只有两种情况会重建:
第一次新建session → 从零构建
上下文压缩事件发生 → 缓存失效,从磁盘重新加载记忆
对于继续中的session(gateway场景),直接从SQLite读取已缓存的prompt,避免重新构建导致Anthropic prefix cache失效。

第4步:预压缩检查(Preflight Compression)

触发条件:历史消息token数超过模型上下文阈值的50%(预压缩阈值)。
压缩会执行最多3轮,直到token降到阈值以下。压缩完成后,所有重试计数器清零——因为压缩后的上下文和压缩前完全不同,上一轮的重试策略不再适用。

第5步:插件钩子(pre_llm_call)

注入逻辑:插件返回的context追加到用户消息,不是插入系统提示词。原因很明确——修改系统提示词会破坏prompt cache前缀,缓存就失效了。
所有插件注入的context都是临时的(ephemeral),不持久化到session数据库。

第6步:进入主循环(工具调用迭代)

每次迭代执行:
  1. 检查中断标志
  2. 消耗1次迭代预算
  3. 构建API消息(注入外部记忆context到用户消息)
  4. 应用Anthropic Prompt Caching(对Claude+OpenRouter自动启用)
  5. 发送LLM API调用
  6. 解析响应(tool_calls提取、文本提取、reasoning提取)
  7. 执行工具(串行或线程池并发)
  8. 将工具结果作为role=tool消息append到messages
  9. 循环回到步骤1

消息交替规则:role不能连续相同

LLM API对消息role有严格约束:User↔Assistant必须交替,不允许连续两个相同role的消息。
Hermes的解决方案是_sanitize_api_messages(第3530行),在每次API调用前执行两个修复:
规则1:清理孤立工具结果
收集所有assistant消息里的tool_call_id,找出所有tool消息里的tool_call_id,删除没有对应调用者的tool结果(孤立结果)。
规则2:注入缺失的工具结果桩
如果有tool_call但没有对应的tool结果,注入一个stub:{“role”: “tool”, “content”: “[Result unavailable — see context summary above]”, “tool_call_id”: xxx}
这个设计保证了即使中间有工具执行失败或结果丢失,API调用也不会崩溃。模型会看到一个stub结果,可以决定是否重试。

可中断API调用:Ctrl+C是怎么工作的

CLI里你按Ctrl+C或者发一条新消息,Agent是怎么立即停下来的?
中断信号的两层机制
第一层:Python线程中断标志
在run_conversation()开头绑定执行线程ID:self._execution_thread_id = threading.current_thread().ident。当interrupt()被调用时,设置_interrupt_requested=True,并通过_set_interrupt(True,
self._execution_thread_id)通知工具层。
第二层:工具层的立即中止
_set_interrupt设置一个线程局部的中断标志,长时间运行的工具(terminal、browser等)在每次循环迭代里检查这个标志,一旦发现就立即中止。
关键设计:线程隔离
中断信号作用域是发起本次Agent的线程,而不是全局。这意味着如果gateway里跑了多个Agent实例,中断一个不会影响另一个。
子Agent(通过delegate_task启动的)也会收到中断信号,递归通知所有子Agent。

三种API模式统一成同一套消息格式

Hermes Agent支持三种API模式:OpenAI Messages API、Anthropic Messages API、Codex Responses API。但最终发给模型的,都是统一的OpenAI消息格式:
reasoning内容通过reasoning_content字段传给需要它的provider(如Moonshot AI),内部存储在reasoning字段里供trajectory记录用。finish_reason字段会被删除,因为Mistral等严格provider不接受这个字段。

Provider Failover:主provider挂了怎么办

如果主provider是Nous Portal且被限流,会尝试激活备用provider。如果没有任何备用provider,返回明确错误信息而不是让循环空转。

今天可以做的事

打开Hermes Agent的verbose日志,看一次完整的9步循环:
hermes –verbose

开启debug日志

观察日志里这四个关键指标:
  1. Iteration budget消耗到了多少
  2. Preflight compression有没有触发(对话长了之后)
  3. step_callback触发了多少次(等于工具调用迭代次数)
  4. interrupted_by_user有没有出现(你主动中断过吗)
理解这9步,是理解整个Agent行为的基础。第二篇我们深入拆解——上下文快满的时候,Agent是怎么给自己减肥的。