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 维度上,不影响主链路。
下面是我现在的脚手架,框架无关,只依赖 httpx 和 pydantic,可以直接复制到项目里:
# 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。原因很现实:
- OpenAI 兼容:迁移到它的 LLM endpoint 几乎不用改代码,旧项目零成本接入。
- 统一 CreateTask / TaskInfo 接口:图像、视频、音频走同一套语义,不需要为每个 vendor 写适配器。
- 结果统一回传:媒体文件被收敛到同一个签名 URL 协议,文件下载和持久化的逻辑只写一份。
- 横向多模型:我可以在生产灰度里同时跑 Nano Banana Pro / Seedream / Flux / Imagen,按业务标签做 A/B,再决定哪个 SKU 走哪个模型。这件事如果自己接 6 家 vendor,工作量直接劝退。
- 账单和日志:
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 工程的护城河,不在模型选择上,在调用层的工程纪律上。
希望这篇能让你少踩一些我已经踩过的坑,欢迎拍砖。