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_KEY="sk-你的TaoToken密钥" export OPENAI_BASE_URL="https://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结尾不要多加/v1,Hermes 的请求构造里已经带了路径拼接,多写一层会 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_LEVEL=DEBUG export HERMES_LOG_PHASE_MARKERS=1如果源码里没有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 1 | budget默认 90 | budget 为 0 说明上一轮没重置 |
| Phase 2 | stable_len应恒定 | 每次 turn 都变说明缓存没命中 |
| Phase 3 | ratio阈值 0.5 | ratio 持续 >0.5 但 rounds=0 说明压缩没触发 |
| Phase 4 | prefetch_len | 每轮迭代都变说明 prefetch 被重复调用 |
| Phase 5 | iteration/finish_reason | iteration 不增长说明卡在工具执行 |
| Phase 6 | end_reason | 出现max_iterations说明预算耗尽 |
| Phase 7 | memory_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_ascii=False) })注意:
ensure_ascii=False别省。工具返回中文时如果转义成\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_id=t-8f3a turn_id=1 budget=90 [PHASE-2] system_prompt_built stable_len=12480 context_len=0 volatile_len=320 [PHASE-3] preflight_compress ratio=0.12 rounds=0 [PHASE-4] memory_inject prefetch_len=0 plugin_ctx_len=0 [PHASE-5] iteration=1 finish_reason=tool_calls tool_calls=1 [PHASE-5] iteration=2 finish_reason=stop tool_calls=0 [PHASE-6] turn_end reason=stop budget_left=88 [PHASE-7] post_process memory_written=True skill_updated=False看到iteration=1触发tool_calls、iteration=2拿到stop,说明工具分发和结果回写都正常。budget_left=88说明两轮迭代消耗了 2 个预算,符合预期。memory_written=True说明 Phase 7 的后台线程跑起来了。
如果iteration=1之后没有iteration=2,卡在工具执行。检查_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 ratio=0.6 rounds=1说明压缩触发了。压缩保留前 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.txt"Phase 6 的_verify_file_operations()会检查文件真实存在,不存在就补 warning。日志里出现file_verify_failed就是这个分支。
6.5 其余分支速查
| 分支 | 触发条件 | 恢复动作 |
|---|---|---|
| Images 被拒 | 后端不支持 vision | strip images,降级 text-only |
| Unicode 错误 | 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 对你就是透明的。