别再为每个模型单独写一套队列了:用 200 行代码封装多模态统一调用层

0 阅读9分钟

TLDR;

有一年左右没在掘金发文了。去年夏天开始,由于工作节奏变快,生活节奏也有所变化,导致没有太多时间输出和总结,只有一些零散和知识沉淀。今年又有所变化,准备把之前的习惯重新拾起来,没事写一写。好了,废话 over。

0. 开头先泼一盆冷水

写这篇之前,我又翻了一遍自己 2025 年初接的第一个多模态项目的代码。

那是一个看起来很正常的 AI 内容生产平台:用户上传商品图,我们调一个图像模型生成场景图,再调一个视频模型把图变成 6 秒短片,最后调一个 TTS 把卖点配音叠上去,输出成可投放素材。

需求清单看着不复杂,三个模型而已。但当时实际写出来的目录是这样的:

src/
├── providers/
│   ├── image_provider_a.py        # 同步返回,超时 30s
│   ├── image_provider_b.py        # 异步轮询,状态枚举有 5 种
│   ├── video_provider_a.py        # webhook 回调,需要公网域名
│   ├── video_provider_b.py        # 长轮询 + 分片下载
│   ├── tts_provider_a.py          # 流式返回 chunk
│   └── tts_provider_b.py          # base64 一次性返回
├── adapters/
│   ├── retry_image.py
│   ├── retry_video.py
│   └── retry_tts.py
├── webhooks/
│   ├── video_callback.py
│   └── tts_callback.py
├── storage/
│   └── result_uploader.py
└── ... # 还有 12 个文件

而真正的"业务代码"——根据 SKU 选择风格、决定生成几张候选、A/B 投放——只占不到全工程的 25%。

我当时以为这是项目复杂度问题。后来才承认这是架构问题:我们一直在错误的层次抽象多模态。

这篇就讲一下,我现在是怎么写这一层的,以及为什么后来直接把"providers 目录"砍掉,换成一个外部统一 API。


1. 真正的工程问题不是"调模型"

很多团队把"接 AI"理解成"调 API"。这是 demo 阶段没问题,进了生产就到处漏。我把它叫做**"假同步幻觉"**——你以为只是 requests.post(...),实际你需要处理:

  • 异步语义差异:图像模型可能 5 秒同步返回,视频模型 60-180 秒异步
  • 鉴权差异:有的走 Authorization: Bearer,有的走 X-API-KEY,有的还要 sign 一道
  • 错误语义差异:A 家把限流写在 HTTP 429,B 家写在 200 响应体的 code 字段
  • 结果格式差异:URL / base64 / 临时 OSS / 流式 chunk
  • 文件交付差异:你要把生成产物存到自己的 OSS / S3 / R2,还得回写数据库
  • 计费差异:有的按 token,有的按秒,有的按分辨率档位
  • 失败语义差异:有的允许节点级重试,有的失败必须整条链路重跑

每多接入一个 vendor,这些差异就要写一遍 if-else。当模型数量超过 5 个,代码库的复杂度增长不是线性的,是 O(n²) 的——因为你的业务代码会和每一个 provider 的"脾气"耦合,任何一个 vendor 升级都会牵动整条链路。

这才是真正卡 AI 团队产能的东西。不是 prompt 不会写,不是模型不够强,而是你没有给"模型调用"建一层抽象


2. 抽象的核心:把所有模型调用统一成 Task

这一节是这篇文章的核心,我会先给出抽象,再给代码。

我现在认为的"正确抽象"是这样的:

不要把 AI 调用当作 RPC,把它当作 Task。

RPC 的语义是:发请求 → 等响应 → 拿结果。这套语义在文本/图像生成时勉强够用,但在视频生成、长音频、Agent 串联场景下完全不够。

Task 的语义是:

CreateTask(input) -> task_id
GetTaskStatus(task_id) -> {pending | running | success | failed}
GetTaskResult(task_id) -> {assets, usage, logs}

无论底层是同步、异步、轮询还是 webhook,业务代码看到的永远是这三个动作。同步模型?SDK 内部第一次调用就直接返回 success。异步视频模型?SDK 内部帮你做轮询和 webhook 收敛。失败重试?落在 task_id 维度上,不影响主链路。

下面是我现在的脚手架,框架无关,只依赖 httpxpydantic,可以直接复制到项目里:

# crun_task.py
# 一个最小化的多模态统一 Task 调用层
# 业务代码只面对 Task,不面对 vendor 差异

from __future__ import annotations
import time
import httpx
from typing import Any, Literal, Optional
from pydantic import BaseModel, Field


# -------- 1. 统一的数据结构 --------

TaskStatus = Literal["pending", "running", "success", "failed"]


