跑微软生成式 AI 入门课示例被限流:仓库自带的重试封装做了什么

2026-08-18

跑 generative-ai-for-beginners 的 Python 示例,连着几次请求之后开始报错,很多人的第一反应是「课程里应该自带重试吧」——毕竟 shared/python/api_utils.py 里明明白白有个叫 make_safe_request 的函数,docstring 第一句就写着提供 “proper timeout, error handling, and retry logic”。

问题是,这个函数和你正在跑的那个示例,很可能一次都没打过照面。

一、先确认报错是从哪条路径出来的

判定动作很简单,在仓库根目录 grep 一下这个模块被谁引用:

grep -rn "api_utils\|make_safe_request" . --exclude-dir=translations --exclude-dir=.git

translations/ 下是官方各语种译本,里面的命中只是同一份文档的翻译副本,排掉它们结果才看得清。)

在 2026-08-18 这个时间点的仓库里,命中的位置只有五处:shared/python/api_utils.py 自身、shared/python/__init__.py(它从 api_utils 里再导出 make_safe_requestcreate_openai_clientcreate_azure_openai_client__all__,同文件里的 download_image 不在导出列表内)、tests/test_api_utils.py,以及 CHANGELOG.mddocs/ENHANCED_FEATURES_ROADMAP.md 两处文字提及。课程各章的 .py.ipynb 示例里没有它。

反过来看你手上的示例。06-text-generation-apps/python/aoai-app.py 开头是 from openai import OpenAI,直接构造 client 然后 client.responses.create(...);同目录的 githubmodels-app.py 用的是 azure.ai.inferenceChatCompletionsClientAzureKeyCredential。两条路径都不经过 requests,自然也不经过 make_safe_request

所以第一步结论就出来了:你被限流时抛出来的那个异常,是 SDK 抛的,不是 api_utils.py 里那段循环抛的。 至于这两个 SDK 各自的重试参数怎么设,课程仓库里没有找到相关说明,得去各自 SDK 的文档看,不要指望课程正文告诉你。

第二个判定动作是看 traceback 最内层那一帧属于哪个包。如果栈底出现的是 openaiazure.ai.inference 里的模块,那就和 shared/python/ 这套工具完全无关;如果栈底是 requests,才轮到下面这一节要讲的内容。这一步花不了十秒,却能省掉一整轮改错地方的功夫——照着 api_utils.pyretries 而示例根本不走它,改多少次都不会有变化。

课程仓里唯一一处正面提到限流的地方,是 00-course-setup/02-setup-local.md 第 5 节的排查表,其中一行把症状写成 OpenAI 401 / 429 errors,给出的处置是 Check OPENAI_API_KEY value / request rate limits.。把 401 和 429 并在一行,是提醒你先分清楚是凭证问题还是速率问题——这两者的处置完全相反,重试对 401 没有任何帮助。

二、再读一遍这个封装到底做了什么

即便课程示例没用它,make_safe_request 仍然值得逐行读一遍,因为很多人会照着它写自己的调用层。它的签名是:

def make_safe_request(
    url: str, method: str = "GET", timeout: int = 30, retries: int = 3, **kwargs: Any
) -> requests.Response:

timeoutretries 的默认值是仓库当前代码里的默认值,随版本可能变动,别把它当成契约。核心循环是这样:

    for attempt in range(retries):
        try:
            response = requests.request(method=method, url=url, timeout=timeout, **kwargs)
            response.raise_for_status()
            return response
        except RequestException as e:
            last_exception = e
            if attempt < retries - 1:
                # Exponential backoff could be added here
                continue
            raise

有几处必须看清楚:

第一,retries 是总调用次数,不是「失败之后再额外试几次」。 循环写的是 range(retries)tests/test_api_utils.pytest_retries_then_raises 用一个永远抛异常的假 requests.request 把这层语义钉死了:传 retries=3,断言底层被调用 3 次(这个 3 是测试里的参数值,不是什么固定上限)。

第二,raise_for_status() 抛出的错误也走重试分支。 它抛的是 HTTPError,属于 requests.exceptions.RequestException 的子类,而 except 捕的正是 RequestException。这意味着 429 会被重试——同时 401、403、404 这类重试多少次都不会变的错误,也一样会被完整地重试满次数。这个函数不看状态码,对它来说所有请求异常都是一个类别。

第三,退避没有实现。 那行注释逐字写的是 Exponential backoff could be added here——“could be added”,也就是当前没有。注释下面紧接着就是 continue,中间没有任何 sleep。对 429 而言,这等于把请求原速再打一遍。响应头里的 Retry-After 也没有被读取,整个文件里没有任何读取响应头的代码。

第四,中间几次的错误信息确实丢了。 last_exception = e 每轮覆盖一次,但最后一轮走的是裸 raise(重抛当前异常),函数末尾那句 raise last_exception or RequestException("Request failed") 上面写着注释 This should never be reached, but just in case。所以前面几次失败既没有日志、也没有被聚合进最终异常,你在栈里只会看到最后一次的样子——被限流时看起来就像「偶发失败一次」。

另外两个细节容易在照抄时踩到:method 的默认值是 "GET",你要发 POST 得显式传进去,否则重试三次发的都是 GET;其余参数走 **kwargs 原样透传给 requests.request,所以 headersjsonparams 这些照常可用,但也意味着这层封装不会替你检查它们。同文件里的 download_image 就是这么调的——只传了 timeoutretries 走默认值,然后 os.makedirs(os.path.dirname(save_path) or ".", exist_ok=True) 建目录再写文件。它是这个函数在仓库里唯一的真实调用点,做的是把图片下载下来存盘,和模型请求无关。

