跑大模型 API 的单元测试时,测试还没发出请求,就在读取密钥环境变量的那一行抛出了 KeyError;如果把密钥临时写进代码,用例又会真的访问 HTTPS,慢而且不稳定。根因通常不是测试框架,而是被测代码把客户端创建与网络调用绑得太紧。读完这篇,你能拿到一套可复现的 mock 方案:使用真实接入参数创建客户端,同时把网络调用锁在测试桩里,不让用例产生真实请求。
1 场景与前置条件
这篇教程解决的不是"怎么调用大模型接口",而是"调用大模型 API 的代码怎么做单元测试"。不少项目已经把 SDK 封装成 complete(prompt) 一类函数,测试时却绕不开真实网络。你手上应该已经有 Python 3.10+、OpenAI SDK 1.x、pytest、一个 sk_live_ 开头的密钥,以及从模型广场复制来的调用名,例如 glm-4.6。
环境限制要说清。本文示例只覆盖 OpenAI 兼容的 /chat/completions,Base URL 使用该平台提供的固定地址。如果你走 Anthropic 兼容端点 /messages,请求体必须传 max_tokens,测试桩目标也要从 chat.completions.create 改成 messages.create。思路一致,但字段不同,别直接套用。
2 环境准备
先装依赖:
bash
pip install openai pytest python-dotenv
python-dotenv 可选,不是运行硬条件。密钥不要写进代码,放到 .env 并让 .gitignore 包含这一项。测试前导出环境变量:
bash
export SILVAMUX_API_KEY="sk_live_xxxx"
为什么不用硬编码?密钥是创建时只显示一次的凭证,提交进仓库等于把凭证交给所有看得到代码的人。CI 环境可以注入同名变量,测试代码不需要变化。Base URL 也不要从配置文件里随手编,OpenAI SDK 会在这个地址后面自动拼接 /chat/completions,所以必须写完整的 /api/v1 这段。
3 最小可跑示例
先创建 llm_client.py:
python
from openai import OpenAI
import os
client = OpenAI(
api_key=os.environ["SILVAMUX_API_KEY"],
base_url="https://www.silvamux.com/api/v1",
)
def complete(prompt: str, model: str = "glm-4.6") -> str:
resp = client.chat.completions.create(
model=model,
messages=[{"role": "user", "content": prompt}],
)
return resp.choices[0].message.content
这段代码的关键是 api_key 和 base_url。它们决定客户端能不能走到兼容端点。model 必须填模型广场详情页里的稳定调用名,不要根据展示名自行拼成 glm-4.6-chat,那样会返回 gateway_error。messages 是列表,哪怕只传一句 prompt,也要写成 [{"role": "user", "content": prompt}],直接传字符串会触发参数校验错误。
接着写测试 test_llm_client.py:
python
from unittest.mock import patch, MagicMock
import llm_client
def test_complete_mocks_http():
fake_resp = MagicMock()
fake_resp.choices[0].message.content = "mock reply"
with patch.object(llm_client.client.chat.completions, "create", return_value=fake_resp):
result = llm_client.complete("hello")
assert result == "mock reply"
这个测试不会有任何网络请求。patch.object 替换的是已经创建好的 client.chat.completions.create,不是 openai.OpenAI 类。如果你 patch 类方法,模块导入时已经存在的实例不会被影响,这是单测 mock 常见的失败点。要盯住 patch 目标。
4 逐步扩展
第一处扩展:把 client 作为参数注入,别再让函数依赖模块级全局对象。
python
def complete(client, prompt: str, model: str = "glm-4.6") -> str:
resp = client.chat.completions.create(
model=model,
messages=[{"role": "user", "content": prompt}],
)
return resp.choices[0].message.content
改完后,测试直接传一个 stub,不需要关心 client 在哪创建。修改前,测试必须知道 llm_client.client 这个内部属性;修改后,函数签名更清楚,也更容易替换成异步客户端或假对象。这个改动没有改变调用协议。
第二处扩展:处理流式返回里的空 choices chunk。很多大模型 API 的流式接口在结束前会返回一个只有 usage 字段、没有 choices 的 chunk,代码如果直接访问 chunk.choices[0],最后会抛 IndexError。写一个能跳过该 chunk 的版本:
python
def stream_complete(client, prompt: str, model: str = "glm-4.6"):
with client.chat.completions.create(
model=model,
messages=[{"role": "user", "content": prompt}],
stream=True,
) as stream:
for chunk in stream:
if chunk.choices and chunk.choices[0].delta.content:
yield chunk.choices[0].delta.content
测试时用两个假 chunk 模拟:第一个正常带 delta.content,第二个 choices=[] 表示用量通知。断言结果只包含第一段内容。if chunk.choices 这一行不能省,省略后错误只在长响应里出现,平时短响应可能测不出来。
5 排错
KeyError: 'SILVAMUX_API_KEY'
直接原因:导入模块时,os.environ[...] 没读到环境变量。修法:先按环境准备里的方式导出,或在测试启动前用 monkeypatch 给 os.environ 注入同名键。不要临时把密钥写进源码,那样会让 mock 失去意义。
openai.APIConnectionError: Connection error.
直接原因:Base URL 填得不完整,SDK 连接不到目标路径。修法:回到 llm_client.py 检查 Base URL,补齐 www 和 /api/v1 两段。不要只查 API key,这个报错先于认证发生。
{"error":{"message":"model not found","type":"gateway_error","code":"MODEL_NOT_FOUND"}}
直接原因:model 参数不在模型调用名列表里,常见于把展示名自动拼成带版本后缀的字符串。修法:打开模型广场详情页,复制稳定 alias,不要自己造。
mock 后仍发出真实请求
直接原因:patch 目标写成了 openai.OpenAI,但模块里已经创建好客户端实例。修法:patch llm_client.client.chat.completions.create,或者采用上面扩展一里的依赖注入,让测试不依赖全局对象。
常见问题
Q: 能不能完全不设置那个密钥环境变量跑单元测试?
A: 可以。把读取环境变量的代码改成 os.getenv 并提供默认值,测试前把 create 方法 mock 掉即可。客户端初始化阶段通常不会校验密钥,只有真正发出请求时才可能返回 401,所以纯本地单测不依赖真实凭证。
Q: 为什么流式测试里会多一个空的 choices chunk?
A: 这是 OpenAI 兼容端点在流末尾返回 usage 的行为,choices 可能是空列表。代码必须用 if chunk.choices 跳过,不能只依赖 data: [DONE] 结束符判断,因为 SDK 已经把协议解析成 chunk 对象,循环里会更早遇到这个空块。
Q: 想 mock Anthropic 兼容端点的请求,应该改哪里?
A: 把调用从 client.chat.completions.create 改成 client.messages.create,请求体里必须传 max_tokens,字段形状也不同。测试桩的目标也跟着换,断言参数时注意 content 是列表而不是字符串,不能照搬 /chat/completions 的断言。
以上示例在千木SilvaMux的 OpenAI 兼容端点上验证,模型调用名为 glm-4.6。流式判断参考该端点的 usage chunk 行为。具体模型调用名和错误处理参数以模型广场与错误页为准。
