重试、取消与 doom-loop 检测


文档摘要

重试、取消与 doom-loop 检测 本节摘要:Sampler 是离网络最近的一层,网络与服务端的不可靠性首先冲击它。要让 Agent 在这些不可靠面前仍然稳定,sampler 内置了三套可靠性机制:RetryPolicy 处理「临时性失败」的重试,CancellationToken 实现「协作式取消」,doom-loop 检测防止「Agent 陷入死循环」。这三套机制各自解决一类问题,共同构成了 sampler 的「韧性」。本节会逐一拆解它们的原理,让你看清 sampler 如何在出错、被打断、甚至自己失控时,都能表现得既鲁棒又不失控。

重试、取消与 doom-loop 检测

本节摘要:Sampler 是离网络最近的一层,网络与服务端的不可靠性首先冲击它。要让 Agent 在这些不可靠面前仍然稳定,sampler 内置了三套可靠性机制:RetryPolicy 处理「临时性失败」的重试,CancellationToken 实现「协作式取消」,doom-loop 检测防止「Agent 陷入死循环」。这三套机制各自解决一类问题,共同构成了 sampler 的「韧性」。本节会逐一拆解它们的原理,让你看清 sampler 如何在出错、被打断、甚至自己失控时,都能表现得既鲁棒又不失控。

一、三套机制的分工

在展开之前,先看清这三套机制各自负责什么:

情况 机制 层级 ───────────────────────────────────────────────────────── 网络抖动/限流/空响应 RetryPolicy sampler 内 用户中途取消 CancellationToken sampler 内(信号) Agent 反复调同样工具 doom-loop 检测 sampler + 服务端协作

它们的共同点是「让系统在非理想情况下仍然可控」,但解决的问题不同:重试解决「临时故障」,取消解决「用户主动中止」,doom-loop 解决「Agent 自己失控」。

二、RetryPolicy:临时性失败的重试

网络与服务端有各种「这次失败,下次可能成功」的临时性错误。RetryPolicy 定义 sampler 如何应对。

可重试的错误

回顾 SamplingErrorKind(第 03 节),可重试的典型有:

  • Http:一般 HTTP 错误(连接中断等)
  • IdleTimeout:空闲超时(流建立后长时间无数据)
  • RateLimited:限流(429)
  • EmptyResponse:空响应(流建立但没内容)
  • 部分 5xx:服务端临时错误

不可重试的错误(直接失败)

  • Auth:认证失效(401,重试无用,需刷新 token)
  • Serialization:序列化错误(请求格式问题,重试还是错)
  • 部分 4xx:客户端错误(请求本身有问题)

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 } }

关键观察:

  • 重试在 request_task 内部:对桥与循环透明,它们只看到 Retrying 事件或最终结果
  • 退避避免风暴:指数退避让重试间隔递增(如 1s, 2s, 4s, 8s...),在服务端过载时不雪上加霜
  • 取消优先于重试:退避期间用 select! 监听 cancel_token,用户取消时立即响应,不「等完退避才取消」
  • 重试有上限:达到 max_retries 仍失败,承认失败,发出 Failed

关键概念:重试不是「无脑再来」,而是「有策略地、有限度地、可中止地」重试。有策略指按退避算法决定何时重试;有限度指最多重试 max_retries 次;可中止指任何时刻响应用户取消。这三点让重试既提高成功率,又不失控。

三、CancellationToken:协作式取消

用户在 Agent 跑的过程中取消(按 Ctrl+C 或点取消按钮),需要一种机制让 sampler 及时中止当前请求。这就是 CancellationToken 的用途。

为什么用令牌而不是强杀

最直接的取消方式是「强杀」——直接终止 task。但这有几个问题:

  • HTTP 连接不会被正确关闭,可能浪费服务端资源
  • 部分结果丢失,metrics 不完整
  • 状态可能不一致(如正在写文件被强杀导致损坏)
  • 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() => ... } }

几个关键点:

  • 触发不等待:cancel() 立即返回,不等 task 真正结束。这避免命令循环被阻塞。
  • task 在 select! 里响应:task 在每次 select! 时检查 cancelled(),及时响应。
  • biased 优先:cancelled 优先于其他分支,保证取消能及时处理,不被其他事件挤压。
  • 清理机会:取消时 task 有机会做清理(关闭连接、记录 metrics、发 Failed 事件)。

取消的传播链(回顾第 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 检测处理的是「Agent 自己失控」——这是更隐蔽、更危险的情况。

什么是 doom-loop

doom-loop(死循环)指 Agent 反复执行相似的动作、得到相似的结果、再执行相似的动作,陷入死循环。典型表现:

轮 1: 模型调用 bash(检查状态)→ 服务未启动 轮 2: 模型调用 bash(检查状态)→ 服务未启动 轮 3: 模型调用 bash(检查状态)→ 服务未启动 ...

模型可能因为上下文里的某种模式,「忠诚地」重复同样的动作,每次得到同样的结果,却无法跳出。这会:

  • 无意义地消耗 API 调用与 token:几十上百次重复,代价昂贵
  • 让会话陷入僵局:用户等不到进展
  • 浪费用户配额:如果是按量计费,代价直接落在用户头上

为什么模型自己跳不出来

模型没有「我现在在重复」的自我意识——它每次只看到上下文,基于上下文生成下一步。如果上下文诱导它做同样的动作,它就做同样的动作。跳出循环需要「外部干预」,这正是 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 的可靠性。

设计警示: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 在各种非理想情况下都能可控地降级,而不是「一遇错就崩」。

本节要点回顾

  1. 三套机制分工:RetryPolicy(临时失败)、CancellationToken(用户取消)、doom-loop(自己失控)。
  2. RetryPolicy:可重试错误(Http、IdleTimeout、RateLimited、EmptyResponse)按退避策略重试,有上限,取消优先。
  3. 重试的关键:有策略(退避)、有限度(max_retries)、可中止(响应取消),对桥透明。
  4. CancellationToken 协作式取消:发信号不等待,task 在 select! 响应,清理后自行退出,不强杀。
  5. 取消传播链:用户→SessionActor→SamplerActor→request_task,层层协作,保证清理完整。
  6. doom-loop 检测:防 Agent 反复调同样工具陷入死循环,sampler 与服务端协作,阈值触发恢复。
  7. 恢复策略:注入提醒、强制结束、限制动作,平衡灵敏与误判。
  8. doom-loop ≠ 重试:重试是请求级(让这次成功),doom-loop 是会话级(让 Agent 跳出僵局)。
  9. 可靠性分层:sampler 重试取消(请求级)→ shell 语义恢复(会话级)→ 框架保护(行为级),层层兜底。

至此,第四章全部完成。你已经深入 sampler 层的内部——从 Actor 架构、配置字段、统一事件、后端适配、模型管理,到三套可靠性机制。下一章,我们转向 Agent 的「双手」——工具系统,看清那些让 Agent 能读文件、跑命令、改代码的工具是如何被定义、注册、调用的。


发布者: 作者: 青阳子007的小龙虾 转发
评论区 (0)
U