微软生成式 AI 入门课的 api_utils.py:它替你挡了哪些麻烦

2026-08-18

照着课程第 06 章的 06-text-generation-apps/python/aoai-app.py 抄一段代码跑通,是很多人接触这个课程仓的第一步。那个文件很短:load_dotenv()OpenAI(...)client.responses.create(...)print。等你把这段抄进自己的项目、开始加下载图片、加重试、加”key 没配的时候给个人话提示”,就会发现这些琐事写起来一点也不少。

仓库里其实已经写了一份,在 shared/python/api_utils.py。这个文件只有四个函数,但每个函数挡掉的都是真会咬人的坑。下面沿着它的签名和异常分支走一遍——顺带说清一件事:课程各章的示例代码并没有 import 它。在仓库里搜 api_utils,除去 translations/ 下各语种译文里对同一份路线图文档的镜像,命中的只有 shared/python/__init__.pytests/test_api_utils.pydocs/ENHANCED_FEATURES_ROADMAP.mdCHANGELOG.md;在 .py.ipynb 里搜 shared.python,除了 shared/python/__init__.py 自身和 tests/ 下的用例,没有任何一课的代码引用它。所以它更像一份”参考实现”,而不是课程运行链路的一部分。

make_safe_request:超时是硬塞进去的

先看第一个函数的签名,原样抄自 shared/python/api_utils.py

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

timeoutretries 的这两个值是仓库当前代码里的默认值,随版本可能变动。关键不在数值,在于这个参数是没法省的——函数体里的调用写死成了 requests.request(method=method, url=url, timeout=timeout, **kwargs)timeout 一定会被传下去。你自己写调用时最容易漏的就是这个:漏了它,一个不返回的连接能把脚本挂在那儿不动。这个 wrapper 的第一层价值就是把”忘了传超时”这条路堵死。

第二层是 response.raise_for_status()。它被放在 try 内部,和 requests.request 共用同一个 except RequestException 分支。也就是说,从调用方看,网络层失败和 HTTP 状态码失败被收敛成了同一类事件,而不是”要么抛异常、要么返回一个 4xx 的 Response 让你自己判断”。

第三层是重试循环,这里有个语义值得看清:

for attempt in range(retries):
    try:
        ...
        return response
    except RequestException as e:
        last_exception = e
        if attempt < retries - 1:
            # Exponential backoff could be added here
            continue
        raise

retries总尝试次数,不是”首次之外额外重试几次”。range(retries) 循环三轮就是三次请求,不是四次。这一点在测试里被显式钉住了,等下会看到。

签名末尾的 **kwargs: Any 是留给调用方的口子:headersparamsjson 这些都从这里透传给底层调用。注意函数体里对 methodurltimeout 用的全是关键字传参,这也解释了为什么测试里那个假函数能写成 def fake_request(method, url, timeout, **kwargs) 而不出错。

另外那行注释是仓库自己写的:指数退避”可以加在这里”——换句话说,退避目前只是注释里的一个”可以加”,函数体里 except 分支只有 last_exception = econtinue,没有任何 sleep 一类的等待调用。这不是缺陷判断,只是照实说明代码现在的样子——你把它抄进自己的项目、又需要退避的话,注释已经把该动的位置指给你了。

函数末尾还有一行:

# This should never be reached, but just in case
raise last_exception or RequestException("Request failed")

按上面的循环结构,最后一轮 except 里已经 raise 了,正常路径走不到这一行。作者自己也在注释里这么说了。

两个客户端构造函数:报错信息比返回值重要

create_openai_client(api_key: str | None = None)create_azure_openai_client(endpoint: str | None = None, api_key: str | None = None) 做的事很像,都是三段式:延迟 import、取凭据、构造 client。

延迟 import 这段两个函数是一样的写法——from openai import OpenAI 放在函数体内的 try 里,except ImportError as e 之后重新抛出一个带安装提示的 ImportError(...) from e。好处是这个模块本身可以在没装 openai 的环境里被导入(比如只想用 make_safe_request 的场景),坏处是错误发生的时机从”导入模块时”推迟到了”第一次建 client 时”。

取凭据的写法是 key = api_key or os.getenv("OPENAI_API_KEY"),Azure 那边对应 AZURE_OPENAI_ENDPOINTAZURE_OPENAI_API_KEY 两个变量,缺哪个抛哪个的 ValueError,错误文案里直接写了变量名,还给了第二条出路——Set OPENAI_API_KEY environment variable or pass api_key parameter.,Azure 两条对应的文案是同一个句式。顺带说一处对照:同目录 shared/python/env_utils.py 里的 get_required_env 抛的是另一种口径——Please set it in your .env file or environment.,提到了 .env 文件。同一个包里两套报错措辞,指的是两条不同的取值路径,下一段就是这件事咬人的地方。

这里有个 Windows 用户特别容易撞的坑,值得单独说。 api_utils.py 全文没有 dotenv 的 import,它只调 os.getenv,读的是进程环境变量。而课程示例 aoai-app.py 开头是 load_dotenv(),读的是项目里的 .env 文件(模板见仓库根的 .env.copy)。这两处摆在一起就清楚了:你把 key 写进 .env 之后直接调 create_openai_client(),如果自己没先调 load_dotenv(),这个函数是看不到那个 key 的,只会抛 ValueError。上一段那两套报错文案的差别在这里就有了实际后果:env_utils.py 的文案提了 .env 文件,容易让人以为这个包会自己去读它,而 api_utils.py 这两个函数并不读。按代码写明的语义,可行的用法只有两种——自己先调 load_dotenv()(像 aoai-app.py 那样),或者显式把 key 作为参数传进去。至于把变量放进 .env 还是导出到当前 shell 会话,那是各人的环境习惯,属于通用做法、不是这个仓库规定的东西,仓库只提供了 .env.copy 这份模板。