class TaskInput(BaseModel):
    """业务侧只关心:我用哪个模型、传什么参数。"""
    model: str  # e.g. "nano-banana-pro" / "veo-3-1" / "qwen3-tts"
    input: dict[str, Any] = Field(default_factory=dict)
    # 业务自定义元数据,会在日志里串起来
    biz_id: Optional[str] = None
    biz_tag: Optional[str] = None


class TaskResult(BaseModel):
    task_id: str
    status: TaskStatus
    assets: list[str] = Field(default_factory=list)  # 已经回传到自己 OSS 的 url
    raw: dict[str, Any] = Field(default_factory=dict)
    error: Optional[str] = None
    cost_ms: Optional[int] = None


# -------- 2. 一个 OpenAI 兼容 / Crun 兼容的统一 client --------

class UnifiedTaskClient:
    """
    把所有多模态调用收敛成 CreateTask + Poll 两个动作。
    底层指向 Crun.ai 这种统一 API(也可换成自家网关)。
    """

    def __init__(
        self,
        api_key: str,
        base_url: str = "https://api.crun.ai/api/v1/client",
        timeout: float = 60.0,
        poll_interval: float = 2.0,
        poll_max_seconds: float = 600.0,
    ) -> None:
        self._client = httpx.Client(
            base_url=base_url,
            headers={"X-API-KEY": api_key},
            timeout=timeout,
        )
        self._poll_interval = poll_interval
        self._poll_max_seconds = poll_max_seconds

    # 2.1 创建任务
    def create_task(self, payload: TaskInput) -> str:
        resp = self._client.post(
            "/job/CreateTask",
            json=payload.model_dump(exclude_none=True),
        )
        resp.raise_for_status()
        data = resp.json()
        task_id = data["data"]["task_id"]
        return task_id

    # 2.2 查询任务
    def fetch_task(self, task_id: str) -> TaskResult:
        resp = self._client.get(f"/job/TaskInfo", params={"task_id": task_id})
        resp.raise_for_status()
        data = resp.json()["data"]

        return TaskResult(
            task_id=task_id,
            status=data.get("status", "pending"),
            assets=data.get("assets", []),
            raw=data,
            error=data.get("error"),
            cost_ms=data.get("cost_ms"),
        )

    # 2.3 同步等待 —— 这一层是给业务最方便的语法糖
    def run(self, payload: TaskInput) -> TaskResult:
        start = time.time()
        task_id = self.create_task(payload)

        while True:
            result = self.fetch_task(task_id)
            if result.status in ("success", "failed"):
                return result
            if time.time() - start > self._poll_max_seconds:
                return TaskResult(
                    task_id=task_id,
                    status="failed",
                    error=f"poll timeout after {self._poll_max_seconds}s",
                )
            time.sleep(self._poll_interval)

这一层不到 80 行,但它做了一件很重要的事:把业务代码从 vendor 差异里拔出来。

你看下面这段业务代码就知道差别了:

# 业务侧的电商素材产线 —— 跟模型完全解耦
from crun_task import UnifiedTaskClient, TaskInput

cli = UnifiedTaskClient(api_key="sk-xxx")

def gen_one_sku_assets(sku_id: str, product_image_url: str) -> list[str]:
    scenes = [
        "Nordic morning kitchen, soft light, marble countertop",
        "Minimalist studio, beige backdrop, top-down flat lay",
        "Cozy reading corner, warm tungsten lamp, autumn vibe",
    ]
    outputs: list[str] = []
    for idx, scene in enumerate(scenes):
        result = cli.run(TaskInput(
            model="nano-banana-pro",
            input={
                "image": product_image_url,
                "prompt": f"{scene}, product centered, photo-realistic",
                "size": "1024x1024",
            },
            biz_id=f"{sku_id}-img-{idx}",
            biz_tag="ecom_main_image_v2",
        ))
        if result.status == "success":
            outputs.extend(result.assets)
    return outputs

注意到了吗?切换模型只需要把 model="nano-banana-pro" 改成 model="seedream-5"model="flux-2"。轮询、回调、文件回传、用量统计全部下沉,业务代码干干净净。


3. 把视频生成"假装成"同步函数

视频是多模态里最难写的一块。常见的反模式就是:业务里 hard-code 了 60 秒 sleep + 自定义状态枚举 + 自己起一个 Redis 任务表。

用同一套抽象写出来是这样的:

def gen_product_video(image_url: str, narration: str) -> Optional[str]:
    # 模型层差异完全被屏蔽:这里换成 sora-2 / wan-2-6 / kling-3 都行
    result = cli.run(TaskInput(
        model="veo-3-1",
        input={
            "first_frame": image_url,
            "prompt": narration,
            "duration": 6,
            "resolution": "1080p",
        },
        biz_tag="ecom_short_ad",
    ))
    return result.assets[0] if result.status == "success" else None

