Agent 在第四分钟突然安静了:Hermes 的超时分层全解


凌晨 2:40,你 2:05 启动的定时任务还显示“运行中”。日志最后一行是请求发出去了。没有报错、没有崩溃、没有重试——只有一个 HTTP 请求挂在那里,连接开着,Agent 在等一个永远不会来的首 token。与此同时,编辑器里是同一个问题的另一种味道:一个模型思考了四分钟,在第三分钟被切断,重试还是被切断,于是你判断“这模型不行”。这两件事都是超时问题,而 Hermes 是分层回答它们的——其中好几层在 2026-09-25 悄悄变了。

“沉默”是没人专门设计的故障模式

大家问得最多的两类故障看起来相反,根子是同一个:Agent 在干活的时候很好推理,Agent 沉默的时候就不好推理了。沉默可能意味着模型正深陷思考阶段,也可能意味着这个 socket 已经僵了。而这两种情况的头 60 秒,看起来一模一样。

所以这套栈不问“它是不是慢”,它问的是“在某一层上,沉默最多允许持续多久“——而每层的答案是有意不同的:

层次 默认值 它实际盯的是什么
socket 读取超时 120 秒(本地 1800 秒) httpx 等下一个数据块
流式僵死检测 180 秒(本地上限 900 秒) SSE 心跳到了但没有内容
非流式僵死检测 90 秒 非流式调用一直不回答
Responses 首事件看门狗 120 秒(本地上限 900 秒) 连接被接受,但一个流事件都没有
API 调用(非流式) 1800 秒 整个非流式请求
终帧后流排空 2 秒 永远不关 SSE socket 的中转

这张表里最有用的一句话是:僵死检测只在保持“隐式默认值”时才会自动调整。你一旦显式设了值,就等于接过了责任——对慢速本地服务器这是对的选择,对某台今晚状态很差的云端模型就是错的选择。

对推理模型来说,180 秒这个默认值是个谎言

聊天模型和推理模型不是一个物种。聊天模型接受请求后三分钟一个字都没出,基本可以判定死了;推理模型这样,很多时候只是正在想。而中间的上游代理不关心这个区别,会在思考中途把流按空闲掐掉,表现出来的往往是一个断管或协议错误,而不是“你的模型还在工作”。

所以项目里维护了一张地板表——按模型给一个最小耐心值,用 max(默认值, 地板值) 把僵死检测往上抬。它从不降低阈值,也从不覆盖你自己设的值。当前的地板,直接从源码里读出来:

地板 覆盖的模型(节选)
600 秒 Nemotron-3 Ultra/Super、DeepSeek 推理与 V4 系列、OpenAI o1/o3/o3-pro 家族、gpt-5.6 与 gpt-6 命名线、claude-fable
300 秒 Nemotron-3 Nano、QwQ-32B、o3-mini/o4-mini、明确带 reasoning 的 xAI Grok 组合、ox-alpha、Inkling、MiniMax M2.x
240 秒 Claude Opus 4.x / 5.x 的 thinking 变体
180 秒 qwen3 系列、Claude Sonnet 5 / 4.5 / 4.6

MiniMax M2.x 是这张表里最新的成员,它是因为三份 bug 报告一起被并进 PR #121555 才加上的:M2.x 会在首个内容 token 之前先流式输出推理内容,且被观测到思考中途停顿约 240 秒——超过了 180 秒的默认值,于是它健康的时候被误杀。

上下文长度用同一套逻辑缩放,因为 10 万 token 的提示词首 token 就是比 2 千 token 慢:估算超过 5 万 token 时,流式检测至少抬到 240 秒;超过 10 万 token 至少 300 秒。非流式那边有自己的一档。注意:只有隐式默认值会被调整,你显式写下的数字不会被碰。

一次卡死的请求,居然能无视 run budget

agent.run_budget_seconds(或 hermes chat --run-budget 850 -q "...")的作用是别让自主运行的 Agent 无限烧下去。一旦设置,会有两件事发生:预算走到 80% 时给 Agent 一次性的收尾提醒,让它停止继续探索、直接产出交付物;同时隐式僵死超时被压到“剩余预算的一半”,下限 60 秒,这样一次静默卡死的调用不至于吞掉整轮运行。

Bug 就出在流式这条路上文档与实现的落差:这个上限只作用于非流式调用,而卡死的流仍然保留完整的僵死窗口;更糟的是,只要有一次调用在飞,收尾提醒就发不出去。于是一个 10 分钟预算、碰上僵死流的运行,可以大大方方超过自己的死线——提醒理论上可达,实际上不可达。修复让两个解析器共用同一个 cap_to_run_budget,流式与非流式的僵死超时从此用同一套算法。有一个不对称是故意的:显式的 HERMES_STREAM_STALE_TIMEOUT 被视为你自己的死线,永不被 run budget 压缩;被压缩的只是隐式值。

