首页 / Hermes Agent 教程 / 核心机制:Agent 循环

Hermes Agent 教程

核心机制:Agent 循环

本教程共 25 篇 · 第 7 篇 · 更新于 2026-07-26 · 约 17 分钟阅读

Hermes AgentHermes Agent 教程Agent Loop智能体循环Iteration BudgetTurn 生命周期

7. 核心机制:Agent 循环

本节目标:搞懂 Hermes Agent 的核心引擎—Agent Loop(智能体循环)。从一条用户消息进入,到最终回复输出,中间经过 Turn 生命周期、消息流转、工具调用、System Prompt 组装,以及 Iteration Budget、Bounded Response 这些边界控制机制。学完你能说清楚「智能体跟聊天机器人到底差在哪」,也能在智能体卡死或跑飞时知道是哪个环节出问题。

Agent Loop 是什么:从「会说话」到「会干活」

语言模型本身只会「生成下一段内容」。它不会自己去执行命令、观察结果、再基于结果继续推理。如果没有一层代码在中间反复做这件事,模型就只是个「会说话的程序」,还不是「会干活的智能体」。

Agent Loop 就是这层代码。它的核心是四个字:循环往复

user message
   |
   v
组装 system prompt(人设 + 记忆 + 项目配置 + 工具定义)
   |
   v
 model API(OpenAI 兼容格式)
   |
   +-- finish_reason: stop -----> 返回最终回复
   |
   +-- finish_reason: tool_calls --> 执行工具
                                       |
                                       v
                                  tool result
                                       |
                                       v
                                  写回 messages
                                       |
                                       v
                                  下一轮继续

模型说完一段话,循环检查它有没有调工具的意图。有就执行工具,把结果塞回消息历史,再喂给模型让它继续。没有就结束,把最终回复返回给用户。

Note

真正关键的不是「有一个循环」,而是两件事:1) 工具结果必须写回消息历史,否则模型下一轮看不到执行结果;2) System Prompt 在每次 API 调用时重新拼装到消息前面,它不是 messages 的一部分,而是每次调用时单独传入的。

一条消息的完整旅程:Turn 生命周期

Hermes Agent 的核心编排引擎是 run_agent.py 里的 AIAgent 类。每个 Turn(回合)严格按这个顺序走:

run_conversation()
  1. 没给 task_id 就生成一个
  2. 把用户消息追加到对话历史
  3. 构建或复用缓存的 system prompt
  4. 检查是否需要 preflight 压缩(上下文超 50% 时)
  5. 从对话历史构建 API 消息
     - chat_completions:OpenAI 格式原样
     - codex_responses:转成 Responses API 输入项
     - anthropic_messages:通过 anthropic_adapter 转换
  6. 注入 ephemeral prompt 层(预算警告、上下文压力)
  7. 如果在 Anthropic 上,打 prompt caching 标记
  8. 发起可中断的 API 调用(_interruptible_api_call)
  9. 解析响应:
     - 有 tool_calls:执行,追加结果,回到步骤 5
     - 文本响应:持久化会话,必要时刷记忆,返回

第 8 步那个「可中断」很关键。API 请求被包在 _interruptible_api_call() 里,实际 HTTP 调用跑在后台线程,主线程同时盯着三件事:响应就绪、中断事件、超时。用户发新消息、敲 /stop、或者收到信号,都能立刻打断当前 API 调用,不会等到 Provider 那边慢慢返回。

Tip

打断时,API 线程被直接丢弃,响应不进对话历史。所以打断不会在消息里留下半截残缺回复,干净利落。

三种 API 模式

Hermes 支持三种 API 执行模式,根据 Provider 选择、显式参数、Base URL 启发式来解析:

API 模式用于客户端类型
chat_completionsOpenAI 兼容端点(OpenRouter、自定义、大多数 Provider)openai.OpenAI
codex_responsesOpenAI Codex / Responses APIopenai.OpenAI + Responses 格式
anthropic_messages原生 Anthropic Messages APIanthropic.Anthropic + 适配器

三种模式最终都收敛到同一种内部消息格式(OpenAI 风格的 role/content/tool_calls 字典),API 调用前后统一处理。