业务侧再也不需要知道 Veo 的轮询节奏跟 Wan 不一样,也不需要在生产环境维护一份"vendor 状态码 → 内部状态"的映射表。


4. 重试和可观测性,必须落到 Task 维度

很多团队会在最外层加一个 @retry,这是错的。

正确的做法是:重试的最小单位必须是单个 Task,不能是一整条业务链路。原因很简单:一条素材生产链路里如果第三步失败了,前两步已经花掉的 GPU 成本不能浪费。

import logging
from contextlib import contextmanager

logger = logging.getLogger("crun")

@contextmanager
def task_span(name: str, biz_id: str):
    t0 = time.time()
    logger.info("task.start", extra={"name": name, "biz_id": biz_id})
    try:
        yield
        logger.info("task.ok", extra={
            "name": name, "biz_id": biz_id,
            "elapsed_ms": int((time.time() - t0) * 1000),
        })
    except Exception as e:
        logger.exception("task.fail", extra={
            "name": name, "biz_id": biz_id, "error": str(e),
        })
        raise


def safe_run(payload: TaskInput, retries: int = 2) -> TaskResult:
    last: Optional[TaskResult] = None
    for attempt in range(retries + 1):
        with task_span(payload.model, payload.biz_id or "-"):
            last = cli.run(payload)
            if last.status == "success":
                return last
        # 指数退避,避免对供应商造成压力
        time.sleep(2 ** attempt)
    return last  # 最后一次失败也带 task_id 出来,方便人工捞

每个 Task 都有自己的 task_id + biz_id,写日志的时候把它们透传到 extra,再到 ELK / Loki / SLS 里就能直接按业务维度聚合。这一层做好之后,"昨天哪条素材失败了"这种问题不再需要靠人肉。


5. 为什么我后来不再自己接 vendor

写到这里,回头看那个 200 行的 crun_task.py,它没有接任何具体 vendor。

因为我现在直接把 base_url 指向了一个已经做好统一 Task 抽象的服务 —— Crun.ai。原因很现实:

  1. OpenAI 兼容:迁移到它的 LLM endpoint 几乎不用改代码,旧项目零成本接入。
  2. 统一 CreateTask / TaskInfo 接口:图像、视频、音频走同一套语义,不需要为每个 vendor 写适配器。
  3. 结果统一回传:媒体文件被收敛到同一个签名 URL 协议,文件下载和持久化的逻辑只写一份。
  4. 横向多模型:我可以在生产灰度里同时跑 Nano Banana Pro / Seedream / Flux / Imagen,按业务标签做 A/B,再决定哪个 SKU 走哪个模型。这件事如果自己接 6 家 vendor,工作量直接劝退。
  5. 账单和日志biz_id 可以贯穿到平台后台的 task trace,不用自己写一份用量分析。

更直接的说法是:这一层抽象本来就该由平台提供,而不是每个团队自己写一遍。

如果你正在做的是产品创新,把这一层留给基建层;如果你正在做的是基建本身,那这篇文章里的代码可以当作起点。


6. 一个最小可跑的 Demo

完整可跑示例,依赖 pip install httpx pydantic。把 sk-xxx 换成你自己的 key 就能直接跑。

# demo.py
from crun_task import UnifiedTaskClient, TaskInput

if __name__ == "__main__":
    cli = UnifiedTaskClient(api_key="sk-xxx")

    # 1. 文本生成(OpenAI 兼容路径,也可走 Task 路径)
    img = cli.run(TaskInput(
        model="nano-banana-pro",
        input={
            "prompt": "a porcelain perfume bottle on a sunlit marble counter, soft light, photo-realistic",
            "size": "1024x1024",
        },
        biz_tag="demo_image",
    ))
    print("image:", img.status, img.assets)

    # 2. 视频生成 —— 同样的 SDK,同样的 .run,模型换个名字
    vid = cli.run(TaskInput(
        model="veo-3-1",
        input={
            "prompt": "the perfume bottle slowly rotates, soft cinematic light",
            "duration": 6,
        },
        biz_tag="demo_video",
    ))
    print("video:", vid.status, vid.assets)

如果你跑通了上面这段,那从今天起,你的代码库就再也不用为"每加一个新模型,重写一遍底座"这件事买单。


7. 写在最后

回到开头那个反思:模型不是瓶颈,抽象层才是。

2026 年还在挨个对接 vendor SDK 的团队,会被那种把模型当成"可插拔后端"的团队甩得越来越远。不是因为后者用了什么黑魔法,只是因为他们更早接受了一件事——

AI 工程的护城河,不在模型选择上,在调用层的工程纪律上。

希望这篇能让你少踩一些我已经踩过的坑,欢迎拍砖。