原文连接:Hermes Agent 04 | Agent 主循环:一次对话背后发生了什么
所有看似流畅的对话背后,都是一个不知疲倦的 while 循环在转。
我们今天要拆的是哪个 loop
Hermes Agent 的主循环代码非常集中------全部在 run_agent.py 里(仓库根目录,10000+ 行)。这个文件长得有点吓人,但核心逻辑只有一个东西:
class AIAgent:
...
def run_conversation(self, user_message, ...) -> Dict[str, Any]:
...
while (api_call_count < self.max_iterations
and self.iteration_budget.remaining > 0) or self._budget_grace_call:
api_call_count += 1
# 1. 构造 api_messages(注入系统提示、ephemeral 上下文、记忆前缀等)
# 2. 调 LLM(三种 API 模式之一)
# 3. 处理响应:truncated / empty / tool_calls / 普通文本
# 4. 如果有 tool_calls:执行(并行或串行),把结果追加到 messages
# 5. 如果有 final text:返回
...
你看到的"Agent 在思考"、"Agent 在调工具"、"Agent 在回答"------底层都是这个 while 循环在转。这一讲的所有内容,围绕这个 loop 展开。
AIAgent 的真实构造:三种 API 模式的统一外壳
先看 AIAgent 类(run_agent.py:588)本身的骨架。它的 __init__ 签名很长(40+ 个参数),但核心字段其实就几个:
class AIAgent:
"""AI Agent with tool calling capabilities."""
def __init__(
self,
base_url: str = None,
api_key: str = None,
provider: str = None,
api_mode: str = None,
model: str = "",
max_iterations: int = 90, # 默认 90 轮工具调用
...
):
...
self.max_iterations = max_iterations
self.iteration_budget = iteration_budget or IterationBudget(max_iterations)
# 三种 api_mode 对应三种底层协议
self.api_mode = api_mode # "chat_completions" | "codex_responses" | "anthropic_messages"
这里有一个很关键的设计:一个 AIAgent 对象在对外表现上是统一的 (同样的 run_conversation 接口、同样的工具调用语义),但它内部能同时驾驭三种不同的 API 协议:
api_mode |
典型来源 | 底层差异 |
|---|---|---|
chat_completions |
OpenRouter、Kimi、xAI、大多数 OpenAI 兼容端点 | 标准 OpenAI Chat Completions |
codex_responses |
openai-codex 、直连 OpenAI、以及被自动升级到 Responses 的 GPT-5.x |
Responses API(Incomplete/Completed 状态、reasoning items) |
anthropic_messages |
Anthropic 直连,以及 URL 以 /anthropic 结尾的 Anthropic-compatible 端点(比如 MiniMax) |
Messages API(stop_reason、content block 列表) |
api_mode 的自动推断发生在 __init__ 里(run_agent.py:682-695)------根据 provider 和 base_url 的特征决定走哪条路径。大多数上层调用者不必直接关心这些分支 。三种模式的响应会被统一规范化(_normalize_codex_response / normalize_anthropic_response)成一个统一的 assistant_message + finish_reason,loop 的主体代码只跟这个标准化后的结果打交道。
设计启发 :多协议兼容的正确做法不是"所有 Provider 都强行套 OpenAI schema"------那会丢失每个协议的独特能力(比如 Anthropic 的
stop_reason比 OpenAI 的finish_reason信息量更大)。正确的做法是保留协议差异,在进入主循环前做规范化 。Hermes Agent 的api_mode分支就是这个思路。
消息协议:OpenAI 兼容 + Hermes 扩展字段
无论底层走哪个 api_mode,run_agent.py 内部维护的消息结构都尽量贴近 OpenAI 兼容格式。这里要注意一个实现细节:常驻在内存里的 messages 列表通常不含 system prompt,system 和 prefill 是在发 API 前临时注入的。所以更准确地说,内部消息主体大概长这样:
messages = [
{"role": "user", "content": "帮我看一下 /tmp/app.py"},
{"role": "assistant", "content": None, "tool_calls": [ # 模型决定调工具
{
"id": "call_abc",
"type": "function",
"function": {"name": "read_file", "arguments": "{\"path\":\"/tmp/app.py\"}"},
}
]},
{"role": "tool", "tool_call_id": "call_abc", # 工具结果
"content": "# ... file content ..."},
{"role": "assistant", "content": "这个文件定义了 ..."}, # 模型给用户的文字回答
]
# 发 API 之前,再把 system prompt / prefill 临时拼成 api_messages
但仔细看 run_agent.py 的实际消息构造,你会发现每条 assistant 消息上还挂了几个 Hermes 特有的字段:
-
reasoning:保存模型的思维链(<think>块或 structured reasoning),作为 trajectory 存档使用 -
reasoning_content:Moonshot、Novita、OpenRouter 等 Provider 要求与tool_calls配套出现的 reasoning 字段 -
finish_reason:这一轮的停止原因(stop/tool_calls/length) -
_thinking_prefill:标记一条消息是"只有思维链、准备 prefill 后让模型续写"的中间状态
这些字段在发给 API 之前 会被 _handle_max_iterations 等函数清理掉(例如 Mistral 对非标准字段会返回 422),但在 Hermes Agent 内部它们是一等公民------用来驱动后面要讲的"空响应恢复"和轨迹持久化。
这是一个务实的设计 :OpenAI 的 messages 协议是事实标准,但它对 Agent 场景太精简了。与其另发明一套,不如在 OpenAI schema 上做扩展------发出去之前清干净,内部可以带丰富的元数据。
主循环的骨架:从 user 消息到 final response
run_conversation()(run_agent.py:8645)是一次对话的总入口。它做的第一件事不是 API 调用,而是重置一堆"这一轮"的状态:
# 每次 turn 开始时重置的计数器
self._invalid_tool_retries = 0
self._invalid_json_retries = 0
self._empty_content_retries = 0
self._incomplete_scratchpad_retries = 0
self._codex_incomplete_retries = 0
self._thinking_prefill_retries = 0
self._last_content_with_tools = None
self._mute_post_response = False
self._unicode_sanitization_passes = 0
# 每次 turn 重建 iteration budget
self.iteration_budget = IterationBudget(self.max_iterations)
为什么这些计数器要在 turn 边界重置?因为它们分别控制不同的兜底策略------上一轮的重试次数不能累加到下一轮,否则一个健康的新请求会在第一次遇到错误时就被踢出。
接下来是核心 while 循环:
while (api_call_count < self.max_iterations
and self.iteration_budget.remaining > 0) or self._budget_grace_call:
if self._interrupt_requested:
break # 用户打断
api_call_count += 1
if self._budget_grace_call:
self._budget_grace_call = False # 用掉 grace 额度
elif not self.iteration_budget.consume():
break # 预算耗尽
# 1. 组装 api_messages(inject 系统提示 / 记忆 / plugin 钩子)
# 2. 调 LLM(带 streaming + interrupt 感知)
# 3. 规范化响应,拿到 (assistant_message, finish_reason)
# 4. 判断分支:
# - finish_reason == "length" → 截断恢复
# - assistant_message 空 → 空响应恢复
# - assistant_message.tool_calls → 执行工具(并行/串行)→ 继续
# - else → final_response,break
每一个分支都是一段独立的"小机器"。我们一个个拆。
IterationBudget:对 Agent "想太久"的硬约束
IterationBudget(run_agent.py:170)是 Hermes Agent 对 "Agent 跑飞了" 的硬约束。它的实现非常短:
class IterationBudget:
"""Thread-safe iteration counter for an agent.
Each agent (parent or subagent) gets its own IterationBudget.
The parent's budget is capped at max_iterations (default 90).
Each subagent gets an independent budget capped at
delegation.max_iterations (default 50) --- this means total
iterations across parent + subagents can exceed the parent's cap.
execute_code (programmatic tool calling) iterations are refunded
via refund() so they don't eat into the budget.
"""
def __init__(self, max_total: int):
self.max_total = max_total
self._used = 0
self._lock = threading.Lock()
def consume(self) -> bool:
with self._lock:
if self._used >= self.max_total:
return False
self._used += 1
return True
def refund(self) -> None:
with self._lock:
if self._used > 0:
self._used -= 1
@property
def remaining(self) -> int:
with self._lock:
return max(0, self.max_total - self._used)
几个设计点值得咀嚼:
1. 父子 Agent 各自预算。 主 Agent 默认 90 轮,子 Agent(通过 delegate_task 派发)默认 50 轮,独立计数。这意味着总迭代数可以超过父 Agent 的 90 ------比如父 Agent 在第 30 轮 delegate 了一个子任务,子 Agent 又跑了 40 轮,加起来 70 轮,但父 Agent 自己只计了 30 轮。用户通过 config.yaml 的 delegation.max_iterations 控制子 Agent 上限。
2. refund() 机制。 不是所有工具调用都该"花钱"。execute_code 是一个特殊工具------它让 Agent 用 Python 脚本以 RPC 方式批量调其他工具,属于"一次消耗,多次效用"的 MoA 式机制。所以主循环里有这么一段(run_agent.py:11230-11234):
# 如果这一轮的工具调用"全部"都是 execute_code,就退还一个迭代
_tc_names = {tc.function.name for tc in assistant_message.tool_calls}
if _tc_names == {"execute_code"}:
self.iteration_budget.refund()
还有一种退还场景:当 Agent 触发了上下文压缩(restart_with_compressed_messages),那一轮的 API 调用其实没产出有效结果,也会退还:
if restart_with_compressed_messages:
api_call_count -= 1
self.iteration_budget.refund()
retry_count += 1
continue
3. _budget_grace_call:当前更像预留钩子。 注意主循环的 while 条件末尾有个 or self._budget_grace_call,而进入 loop 后也确实会消费这个 flag(self._budget_grace_call = False)。但按当前仓库实现,这个标志只看到初始化和消费,没有看到任何地方把它设为 True。所以更稳妥的说法是:主循环为"预算后的宽限调用"预留了扩展点,但它不是当前稳定生效的主流程机制。
4. 线程安全。 IterationBudget 用 threading.Lock,更准确地说,是为了让单个 budget 对象的 consume() / refund() / remaining 访问保持一致性,而不是因为父子 Agent 共享同一份预算。源码里子 Agent 是 fresh budget(delegate_tool.py 里传的是 iteration_budget=None),并且每次 run_conversation() 开头还会重建本 turn 的预算。
5. 预算耗尽后的收尾。 耗尽不是"直接 die"------主循环外有这段(run_agent.py:11649):
if final_response is None and (
api_call_count >= self.max_iterations
or self.iteration_budget.remaining <= 0
):
# 预算耗尽:再发一次"不带 tools 的请求"让模型总结
final_response = self._handle_max_iterations(messages, api_call_count)
_handle_max_iterations() 的逻辑是:追加一条 "你已用完预算,请用文字总结你做了什么" 的 user 消息,把 tools 数组清空 ,再发一次 API 调用,让模型交一份"工作报告"。这比给用户一个 "Iteration exhausted" 的冰冷错误人道得多------至少你知道它做到哪儿了。
一个易混淆的点:IterationBudget ≠ 上下文压缩
这里澄清一个容易混淆的概念。Hermes Agent 里有两种不同的资源约束:
-
IterationBudget:控制"Agent 跑了多少轮工具调用"------硬约束,耗尽就停
-
上下文压缩:控制"上下文 token 用了多少"------达到阈值就自动压缩
注意:当前版本没有中间态的"压力预警"。 早期版本曾有 85% / 95% 两档压力通知,但在 #7915 中被移除------原因是这些警告会让模型"提前放弃"复杂任务(run_agent.py:893 的注释:"No intermediate pressure warnings --- they caused models to 'give up' prematurely on complex tasks")。
现在的策略更简洁:上下文到了压缩阈值就直接压缩,不做预警。 这比"先警告再压缩"少了一层状态,也避免了模型因为看到警告而改变行为的副作用。
迭代预算和上下文管理是两件事,控制它们的代码路径也不同。 但作为一个 Agent 的"运行资源",两者合起来才完整。
工具调用的并行执行:_should_parallelize_tool_batch()
模型在一轮里可能返回多个 tool_calls------比如 "read_file a.py 和 read_file b.py"。朴素做法是挨个跑;但如果两个调用互不影响,可以并行,能把整轮耗时从几秒压到一秒。
这事儿听起来简单,真正做对却不容易。Hermes Agent 在 run_agent.py:267 有一个专门的 _should_parallelize_tool_batch() 函数决定"这一批能不能并发":
def _should_parallelize_tool_batch(tool_calls) -> bool:
"""Return True when a tool-call batch is safe to run concurrently."""
if len(tool_calls) <= 1:
return False
tool_names = [tc.function.name for tc in tool_calls]
if any(name in _NEVER_PARALLEL_TOOLS for name in tool_names):
return False
reserved_paths: list[Path] = [ ]
for tool_call in tool_calls:
tool_name = tool_call.function.name
try:
function_args = json.loads(tool_call.function.arguments)
except Exception:
return False # JSON 坏了 → 保守串行
if not isinstance(function_args, dict):
return False
if tool_name in _PATH_SCOPED_TOOLS:
scoped_path = _extract_parallel_scope_path(tool_name, function_args)
if scoped_path is None:
return False
if any(_paths_overlap(scoped_path, existing) for existing in reserved_paths):
return False # 路径冲突 → 串行
reserved_paths.append(scoped_path)
continue
if tool_name not in _PARALLEL_SAFE_TOOLS:
return False # 不在白名单 → 串行
return True
并行判断的逻辑可以翻译成一张决策表:
| 条件 | 结论 |
|---|---|
| 只有 1 个工具调用 | 不并行(没意义) |
任何调用是 _NEVER_PARALLEL_TOOLS(比如 clarify------它要弹交互提示给用户) |
不并行 |
任何调用的 arguments JSON 解析失败 |
不并行(保守) |
调用是 _PATH_SCOPED_TOOLS(read_file / write_file / patch) |
检查路径前缀是否重叠,重叠就不并行 |
所有调用都在 _PARALLEL_SAFE_TOOLS 白名单里 |
并行 |
| 其他 | 不并行 |
_PARALLEL_SAFE_TOOLS 白名单(run_agent.py:219)保守得刚好:
_PARALLEL_SAFE_TOOLS = frozenset({
"ha_get_state",
"ha_list_entities",
"ha_list_services",
"read_file",
"search_files",
"session_search",
"skill_view",
"skills_list",
"vision_analyze",
"web_extract",
"web_search",
})
这些工具的共同特征:只读,没有共享可变状态。搜索、读文件、读 HA 状态------这些并发跑不会互相踩。
路径感知的核心是 _paths_overlap()(run_agent.py:328):
def _paths_overlap(left: Path, right: Path) -> bool:
"""Return True when two paths may refer to the same subtree."""
left_parts = left.parts
right_parts = right.parts
if not left_parts or not right_parts:
return bool(left_parts) == bool(right_parts) and bool(left_parts)
common_len = min(len(left_parts), len(right_parts))
return left_parts[:common_len] == right_parts[:common_len]
两个路径只要有一个是另一个的前缀(包括相等),就认为重叠。举例:
| 路径 A | 路径 B | 重叠? |
|---|---|---|
/tmp/foo.py |
/tmp/bar.py |
否(可以并行读写) |
/tmp/foo.py |
/tmp/foo.py |
是 |
/tmp/ |
/tmp/foo.py |
是(父子关系) |
/home/a/ |
/home/b/ |
否 |
为什么这么保守?因为 write_file 和 patch 会改文件,源码直接把同一路径 以及父子路径 视为冲突子树,不再去做更细的依赖猜测。也正因为如此,像 /tmp/foo.py 和 /tmp/bar.py 这种兄弟文件并不会被判重叠;真正被拦下的是"同文件"或"一个路径覆盖另一个路径子树"的情况。宁可错杀,不可错放。
并发的上限 :_MAX_TOOL_WORKERS = 8(run_agent.py:237)------一批最多 8 个 worker 线程。为什么是 8 不是 16 或 32?Agent 场景下单个工具的执行时间通常在秒级,8 个并发足够把吞吐打满,再多只会让调度开销变大。
这套路径感知的并行 是一个很好的工程案例:只在绝对安全的场景下并行。要么保守串行,要么保证正确并行------不给"我觉得应该能并行"留机会。
空响应恢复:(empty) 是最后的兜底
生产环境里最让人头疼的问题之一:模型返回了空响应。
空响应有很多种形态:
-
完全空 :
content=None、tool_calls=None、reasoning=None。纯粹的空。 -
Reasoning-only :
reasoning字段里有思维链,但content空。模型"想了但没说"。 -
Codex 中间确认 :
codex_responsesAPI 下,模型说了句 "OK, I'll read the file" 但没有实际的 tool_calls------一个中间态的确认。 -
Tool 调用后续空:工具跑完返回给模型,模型应该基于结果回答,但返回了空。
朴素的做法是遇到空就重试。Hermes Agent 的做法更精细------分类处理,每种空响应走不同的恢复路径 。核心逻辑在 run_agent.py:11315-11519。
恢复策略一:流式输出已经送达,就直接复用
这是最靠前、也最容易被忽略的一段。若这轮其实已经通过 streaming 向用户送出了一部分正文,只是连接后半截断了,那么 Hermes Agent 会直接把这段已成功送达的文本当最终答案:
_partial_streamed = (
getattr(self, "_current_streamed_assistant_text", "") or ""
)
if self._has_content_after_think_block(_partial_streamed):
_turn_exit_reason = "partial_stream_recovery"
final_response = self._strip_think_blocks(_partial_streamed).strip()
break
这段逻辑的含义很简单:既然用户已经看到了可用内容,就不要为了"形式完整"再浪费额外 API 调用。
恢复策略二:用前一轮的 content 兜底
最精妙的一个分支------ "前一轮的 tool 结果后面附带过一段正文"就直接复用:
# 假设上一轮模型说 "正在为你保存到记忆,请稍等" 并同时调了 memory 工具
# 这一轮 memory 工具返回成功,模型本应说 "已保存",但返回了空
# → 不要浪费 API 调用,直接用上一轮那句话当最终回答
fallback = getattr(self, '_last_content_with_tools', None)
if fallback:
logger.info("Empty follow-up after tool calls --- using prior turn content as final response")
self._emit_status("↻ Empty response after tool calls --- using earlier content as final answer")
...
final_response = self._strip_think_blocks(fallback).strip()
break
这是对"tool 之后一定要再发一轮让模型总结"这种刻板做法的反叛------如果模型已经把话说完了,就不要再逼它说一次。
恢复策略三:思维链 prefill 续写
如果模型产出了结构化 reasoning 但没产出 content("想了没说"),Hermes Agent 会把 reasoning 作为已完成内容 append 进 messages,然后继续 loop------相当于让模型看到"自己的思考"后去续写文字部分:
_has_structured = bool(
getattr(assistant_message, "reasoning", None)
or getattr(assistant_message, "reasoning_content", None)
or getattr(assistant_message, "reasoning_details", None)
)
if _has_structured and self._thinking_prefill_retries < 2:
self._thinking_prefill_retries += 1
...
interim_msg = self._build_assistant_message(assistant_message, "incomplete")
interim_msg["_thinking_prefill"] = True
messages.append(interim_msg)
continue
最多 prefill 2 次------再多说明模型真的卡住了,不值得继续烧 token。
恢复策略四:直接重试
既不是 prior-turn fallback、也不是 reasoning prefill 的情况------那就是真正的"模型抽风"。重试 3 次:
_truly_empty = not self._strip_think_blocks(final_response).strip()
_prefill_exhausted = (_has_structured
and self._thinking_prefill_retries >= 2)
if _truly_empty and (not _has_structured or _prefill_exhausted) and self._empty_content_retries < 3:
self._empty_content_retries += 1
logger.warning("Empty response (no content or reasoning) --- retry %d/3", ...)
self._emit_status(f"⚠️ Empty response from model --- retrying ({self._empty_content_retries}/3)")
continue
恢复策略五:切到 fallback Provider
重试 3 次还是空?说明不是偶发抖动,是这个模型/Provider 真的出了问题。这时候触发 _try_activate_fallback()------上一讲讲过的故障转移:
if _truly_empty and self._fallback_chain:
logger.warning("Empty response after %d retries --- attempting fallback", ...)
self._emit_status("⚠️ Model returning empty responses --- switching to fallback provider...")
if self._try_activate_fallback():
self._empty_content_retries = 0 # 新 provider 重置计数
continue
最终兜底:(empty)
如果 fallback 链也耗尽了(或者根本就没配),就只能接受现实:
final_response = "(empty)"
用户会看到一个 (empty) 字样的响应。这不是 bug,而是 诚实地告诉用户"模型没给我任何东西" ------比静默挂起或给一个假装合理的回答都好。
这五段恢复链的顺序很讲究 :先试 partial stream recovery(零成本),再试 prior-turn fallback(零成本),再试 prefill(便宜),再试重试(中等成本),最后切 provider(昂贵且有风险)。总是先用便宜的办法解决问题。
长度截断:模型说了一半就没了怎么办
另一个常见问题:finish_reason == "length"------模型的 max_output_tokens 被耗光了,回答还没说完。
这种情况又分两种:
情况 A:输出里有可执行的 tool_calls,但 JSON 被截断了。 这种最危险------如果把不完整的 tool_call 喂给执行器,参数是坏的。Hermes Agent 给 1 次重试机会(truncated_tool_call_retries < 1),重试还是坏就放弃。
情况 B:纯文本被截断。 这种有救------把截断的内容存下来作为 prefix,发一条 "接着你上次被截断的地方继续,别重复" 的 continue 消息:
continue_msg = {
"role": "user",
"content": (
"[System: Your previous response was truncated by the output "
"length limit. Continue exactly where you left off. Do not "
"restart or repeat prior text. Finish the answer directly.]"
),
}
messages.append(continue_msg)
最多续 3 次(length_continue_retries < 3),再不行就放弃,返回已经拼好的部分当作 partial_response。
一个很细节的判断:thinking 耗尽 。如果模型把 reasoning 的 token 都吃光了,一个字都没剩给正文(代码里叫 _thinking_exhausted),再怎么续写都没用------因为它永远会先跑 reasoning。这种情况 Hermes Agent 会识别并给出有针对性的错误提示:
⚠️ **Thinking Budget Exhausted**
The model used all its output tokens on reasoning and had none left for the actual response.
To fix this:
→ Lower reasoning effort: `/thinkon low` or `/thinkon minimal`
→ Increase the output token limit: set `model.max_tokens` in config.yaml
识别 thinking 耗尽的关键 :响应里出现了 <think> / <thinking> / <reasoning> / <REASONING_SCRATCHPAD> 这类标签(run_agent.py:9638),但标签之后没有 content。有标签、没后文 = 思考完了没时间说。
工具执行后的上下文压缩检查
工具跑完、结果追加到 messages 之后,主循环会检查是否需要上下文压缩 (run_agent.py:11236 附近)。这是一个很容易被忽略的工程细节。
# 用真实的 prompt_tokens + completion_tokens 估算下一轮的上下文大小
_compressor = self.context_compressor
if _compressor.last_prompt_tokens > 0:
_real_tokens = _compressor.last_prompt_tokens + _compressor.last_completion_tokens
else:
_real_tokens = estimate_messages_tokens_rough(messages)
# 达到压缩阈值就压缩(无中间预警)
if self.compression_enabled and _compressor.should_compress(_real_tokens):
self._safe_print(" ⟳ compacting context...")
messages, active_system_prompt = self._compress_context(
messages, system_message,
approx_tokens=self.context_compressor.last_prompt_tokens,
task_id=effective_task_id,
)
为什么在这里检查?因为工具结果是 messages 增长最快的源头 ------一个 read_file 就可能加 20000 token。检查点设在"结果 append 之后、下一轮 API 调用之前",让 Agent 在下一轮有干净的上下文工作。
这个 _real_tokens 的计算很值得聊:不是凭空估算 ,而是用上一次 API 响应里的真实 prompt_tokens + completion_tokens。这是 Provider 告诉你的权威数字,比任何 tokenizer 近似都准。只有在 last_prompt_tokens == 0 的兜底场景(Provider 没返回 usage、或者连接断了)才退回到 estimate_messages_tokens_rough() 粗估。
实战:触发一次迭代预算耗尽看看
光讲原理枯燥,我们动手验证一下 IterationBudget 的行为。
第一步:创造一个容易超预算的场景
在 CLI 里故意给一个会让 Agent 反复调工具的任务:
hermes chat --max-turns 8 \
-q "遍历整个 /tmp 目录下所有 .txt 文件,对每个文件
先 read_file 一遍,再在末尾追加 '# reviewed' 标记"
--max-turns 8 把预算压得很低,故意让它跑不完。
第二步:观察预算耗尽时的行为
如果 /tmp 下有 10 个 .txt 文件,Agent 需要 ~20 轮(每个文件 read + patch 两轮)。跑到第 8 轮时预算耗尽,你会看到类似:
⚠️ Iteration budget exhausted (8/8 iterations used)
⚠️ Iteration budget exhausted (8/8) --- asking model to summarise
🔄 Making API call (summary request, no tools)...
I processed 4 of the 10 .txt files before running out of iterations.
Completed: a.txt, b.txt, c.txt, d.txt.
Remaining: e.txt ... j.txt --- please run again or increase --max-turns.
注意不是一个冷冰冰的错误 ------主循环退出后走了 _handle_max_iterations(),多发了一次"不带 tools 的请求",让模型用文字交代清楚"做到哪儿了、还剩什么"。
第三步:观察 /usage 里的计数
会话结束后敲 /usage,你会看到:
Model: claude-sonnet-4-6
Input tokens: xxxx
Output tokens: xxxx
Prompt tokens (total): xxxx
Completion tokens: xxxx
Total tokens: xxxx
...
(/usage 显示的是 session 级别的 token 用量,不直接展示 iteration budget,但 agent.log 里会有 Turn ended: reason=max_iterations_reached(8/8) budget=8/8 ... 这样的 diagnostic 行。要按 session 过滤看它,可以用 hermes logs --session <id>。)
第四步:用 delegate_task 观察父子预算独立
写个任务让父 Agent 派子 Agent 并行做几件事:
hermes chat -q "并行做以下三件事:
1) 搜索 Python 里所有的 threading 用法示例
2) 搜索 Python 里所有的 asyncio 用法示例
3) 总结两者的区别
请用 delegate_task 并行调度。"
你会在日志里看到类似:
delegate_task dispatched: subagent_id=xxx, max_iterations=50
subagent xxx: 8/50 iterations used
subagent yyy: 12/50 iterations used
parent agent: 3/90 iterations used ← 父 Agent 只计一次 delegate 调用
父 Agent 只花了 3 轮预算,但整个任务跑了 3 + 8 + 12 = 23 轮------这就是"父子独立预算"的实际效果。子任务跑多久不吃父预算。
一个总被忽略的细节:_turn_exit_reason
翻 run_agent.py 你会发现一个看起来"只是调试用"的变量:_turn_exit_reason。它贯穿整个主循环,每一个 break 出口都会给它赋值:
_turn_exit_reason = "interrupted_by_user" # 用户打断
_turn_exit_reason = "budget_exhausted" # 预算用光
_turn_exit_reason = "partial_stream_recovery" # 复用已送达的流式内容
_turn_exit_reason = "empty_response_exhausted" # 空响应兜底
_turn_exit_reason = "fallback_prior_turn_content" # 复用前轮内容
_turn_exit_reason = f"max_iterations_reached({api_call_count}/{self.max_iterations})"
_turn_exit_reason = "all_retries_exhausted_no_response"
_turn_exit_reason = f"error_near_max_iterations({error_msg[:80]})"
turn 结束后它会被写进 log(run_agent.py:11691):
Turn ended: reason=%s model=%s api_calls=%d/%d budget=%d/%d
tool_turns=%d last_msg_role=%s response_len=%d session=%s
为什么要这么详细地记录退出原因?因为 "Agent 就这么停了" 是最让用户困惑的场景 。有了每个 break 分支打的标签,事后用 hermes logs --session <id> 过滤对应 session,就能立刻知道 "哦,这轮是因为预算耗尽" 还是 "空响应兜底" 还是 "用户 Ctrl-C 了"。
这是一个很好的可观测性实践 :在 loop 的每个退出点打一个语义化的 reason------比用栈追踪有用得多。你自己写 Agent 系统时,可以直接抄这个思路。