三、要补的东西补在哪一层

先说位置:补在调用点,不是把模型请求塞进 make_safe_request 那个函数是给 requests 发的普通 HTTP 请求用的(download_image 就是这个用途),模型调用走的是 SDK 客户端,套不进去。

真要在自己的封装里做,按仓库代码里已有的语义,缺的是三件事:按状态码分流(401 之类不重试)、失败之间加退避、读取 Retry-After。写成代码大致是:

import time
from requests.exceptions import HTTPError, RequestException

def request_with_backoff(url, retries=3, base_delay=1.0, **kwargs):
    for attempt in range(retries):
        try:
            resp = requests.request(method="GET", url=url, timeout=30, **kwargs)
            resp.raise_for_status()
            return resp
        except HTTPError as e:
            status = e.response.status_code if e.response is not None else None
            if status in (401, 403, 404) or attempt == retries - 1:
                raise
            delay = float(e.response.headers.get("Retry-After", base_delay * (2 ** attempt)))
            time.sleep(delay)
        except RequestException:
            if attempt == retries - 1:
                raise
            time.sleep(base_delay * (2 ** attempt))

以上为按仓库代码中的接口语义组合的示例,未经实测,以仓库最新代码为准。这段是通用做法,不是课程仓库的官方内容——仓库里并没有提供这样的实现,别把它当成课程的一部分引用。

还有两条容易把限流排查带偏的岔路,都在同一批文件里写着:

其一,06-text-generation-apps/README.md 写明,当前 Microsoft Foundry 上未废弃的模型是 reasoning 模型(GPT-5 家族、o 系列),它们不支持 temperature / top_p,也不支持 max_tokens(改用 max_output_tokens,传了会得到「参数不支持」的报错。如果你在加重试的同时顺手把采样参数也调了,一个限流问题会变成两个错误交叠。

其二,githubmodels- 这个文件名前缀已经名不副实。00-course-setup/03-providers.md06-text-generation-apps/python/githubmodels-app.py 的注释都逐字写明,GitHub Models 于 2026 年 7 月底退役,直接替代者是 Microsoft Foundry Models;样例读的环境变量也已经是 AZURE_INFERENCE_ENDPOINTAZURE_INFERENCE_CREDENTIAL。所以「三条路线怎么选」的第三条,现在实际是 Microsoft Foundry Models(原 GitHub Models 路线)。如果你还在按旧写法配 GITHUB_TOKEN,那报错根本不是限流。

四、改完怎么验证

验证重试逻辑不需要真的去打限流,tests/test_api_utils.py 已经给了范式:用 monkeypatch.setattr("shared.python.api_utils.requests.request", fake_request) 把底层请求换成计数器,再断言调用次数。你自己的封装照这个思路写一份即可——把假 request 换成按次序返回不同状态码的版本,断言 401 只被调用一次、429 被调用到上限。

Linux / macOS 侧照常 pip install -r requirements.txt 即可。Windows 侧多一步:00-course-setup/02-setup-local.md 的排查表里单列了一行 Windows 专属症状——pip 无法构建 wheel 时,先 pip install --upgrade pip setuptools wheel 再重试。另外 Windows 上 .env 与系统环境变量重名时容易搞混,验证时建议在调用点直接打印你实际读到的 endpoint 主机名(别打印 key),确认走的是你以为的那个 provider。

五、什么情况说明不是限流

最后一步别省,下面几种报错和速率没关系:

  • 抛的是 ValueError,信息里有 Missing required environment variable —— 这是 shared/python/env_utils.pyget_required_env 抛的,环境变量没配。
  • create_azure_openai_clientValueError,信息里是 endpoint 或 API key 缺失 —— tests/test_api_utils.py 里两条用例分别钉住了 AZURE_OPENAI_ENDPOINTAZURE_OPENAI_API_KEY 缺失这两种情形。顺带记一下这个函数的行为:它把 base_url 拼成 <endpoint>/openai/v1/,docstring 里写明因为用的是 v1 endpoint,所以不需要 api_version
  • 抛的是 ImportError,信息是 The 'openai' package is required —— 依赖没装,和网络无关。
  • 报的是参数不支持 —— 大概率是上面说的 reasoning 模型采样参数那一条。
  • 401 而不是 429 —— 凭证问题,重试再多次也是同一个结果。
  • 请求一直挂到超时才抛出、而不是很快带回一个 HTTP 状态码 —— 那是连接层面的事(网络、代理、证书),timeout 参数管的就是这一段,和服务端限流不是一回事。

分清这几类之后再决定要不要加退避,比一上来就把 retries 调大有用得多。


本文依据 github.com/microsoft/generative-ai-for-beginners 仓库于 2026-08-18 的公开内容整理, 事实来自仓库内的课程正文与代码示例。我们没有跑过文中涉及的代码, 因此不涉及运行结果、耗时与生成质量的任何描述。 该课程持续更新,文中涉及的文件路径、依赖与接口写法随版本变动,请以仓库最新内容为准。 文中涉及的云端服务调用会产生费用并可能上传数据,请自行评估密钥与数据边界。

安全与合规相关做法请结合自身环境评估,本文不构成安全方案建议。

想系统学会用 AI?报名体系课或加入会员,照着学、照着用。