DeepSeek API 图像与文本分类调用实战:环境、请求与排错
简介:面向具备Python基础、希望将云端机器学习服务接入实际项目的开发者,这份docx文档聚焦DeepSeek API在图像分类与文本分类两类任务中的调用方法。内容围绕注册获取API密钥、安装requests与Pillow、构造POST请求及设置Authorization请求头展开,并结合代码示例说明图像上传、JSON文本提交、响应解析与错误排查的完整流程,可帮助读者理解HTTP接口调用与分类结果字段提取。资源包共1个docx文件,约17KB,以步骤说明与代码片段结合的方式组织,便于按章节查阅和本地实践。目前已有2676人学习,说明其在API接入类内容中具有一定参考价值。读者可从中获得两类分类任务的请求构造思路、图像尺寸预处理与结果解析方法,以及遇到无效密钥、请求格式错误时的基本排查方向,适合用于快速搭建图像或文本分类应用原型。
1. 一张 3MB 的相机原图,让我在 DeepSeek API 调用上卡了四十分钟
第一次跑 DeepSeek API 调用,代码不到三十行,环境也就 Python 加 requests、Pillow 两个库,我却在一张 3MB 的相机原图上卡了四十多分钟:接口一直回 400,日志只有一句参数错误。图像分类和文本分类这两条链路装起来不复杂,难的是版本、编码、请求体三处对不齐——requests 的 files 和 json 不能混着传,Pillow 读出来的 RGB 和带 alpha 的 PNG 不是一回事,密钥写进环境变量也可能带个换行。这套环境适合会写 Python、想把分类能力接进原型的开发者;装不好的典型症状是本地能跑、换台机器就 401,或者换张图就 413。下面按我实际装机的顺序来。
2. 装环境先定版:Python、requests、Pillow 的安装与版本取舍
2.1 用虚拟环境把 Python 版本锁在 3.10~3.12
这件事我吃过亏。系统自带的 Python 3.8 上装 Pillow,pip 会退到一个很老的版本去编译,装完Image.resize的默认重采样行为和新版本不一致,同一张图两次跑出来的信心分数差了好几个点,排查了半天才发现是解释器版本问题。所以现在我的做法很固定:解释器落在 3.10~3.12 这个区间。下限 3.10 是因为大部分二进制 wheel 在这个版本上覆盖最全,装 Pillow 不会触发源码编译;上限不碰 3.13 及更新版本,是因为新解释器刚出来那阵,轮子生态往往要慢半拍,为一个分类脚本去折腾编译链不值当。
装之前先确认手上是哪个解释器,别想当然。多版本共存的机器上,python和python3指向的可能不是同一个东西,后面所有命令我都统一用python -m pip这种模块调用写法,避开 PATH 里的歧义。
# 先确认解释器版本,低于 3.9 直接换,别硬装 python3 -V # 在项目目录下建独立环境,避免与系统包互相污染 python3 -m venv .venv # 激活:Linux / macOS source .venv/bin/activate # 激活:Windows PowerShell # .venv\Scripts\Activate.ps1 # 升级 pip 本身,老 pip 解析依赖时给出的冲突提示经常是误导 python -m pip install --upgrade pipvenv生成的目录里带的是一份独立的 site-packages,装坏了直接rm -rf .venv重建,不会波及系统环境,这是它比pip install --user更值得用的原因。激活成功的标志是命令行提示符前面出现(.venv),如果没出现,先别急着往下装,多半是激活脚本没跑对。
Windows 上这一步最容易翻车:PowerShell 默认执行策略禁止运行脚本,Activate.ps1会报「无法加载文件,因为在此系统上禁止运行脚本」。解决方式不是去改全局策略,而是在当前窗口临时放开:
# 只对当前进程生效,关掉窗口就恢复,比改全局策略安全 Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass .\.venv\Scripts\Activate.ps1如果连 PowerShell 都不想动,直接用.venv\Scripts\python.exe这个绝对路径去跑脚本,效果一样,只是命令长一点。我一般会把这个路径记在便签上,出问题时用它来确认「到底是环境没激活,还是包真没装上」。
2.2 requests 与 Pillow 的安装命令和版本钉法
两个库的安装命令本身没什么可说的,值得说的是版本怎么写。完全放开版本约束(pip install requests Pillow)在个人机器上没事,一旦进了 CI 或者换了台机器,拉到的可能是半年前的大版本,行为差异足够让你怀疑人生。我习惯用「下限 + 上限」的区间写法:下限保证用到的能力存在,上限挡住破坏性变更。
# requests 2.31 起 timeout 元组、Session 复用行为稳定 # Pillow 10 起 resize 的重采样常量换成了 Image.Resampling,老代码要留意 python -m pip install "requests>=2.31,<3" "Pillow>=10.0,<12"requests>=2.31这个下限不是随便定的:早期版本在连接超时和读取超时的拆分上行为不够清晰,而我们后面要传timeout=(5, 30)这种元组,老版本对元组的支持不完全一致,容易出现「设了 30 秒却卡了两分钟」的怪现象。上限<3则是挡住 requests 下一个大版本,避免某天自动升级后请求行为变化。Pillow 的<12同理,图片处理库的大版本改动往往集中在编码器和色彩模式转换上,正是我们要用的部分。
装完立刻验证,别等写代码时才发现装了个空壳:
# 打印实际版本,确认装的是 venv 里的那一份 python -c "import requests, PIL; print('requests', requests.__version__); print('Pillow', PIL.__version__)"如果这一步报ModuleNotFoundError,九成是环境没激活,装到系统 Python 里去了,用python -c "import sys; print(sys.executable)"看一眼解释器路径就能确认。Pillow 装不上是另一类问题,Linux 上常见于缺libjpeg、zlib这些系统库,导致 pip 回退到源码编译然后失败;Windows 上则是缺 C 编译器。我的处理顺序是:先升级 pip,再强制只用预编译轮子,最后才考虑补系统库。
# 强制只用二进制轮子,装不上就立刻报错,而不是默默去编译 python -m pip install --only-binary=:all: "Pillow>=10.0,<12" # Debian / Ubuntu 上确实需要源码编译时,先补齐这几个开发库 # sudo apt-get install -y libjpeg-dev zlib1g-dev libfreetype6-dev--only-binary=:all:这个参数的价值在于把「静默编译十分钟然后失败」变成「三秒内告诉你没有轮子」,省掉大量等待。真碰上必须编译的场景,再回头补系统库,顺序别搞反。
2.3 requirements.txt 与装坏了的回滚
单人调试可以随手pip install,但只要这套环境要在第二台机器上复现,就该有一份文件把依赖写死。我的做法是两层:requirements.txt写区间约束,给人看、给新机器装;requirements.lock.txt写精确版本,给自己回滚用。
# requirements.txt —— 区间约束,允许小版本漂移 requests>=2.31,<3 Pillow>=10.0,<12# 按约束装 python -m pip install -r requirements.txt # 装完后把当前精确版本冻结下来,作为可回滚的快照 python -m pip freeze > requirements.lock.txtfreeze出来的文件包含所有传递依赖的精确版本,哪天某个库自动升级把调用弄挂了,python -m pip install -r requirements.lock.txt就能一键回到装好那天的状态。这是这套环境里唯一的「后悔药」,成本几乎为零,但真出事的时候能省掉一小时。
再说一个很常见的坏味道:同一个包在系统目录和 venv 里各装了一份,运行时到底加载的哪份全靠运气。判断依据就是sys.executable和sys.path,python -c "import requests; print(requests.__file__)"打印出的路径必须落在.venv目录下,落在/usr/lib/python3/dist-packages之类的地方就说明串了。出现这种情况,先deactivate再重新激活,还不行就把.venv删了重建,比重装包快得多。
提示:不要在激活失败的状态下反复
pip install,那只会往系统环境里堆垃圾,让后续判断更难。
3. 配置收口:API Key、请求 URL、Headers 三处必须对齐
3.1 API Key 进环境变量,别写死在第十七行
密钥硬编码在脚本里,本地跑着没事,一旦提交到仓库、发给同事、贴进工单,就是一次标准的泄露。我现在的规矩是:代码里永远只有一个读取函数,密钥只从环境变量来。
# Linux / macOS:当前会话有效,关掉终端就没了,适合临时调试 export DEEPSEEK_API_KEY="你的密钥" # 需要长期生效,写进 shell 配置文件后再 source 一次 echo 'export DEEPSEEK_API_KEY="你的密钥"' >> ~/.zshrc && source ~/.zshrc# Windows:当前窗口有效 $env:DEEPSEEK_API_KEY = "你的密钥" # 永久写入用户环境变量,注意要重开终端才生效 setx DEEPSEEK_API_KEY "你的密钥"读取这一端我写了个带检查的函数,比直接os.environ["DEEPSEEK_API_KEY"]多做三件事:去掉首尾空白、拦掉空值、把长度异常的情况打出来。别小看这几行,密钥从聊天窗口复制过来尾部带一个换行,Authorization头就会变成Bearer sk-xxx\n,服务端返回的往往只是一句笼统的「Invalid API key」,肉眼根本看不出来。
import os def load_api_key() -> str: raw = os.environ.get("DEEPSEEK_API_KEY", "") key = raw.strip() # 复制粘贴最容易带进来的换行和空格 if not key: raise RuntimeError("DEEPSEEK_API_KEY 未设置,先 export 再运行") if raw != key or "\n" in raw: # 只提示异常,不打印密钥本身 print("警告:密钥首尾含空白字符,已自动清理") return key这里用.get()而不是下标取值,是为了自己抛出可读的错误信息;KeyError: 'DEEPSEEK_API_KEY'这种报错对新手很不友好,看不出是环境变量的问题还是代码的问题。另外,密钥的长度和前缀可以打印,内容绝不能进日志,出问题时我只看len(key)和前四位。
配套要做的是把本地环境文件挡在版本控制之外。哪怕你用的是.env方案,也先加一条忽略规则:
# .gitignore .venv/ .env *.log3.2 URL 与 Content-Type:图像走 files,文本走 json
原文给了两个地址,一个是/v1/classify,一个是/v1/text_classify,并且明确写了「这只是示例,具体 URL 请查阅文档」。这一点必须原样保留:接口路径以官方文档为准,本文里的地址只用来演示拼接方式。
真正决定请求能不能通的,是「请求体形态」和「Content-Type」这一对必须匹配。图像上传走multipart/form-data,用files=参数;文本分类走application/json,用json=参数。两者混用是新手最常见的翻车点。
| 任务类型 | 传参方式 | Content-Type | 是否手动设置 | 备注 |
|---|---|---|---|---|
| 图像分类 | files={"image": (...)} | multipart/form-data; boundary=... | 否,交给 requests 生成 | 手动设成 json 会直接 400/415 |
| 文本分类 | json={"text": "..."} | application/json | 否,json=会自动带 | 自己dumps时注意编码 |
| 通用 | 请求头 | Authorization: Bearer <key> | 是 | 两种任务都要 |
我特意在表里写了「是否手动设置」这一列,因为原文示例里 headers 同时带了Authorization和Content-Type: application/json,而请求用的又是files=。这两者在实际发送时是冲突的:multipart必须带 boundary 参数,手写的application/json会把这个 boundary 覆盖掉,服务端解析不出文件字段。我的做法是只保留Authorization,剩下的交给库。
提示:接口路径和字段名以官方文档当前版本为准,示例地址只用于说明请求结构,不要当成固定值抄进生产代码。
还有个小细节是 URL 拼接。自己拼 base_url 时,https://api.deepseek.com/v1后面接/classify和接classify结果完全不同,前者正常,后者变成v1classify。下面这个写法能同时容错带斜杠和不带斜杠两种配置:
def join_url(base: str, path: str) -> str: # 统一去掉重复斜杠,避免 v1//classify 这种 404 return base.rstrip("/") + "/" + path.lstrip("/")3.3 一份 config.py 把配置收口,再抽一个 call 函数
配置散落在各个脚本里,改一次地址要翻五个文件,这种事干过一次就够了。我习惯在项目根目录放一个config.py,把三样东西收进去:密钥、基地址、超时。超时写成元组是我踩过坑之后固定下来的习惯,(5, 30)表示连接阶段最多等 5 秒、读完响应最多等 30 秒;只写一个数字的话,连接阶段和读取阶段共用一个值,遇到大图上传时很容易在建立连接阶段就超时。
# config.py import os API_KEY = os.environ.get("DEEPSEEK_API_KEY", "").strip() BASE_URL = os.environ.get("DEEPSEEK_BASE_URL", "https://api.deepseek.com/v1") TIMEOUT = (5, 30) # (连接超时, 读取超时),单位秒 MAX_RETRIES = 3在这个基础上再抽一个统一的调用函数,把鉴权头、超时、重试、日志一次性做掉。中间章里我最推荐先写的就是这个函数,后面图像和文本两条链路都复用它,代码量能砍掉一半。
# client.py import logging, time, requests from config import API_KEY, BASE_URL, TIMEOUT, MAX_RETRIES log = logging.getLogger("deepseek") def call(path: str, *, json_body=None, files_factory=None, retries=MAX_RETRIES): url = BASE_URL.rstrip("/") + "/" + path.lstrip("/") headers = {"Authorization": f"Bearer {API_KEY}"} for attempt in range(1, retries + 1): files = files_factory() if files_factory else None # 每次重试重新开文件 try: resp = requests.post(url, headers=headers, json=json_body, files=files, timeout=TIMEOUT) if resp.status_code == 429 or resp.status_code >= 500: raise requests.HTTPError(f"可重试状态码 {resp.status_code}") return resp except (requests.Timeout, requests.ConnectionError, requests.HTTPError) as e: if attempt == retries: raise wait = 2 ** (attempt - 1) # 1s、2s、4s 退避 log.warning("第 %s 次失败:%s,%s 秒后重试", attempt, e, wait) time.sleep(wait)几个参数值得单独说。files_factory传进来的是函数而不是文件对象,因为文件流读过一次就到底了,重试时再用同一个对象上传,服务端收到的会是空 body——这个坑我在第 5 章还会展开。2 ** (attempt - 1)是标准的指数退避,第一次失败等 1 秒、第二次 2 秒、第三次 4 秒,比固定间隔更不容易把限速撞得更狠。429和5xx单独判出来重试,4xx里的 400、401、413 则原样返回,因为这几类重试一百次也没用,属于请求本身有问题。
日志这块我一般只记三样:请求路径、状态码、耗时。请求体和密钥不进日志。有次为了查一个 400 把完整请求打了进去,结果日志被同事顺手贴到公开频道,密钥就这么泄了,从那以后这条规矩我没破过。
4. 两类请求跑通:图像分类的 files 上传与文本分类的 json 提交
4.1 Pillow 预处理三件事:尺寸、色彩模式、体积
模型对输入的要求通常写得比较含糊——「适当尺寸」「常见格式」。落到代码上就是三件事:把图缩到固定尺寸、统一成三通道、把文件压到合理体积。原文示例用的是 224×224,并且用了Image.resize,这个思路是对的,只是少了两个必要步骤。
第一是色彩模式。手机截图、设计稿导出的 PNG 常常带 alpha 通道,模式是RGBA,直接存成 JPEG 会抛OSError: cannot write mode RGBA as JPEG。第二是 EXIF 方向,手机拍的竖图在像素层面可能是横的,靠 EXIF 标记旋转,不处理的话上传后分类结果会莫名其妙。第三是体积,3MB 的原图缩到 224 之后通常只剩几十 KB,这一步本身就把 413 挡掉了大半。
from PIL import Image, ImageOps def prepare_image(src: str, dst: str, size=(224, 224)) -> str: with Image.open(src) as im: im = ImageOps.exif_transpose(im) # 按 EXIF 把方向摆正 im = im.convert("RGB") # RGBA / P 模式统一成三通道 im = im.resize(size, Image.LANCZOS) # 高质量下采样 im.save(dst, format="JPEG", quality=88, optimize=True) return dstImage.LANCZOS是缩小时最稳的重采样方式,比默认的BICUBIC慢一点,但边缘更干净,对分类精度有小幅正向影响。quality=88是我反复试出来的平衡点,再往上体积涨得快、肉眼与模型都分辨不出;optimize=True会额外做一遍无损压缩,多花几十毫秒换更小的文件。注意 Pillow 10 之后常量搬到了Image.Resampling.LANCZOS,老代码里的Image.ANTIALIAS已经删了,如果搜到的是老教程,照着抄会直接报AttributeError。
我一般还会加一道体积闸门,超过阈值先压再传,而不是等接口回 413:
import os def ensure_under(path: str, limit_mb: float = 1.0) -> str: # 超过限制就降质量重存,最多降三档 if os.path.getsize(path) <= limit_mb * 1024 * 1024: return path from PIL import Image with Image.open(path) as im: for q in (70, 55, 40): im.save(path, format="JPEG", quality=q, optimize=True) if os.path.getsize(path) <= limit_mb * 1024 * 1024: break return path4.2 图像请求:requests.post 的 headers 与 files 组合
到这一步才真正发请求。图像上传的关键是files参数的三元组写法:字段名、文件名、MIME 类型。字段名必须和接口文档一致,示例里是image;文件名影响不大,但最好带上正确扩展名,某些服务端会据此判断格式;MIME 类型写image/jpeg要和实际存的文件格式对上。
import requests from config import API_KEY, BASE_URL, TIMEOUT def classify_image(image_path: str) -> dict: url = BASE_URL.rstrip("/") + "/classify" # 路径以官方文档为准 headers = {"Authorization": f"Bearer {API_KEY}"} # 不要手写 Content-Type with open(image_path, "rb") as f: files = {"image": ("image.jpg", f, "image/jpeg")} resp = requests.post(url, headers=headers, files=files, timeout=TIMEOUT) if resp.status_code != 200: raise RuntimeError(f"HTTP {resp.status_code}: {resp.text[:200]}") return resp.json()用with open(...)是必须的,不关文件句柄在批量处理几千张图时会直接把文件描述符耗光,报Too many open files。headers里只留鉴权,Content-Type完全交给 requests——它在发files时会自动生成multipart/form-data; boundary=----WebKitFormBoundary...,这个 boundary 是随机串,手写不可能对。
还有个常见误区是「先把图片读成字节流塞进 JSON」。原文正文里提到「通常图像数据会作为文件上传,而非直接放入 JSON 中」,这句话是对的,二进制转 base64 再塞 JSON 会让体积膨胀三分之一,还得自己处理编码,没有任何好处。除非接口文档明确要求 base64,否则一律走files。
4.3 文本请求:json= 参数与中文编码
文本这条链路简单得多,一个json=参数就完事。它的好处是内部会自动做序列化并设置正确的Content-Type: application/json,不用你操心。需要留意的只有编码:中文文本如果自己json.dumps再传,ensure_ascii默认是True,会被转成一堆\uXXXX,虽然服务端通常能解析,但日志里完全没法看。
def classify_text(text: str) -> dict: url = BASE_URL.rstrip("/") + "/text_classify" # 路径以官方文档为准 headers = {"Authorization": f"Bearer {API_KEY}"} # 直接用 json=,requests 负责序列化和 Content-Type resp = requests.post(url, headers=headers, json={"text": text}, timeout=TIMEOUT) if resp.status_code != 200: raise RuntimeError(f"HTTP {resp.status_code}: {resp.text[:200]}") return resp.json()如果你因为某些原因必须手动序列化(比如要复用已经构造好的字符串),写法是data=json.dumps(payload, ensure_ascii=False).encode("utf-8"),并且手动补Content-Type: application/json; charset=utf-8。少了charset那一截,部分服务端会按 latin-1 解析,中文直接变乱码。这就是我说的「编码对不齐」的具体形态,肉眼看到的是一堆问号,实际是字符集声明缺失。
4.4 响应解析:label 与 confidence 怎么用
接口成功时返回的 JSON 结构是这样的:
{ "status": "success", "data": { "label": "cat", "confidence": 0.98 } }label是分类标签,confidence是置信度,0.98 表示模型有 98% 的把握。解析代码要防两件事:一是data字段缺失,二是 HTTP 200 但业务层返回失败。后者是最阴的,因为resp.status_code == 200会骗过所有只看状态码的代码。
def parse_result(body: dict, threshold: float = 0.6): if body.get("status") != "success": # 业务层失败也会是 200,必须单独判 raise RuntimeError(f"接口返回失败:{body.get('message', '未知原因')}") data = body.get("data") or {} label = data.get("label") conf = data.get("confidence") if label is None or conf is None: raise ValueError(f"响应结构不符合预期:{body}") conf = float(conf) return {"label": label, "confidence": conf, "reliable": conf >= threshold}threshold=0.6是我在业务里常用的默认值,低于这个分数的结果我一般会转人工复核,而不是直接写进下游系统。这个值没有标准答案,看你的容错成本——分类错了只是显示个图标,阈值可以放到 0.5;错了要触发扣款或者告警,那就调到 0.8 以上。把reliable这个布尔值单独返回,比在调用方到处写if conf > 0.6更好维护。
5. 排查与避坑:五条踩过的真实记录
5.1 请求阶段的三条
第一条:本地能跑,换台机器就 401。
现象是同一份代码在同事机器上始终返回Invalid API key,日志里看不出任何异常。原因有两个层面:一是新机器上环境变量根本没设,os.environ.get返回空字符串,拼出来的头变成Bearer,服务端自然拒绝;二是用setx在 Windows 上设了变量但没重开终端,当前进程读到的还是旧值。解决办法是把密钥检查前置成启动时的断言,跑主流程之前先打印len(key),长度为 0 就直接退出并提示怎么设置,而不是让它走到请求里变成一句含糊的鉴权失败。
第二条:图像请求返回 400 或 415,文本请求却正常。
现象是同一个call函数,文本分类跑得通,图像分类一直报格式错误。原因是请求头里手写了Content-Type: application/json,同时又传了files=,两者冲突,服务端收到的 multipart 缺少 boundary,解析不出文件字段。解决就是图像这条路径的 headers 里只留Authorization,Content-Type一律交给 requests 生成。判断是否属于这一类,看resp.text里有没有boundary或者multipart字样,有就是这个问题。
第三条:重试机制上线后,第二次上传永远是空文件。
现象是开了指数退避,第一次超时后重试,服务端返回「图片为空」或者「无法解码」。原因是第一次请求已经把文件流读到了末尾,重试时传的是同一个文件对象,read()直接返回b""。解决办法就是第 3 章那个files_factory的写法:把「打开文件」这件事包成函数,每次重试重新open一次。这个 bug 特别隐蔽,因为没有重试的时候一切正常,只有网络抖动时才偶发。
5.2 响应与网络的两条
第四条:HTTP 200 但结果是空的。
现象是状态码判断全部通过,resp.json()也能解析,但取data.label时抛KeyError。原因是业务层错误同样用 200 返回,错误信息在status和message字段里,比如{"status": "error", "message": "Invalid API key"}。解决是把「HTTP 层成功」和「业务层成功」拆成两道判断,先看状态码,再看body["status"],两个都过了才去取data。这条我建议写进公共解析函数里,别指望每个调用点都记得判。
第五条:批量处理时零星出现 429,越重试越多。
现象是单张图跑得好好的,一上批量就有百分之几的请求失败,加上重试之后失败率反而上升。原因是并发数开得太高,短时间打出去的请求超过了配额,重试又进一步推高了瞬时压力。解决分两步:把并发压到 2~4 这个量级先测,确认稳定后再逐步往上加;重试必须带退避,time.sleep(2 ** n)这种,不能用固定 100 毫秒疯狂重发。另外记得区分 429 和 5xx,前者是限速、后者是服务端抖动,处理策略不一样,但都需要退避。
6. 装完怎么验证跑通:一份自检脚本和三道必须过的关
环境装完、代码写完,别急着往业务里接。我习惯先跑一遍自检,把「环境问题」和「业务问题」分开,不然一个 401 能让你在业务逻辑里找两小时。自检脚本不长,五步,任何一步失败就带着明确的提示退出。
# selfcheck.py import os, sys, requests from PIL import Image def main() -> int: # 第一关:密钥是否就位 key = os.environ.get("DEEPSEEK_API_KEY", "").strip() if not key: print("[FAIL] DEEPSEEK_API_KEY 未设置"); return 1 print(f"[OK] 密钥长度 {len(key)},前四位 {key[:4]}") # 第二关:依赖版本是否落在预期区间 import PIL print(f"[OK] requests {requests.__version__} / Pillow {PIL.__version__}") # 第三关:本地预处理链路是否通 Image.new("RGB", (640, 480), (128, 128, 128)).save("_probe.jpg", quality=88) with Image.open("_probe.jpg") as im: assert im.mode == "RGB" and im.size == (640, 480) print("[OK] Pillow 读写正常") # 第四关:文本分类链路 from client import call r = call("text_classify", json_body={"text": "smoke test"}) print(f"[{'OK' if r.status_code == 200 else 'FAIL'}] 文本接口 HTTP {r.status_code}") # 第五关:图像分类链路(用刚生成的探针图) from client import call as call2 import io def opener(): return {"image": ("probe.jpg", open("_probe.jpg", "rb"), "image/jpeg")} r2 = call2("classify", files_factory=opener) print(f"[{'OK' if r2.status_code == 200 else 'FAIL'}] 图像接口 HTTP {r2.status_code}") return 0 if r.status_code == 200 and r2.status_code == 200 else 2 if __name__ == "__main__": sys.exit(main())这个脚本的设计思路是「先本地、后远程,先便宜、后昂贵」。前三关完全不发网络请求,密钥没设、版本不对、Pillow 装坏,都能在本地就拦下来;后两关才真正打接口,而且图像那关用的是自己生成的纯色探针图,不依赖任何业务素材。退出码分三档:0 全通、1 环境问题、2 接口问题,方便直接塞进 CI 或者部署前钩子。
配套我还留了一张人工核对表,有些东西脚本测不出来,只能靠眼看:
| 检查项 | 期望结果 | 不通过时的第一反应 |
|---|---|---|
.venv是否激活 | 提示符带(.venv),sys.executable指向项目内 | 重新激活,别在这状态下装包 |
| 密钥是否进了版本库 | git grep搜不到密钥明文 | 立刻轮换密钥并清理历史 |
| 图像预处理输出 | 模式 RGB、尺寸 224×224、体积小于 1MB | 检查是否漏了convert("RGB") |
| 响应解析 | status == "success"且data.label有值 | 先看message字段,别只看状态码 |
| 重试行为 | 断网跑一次,能看到退避日志且最终抛错 | 检查文件流是否被复用 |
真正让我养成这套习惯的,是有一次把「HTTP 200 就当成功」的代码发上了预发环境,跑了两天才有人发现分类结果全是空字符串——接口一直在返回业务错误,只是状态码是 200。从那以后我每次接新接口,都强制先跑一遍这个自检脚本,五步全绿才动业务代码,宁可多花十分钟,也不想再在业务层里追一个其实是鉴权引起的空值。希望这套东西能帮到你,少走我走过的那四十分钟。
本文还有配套的精品资源,点击获取