智学文枢:AI 客户端封装

AI 客户端封装:Key 校验、请求与降级重试

一句话摘要:本文剖析「智学文枢」AI 服务层的统一出口 ai_client.py------如何在发起 HTTP 请求前拦截空 Key、如何用一次封装同时支持文字对话与视觉 OCR 双模型节点,以及 JSON 模式下"400 降级重试"这一关键容错设计。

一、引言

把"调用大模型"想清楚,第一性原理其实只有一句话:一个桌面应用要跟任意一家模型厂商的 HTTP 服务对话,最省心的方式是找一个大家都认的"普通话"。

不同厂商的接口五花八门:阿里云百炼、DeepSeek、OpenAI......如果每处业务都直接拼 URL、拼 Header、写 try/except,那么分析引擎、变式引擎、讲解引擎、追问引擎会各自维护一套网络代码,改一次鉴权方式就要改五处,任何一处漏改都会让整个功能悄悄失效。

「智学文枢」要跑五个 AI 引擎(分析/变式/教学/跟进/对话),还要支持 OCR 识别试卷图片,并且允许用户在设置页自由切换"通义千问 / DeepSeek"两家服务商。这就逼出了两个根本需求:

  1. 统一出口:所有 AI 调用只走一个类,网络细节、鉴权、日志、错误归一化只写一遍;
  2. 双节点模型 :文字对话用 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"两端做文章------而这两端的进出,都只经过这一个出口。

相关推荐
buddyMe智能助手1 小时前
记忆还没失效,但动作已经危险:该不该先核实,是动作说了算
1024程序员节
l1t1 小时前
在windows中测试musql数据库
数据库·sqlite·go·1024程序员节
大江东去浪淘尽千古风流人物1 小时前
【HIMO】跨模态图像匹配算法:IMO方向场提取与PolarP描述符源码精讲
matlab·跨模态·遥感·图像匹配·1024程序员节·特征描述符
小南家的青蛙1 小时前
华为云DCS全产品对比
redis·1024程序员节
芝麻开门2921 小时前
c++11
c++11·1024程序员节
乃嘿仔1 小时前
AI 热点日报 · 2026-10-24
1024程序员节
HY小宝F1 小时前
智能摄像头系统 30 天学习计划 Day 08 · FFmpeg:成为你的“视频瑞士军刀”
1024程序员节
zyseo81 小时前
谷歌SEO 排名因素权重地图:把精力花在刀刃上
1024程序员节
basketball6161 小时前
动态规划第一章:引论
算法·动态规划·1024程序员节