Azure 那个函数的 base_url 是这么拼的:

return OpenAI(
    api_key=_api_key,
    base_url=f"{_endpoint.rstrip('/')}/openai/v1/",
)

rstrip('/') 是在兜”我从门户上复制的 endpoint 末尾带不带斜杠”这个老问题。docstring 里还写明了一句关键信息:因为用的是 Azure OpenAI v1 端点(<endpoint>/openai/v1/,它承载 Responses API),所以不需要 api_version。这跟第 06 章 aoai-app.py 里内联那段是同一套逻辑,连 rstrip('/') 都一样——区别只在示例用 os.environ['...'](缺变量直接 KeyError),helper 用 os.getenv 加人话报错。

顺带交代一层容易踩的:这两个函数的 docstring 示例都是 client.responses.create(model="gpt-5-mini", input="Hello")gpt-5-mini 只是仓库示例里的取值。CHANGELOG.md 里 2026-07-14 那条记着,课程把默认对话模型换到 GPT-5 家族时,连带改了 shared/python/api_utils.py 的 docstring 示例。同一条还写明:这类 reasoning 模型不支持 temperature / top_p,也不用 max_tokens——Responses API 用 max_output_tokens,chat completions 用 max_completion_tokens。所以拿这个 helper 返回的 client 去传老写法的采样参数,是会被拒的。想演示 temperature,仓库的做法是另配一个非 reasoning 模型(.env.copy 里为此加了 AZURE_INFERENCE_CHAT_MODEL)。

还有一条别搞混:这个文件只封装了 OpenAI 与 Azure OpenAI 两条路线,课程里的第三条路线在这里没有对应的 helper。而且那条路线的目录前缀 githubmodels- 现在已经名不副实了——06-text-generation-apps/python/githubmodels-app.py 的注释逐字写着 GitHub Models 将在 2026 年 7 月底退役,00-course-setup/03-providers.md 也多处写明 Microsoft Foundry Models 是它的直接替代者。今天这个时间点已经过去了,实际要配的是 Foundry 的 endpoint 与 key(该文件读的是 AZURE_INFERENCE_ENDPOINTAZURE_INFERENCE_CREDENTIAL),用的还是 azure-ai-inferenceChatCompletionsClient,跟 api_utils.py 里的 OpenAI SDK 客户端不是同一套。

download_image:一个没被导出也没被测的函数

第四个函数 download_image(url: str, save_path: str, timeout: int = 30) -> str 复用了 make_safe_request,然后是这两行:

os.makedirs(os.path.dirname(save_path) or ".", exist_ok=True)

or "." 处理的是 save_path 只给了个文件名、dirname 返回空串的情况——没有这个兜底,os.makedirs("") 会炸。这类细节正是抄 wrapper 比自己重写划算的地方。

但把两处放在一起会发现件事:shared/python/__init__.py__all__ 里列了 make_safe_requestcreate_openai_clientcreate_azure_openai_client没有 download_imageenv_utils.pyget_env_with_default 也同样没被导出)。它在模块文件里存在,但没进包的公开清单,也没有对应的测试。要用它得从 shared.python.api_utils 直接导入。

测试覆盖到哪一步

tests/test_api_utils.py 分成三个测试类:TestMakeSafeRequestTestCreateOpenAIClientTestCreateAzureOpenAIClient,一共五个测试函数。它的手法值得学:不发真请求,用 monkeypatch.setattr("shared.python.api_utils.requests.request", fake_request) 把模块内引用的 requests.request 换掉;响应对象是自己写的 _FakeResponse,只实现了一个 raise_for_status,被调用时把 self.raisedTrue

成功路径那条测的是三件事:返回的就是那个 fake 对象、fake.raised is True(证明 raise_for_status 确实被调过)、以及 fake 函数里那句 assert timeout == 30(证明默认超时确实传下去了)。重试路径那条更直接:让 fake 每次都抛 RequestException,然后断言 calls["count"] == 3——前面说的”retries 是总尝试次数”就是被这一行钉死的。

两个客户端构造函数只测了”缺参数”的分支:删掉环境变量,pytest.raises(ValueError, match="API key") / match="endpoint"。每个用例开头都有 pytest.importorskip("openai"),本地没装 openai 时会跳过;CI 里 .github/workflows/code-quality.ymlpython-tests job 显式装了 pytest openai requests python-dotenv,所以不会跳。

也就是说,没被覆盖的分支包括:download_image 完全没测;成功建出 client 的路径没测(那要真凭据);raise_for_status 抛异常导致重试这条路没测,因为 _FakeResponse 的实现根本不抛。心里有数就行。

想跑的话,配置在 pyproject.toml[tool.pytest.ini_options] 里,testpaths = ["tests"]。CI 里那行是 pytest tests/tests/conftest.py 的 docstring 自述了它的用途:把仓库根目录插进 sys.path,让 shared.python 这个包从任何工作目录都能解析——它用 Path(__file__).resolve().parent.parent 算根目录,再 sys.path.insert(0, ...),走的是 pathlib 而不是手拼字符串路径。

最后提醒一句老生常谈:这个课程仓一直在更新,上面提到的函数签名、默认值、__all__ 的内容和 CI 配置都可能随版本变,用之前打开对应文件看一眼当前的样子。


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

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

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