AI 客户端封装:Key 校验、请求与降级重试
一句话摘要:本文剖析「智学文枢」AI 服务层的统一出口
ai_client.py------如何在发起 HTTP 请求前拦截空 Key、如何用一次封装同时支持文字对话与视觉 OCR 双模型节点,以及 JSON 模式下"400 降级重试"这一关键容错设计。
一、引言
把"调用大模型"想清楚,第一性原理其实只有一句话:一个桌面应用要跟任意一家模型厂商的 HTTP 服务对话,最省心的方式是找一个大家都认的"普通话"。
不同厂商的接口五花八门:阿里云百炼、DeepSeek、OpenAI......如果每处业务都直接拼 URL、拼 Header、写 try/except,那么分析引擎、变式引擎、讲解引擎、追问引擎会各自维护一套网络代码,改一次鉴权方式就要改五处,任何一处漏改都会让整个功能悄悄失效。
「智学文枢」要跑五个 AI 引擎(分析/变式/教学/跟进/对话),还要支持 OCR 识别试卷图片,并且允许用户在设置页自由切换"通义千问 / DeepSeek"两家服务商。这就逼出了两个根本需求:
- 统一出口:所有 AI 调用只走一个类,网络细节、鉴权、日志、错误归一化只写一遍;
- 双节点模型 :文字对话用
ai节点(如 qwen-max),图片识别用aiocr节点(如 qwen-vl-max),两个节点可以配不同的 Key、地址、模型,互不干扰。
于是有了 services/ai/ai_client.py 中这个不到 280 行的 AIClient 类。它像一层"防弹玻璃":业务层永远拿不到裸的 HTTP 异常,也永远不会带着空 Key 发出请求。
#mermaid-svg-aT1DXMfeWMroddeB{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-aT1DXMfeWMroddeB .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-aT1DXMfeWMroddeB .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-aT1DXMfeWMroddeB .error-icon{fill:#552222;}#mermaid-svg-aT1DXMfeWMroddeB .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-aT1DXMfeWMroddeB .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-aT1DXMfeWMroddeB .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-aT1DXMfeWMroddeB .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-aT1DXMfeWMroddeB .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-aT1DXMfeWMroddeB .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-aT1DXMfeWMroddeB .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-aT1DXMfeWMroddeB .marker{fill:#333333;stroke:#333333;}#mermaid-svg-aT1DXMfeWMroddeB .marker.cross{stroke:#333333;}#mermaid-svg-aT1DXMfeWMroddeB svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-aT1DXMfeWMroddeB p{margin:0;}#mermaid-svg-aT1DXMfeWMroddeB .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-aT1DXMfeWMroddeB .cluster-label text{fill:#333;}#mermaid-svg-aT1DXMfeWMroddeB .cluster-label span{color:#333;}#mermaid-svg-aT1DXMfeWMroddeB .cluster-label span p{background-color:transparent;}#mermaid-svg-aT1DXMfeWMroddeB .label text,#mermaid-svg-aT1DXMfeWMroddeB span{fill:#333;color:#333;}#mermaid-svg-aT1DXMfeWMroddeB .node rect,#mermaid-svg-aT1DXMfeWMroddeB .node circle,#mermaid-svg-aT1DXMfeWMroddeB .node ellipse,#mermaid-svg-aT1DXMfeWMroddeB .node polygon,#mermaid-svg-aT1DXMfeWMroddeB .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-aT1DXMfeWMroddeB .rough-node .label text,#mermaid-svg-aT1DXMfeWMroddeB .node .label text,#mermaid-svg-aT1DXMfeWMroddeB .image-shape .label,#mermaid-svg-aT1DXMfeWMroddeB .icon-shape .label{text-anchor:middle;}#mermaid-svg-aT1DXMfeWMroddeB .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-aT1DXMfeWMroddeB .rough-node .label,#mermaid-svg-aT1DXMfeWMroddeB .node .label,#mermaid-svg-aT1DXMfeWMroddeB .image-shape .label,#mermaid-svg-aT1DXMfeWMroddeB .icon-shape .label{text-align:center;}#mermaid-svg-aT1DXMfeWMroddeB .node.clickable{cursor:pointer;}#mermaid-svg-aT1DXMfeWMroddeB .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-aT1DXMfeWMroddeB .arrowheadPath{fill:#333333;}#mermaid-svg-aT1DXMfeWMroddeB .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-aT1DXMfeWMroddeB .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-aT1DXMfeWMroddeB .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-aT1DXMfeWMroddeB .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-aT1DXMfeWMroddeB .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-aT1DXMfeWMroddeB .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-aT1DXMfeWMroddeB .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-aT1DXMfeWMroddeB .cluster text{fill:#333;}#mermaid-svg-aT1DXMfeWMroddeB .cluster span{color:#333;}#mermaid-svg-aT1DXMfeWMroddeB div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-aT1DXMfeWMroddeB .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-aT1DXMfeWMroddeB rect.text{fill:none;stroke-width:0;}#mermaid-svg-aT1DXMfeWMroddeB .icon-shape,#mermaid-svg-aT1DXMfeWMroddeB .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-aT1DXMfeWMroddeB .icon-shape p,#mermaid-svg-aT1DXMfeWMroddeB .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-aT1DXMfeWMroddeB .icon-shape .label rect,#mermaid-svg-aT1DXMfeWMroddeB .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-aT1DXMfeWMroddeB .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-aT1DXMfeWMroddeB .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-aT1DXMfeWMroddeB :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 模型节点
AIClient 统一出口
业务引擎
chat_json/chat
recognize_image
理科分析引擎
变式引擎
讲解引擎
追问引擎
OCR识别
chat / chat_json / recognize_image
Key校验 _get_headers
日志 log_prompts
ai 节点
qwen-max / deepseek-chat
aiocr 节点
qwen-vl-max
二、核心原理
2.1 OpenAI 兼容协议的"最小契约"
AIClient 只认一件事:POST {base_url}/chat/completions,请求体带 model、messages、temperature、max_tokens,请求头带 Authorization: Bearer {key}。无论是千问的 dashscope.aliyuncs.com/compatible-mode/v1 还是 DeepSeek 的 api.deepseek.com/v1,都实现了这一契约------这就是"兼容模式"的含义:厂商迁就协议,而不是我们的代码迁就厂商。
2.2 Key 校验前置:宁可报错,不可发空头
网络请求里最隐蔽的 bug 是"带空 token 的请求"。它不会崩,只会被服务端回一个 401,然后调用方拿着模棱两可的错误去排查半天。所以 AIClient 把校验放在了构造 Header 的瞬间 :Key 为空直接抛 RuntimeError("未配置API Key..."),从根上保证"Bearer "后面永远跟着真实内容。这里有个细节:aiocr 节点允许没配 Key,此时回退用 ai.apiKey,避免用户为 OCR 单独再填一次。
2.3 两类调用、三种入口
模型服务 AIClient 分析引擎 模型服务 AIClient 分析引擎 #mermaid-svg-zSuJ5FO3lf2AcY9X{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-zSuJ5FO3lf2AcY9X .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-zSuJ5FO3lf2AcY9X .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-zSuJ5FO3lf2AcY9X .error-icon{fill:#552222;}#mermaid-svg-zSuJ5FO3lf2AcY9X .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-zSuJ5FO3lf2AcY9X .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-zSuJ5FO3lf2AcY9X .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-zSuJ5FO3lf2AcY9X .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-zSuJ5FO3lf2AcY9X .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-zSuJ5FO3lf2AcY9X .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-zSuJ5FO3lf2AcY9X .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-zSuJ5FO3lf2AcY9X .marker{fill:#333333;stroke:#333333;}#mermaid-svg-zSuJ5FO3lf2AcY9X .marker.cross{stroke:#333333;}#mermaid-svg-zSuJ5FO3lf2AcY9X svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-zSuJ5FO3lf2AcY9X p{margin:0;}#mermaid-svg-zSuJ5FO3lf2AcY9X .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-zSuJ5FO3lf2AcY9X text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-zSuJ5FO3lf2AcY9X .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-zSuJ5FO3lf2AcY9X .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-zSuJ5FO3lf2AcY9X .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-zSuJ5FO3lf2AcY9X .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-zSuJ5FO3lf2AcY9X #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-zSuJ5FO3lf2AcY9X .sequenceNumber{fill:white;}#mermaid-svg-zSuJ5FO3lf2AcY9X #sequencenumber{fill:#333;}#mermaid-svg-zSuJ5FO3lf2AcY9X #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-zSuJ5FO3lf2AcY9X .messageText{fill:#333;stroke:none;}#mermaid-svg-zSuJ5FO3lf2AcY9X .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-zSuJ5FO3lf2AcY9X .labelText,#mermaid-svg-zSuJ5FO3lf2AcY9X .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-zSuJ5FO3lf2AcY9X .loopText,#mermaid-svg-zSuJ5FO3lf2AcY9X .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-zSuJ5FO3lf2AcY9X .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-zSuJ5FO3lf2AcY9X .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-zSuJ5FO3lf2AcY9X .noteText,#mermaid-svg-zSuJ5FO3lf2AcY9X .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-zSuJ5FO3lf2AcY9X .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-zSuJ5FO3lf2AcY9X .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-zSuJ5FO3lf2AcY9X .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-zSuJ5FO3lf2AcY9X .actorPopupMenu{position:absolute;}#mermaid-svg-zSuJ5FO3lf2AcY9X .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-zSuJ5FO3lf2AcY9X .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-zSuJ5FO3lf2AcY9X .actor-man circle,#mermaid-svg-zSuJ5FO3lf2AcY9X line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-zSuJ5FO3lf2AcY9X :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} alt返回200返回400(模型不支持JSON模式) chat_json(messages, temperature=0.1)_get_headers("ai") 校验KeyPOST /chat/completions + response_format=json_objectchoices0.message.content降级重试 chat(messages) 普通文本调用文本内容文本响应
chat:普通文本对话,温度默认 0.3,max_tokens=4096;chat_json:强制 JSON 输出,温度默认 0.1,max_tokens=8192,且带response_format={"type": "json_object"};遇到 400 自动降级为chat再试一次;recognize_image:走aiocr节点,把本地图片转成 base64 data URL 塞进多模态消息。
2.4 授权门禁:License 与 AI 解耦
chat/chat_json 在发请求前会调用 _require_ai():若许可证未激活或试用过期,直接抛异常拒绝调用;但 recognize_image 不受影响。这保证"没付费也能用 OCR 录题",而"核心 AI 功能必须激活"------商业边界用一行代码落在客户端,而不是靠服务端。
三、代码实现
3.1 Key 校验与请求头构建
Key 校验是整个类的第一道闸门,源码就在 _get_headers 里:
python
# 源码位置:services/ai/ai_client.py L30-L46
def _get_api_key(self, node: str = "ai") -> str:
"""获取API Key。aiocr 为空时回退到 ai.apiKey"""
key = self.config.get(node, "apiKey", default="")
if not key and node == "aiocr":
key = self.config.get("ai", "apiKey", default="")
return key
def _get_headers(self, node: str = "ai") -> dict:
"""构建请求头"""
headers = {
"Content-Type": "application/json",
}
key = self._get_api_key(node)
if not key:
raise RuntimeError("未配置API Key,请在设置中填写AI接口的API Key")
headers["Authorization"] = f"Bearer {key}"
return headers
注意两件事:一是"空 Key 抛错"发生在组 Header 阶段 ,业务层根本拿不到一个残缺请求;二是 aiocr 的回退逻辑------OCR 节点没有独立 Key 时直接借用 ai.apiKey,这是"双节点配置"里唯一的耦合点,且耦合方向是单向的(ai 不会回退 aiocr)。
3.2 地址解析:provider 决定默认域名
如果用户在设置页只选了服务商、没填地址,_get_url 会按 provider 补默认值:
python
# 源码位置:services/ai/ai_client.py L48-L57
def _get_url(self, node: str = "ai") -> str:
"""获取API地址"""
base_url = self.config.get(node, "baseUrl", default="")
if not base_url:
provider = self.config.get(node, "provider", default="qwen")
if provider == "deepseek":
base_url = "https://api.deepseek.com/v1"
else:
base_url = "https://dashscope.aliyuncs.com/compatible-mode/v1"
return f"{base_url.rstrip('/')}/chat/completions"
rstrip('/') 防止用户在设置页多打了个斜杠导致双斜杠 URL;"未配置则用默认域名"让新用户零配置即可跑通。
3.3 chat_json:强制 JSON 与 400 降级重试
JSON 解析的成败取决于模型输出格式,而部分模型不支持 response_format。这里的策略是"先强后弱":
python
# 源码位置:services/ai/ai_client.py L139-L163
payload = {
"model": model,
"messages": messages,
"temperature": temperature,
"max_tokens": 8192,
}
if json_object:
payload["response_format"] = {"type": "json_object"}
try:
with httpx.Client(timeout=timeout) as client:
resp = client.post(url, headers=headers, json=payload)
resp.raise_for_status()
data = resp.json()
reply = data["choices"][0]["message"]["content"]
logger.info("[AIClient.chat_json] 响应成功 content(前300字): %s", str(reply)[:300])
return reply
except httpx.HTTPStatusError as e:
# 某些模型不支持response_format,降级为普通调用
if e.response.status_code == 400:
logger.warning("[AIClient.chat_json] 模型不支持JSON模式,降级为普通chat调用")
return self.chat(messages, temperature)
raise RuntimeError(f"API返回错误 {e.response.status_code}: {e.response.text[:200]}")
except httpx.RequestError as e:
raise RuntimeError(f"网络请求失败: {e}")
三处设计值得展开:
max_tokens=8192:JSON 输出往往比普通文本更长(8 个字段、步骤数组),给足配额避免截断导致 JSON 不闭合;json_object参数可关 :变式题要输出 JSON 数组 ,而json_object模式强制"单一对象",两者冲突------所以变式引擎调用时传json_object=False(这个坑在第 34 篇还会展开);- 400 降级:不是所有 400 都降级,只对 400 这一个状态码降级重试一次,401/429/500 等直接抛错,避免掩盖真实问题。
3.4 OCR:本地图片转 data URL
python
# 源码位置:services/ai/ai_client.py L167-L174, L201-L207
def _image_to_data_url(self, image_path: str) -> str:
"""将本地图片转为 base64 data URL"""
ext = os.path.splitext(image_path)[1].lower().lstrip(".")
mime = {"jpg": "jpeg", "jpeg": "jpeg", "png": "png",
"bmp": "bmp", "gif": "gif", "webp": "webp"}.get(ext, "png")
with open(image_path, "rb") as f:
b64 = base64.b64encode(f.read()).decode("utf-8")
return f"data:image/{mime};base64,{b64}"
# user 消息为多模态结构:文本 + 图片双段
messages.append({
"role": "user",
"content": [
{"type": "text", "text": prompt},
{"type": "image_url", "image_url": {"url": data_url}},
],
})
扩展名到 MIME 的映射表只有 6 种常见格式,未知扩展名兜底为 png;base64 编码后直接拼 data URL,视觉模型按 OpenAI 多模态消息规范解析。
四、设计权衡 / 踩坑记录
1. 为什么不直接用 requests? 项目选 httpx 是因为它自带 timeout 参数(requests 的 timeout 只在 socket 层,httpx 有更细的超时控制),且 resp.raise_for_status() 能统一把 4xx/5xx 转成 HTTPStatusError。代价是又多了一个依赖,但对于 AI 调用这种"可能挂 60 秒"的场景,超时控制是第一位的。
2. 空 Key 校验放哪? 曾经有一种思路是"发出去,收到 401 再提示"。实际踩坑发现:未配置 Key 时用户看到的是"API返回错误 401: Unauthorized",根本不知道去哪解决。改为组 Header 时前置校验后,错误信息直接说"请在设置中填写AI接口的API Key",把配置问题 和调用问题分开了。
3. 日志防爆炸。 AI 响应动辄几千字,全量打日志会让日志文件几小时涨几 GB。这里的折中是:请求消息只打前 200 字、响应只打前 300 字(content(前200字) / content(前300字));OCR 场景图片 base64 可能上万字符,用 _content_for_log 把图片段替换成 [OCR图片已省略] 占位符。另外模块尾部单独定义了 prompt_logger = logging.getLogger("prompt") 和 log_prompts() 函数------把所有即将发送的 messages 完整写入独立的 "prompt" logger,日志查看页可用"仅提示词"过滤,方便核对组装是否正确,且 try/except Exception: pass 保证日志故障绝不拖垮主流程。
4. _require_ai 的放置位置。 它放在 chat/chat_json 里而不是 __init__ 里,是为了 OCR 调用不受许可限制;放在每次调用里而不是应用启动时,是为了"激活后立即生效,无需重启"。
五、小结
| 机制 | 一句话总结 |
|---|---|
| Key 校验 | 组 Header 时校验,空 Key 直接抛错,绝不发 Bearer 空头 |
| 双节点 | ai 文字节点与 aiocr 视觉节点,后者 Key 可回退前者 |
| 降级重试 | 仅对 400 状态码降级为普通 chat 再试,其余错误原样抛出 |
| 错误归一 | 所有 HTTP 异常统一转成带状态码的 RuntimeError,业务层只 catch 一种 |
| 日志安全 | 消息截断 200/300 字、OCR 图片占位、独立 prompt logger 且异常不影响主流程 |
ai_client.py 是整个 AI 服务层的地基:它向上给五个引擎提供"一句话就能调模型"的接口,向下把配置、鉴权、网络、日志全部收敛。后续 29-36 篇的所有引擎,本质上都是在"如何组装 messages"和"如何消费返回的 JSON"两端做文章------而这两端的进出,都只经过这一个出口。