核心机制:Agent 循环
本教程共 25 篇 · 第 7 篇 · 更新于 2026-07-26 · 约 17 分钟阅读
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_completions | OpenAI 兼容端点(OpenRouter、自定义、大多数 Provider) | openai.OpenAI |
codex_responses | OpenAI Codex / Responses API | openai.OpenAI + Responses 格式 |
anthropic_messages | 原生 Anthropic Messages API | anthropic.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 会校验这些序列,畸形历史直接拒绝。
NoteReasoning content(来自支持扩展思考的模型)存在
assistant_msg["reasoning"]里,通过reasoning_callback可选展示给用户。这是为什么需要 messages/api_messages 两份—reasoning 是内部字段,不能发给 API。
工具调用:从模型决策到结果回写
当模型返回 finish_reason: tool_calls,Hermes 执行工具并把结果写回消息历史。这是智能体「干活」的核心。
顺序 vs 并发
模型一轮可能同时调多个工具。Hermes 的处理:
- 单个工具调用 -> 直接在主线程执行
- 多个工具调用 -> 通过
ThreadPoolExecutor并发执行- 例外:标记为 interactive 的工具(如
clarify)强制顺序执行 - 结果按原始 tool_call 顺序回插,不管谁先完成
- 例外:标记为 interactive 的工具(如
执行流程
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 调用复用同一份。为什么要缓存?两个原因:
- 不用每次都重新读文件和拼字符串
- Anthropic 的 prompt caching 要求 system prompt 在多轮间保持不变。变了缓存就失效,要多花钱
NoteGateway 续接 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 | 自动提到 1800s | HERMES_STREAM_READ_TIMEOUT |
| 非流式调用 stale 检测 | 有 | 自动调整 | - |
Bounded HTTP 错误读取
这是个容易忽略但很重要的细节。Provider 在流式请求上返回非 OK 状态时,Hermes 要读响应体来构造有用的错误诊断。但裸的 response.read() 在两个方向上无界:
- 服务器能声明(或流式发送)任意大的 body,读取消耗内存
- 服务器能打开 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 怎么让你从历史会话里检索信息。