重试、取消与 doom-loop 检测 本节摘要:Sampler 是离网络最近的一层,网络与服务端的不可靠性首先冲击它。要让 Agent 在这些不可靠面前仍然稳定,sampler 内置了三套可靠性机制:RetryPolicy 处理「临时性失败」的重试,CancellationToken 实现「协作式取消」,doom-loop 检测防止「Agent 陷入死循环」。这三套机制各自解决一类问题,共同构成了 sampler 的「韧性」。本节会逐一拆解它们的原理,让你看清 sampler 如何在出错、被打断、甚至自己失控时,都能表现得既鲁棒又不失控。
本节摘要:Sampler 是离网络最近的一层,网络与服务端的不可靠性首先冲击它。要让 Agent 在这些不可靠面前仍然稳定,sampler 内置了三套可靠性机制:RetryPolicy 处理「临时性失败」的重试,CancellationToken 实现「协作式取消」,doom-loop 检测防止「Agent 陷入死循环」。这三套机制各自解决一类问题,共同构成了 sampler 的「韧性」。本节会逐一拆解它们的原理,让你看清 sampler 如何在出错、被打断、甚至自己失控时,都能表现得既鲁棒又不失控。
在展开之前,先看清这三套机制各自负责什么:
情况 机制 层级 ───────────────────────────────────────────────────────── 网络抖动/限流/空响应 RetryPolicy sampler 内 用户中途取消 CancellationToken sampler 内(信号) Agent 反复调同样工具 doom-loop 检测 sampler + 服务端协作
它们的共同点是「让系统在非理想情况下仍然可控」,但解决的问题不同:重试解决「临时故障」,取消解决「用户主动中止」,doom-loop 解决「Agent 自己失控」。
网络与服务端有各种「这次失败,下次可能成功」的临时性错误。RetryPolicy 定义 sampler 如何应对。
可重试的错误
回顾 SamplingErrorKind(第 03 节),可重试的典型有:
不可重试的错误(直接失败)
RetryPolicy 的核心字段
RetryPolicy { max_retries: u32, # 最大重试次数 backoff: BackoffStrategy, # 退避策略 retryable_kinds: Set, # 哪些 ErrorKind 可重试 } BackoffStrategy: Fixed(间隔) # 每次等固定时间 Linear(起始, 增量) # 每次递增固定量 Exponential(基数, 上限) # 指数退避(常见)
重试的流程
回顾第 01 节的 request_task,重试在那里发生:
request_task 内部: let mut retry_state = RetryState::new(config.retry_policy) loop { match 流的下一个 chunk { Ok(chunk) => 正常处理,产出事件 Err(err) => { # 判断是否可重试 if retry_state.can_retry(&err) { # 计算退避时间 let backoff = retry_state.next_backoff() # 发出 Retrying 事件(让桥/UI 知道) 发出 Retrying { attempt, max_retries, kind: err.kind, reason, ... } # 等待退避(但响应取消) tokio::select! { _ = cancel_token.cancelled() => 取消,退出 _ = sleep(backoff) => 继续 } # 重新建立流 重新发起 conversation_stream 请求 continue } else { # 不可重试,直接失败 发出 Failed { error: err } return } } 流结束 => 聚合,发出 Completed,return } }
关键观察:
关键概念:重试不是「无脑再来」,而是「有策略地、有限度地、可中止地」重试。有策略指按退避算法决定何时重试;有限度指最多重试 max_retries 次;可中止指任何时刻响应用户取消。这三点让重试既提高成功率,又不失控。
用户在 Agent 跑的过程中取消(按 Ctrl+C 或点取消按钮),需要一种机制让 sampler 及时中止当前请求。这就是 CancellationToken 的用途。
为什么用令牌而不是强杀
最直接的取消方式是「强杀」——直接终止 task。但这有几个问题:
协作式取消 是更好的方式:发一个「取消信号」,task 在合适的时机响应,完成清理后自行退出。CancellationToken 就是这个信号的载体。
CancellationToken 的工作
CancellationToken(来自 tokio_util)是一个可以被「触发」的信号:
let cancel_token = CancellationToken::new() # 在命令循环里(取消命令到达时): cancel_token.cancel() # 触发信号,不等待 # 在 request_task 里: loop { tokio::select! { biased; # 优先响应取消 _ = cancel_token.cancelled() => { # 取消时: # - HTTP 请求会被 drop(自动关闭连接) # - 发出 Failed(Cancelled) 事件 # - 清理局部状态 # - 退出 task return } # 正常处理 chunk = stream.next() => ... } }
几个关键点:
取消的传播链(回顾第 01 节):
用户取消 → SessionActor 收 Cancel 命令 → sampler_handle 发 Cancel(request_id) → SamplerActor 在命令循环触发 cancel_token.cancel() → request_task 在 select! 感知,中断 HTTP,清理,发 Failed(Cancelled) → task 退出,被 join_next 回收 → 桥收到 Failed(Cancelled),退出事件循环 → shell 循环感知,结束当前调用(但会话不结束)
整条链路是「协作式」的,每一层发信号,下一层响应。没有强杀,保证了清理的完整性。
重试与取消处理的是「外部不可靠」,doom-loop 检测处理的是「Agent 自己失控」——这是更隐蔽、更危险的情况。
什么是 doom-loop
doom-loop(死循环)指 Agent 反复执行相似的动作、得到相似的结果、再执行相似的动作,陷入死循环。典型表现:
轮 1: 模型调用 bash(检查状态)→ 服务未启动 轮 2: 模型调用 bash(检查状态)→ 服务未启动 轮 3: 模型调用 bash(检查状态)→ 服务未启动 ...
模型可能因为上下文里的某种模式,「忠诚地」重复同样的动作,每次得到同样的结果,却无法跳出。这会:
为什么模型自己跳不出来
模型没有「我现在在重复」的自我意识——它每次只看到上下文,基于上下文生成下一步。如果上下文诱导它做同样的动作,它就做同样的动作。跳出循环需要「外部干预」,这正是 doom-loop 检测的职责。
检测的原理
Grok Build 的 doom-loop 检测,是 sampler 与服务端协作的机制(在 SamplerConfig 里有 DoomLoopRecoverySettings):
检测逻辑(简化): 维护最近的请求/工具调用序列 分析序列的模式: - 是否高度重复(同样的工具 + 相似参数) - 重复频率是否超过阈值 若判定为疑似 doom-loop: 触发恢复策略
服务端也可能参与——它从全局视角观察到某个会话的请求模式异常,通过响应头或错误返回提示 sampler「这看起来像 doom-loop」。
恢复策略
检测到 doom-loop 后,Grok Build 采取的恢复策略(具体以 DoomLoopRecoverySettings 配置为准):
DoomLoopRecoverySettings 的典型字段(基于探索):
DoomLoopRecoverySettings { enabled: bool, max_threshold: u32, # 重复多少次触发(如 8) max_retries: u32, # 恢复尝试多少次 }
doom-loop 与重试的区别
注意 doom-loop 检测与 RetryPolicy 是两回事:
重试解决「这一次」的问题,doom-loop 解决「这一系列」的问题。两者互补,共同保证 Agent 的可靠性。
设计警示:doom-loop 检测是 Agent 系统的「安全阀」。没有它,一个诱导死循环的上下文可能让 Agent 无限消耗资源。但检测也不能过于激进——正常的迭代式工作(如反复跑测试直到通过)不应被误判。阈值(max_threshold 等)的设定需要平衡,通常以默认值为起点,根据实际调整。
把三套机制放在一起,它们如何协作应对不同情况:
情况一:网络抖动
模型调用 → 网络中断 → Err(Http) → RetryPolicy 判定可重试 → 退避 1s → 重发 → 成功 → 继续
用户无感(可能只看到「重试中」一闪而过)。
情况二:用户取消
模型正在生成 → 用户按取消 → CancellationToken 触发 → request_task 中断 HTTP,清理 → Failed(Cancelled) → 桥退出,循环结束当前调用 → 会话保持,用户可发新 prompt
用户立即看到响应停止。
情况三:认证失效
模型调用 → 401 → Err(Auth) → RetryPolicy 判定不可重试 → Failed(Auth) → 桥翻译成 RefreshAuthAndResubmit → shell 刷新 token → continue 重试(走第 3 章的错误分层)
用户基本无感(token 自动刷新)。
情况四:doom-loop
模型连续调用同样工具 → 模式被检测 → DoomLoopRecoverySettings 触发 → 注入提醒 或 强制结束 → 模型换思路 或 用户介入
避免无限消耗。
情况五:服务端严重过载
模型调用 → 限流(429)→ 重试 → 还是 429 → 重试... → 达到 max_retries → Failed → 桥告知循环,循环决定怎么办(可能通知用户稍后再试)
有上限地放弃,不让重试无限进行。
把本章的可靠性内容与第 3 章的错误分层联系起来,Agent 的可靠性是分层的:
最底层:sampler 的重试与取消(RetryPolicy + CancellationToken) 处理「请求级别」的临时故障与用户中止 中间层:shell 的语义错误恢复(RefreshAuthAndResubmit + CompactAndResubmit) 处理「会话级别」的语义问题(认证、上下文) 最高层:框架级保护(doom-loop 检测、max-turns) 处理「Agent 行为级别」的失控(死循环、无限轮)
每一层处理自己最了解的问题,层层兜底。这种分层让 Agent 在各种非理想情况下都能可控地降级,而不是「一遇错就崩」。
至此,第四章全部完成。你已经深入 sampler 层的内部——从 Actor 架构、配置字段、统一事件、后端适配、模型管理,到三套可靠性机制。下一章,我们转向 Agent 的「双手」——工具系统,看清那些让 Agent 能读文件、跑命令、改代码的工具是如何被定义、注册、调用的。