cron:卡在“第二次调用”上

同一个 PR 里的第三个修复,解释了一类“我的定时任务就是永远跑不完”。cron 的 Codex 调用跑在 worker 线程路径上,可能卡在连续第二次流式调用上——第一次正常,第二次无限等。现在 cron 运行(chat_completions 与 codex_responses)改为内联执行;更重要的是,内联路径拿到了和 worker 路径相同的大上下文地板和硬上限。PR 里给出的实测:在 3 万 token 的探测点上,内联路径过去只允许 90 秒,而 worker 允许 600 秒;现在两者都算出 600,硬上限设为 300 时两者都算出 300。

如果你跑无人值守的自动化,这条最值得记住:两条本该拥有相同耐心的代码路径,其中一条的耐心只有另一条的 1/6.7,而且可以在超时根本不触发的情况下把任务卡死。

参数打架时谁说了算

当好几个值都可能适用时,优先级是明确的,学一次就够:

  • 显式配置压过一切隐式地板。 providers.<id>.stale_timeout_seconds(或 providers.<id>.models.<model>.stale_timeout_seconds)是运维者给出的死线:它压过上下文分档和推理地板,也正因如此,它可以被用来故意缩短对卡死流的容忍时间。
  • 只有 180 秒这个默认值会被缩放和抬地板。 别动它,你就白得上下文分档 + 模型地板。
  • HERMES_STREAM_STALE_TIMEOUT 是用户值。 原样采纳,永不被 run budget 压缩。
  • 本地端点拿到的是有限上限,不是“永不超时”。 检测到本地端点且处于隐式默认时,agent.local_stream_stale_timeout(900 秒)生效——过去那种“永不超时”的行为意味着一个僵死的本地服务器会永远挂着。
  • HERMES_STREAM_STALE_GIVEUP(默认 5,0 关闭)是熔断器:连续这么多次僵死击杀且没有任何一次完成的回答之后,后续每个调用直接带可操作的错误中止,而不是再等一遍超时。任何一次完成的回答、切换 /model、激活 fallback,或新的一轮对话,都会重置它。

一个自建推理模型的实用写法(它就是需要思考几分钟):

providers:
  local-reasoning:
    stale_timeout_seconds: 900     # 显式:不缩放、不封顶

对可信的云端推理供应商则相反——保持隐式,让地板和上下文分档替你兜底:

agent:
  run_budget_seconds: 1800         # 80% 处提醒收尾,僵死上限取剩余预算的一半

怎么判断是谁杀了你的调用

  • 被掐断后重试能成功:通常是僵死检测在长思考阶段触发——看看你的模型是否在地板表里,以及上下文是不是已经超过 5 万 token。
  • 连续五次被杀,然后立刻中止:这是熔断器而不是供应商的问题,错误文本会点出连续次数。换个模型或新开一个会话;计数器会在下一次完成的回答后清零。
  • 一次运行无视了自己的预算:那就是流式路径的那个缺口——这个症状值得重测,而不是靠调大超时绕过去。
  • 定时任务卡在第二次供应商调用上:对应本批的内联执行修复。

这些东西发在哪

三个修复都在 PR #121555 里,2026-09-25 合并——比 v0.21.5 打标签晚,所以只在 main 上,会随下一个版本或跟踪 main 的 hermes update 到达。如果你现在正在调超时,上面的分层今天就已经生效,这个修复补的是两处实现落差。

站内相关阅读:连接静默断连一文讲的是同一问题在传输层的版本——连接看起来活着,其实早就没了;网关循环看门狗指南讲的是第三层:盯循环而不是单次调用的那一层。如果你的问题不是“卡住”而是“花钱”,接下来该读的是成本追踪一文。

结论

每一层上的沉默都需要一个预算,而这个预算必须诚实:它在衡量什么?对推理模型,诚实的默认值远长于聊天模型;对无人值守的运行,诚实的默认值就是这个运行自己的死线。值得带去你自己系统的做法和这里一样:按“沉默意味着什么”给超时分层——还没有事件、还没有内容、还没有答案、任务已经跑太久——并确保每一条会等待的代码路径都用同一套分层。一条悄悄等 90 秒、而它的兄弟路径等 600 秒的路径,不是调参差异,是一个带着秒表的 bug。