模式解析优先级:显式 api_mode 构造参数 > Provider 特定检测(如 anthropic Provider -> anthropic_messages)> Base URL 启发式(如 api.anthropic.com -> anthropic_messages)> 默认 chat_completions

消息流转:messages 与 api_messages

Hermes Agent 内部维护两份消息,这是初学者最容易忽略但很重要的设计:

  • messages:内部保存的「完整账本」,什么都有,包含内部状态字段(如 reasoning_internal_token_count
  • api_messages:每次 API 调用前从 messages 临时清洗出来的副本,只留模型看得懂的字段

举个例子:

# messages(内部完整版)
messages = [
    {"role": "user", "content": "今天天气怎么样"},
    {
        "role": "assistant",
        "content": "我查一下",
        "reasoning": "用户问天气,我应该调用工具",  # ← 内部字段
        "_internal_token_count": 42,                # ← 内部字段
    },
]

# 调 API 前,清洗一下 ->
api_messages = [
    {"role": "system", "content": "你是 Hermes..."},   # ← 拼在最前面
    {"role": "user", "content": "今天天气怎么样"},
    {
        "role": "assistant",
        "content": "我查一下",
        # reasoning 和 _internal_token_count 被去掉了
    },
]

client.chat.completions.create(messages=api_messages, ...)

为什么要分两份?

  • messages 要持久化、要给你调试看,信息越全越好
  • api_messages 要发给 OpenAI 兼容 API,多余字段会报错或浪费 token

一句话:messages 是底稿,api_messages 是每次寄出去的信件

消息角色交替规则

Agent Loop 强制严格的消息角色交替:

  • System 消息之后:User -> Assistant -> User -> Assistant -> ...
  • 工具调用期间:Assistant (带 tool_calls) -> Tool -> Tool -> ... -> Assistant
  • 绝不两个 Assistant 消息连着
  • 绝不两个 User 消息连着
  • 只有 tool 角色可以连续多条(并行工具结果)

Provider 会校验这些序列,畸形历史直接拒绝。

Note

Reasoning content(来自支持扩展思考的模型)存在 assistant_msg["reasoning"] 里,通过 reasoning_callback 可选展示给用户。这是为什么需要 messages/api_messages 两份—reasoning 是内部字段,不能发给 API。

工具调用:从模型决策到结果回写

当模型返回 finish_reason: tool_calls,Hermes 执行工具并把结果写回消息历史。这是智能体「干活」的核心。

顺序 vs 并发

模型一轮可能同时调多个工具。Hermes 的处理:

  • 单个工具调用 -> 直接在主线程执行
  • 多个工具调用 -> 通过 ThreadPoolExecutor 并发执行
    • 例外:标记为 interactive 的工具(如 clarify)强制顺序执行
    • 结果按原始 tool_call 顺序回插,不管谁先完成

执行流程

for each tool_call in response.tool_calls:
    1. 从 tools/registry.py 解析 handler
    2. 触发 pre_tool_call 插件钩子
    3. 检查是否危险命令(tools/approval.py)
       - 危险:调用 approval_callback,等用户确认
    4. 用参数 + task_id 执行 handler
    5. 触发 post_tool_call 插件钩子
    6. 把 {"role": "tool", "content": result} 追加到历史

tool_call_id:结果对号入座

模型一轮调两个工具时,每个结果都要带 tool_call_id 对应回它的调用:

# 助手消息(带工具调用)
{
    "role": "assistant",
    "content": None,
    "tool_calls": [
        {
            "id": "call_abc",
            "function": {
                "name": "web_search",
                "arguments": '{"query": "Python 3.12 新特性"}',
            },
        }
    ],
}

# 工具结果
{
    "role": "tool",
    "tool_call_id": "call_abc",   # ← 这个 ID 把结果和调用对应起来
    "content": "Python 3.12 新增了...",
}

不绑 ID,模型分不清哪条结果对应哪个调用,下一轮推理就乱了。

Agent 级工具

有些工具在到达 handle_function_call() 之前就被 run_agent.py 拦截了,因为它们直接修改智能体状态:

工具为什么拦截
todo读写智能体本地的任务状态
memory写持久记忆文件,有字符限制
session_search通过智能体的会话库查历史
delegate_task派生子智能体,隔离上下文

这些工具直接改智能体状态,返回合成的工具结果,不走注册表。

System Prompt:六层组装

System Prompt 不是一段硬编码字符串,而是从六七个来源按顺序组装出来的。每个来源独立维护,启动时按顺序拼在一起。

_build_system_prompt()
  |
  v
Layer 1: 人设(SOUL.md,或默认身份)
  |
  v
Layer 2: 行为指导(工具使用规范、模型特定指导)
  |
  v
Layer 3: 记忆(MEMORY.md + USER.md 快照)
  |
  v
Layer 4: 技能清单(已安装技能的索引)
  |
  v
Layer 5: 项目配置(HERMES.md / AGENTS.md / CLAUDE.md / .cursorrules)
  |
  v
Layer 6: 时间戳 + 模型信息
  |
  v
拼成一条完整字符串,缓存

项目配置优先级

.hermes.md / HERMES.md   (最高,从当前目录往上找到 git root)
AGENTS.md / agents.md     (仅当前目录)
CLAUDE.md / claude.md     (仅当前目录)
.cursorrules              (仅当前目录)

只用第一个找到的,不会全部加载。这设计是为了兼容—从其他 Agent 框架迁移过来的项目可能已经有 CLAUDE.md 或 .cursorrules,Hermes Agent 直接用,不需要你重写。

Warning

每个来源最多 20,000 字符,超出截断。这防止一个巨大的 AGENTS.md 把整个上下文窗口占满。如果你写项目配置写超了,要么精简,要么拆成多个文件让智能体按需读。

缓存复用

System Prompt 组装一次后缓存下来,同一个 session 的所有 API 调用复用同一份。为什么要缓存?两个原因:

  1. 不用每次都重新读文件和拼字符串
  2. Anthropic 的 prompt caching 要求 system prompt 在多轮间保持不变。变了缓存就失效,要多花钱
Note

Gateway 续接 session 时,Hermes 从 SQLite 读回之前存的 system prompt,而不是重新组装。因为 MEMORY.md 可能已经被上一轮的智能体改了,重新组装出来的 prompt 跟上一轮不一样,Anthropic 的 prompt cache prefix 就失效了。只有上下文压缩事件才会清除缓存并重建。

ephemeral_system_prompt 不进缓存

有些系统级指令只在 API 调用时临时加入(比如 Gateway 的 ephemeral 配置),不存到 SQLite,不进缓存。它在每次 API 调用时拼在 cached prompt 后面:

effective_system = cached_prompt + "\n\n" + ephemeral_prompt

记忆注入也分两条路:内置记忆(MEMORY.md / USER.md)进 system prompt;外部记忆提供者(plugin)注入到 user message,不进 system prompt—因为外部记忆内容每轮可能不同,放进 system prompt 会破坏缓存。

Iteration Budget:迭代预算

Iteration Budget(迭代预算)是 Agent Loop 的第一道安全网。一次 API 调用算一个 iteration,Hermes 默认最多 90 个。

budget(预算) 可以理解成「配额」或「信用额度」—这次对话总共允许调用多少次 API 的上限。为什么叫 budget 而不是 max_iterations?因为它是一个可以被消耗、被分享的资源,不只是一个静态计数器。

简单例子

budget = 90

用户:"帮我重构这个文件"
  iter 1: 模型说"我先读文件" -> 调用 read_file        (剩 89)
  iter 2: 模型说"再看看测试" -> 调用 read_file        (剩 88)
  iter 3: 模型说"我来改"     -> 调用 edit_file        (剩 87)
  iter 4: 模型说"完成了"     -> stop                  (剩 86)

委托场景下的预算共享

父 agent budget = 90
  iter 1-10: 父 agent 自己干活                        (剩 80)
  iter 11:   父 agent 派一个子 agent 去搜索
             └─ 子 agent 用了 15 iter                 (剩 65)
  iter 12+:  父 agent 继续,从 65 开始

子 agent 不是「另外给 90」,而是从父 agent 的钱包里扣。所以叫 budget。

Note

每个智能体有自己的预算。子智能体拿独立预算,上限是 delegation.max_iterations(默认 50)。父+子的总迭代数可以超过父级的上限—子智能体花的是自己那份,不是从父级继续扣。

预算耗尽时怎么办

Hermes 不在任务中途注入压力警告。早期版本会在 70%/90% 预算时警告模型,结果模型经常因此放弃复杂任务,这个机制在早期版本迭代中被移除了。

现在的做法是:预算真的耗尽(90/90)时,Hermes 注入一条消息让模型收尾,并允许一次 grace call(宽限调用),让它交付最终回复。如果宽限调用还是没产出文本,就让模型总结它完成了什么。

agent:
  max_turns: 90                # 每个对话回合的最大迭代数(默认 90)
  api_max_retries: 3           # fallback 触发前每个 Provider 的重试次数(默认 3)

预算完全耗尽时,CLI 会给用户一个通知:⚠ Iteration budget reached (90/90) - response may be incomplete

Tip

复杂任务(大重构、多文件搜索)可能需要更多迭代。把 agent.max_turns 调到 150 或 200 不会有问题,只是单次对话的 API 成本会更高。反过来,定时任务、简单查询可以调低到 20-30,防止跑飞。

Bounded Response:给循环加边界

Agent Loop 如果只有迭代预算,还不够安全。模型可能在一轮里产出超长回复、Provider 可能返回畸形响应、网络可能挂死。Bounded Response(边界响应)是 Hermes 给循环加的多层边界控制。

边界控制的三层含义

1. 迭代边界:Iteration Budget 限制回合内的 API 调用次数(默认 90),见上一节。

2. 响应大小边界:工具输出有截断限制,防止一个工具返回几十 MB 把上下文撑爆:

# 工具输出截断的三道闸
tool_output_max_bytes: 1048576      # 单次工具输出的硬上限(默认 1MB)
tool_output_truncation_marker: true # 截断时插入标记
context_pressure_warn_at: 0.8       # 上下文压力到 80% 时警告

3. 时间边界:API 调用有多层超时,防止 Provider 挂死拖垮整个智能体:

超时默认值本地 Provider配置 / 环境变量
Socket 读超时120s自动提到 1800sHERMES_STREAM_READ_TIMEOUT
非流式调用 stale 检测自动调整-

Bounded HTTP 错误读取

这是个容易忽略但很重要的细节。Provider 在流式请求上返回非 OK 状态时,Hermes 要读响应体来构造有用的错误诊断。但裸的 response.read() 在两个方向上无界:

  1. 服务器能声明(或流式发送)任意大的 body,读取消耗内存
  2. 服务器能打开 body 后永远卡住(没有 Content-Length,没有后续字节),读取无限挂起智能体

agent/bounded_response.py 里的 read_streaming_error_body 给读取加了字节上限(默认 64KB)和硬性墙钟 deadline(默认 10 秒)。读取跑在守护线程上,主线程用硬 deadline 等;超时就关闭响应(解锁/取消读取),返回已收到的部分字节。

Note

这个机制看起来不起眼,但它的哲学贯穿整个 Agent Loop:任何可能无界的东西都要加边界。迭代次数有界、工具输出有界、API 调用有界、错误体读取有界。一个行为不端的 Provider 或代理不能把智能体拖死。

上下文压力警告

跟迭代预算分开,上下文压力追踪对话离压缩阈值有多近—也就是上下文压缩触发去摘要老消息的那个点。这帮你和智能体都理解对话什么时候变长了。

context_pressure_warn_at: 0.8    # 80% 时警告
compaction_threshold: 0.85       # 85% 时触发压缩(网关更激进)
preflight_compression: 0.5       # 50% 时 preflight 检查

上下文压力跟迭代预算是两套独立的安全网—一个管「调了多少次 API」,一个管「对话塞了多少 token」。

同步循环 + 异步桥接

Agent Loop 本身是同步的 def,不是 async def。但有些工具(网络请求、浏览器操作)需要 async。怎么在一个循环里同时支持两种?

有两种方案:

方案 A:整个循环都用 async def

async def run_conversation(...):
    ...
    result = await run_tool(...)  # 所有工具都 await

看起来统一,但代价是:所有同步工具也要包一层 async,错误堆栈变复杂,调试变难。

方案 B(Hermes 的选择):循环是同步的,遇到 async 工具时桥接过去

def run_conversation(...):          # 普通 def,不是 async
    ...
    if tool_is_async:
        result = event_loop.run(async_tool(...))  # 桥接到事件循环
    else:
        result = sync_tool(...)     # 直接调用

持久化的事件循环

asyncio.run() 每次调用都创建一个新事件循环,用完销毁。Hermes 不用这种方式,而是启动时创建一个事件循环一直留着复用:

# 启动时创建一次
loop = asyncio.new_event_loop()

# 每次需要跑 async 工具时复用它
def bridge_async(coro):
    return asyncio.run_coroutine_threadsafe(coro, loop).result()

为什么要持久化?因为有些异步资源(浏览器 session、WebSocket 连接)跨多次工具调用存活。每次都新建事件循环,这些连接就断了。

Tip

一句话总结:主循环保持同步以求简单,只在遇到 async 工具时把任务扔给一个常驻事件循环执行,执行完把结果拿回来继续同步流程。这是 Hermes Agent 在「简单」和「能干复杂活」之间找的平衡点。

Gateway 的实例管理

CLI 模式下,一个 AIAgent 实例从头跑到尾。Gateway 模式下,每条消息到达时,Hermes 会复用或新建实例。

实际实现比「每条消息新建」更聪明—Gateway 维护了一个实例缓存:

# gateway/run.py
self._agent_cache: Dict[str, tuple] = {}  # session_key -> (AIAgent, 配置签名)

流程:

消息到达 -> 计算配置签名(model + api_key + provider + toolsets)
         -> 查缓存
           ├─ 命中 -> 复用已有实例(system prompt、工具定义都不变)
           └─ 未命中 -> 创建新实例,存入缓存
         -> 更新轻量的每条消息字段(callbacks、reasoning_config)
         -> 调用 run_conversation()

只有用户执行 /new(重置会话)、/model(换模型)或触发 fallback 时才淘汰缓存。

复用实例最重要的原因是 prompt caching—Anthropic API 要求 system prompt 在多轮间保持不变才能命中缓存,复用实例 = 省钱省时间。

Warning

核心原则:不要把跨消息状态存在实例变量里。对话历史每次从 SQLite 传入,不存在实例内存里。Agent 实例可能被随时淘汰重建,代码不能依赖它「一直活着」。

常见踩坑

这一节列几个初学者最容易犯的错,理解它们能帮你更深入掌握 Agent Loop:

1. 不写回 assistant 消息:工具结果写回了,但 assistant 消息没写回。下一轮 API 调用时,模型看不到自己上一轮说了什么。

2. 不绑定 tool_call_id:模型一轮调了两个工具,但两个结果都没带 id。模型分不清哪条结果对应哪个调用。

3. system prompt 放在 messages 列表里:system prompt 应该每次 API 调用时单独拼在前面,不应该作为 messages 列表的一部分存下来。否则它会被持久化、被压缩、被重复。

4. 不设迭代上限:没有 max_iterations 的循环会在模型反复调用工具时永远不停。Hermes 默认 90 次。

5. 以为 agent 实例是长生命周期的:Gateway 模式下实例可能被淘汰重建。不要在实例变量里存跨消息的状态。

6. 每轮 API 调用都重新组装 system prompt:浪费时间,还会破坏 prompt cache。应该组装一次,缓存复用。

7. 加载全部项目配置文件而不是只用优先级最高的:同时加载 HERMES.md 和 AGENTS.md 和 .cursorrules,内容可能冲突或重复。只用第一个找到的。


这一章拆了 Agent Loop 的内核。下一章我们看会话是怎么存储和管理的—一条消息发出去之后存在哪、怎么恢复、上下文太长时怎么压缩、以及 session_search 怎么让你从历史会话里检索信息。