ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Hermes Agent 核心循环拆解:run_conversation() 的 7 个阶段与 15 种故障自愈

Hermes Agent 核心循环拆解:run_conversation() 的 7 个阶段与 15 种故障自愈 1. 为什么我要逐行拆 run_conversation()Hermes Agent 的run_conversation()是我见过把「一条用户消息」拆得最细的 Agent Loop 实现。它藏在agent/conversation_loop.py里整个文件接近 4800 行核心函数把一次对话切成 7 个阶段并在主循环里塞进了 15 种以上的故障自愈分支。如果你正在自己写 Agent、或者被「循环跑飞、工具报错、上下文溢出」折磨过这套结构值得抄作业。这篇不聊虚的架构图直接给你能跑的东西阶段断点日志怎么配、每个阶段打印什么、15 种自愈分支怎么用最小用例触发、报错时怎么判断卡在哪一阶段。适合两类人一是想读懂 Hermes Agent 源码的开发者二是自己写 Agent Loop 想加故障恢复的工程师。读完你能在本地复现整条 Agent Loop并且知道异常到底出在入参校验、上下文装配、模型调用、工具分发还是结果回写。我试过把这套阶段日志接到自己的小 Agent 上定位一个「工具结果回填后模型不继续」的 bug从原来靠猜变成看日志三分钟锁定。下面按阶段拆。2. 前置把 TaoToken 接进 Hermes 的模型调用层Hermes 的 Phase 5 主循环最终要发一次模型请求请求走的是 OpenAI 兼容协议。本地复现时模型侧我用 TaoToken 的 API 来承接原因是它兼容标准 Chat Completion 格式base_url一改就能接上不用动conversation_loop.py里的请求构造逻辑。你需要先拿到一个 API Key。打开 https://taotoken.net/api-keys 登录后在控制台创建密钥复制出来。注意 Key 只在创建时完整显示一次丢了就重建。拿到 Key 后在 Hermes 的配置里指向 TaoToken 的 API 地址。API 基础地址是https://taotoken.net/api注意这个地址不带任何查询参数。配置方式有两种环境变量最省事export OPENAI_API_KEYsk-你的TaoToken密钥 export OPENAI_BASE_URLhttps://taotoken.net/api如果你用的是 Hermes 的配置文件通常是~/.hermes/config.toml或项目内的config.yaml对应字段这样写[model] provider openai-compatible base_url https://taotoken.net/api api_key_env OPENAI_API_KEY model claude-sonnet-4-20250514注意base_url结尾不要多加/v1Hermes 的请求构造里已经带了路径拼接多写一层会 404。这个坑我在 Phase 5 的 API 调用日志里见过报错是404 page not found但日志显示请求发出去了很容易误判成模型侧问题。模型名按你实际要用的填。想先确认 Key 和模型通不通不用急着跑整个 Agent直接去 https://taotoken.net/api 的模型对话页发一条消息验证即可比在代码里 debug 快得多。3. 七个阶段的断点日志配置Hermes 的run_conversation()内部有大量logger.debug调用但默认日志级别是 INFO阶段边界看不到。要复现整条 Loop先把日志级别调到 DEBUG并且给每个阶段加一个显式断点。3.1 打开阶段级日志在启动 Hermes 前设置export HERMES_LOG_LEVELDEBUG export HERMES_LOG_PHASE_MARKERS1如果源码里没有HERMES_LOG_PHASE_MARKERS这个开关不同版本命名可能不同直接在conversation_loop.py的每个阶段入口插一行。七个阶段的插入位置和打印内容如下# Phase 1: Turn 初始化 logger.debug([PHASE-1] turn_init task_id%s turn_id%s budget%s, task_id, turn_id, iteration_budget.remaining) # Phase 2: System Prompt 构建 logger.debug([PHASE-2] system_prompt_built stable_len%d context_len%d volatile_len%d, len(stable), len(context), len(volatile)) # Phase 3: Preflight 压缩 logger.debug([PHASE-3] preflight_compress ratio%.2f rounds%d, context_ratio, compress_rounds) # Phase 4: 外部记忆注入 logger.debug([PHASE-4] memory_inject prefetch_len%d plugin_ctx_len%d, len(prefetch), len(plugin_ctx)) # Phase 5: 主循环每轮迭代 logger.debug([PHASE-5] iteration%d finish_reason%s tool_calls%d, iteration, finish_reason, len(tool_calls)) # Phase 6: Turn 结束 logger.debug([PHASE-6] turn_end reason%s budget_left%d, end_reason, iteration_budget.remaining) # Phase 7: 后置处理 logger.debug([PHASE-7] post_process memory_written%s skill_updated%s, memory_written, skill_updated)跑起来后日志里会按顺序出现[PHASE-1]到[PHASE-7]。哪一阶段没打印问题就在那一阶段之前。这是定位异常阶段最直接的办法。3.2 各阶段的关键参数阶段关键参数异常信号Phase 1budget默认 90budget 为 0 说明上一轮没重置Phase 2stable_len应恒定每次 turn 都变说明缓存没命中Phase 3ratio阈值 0.5ratio 持续 0.5 但 rounds0 说明压缩没触发Phase 4prefetch_len每轮迭代都变说明 prefetch 被重复调用Phase 5iteration/finish_reasoniteration 不增长说明卡在工具执行Phase 6end_reason出现max_iterations说明预算耗尽Phase 7memory_written恒为 False 说明后台线程没启动Phase 2 的stable_len恒定是重点。Hermes 的 System Prompt 分三层Stable 层整个 session 只构建一次Context 层按优先级加载项目文件Volatile 层每 turn 变。如果stable_len每次都不同说明前缀缓存被破坏Anthropic 的 prompt caching 会全部 miss费用和时间都会涨。4. 主循环里的工具分发与结果回写Phase 5 是整个文件最长的部分从入参校验到结果回写都在这里。它的迭代顺序是固定的中断检查 → API 消息准备 → API 调用 → 响应验证 → finish_reason 分流 → 工具执行 → 空响应兜底。4.1 finish_reason 分流模型返回后finish_reason决定下一步if finish_reason tool_calls: results _execute_tool_calls(tool_calls) _backfill_tool_results(messages, results) continue # 进入下一轮迭代 elif finish_reason length: _handle_length_overflow() # 最多 3 次 continuation retry elif finish_reason stop: return _finalize_response(content)工具执行前有一道 Guardrail 检查。如果某个工具被安全护栏拦截Agent 直接生成终止响应工具根本不会执行。这一点在调试时容易误判你以为工具报错了其实是 Guardrail 拦了日志里搜guardrail_blocked能看到。4.2 工具结果回写_backfill_tool_results()把工具返回塞回消息历史格式必须是role: tool且带tool_call_id。如果回写格式不对下一轮 API 调用会报消息角色交替异常触发第 15 种自愈分支。def _backfill_tool_results(messages, results): for call, result in zip(tool_calls, results): messages.append({ role: tool, tool_call_id: call.id, content: json.dumps(result, ensure_asciiFalse) })注意ensure_asciiFalse别省。工具返回中文时如果转义成\uXXXX模型读起来没问题但你的日志会很难看排查时容易看漏。4.3 空响应兜底链模型返回空内容时Hermes 不是直接抛异常而是走一条 7 级降级链块检测 → Streaming 恢复 → 前序 turn fallback → Nudge → Prefill → 最多 3 次重试 → Fallback Provider →(empty)占位。每一步都检查上一步能不能恢复全走完还不行才塞占位文本。这条链的存在是因为早期版本空响应直接抛异常整个 session 终止用户丢上下文。现在最差情况也只是拿到一个(empty)循环不崩。5. 验证请求与成功结果配好日志和模型接入后发一条会触发工具调用的消息来验证整条 Loop。用一个简单的文件读取任务hermes run 读取当前目录下的 README.md总结它的前三个小节预期日志顺序[PHASE-1] turn_init task_idt-8f3a turn_id1 budget90 [PHASE-2] system_prompt_built stable_len12480 context_len0 volatile_len320 [PHASE-3] preflight_compress ratio0.12 rounds0 [PHASE-4] memory_inject prefetch_len0 plugin_ctx_len0 [PHASE-5] iteration1 finish_reasontool_calls tool_calls1 [PHASE-5] iteration2 finish_reasonstop tool_calls0 [PHASE-6] turn_end reasonstop budget_left88 [PHASE-7] post_process memory_writtenTrue skill_updatedFalse看到iteration1触发tool_calls、iteration2拿到stop说明工具分发和结果回写都正常。budget_left88说明两轮迭代消耗了 2 个预算符合预期。memory_writtenTrue说明 Phase 7 的后台线程跑起来了。如果iteration1之后没有iteration2卡在工具执行。检查_execute_tool_calls()的返回大概率是工具本身抛异常但被吞了。6. 十五种自愈分支的触发与排查这 15 种恢复策略不是一次性全跑而是分层部署每层恢复不了才进下一层。下面挑几个最容易在本地复现的给出触发条件和验证方法。6.1 空响应降级链触发条件模型返回content为空且无tool_calls。验证方法把模型名改成一个不存在的或者临时在请求里把max_tokens设成 1强制模型输出被截断。# 临时在 API 消息准备阶段注入 payload[max_tokens] 1日志里会看到empty_response_detected→streaming_recovery→nudge的序列。如果直接跳到(empty)sentinel说明中间几级都没恢复成功。6.2 Context 溢出压缩触发条件会话历史超过模型 context window 的 50%。验证方法连续发 30 条长消息把历史撑大。for i in $(seq 1 30); do hermes run 这是第 $i 条测试消息请回复收到 done日志里[PHASE-3] preflight_compress ratio0.6 rounds1说明压缩触发了。压缩保留前 3 轮和后 20 轮中间的有损摘要。如果rounds到了 3 还是 ratio 0.5说明单轮压缩不够需要检查context_compressor.py的摘要质量。6.3 工具调用格式错误触发条件模型返回的tool_calls结构不符合预期。验证方法用一个对工具调用支持不好的模型或者手动构造一个缺function.name的响应。Hermes 用_invalid_tool_retries计数超限后强制模型用文本回复不再尝试工具调用。日志里搜invalid_tool_retries能看到计数增长。6.4 文件写入未落盘触发条件write_file或patch调用返回成功但文件实际没写。验证方法把工作目录设成只读。chmod -w /tmp/hermes_test hermes run 在 /tmp/hermes_test 下创建 test.txtPhase 6 的_verify_file_operations()会检查文件真实存在不存在就补 warning。日志里出现file_verify_failed就是这个分支。6.5 其余分支速查分支触发条件恢复动作Images 被拒后端不支持 visionstrip images降级 text-onlyUnicode 错误surrogate 字符ASCII codec 清理最多 2 次Rate Limit 429请求过频Fallback Provider 凭证轮换Auth 过期凭据失效主动刷新对应 provider 凭据死 TCP 连接socket 僵尸调用前清理Budget 耗尽迭代到 90去工具化最终调用API 超时请求超时重试 fallback 链Thinking 不足reasoning 参数不够降低或关闭 thinking权限不足Guardrail 拦截生成终止响应Session DB 损坏SQLite 异常WAL fallback 重试角色交替异常消息顺序错插入/清理 orphan tool results安全违规输出不合规过滤后截断或重生成排查时先看日志里哪个分支的关键字出现再对照上表定位。大部分分支在 DEBUG 级别都有独立日志行。7. 接入与排障的下一步如果你在本地复现时卡在模型调用阶段先确认base_url和 Key 没问题去 https://taotoken.net/api-keys 重新生成一个 Key 试试再对照 https://taotoken.net/api 的接入文档检查请求格式。文档里有标准的 Chat Completion 请求示例和你conversation_loop.py里构造的 payload 对一遍字段名对不上就是这里的问题。如果你是要长期跑 Agent、频繁触发工具调用和自愈分支建议用 Coding Plan 来承接高频请求比按次调用更稳。入口在 https://taotoken.net/coding-plan 适合这种需要反复迭代、每轮都发模型请求的场景。排障的核心思路就一条先看[PHASE-N]日志断在哪再进对应阶段的源码看自愈分支有没有触发。阶段日志配好之后整条 Agent Loop 对你就是透明的。
返回列表