第 42 章:Agent 超时与 Watchdog
“90 秒什么都没发生”还不是故障描述,除非你能说出循环当时究竟在等待什么。
快速通道(5 分钟掌握核心)
- 显式建模阻塞阶段;单看墙上时间无法区分卡死与正当工作
- 仅在等待远端 LLM 响应时累计空闲时间,包括最终的强制停止总结
- 每个阶段实例只报告一次轻度空闲;硬性空闲用类型化原因取消,并预留清理时间
- 流间隙 Watchdog 是另一只时钟——它在传输层内观察数据块间隔
- 阶段所有权一旦不可信,就停用计时动作并暴露结构性错误
公开源码快照:实现细节取自 Kocoro 公开仓库
origin/main的4ec6772提交,复核日期为 2026-07-27。章节讲的是不变量;具体常量应以当前源码为准。
42.1 从一只杀错工作的计时器说起
一个 Agent 要求图像服务渲染大型素材。八分钟很慢,但合理;工具拥有十分钟超时限制,而且仍在取得进展。
另一次运行中,LLM 流发出半句话后,TCP 连接陷入沉默。没有终止事件,没有错误,也没有新字节。此时等待八分钟不是耐心,而是卡死。
给两者套一只“整轮超时”计时器,就必须选择破坏哪次运行。90 秒会杀掉合法工具;十分钟会让死亡数据流长时间占用工作线程。每次出现任何活动就重置,又会让一个交替执行无用工作的循环永远活着。
问题不在于选出正确时长,而在于:不同组件拥有不同类型的等待。
42.2 Watchdog 需要阶段模型
循环暴露显式 TurnPhase,而不是根据最近哪只计时器被触碰来猜测状态:
| 阶段 | 计入 LLM 空闲? | 等待的所有者 |
|---|---|---|
| init | 否 | 循环构建 |
| setup | 否 | 本地初始化 |
| awaiting LLM | 是 | 整轮 Watchdog |
| retrying LLM | 否 | 重试策略 |
| compacting | 否 | 压缩协调器 |
| awaiting approval | 否 | 审批界面与策略 |
| executing tools | 否 | 工具专属超时 |
| injecting message | 否 | 追问注入路径 |
| force stop | 是 | 整轮 Watchdog |
| done | 否 | 无 |
只有严格等待远端模型响应的阶段才计入空闲。Bash 命令、审批对话框或本地历史重写,从循环视角也可能很安静,但它们有不同所有者和不同的合法时长。
这样,每只超时计时器都只回答一个问题:
这个阶段由哪个组件承诺推进?什么观测证明它正在推进?
如果没有所有者,再加一只计时器只是掩盖设计缺口;如果两只计时器同时拥有同一段等待,它们迟早会对用户应看到哪个错误产生分歧。
42.3 Soft Idle 与 Hard Idle 是两个决策
公开快照的默认值是 Soft Idle 90 秒、Hard Idle 540 秒。这些数字描述一个具体传输栈,不代表普遍耐心。
Soft Idle 发出状态:这个阶段已经多久没有 LLM 活动。它不取消。每次阶段迁移都有单调递增的序号,同一阶段实例只触发一次。离开 awaiting_llm、重试,再重新进入 awaiting_llm,新的阶段实例可以再次报告。
Hard Idle 发出终态状态,并用 ErrHardIdleTimeout 取消运行。540 秒默认值比 600 秒传输上限少 60 秒,让取消信号有时间传播,也让清理能在外层 HTTP 自己放弃前完成。
这种分离对运维很重要。Soft 负责可见性,Hard 负责策略。可以只修改其中之一,而不必假装另一项也改变了。
类型化取消原因同样重要。用户取消和 Watchdog 取消都会关闭上下文,但不是同一种结果。调用方必须分别呈现“你停止了它”和“Provider 不再取得进展”;恢复逻辑也必须知道重试会不会违背用户意图。
42.4 流空闲是另一只时钟
整轮 Watchdog 知道循环在 awaiting_llm 里停留多久,却不拥有阻塞 SSE 响应体的内部机制。
因此网关在 CompleteStream 内部运行逐数据块计时器。每收到一行就重置;达到配置间隙的一半时记录警告,达到完整间隙时取消扫描器并关闭响应体。快照默认值是 90 秒。
它捕捉的是外层整轮计时器不擅长处理的故障:连接已经交付一些内容,却不再产生字节,也不返回错误。此时自动改用非流式模式重试同一个上游,可能只是再买一次完整的传输等待。因此循环把流间隙错误当成部分结果:保留已经收到的文本,如果一个字也没有则插入明确的占位文本,记录超出截止时间的状态,然后退出。
三只时钟回答不同问题:
- 整轮空闲: 这个 LLM 阶段是否存在太久?
- 流空闲: 这条传输最近是否产生过数据块?
- 工具超时: 这个工具是否超出自己的执行契约?
不要让其中一只由另一只的事件重置。截图工具完成不能宽恕先前卡住的 Provider 数据流;一个 Provider token 也不能延长工具的截止时间。
42.5 嵌套调用仍需所有权
压缩是本地阶段,所以 Watchdog 不应计算整理历史的 CPU 工作。但压缩也会调用 LLM 来持久化经验或生成摘要;这段嵌套的远端调用确实需要覆盖。
阶段跟踪器用 EnterTransient(PhaseAwaitingLLM) 处理它,并返回一个幂等的恢复闭包:
restore := tracker.EnterTransient(PhaseAwaitingLLM)
summary, err := client.Complete(ctx, request)
restore()
外层阶段仍是 compacting;只有远端阻塞区间暂时归 LLM Watchdog 所有。忘记恢复,循环会停在错误阶段;完全不进入临时阶段,卡死的摘要器就会隐形。
这说明,把手工“暂停 Watchdog / 恢复 Watchdog”散落在业务逻辑里很脆弱。阶段拥有计时策略,嵌套工作借用阶段,再归还它。
42.6 阶段跟踪器说谎怎么办
这里有一个危险诱惑:把跟踪器称为“失败即关闭”,只要状态不一致就取消。
但不一致的跟踪器无法告诉你循环正在等待模型,还是合法地执行工具。根据坏状态行动,可能杀掉正当工作。快照选择了相反的生产取舍:任何结构性违规都会把跟踪器标为无效,记录警告,并让 Watchdog 观察器在本次运行剩余时间内停用。在测试或 SHANNON_PHASE_STRICT=1 下,同一违规会触发 panic,避免开发过程把它常态化。
因此行为是:
development / strict run: structural bug → panic
ordinary production run: structural bug → warning + watchdog disabled
这可能让真正的卡死继续存活;另一种选择却可能在证据不可信时取消一次合法、破坏性或昂贵的操作。两边都有代价。重要的是明确选择,并让失去的覆盖可见。
恢复闭包是幂等的,消掉一类所有权错误;退出断言能捕捉忘记结束的临时阶段;一个临时阶段尚未结束时再做顶层 Enter,本身也会构成违规。这些检查把“计时器感觉不对”变成具体的阶段嵌套错误。
42.7 超时是一种结果,不只是错误
Hard Idle 触发时,取消信号必须到达四个边界:
- 正在进行的 Provider 调用或数据流;
- 从流增量启动的任何推测性工具工作;
- 检查点与持久层,避免这一轮被错误标成完整;
- 调用方,并携带阶段与部分输出状态。
只返回 context canceled 不够。没有类型化原因,界面无法区分 Watchdog 与用户意图;没有部分文本,一个九分钟的回答会因为最后十秒卡住而全部消失;没有终态运行状态,第 40 章所述的持久层及其调用方,也无法区分因超时截断的一轮与正常完成。
同样,Soft 事件应包含活动阶段与实测空闲时长。“Agent slow”对运维人员毫无帮助;“No LLM activity for 90s, phase=force_stop”则明确指出最终总结调用是所有者。
Watchdog 应当限制沉默,而不是掩盖沉默。
42.8 快照证据
| 观察 | 4ec6772 源码 |
|---|---|
| 单次 Agent 运行的显式阶段清单 | phase.go L12 |
| 只有 awaiting LLM 与 force stop 计入空闲 | phase.go L58 |
| 嵌套阻塞调用借用临时阶段 | phase.go L138 |
| 结构性违规令观察器失效,并警告或 panic | phase.go L117 |
| Watchdog 的 Soft/Hard 语义与跟踪器失效行为 | watchdog.go L19 |
| 快照默认值:Soft 90 秒、Hard 540 秒、流间隙 90 秒 | config.go L162 |
| 流响应体拥有自己的逐数据块空闲 Watchdog | gateway.go L1144 |
| 流间隙退出时保留部分输出 | loop.go L3679 |
这些内容描述的是一个有日期的实现与传输栈,不是普遍的超时数值。
42.9 常见陷阱
整个轮次只有一只超时计时器。 它会杀掉合法工具,或对死亡的 Provider 等太久。
任何活动都重置计时。 无关进展可以让真正卡死的所有者永远活着。
把压缩全部排除。 本地包装层不计入空闲,但里面的摘要调用仍需 LLM 覆盖。
把审批等待计作 Agent 空闲。 那段停顿归人类所有;用模型计时器取消它是类别错误。
只有阶段名称,没有迁移标识。 再次进入 awaiting_llm 时永远无法重新触发 Soft 告警。
流间隙超时后自动回退。 同一个上游可能再次卡死,让延迟和成本翻倍。
根据无效的阶段数据取消。 所有权一旦不可信,计时器就不知道自己正在杀什么。
只返回通用取消错误。 调用方失去原因、阶段、部分输出与选择恢复方式的能力。
本章要点
- 时间有所有者。 只有位于已知阻塞阶段内,时长才有意义。
- Soft 是可见性,Hard 是策略。 两者应独立配置、独立观测。
- 流间隙需要传输层时钟。 整轮经过时间不能替代数据块间隔。
- 嵌套远端工作借用远端阶段。 阶段所有权胜过四处散落的暂停与恢复。
- 不可信的计时数据不能触发自信的行动。 暴露结构性错误,并有意识地选择生产取舍。
下一章:第 43 章处理另一种没有进展的状态:循环仍在活动、仍在产生事件,却始终原地踏步。