重试与超时常量:请求失败之后它会怎么退
排查”请求失败之后它到底重试了几次、等了多久”这类问题,最怕的情况是:你在仓库里搜 retry,搜到三处不同的常量,每一处看起来都像是正解。DeepTutor 就是这种情况。
下面所有行号与数字对应我们采集的仓库快照:HEAD 456f9c2,采集日 2026-08-10,deeptutor/__version__.py 里的版本号是 1.5.11。行号在你 clone 的版本里可能已经漂了,但文件名与常量名是稳的,照着搜即可。
先把结论摆在前面:这个仓库里的重试策略至少有三套并存的常量,数量级互相差得很远;而”超时”这件事根本不归重试管,它散在十来个模块里,各写各的。 下面逐层拆。
一、第一层:全局默认值与那条被封顶的延迟序列
最顶上的一层是 pydantic settings。deeptutor/config/settings.py:20-22 定义了三个字段:
max_retries默认 8base_delay默认 5.0 秒exponential_backoff(指数退避开关)
这三个值在 deeptutor/services/llm/factory.py:27-29 被导出成 DEFAULT_MAX_RETRIES / DEFAULT_RETRY_DELAY / DEFAULT_EXPONENTIAL_BACKOFF。也就是说,你在 factory.py 里看到的那三个大写常量不是写死的字面量,它们的源头在 config/settings.py——搜常量名搜到 factory.py 就停手,会误以为改不了。
真正把这三个值变成一串等待时间的是 _build_retry_delays(),在 llm/factory.py:62-74。它的逻辑只有两句话:按 base * 2**attempt 逐个生成,然后 min(delay, 120.0) 封顶。
按卡里给的这个公式,配上 max_retries 默认 8 次代进去,逐项算出来是:
5.0, 10.0, 20.0, 40.0, 80.0, 120.0, 120.0, 120.0
这一串是我们按公式推算的,不是直接读到的返回值;项数我们也是按 max_retries=8 推的,实际返回几项以代码为准。后三项都撞上了 120 秒的天花板——5 × 2^5 = 160、320、640,全被压回 120。这是这一层最容易被忽略的一处:指数退避从第六项起就退化成了固定间隔,再往后调大 max_retries,增加的都是等长的 120 秒,不再翻倍。
需要明确边界:上面这串数字是把默认值代入那个公式算出来的,只说明”这个函数在默认配置下按公式产出什么”。这 8 个延迟在实际调用里会不会全部走完、每次消费到第几项,取决于调用方的循环逻辑,我们没有逐行读完那段消费代码,不做推断。它是代码里的默认配置,不是”你会等多久”的保证。
同一段常量区里还并排放着流式合并的两个参数:DEFAULT_STREAM_COALESCE_CHARS = 64、DEFAULT_STREAM_COALESCE_SECONDS = 0.04,以及 STREAM_CONTROL_TOKENS = {"<think>", "</think>"}(llm/factory.py:30-32)。它们和重试无关,只是位置挨着,搜索的时候容易一并看到。
二、第二层:provider 基类里那组 (1, 2, 4)
往下一层,deeptutor/services/llm/provider_core/base.py:74 上有这么一行:
_CHAT_RETRY_DELAYS = (1, 2, 4)
这是 LLMProvider 的类属性,一个写死的三元组。
把它和第一层放在一起看:一边是 8 次、5 秒起步、封顶 120 秒的序列,一边是 3 项、1 秒起步、最大 4 秒的元组。两处都是”LLM 调用失败之后等多久再来一次”这件事的默认值,取值不在一个量级上。
按纪律,我们说完差异就停:两处位置分别是 deeptutor/config/settings.py:20-22(经 llm/factory.py:27-29 导出)与 deeptutor/services/llm/provider_core/base.py:74;两组值不一致。 谁在什么调用路径上生效、是否存在覆盖关系,需要你自己跟着调用链核,我们没有读完,不推断,也不据此评价这个设计。
对排查有用的动作是:确认你踩的是哪一条路。llm/__init__.py:7-17 的 docstring 画了调用链——Agents → BaseAgent.call_llm()/stream_llm() → LLM Factory(complete / stream)→ CloudProvider / LocalProvider。如果你的日志里能看到两次调用之间的间隔,可据此反推是 1 秒起步还是 5 秒起步,基本能定位到你落在哪一层——日志里具体会打印哪些字段,我们没有核实。
三、第三层:那个带熔断的子包,生产代码里没人引用
第三处最容易带偏人。deeptutor/services/llm/providers/base_provider.py:35-36 定义了:
MAX_RETRY_DELAY_SECONDS = 60.0BASE_RETRY_DELAY_SECONDS = 1.0
而且这个文件在 :8-16 引入了 tenacity 与 deeptutor.utils.network.circuit_breaker 的熔断。看到这里,很自然会得出”这个项目的 LLM 调用是带熔断的”这个印象。
问题在于这个子包本身。deeptutor/services/llm/providers/ 一共 4 个文件、896 行(base_provider.py 204、routing.py 264、anthropic.py 258、open_ai.py 170)。全仓执行 grep -rn 'services\.llm\.providers' . --include=*.py 只命中 2 处,且都在 tests/ 目录下(tests/services/llm/test_base_provider.py:7、tests/services/llm/test_routing_provider.py:7);deeptutor/services/llm/*.py 里也没有 from .providers 之类的相对导入。这个子包还自带一套独立的 register_provider 注册表(llm/registry.py),与 services/provider_registry.py 并存。
它的定位在源码注释里写得很清楚。llm/providers/routing.py:1-9 自述:这个 provider 委托给既有的、基于函数的 providers,存在的目的是”在把调用点逐步迁移到 provider 对象的过程中保持公共 API 稳定”——即一层过渡性桥接。llm/__init__.py:106 也把 LLMClient / get_llm_client / reset_llm_client 标注为 “Client (legacy, prefer factory functions)”。
可核查的结论到这里为止:这两个常量与那处熔断引用存在于仓库中,而引用该子包的生产代码路径我们没有找到(只找到 2 处测试引用)。至于它是不是”死代码”、将来会不会接上,我们不下判断。
给你的核查动作很直接:在自己 clone 的仓库里跑一遍上面那条 grep,看命中数与命中位置。如果你的目标是”给 LLM 调用加熔断”,先确认自己要改的是不是这条没被引用的路径。
顺带一句异常类型:llm/__init__.py:74-83 导出了 8 个 LLM 异常类——LLMError / LLMConfigError / LLMProviderError / LLMAPIError / LLMTimeoutError / LLMRateLimitError / LLMAuthenticationError / LLMModelNotFoundError;llm/exceptions.py 里还有一个 LLMCircuitBreakerError,它被 llm/providers/base_provider.py:22 引用——正是上面那个子包。
四、什么算”值得重试”:判定靠的是字符串匹配
这是本文里第二处反直觉的地方。
provider_core/base.py:75-88 上有一个 _TRANSIENT_ERROR_MARKERS 元组,12 个标记:
429 / rate limit / 500 / 502 / 503 / 504 / overloaded /
timeout / timed out / connection / server error / temporarily unavailable
看名字像是状态码判定,实际是字符串标记——判定输入是错误内容的文本,命中其中任何一个子串才被认作瞬时错误、才进入重试分支。这处判定的输入是错误文本本身;异常类型在这条分支里如何参与,我们没有核实。
这意味着一件很具体的事:同一个网络故障,provider 返回的错误文案措辞不同,会不会命中这 12 个标记就可能不同。 排查”为什么这个错没重试”时,正确的动作不是去看 HTTP 状态码,而是把日志里那条错误文本原样拿出来,逐个比对这 12 个子串——这是你能直接执行、也能立刻得到答案的判定方法。
还有一处特殊的”重试”,它不属于上面这套。provider_core/base.py:321-343:当错误被判定为非瞬时错误、且消息里含有图片时,会去掉图片重试一次(Stage-2 vision fallback),并且只有 allow_image_fallback 为真时才走。也就是说,这次重试的触发条件与前面那套正好相反——前一套要求命中瞬时标记,这一套要求没命中。看到日志里出现”重试了一次而且内容变了”,先想到的应该是这条支路,而不是延迟序列。
五、超时常量:它们不归重试管,而且散在十来个模块
第三件容易搞混的事:上面讲的全是”失败之后隔多久再来”,而”多久算失败”是另一套常量,写在各自的模块里。下面这张表把卡里核到的都列出来,按模块归位:
| 模块 | 常量 / 语义 | 位置 |
|---|---|---|
| 本地 provider | DEFAULT_TIMEOUT = 300 | llm/local_provider.py:65 |
| 运行期配置 | HTTP_KEEP_ALIVE_TIMEOUT = 300 | config/runtime_settings.py:1031 |
| MCP | 连接 15s | mcp/manager.py:51 |
| MCP OAuth | 流程 600s | mcp/oauth.py:326 |
| Codex OAuth | 登录 300s | codex_auth/constants.py:11 |
| CLI apps 安装 | 900s,可被 DEEPTUTOR_CLI_APP_INSTALL_TIMEOUT_S 覆盖 | cli_apps/installer.py:60 |
| CLI apps 运行 | 默认 120s / 上限 600s | cli_apps/runner.py:30-31 |
| 文档解析(MinerU 云端) | 默认 300s、提交 60s、上传 300s、下载 300s | parsing/engines/mineru/cloud.py:38-41 |
| 沙箱 | 30s | sandbox/runner/server.py:81 |
| skill hub | HTTP 30s / fetch 命令 120s | skill/hub.py:93-94 |
| subagent(opencode 系) | 探测 15s、attach 15s、httpx 连接 10s、读无限、写 60s | subagent/opencode_family.py:60-65 |
| subagent(opencode server) | ready 30s | subagent/opencode_server.py:39 |
| subagent(claude models) | 35s | subagent/claude_models.py:36 |
这张表的读法有三条:
第一,别把它当”一个可调的全局超时”。 这里没有任何一处是从统一配置读的——除了 cli_apps/installer.py:60 那一项明确写了可被环境变量 DEEPTUTOR_CLI_APP_INSTALL_TIMEOUT_S 覆盖,其余在卡的核对范围内都是模块内常量。想改哪个就得去改哪个文件。
第二,表里下面那一大半不是在等模型返回。 cli_apps 与 subagent 这两组的超时,对应的是安装与运行本机命令行程序这条路径——services/subagent/__init__.py:1-7 的 docstring 写明它的职责是”驱动用户本机的 agent CLI 作为子代理”,cli_apps/__init__.py:1 写的是”管理员安装、chat agent 调用的命令行工具”。这一点要说清楚:这些代码路径会在你的机器上安装并执行外部程序,900 秒的安装超时对应的就是这类动作,不是一次 API 调用。是否启用这条路径,请结合自己的环境评估。
第三,这些值同样是代码里的默认配置,不是”你会不会超时”的结论。别拿 300 秒去反推”我这个文档能不能解析完”。
六、还有一种超时:拿不到并发槽位
llm/traffic_control.py:21-48 的 TrafficController 有三个默认参数:max_concurrency=20、requests_per_minute=600、acquisition_timeout=30.0,实现是信号量加令牌桶。
单拎出来说是因为 acquisition_timeout 这个名字里也带 timeout,但它和请求超时不是一回事:它是等待槽位的上限,等不到就失败,此时请求还没发出去。排查时如果把这类失败误当成上游超时,方向就反了。
七、一个对照:MCP 那一层的重试是另一种写法
想理解上面那套字符串判定的特点,把 MCP 层拿来对照最省事。mcp/manager.py:65-81 的做法是:失败的服务器采用退避重试,起始 30.0 秒、上限 300.0 秒;每账号 scope 上限 64,空闲 TTL 900 秒;而且只对 BrokenPipeError 与 ConnectionResetError 这两个异常类型重试一次。
同一个仓库,两层的判定依据不同——一层匹配错误文本里的子串,一层匹配具体的异常类型。这里只陈述两处写法的差异与位置,不评价哪种更好。
八、你可以照着核的五步
- 打开
deeptutor/config/settings.py:20-22,确认三个默认值是 8 / 5.0 / 指数退避;再到llm/factory.py:27-29确认它们被导出成三个大写常量。 - 打开
llm/factory.py:62-74,把min(delay, 120.0)那一行找出来,自己把默认值代进去按公式算一遍,确认从第六项起撞顶。 - 打开
provider_core/base.py:74,确认_CHAT_RETRY_DELAYS = (1, 2, 4)与上一步的序列不是同一组值。 - 在仓库根目录跑
grep -rn 'services\.llm\.providers' . --include=*.py,看命中是不是只在tests/下——这决定了MAX_RETRY_DELAY_SECONDS = 60.0与那处熔断引用是否在你要改的路径上。 - 如果你能从日志或异常回显里拿到那条没被重试的错误文本,就把它原样取出来,逐个比对
provider_core/base.py:75-88的 12 个子串标记——日志里究竟会不会打印原始错误文本,我们没有核实。
什么情况说明”不是重试策略的问题”:如果错误文本里根本没有上面 12 个子串中的任何一个,那么按代码语义它不会进入重试分支,再怎么调 max_retries 也不会有变化,该往错误本身查;如果失败发生在请求发出之前,先看 TrafficController 那三个并发参数;如果超时秒数和上面那张表里某一行对得上,那是模块自己的超时常量,与 LLM 重试无关。
边界
本文只读了上述文件的常量段、类属性与关键分支,没有逐行读完任何一个文件;_build_retry_delays() 返回的延迟序列在真实调用里如何被消费、三层常量之间是否存在覆盖关系,我们都没有核实。deeptutor/config/settings.py 中 retry 之外的其它默认值,以及这些默认值在运行期是否会被 data/user/settings/*.json 覆盖,同样没有核实。文中所有数值都是源码中的默认配置,不构成对实际运行结果的任何保证。
本文依据 DeepTutor 官方仓库(github.com/HKUDS/DeepTutor)的 README、AGENTS.md、
pyproject.toml 与 deeptutor/ 下的源码整理,核对日 2026-08-10,对应仓库快照 456f9c2(版本 1.5.11)。
本文内容为仓库源码与文档口径,我们没有安装、部署或运行过该项目,也没有调用过其中任何一个模型 API,
因此不涉及生成质量、响应速度与教学效果的任何描述。
参数与默认值随版本变动,请以仓库最新代码与 --help 的实际输出为准。
安全相关做法请结合自身环境评估,本文不构成安全方案建议。