我用 FastAPI + 阿里万相搭了条数字人流水线,做完才发现难点不在生成

3 阅读11分钟

先说结论,也是我做完这个项目之后最大的一个认知变化:

AI 视频这个方向,"能不能生成"早就不是问题了,问题是"生成的东西能不能交付"。

一条 15 秒的品牌口播,客户不会因为画面会动就付钱。他会问:这句台词不对能改吗?这个镜头换个说法行不行?上次那版的风格能复用吗?改完之后,之前审过的部分还要重审吗?

这些问题一个都不属于"生成",全部属于生产管理。

所以过去几个月我做的事,不是接一个大模型 API 然后包装成界面,而是把「脚本 → 成片」拆成一条可以被审核、被回退、被审计的流水线。上周把它开源了,这篇文章讲讲架构里真正有意思的部分,以及五个我踩了很久的坑。

这套东西长什么样

先看效果:一段脚本进去,一条 15 秒的数字人口播视频出来。

s2v-demo.gif

一句话:Script2Video Studio,一个以服务形态部署的 AI 短视频生产平台——FastAPI + MySQL + Redis + MinIO + FFmpeg Worker,数字人链路接阿里云百炼的万相(wan2.2-s2v)。

规模大概是这样的:

维度数字
Python 代码7.1 万行 / 172 个 .py 文件
API 端点124 路径 / 156 操作(OpenAPI 可导出)
数据库36 张表
测试812 项 pytest
流水线7 条(数字人口播为正式版,其余 beta)
运行时工具26 个(TTS / 图像 / 数字人 / 合成 / 剪辑)

它不是一个"输入文案、出一段视频"的黑盒,而是一个每次 run 都留痕的生产系统:每个阶段的产出都叫 artifact,带版本和血缘(producer / lineage / derived_from),关键决策落事件日志。

为什么不直接用现成方案

这点说清楚了,后面的设计才成立。我评估过也用过:

  • ComfyUI / 各种工作流:单次生成很强,但它没有"这个 run 走到哪了""谁批的这一版""重跑之后旧版本怎么作废"这些概念。它解决的是节点编排,不是生产管理。
  • Coze / Dify 这类平台:适合对话和轻流程,缺产物版本化、人工关口的权限边界、成本闸门。
  • 剪映这类剪辑软件:最终交付确实在这里,但它不负责"批量、可复现、可追溯"。

所以定位很明确:给需要把视频生产当成一项能力对外提供的团队用的——内部 AI 平台要接视频能力、要给合作伙伴开 API、要给客户做白标工作台,这类场景。

如果你是个人想快速剪个视频,这个项目对你没用,直接用剪映。

核心设计:把黑盒拆成五道关口

整条流水线是 6 个阶段,其中 5 个带 gate: true:

脚本 → ①plan → ②script → ③storyboard → ④asset_review → 出片 → ⑤final_review

带 gate 的阶段跑完不会自动往下走,而是把状态置成 waiting_review,等人来批。批的时候有三个约束:

机制解决什么
批准范围版本锁定批的是"第 3 版分镜",后面改出了第 4 版,旧的批准自动失效——不会出现"我明明批过了"
决策只增不改决策日志 append-only:谁、什么时候、批的哪一版、理由是什么,事后改不了
重跑自动作废旧关口从任意阶段重跑,下游已通过的关口按轮次 +1 重新打开,旧 review request 立即作废

第三条是我最满意的设计。真实生产里最高频的动作不是"从头再来",而是**"第 4 关不过,回到第 2 关改台词"**。如果回退一次就要重开一个 run,前面 3 关的审核全白做,没人受得了。

所以状态机里 rerun_stage 允许从 completed 发起——片子都出来了,还能退回去改。

五个真实的坑

这是我写这篇文章的主要动机。网上讲"怎么调通万相"的内容不少,讲"调通之后怎么让它稳定跑在生产里"的很少。

坑 1:同一个供应商,两套协议方言

最早接的是百炼的 EMO(emo-v1),协议要求传人脸框(face_bbox / ext_bbox),还得额外调一次 face-detect 拿坐标,style_level 也是必填。

后来 EMO 下线,换万相 wan2.2-s2v。它完全不吃 bbox——传了直接拒。

看起来是改个模型名的事,实际是两套协议。我的处理是按模型名前缀分流:

def requires_bbox(self) -> bool:
    return self.model_name.startswith("emo")

一个判据决定两件事:要不要跑人脸检测那一跳、要不要发 bbox 字段。

这里有个特别隐蔽的细节:方言必须由请求体里的 model 决定,不能由适配器的构造参数决定。因为 pydantic 模型给 model 字段设了默认值,请求序列化时总会带上这个字段——你改适配器的默认值,实际发出去的还是请求体里的那个。我第一次就是这么改的,改完毫无反应,查了两小时。

还有一个教训:多调一次人脸检测不会报错,只是白烧一次调用。EMO 下漏调会 KeyError 立刻炸,s2v 下多调是静默浪费钱。静默的错比炸出来的错危险得多。

坑 2:wan2.2-s2v 的音频有硬上限:20 秒

官方限制:音频必须 < 20 秒且 < 15MB。

我实测一条 24000Hz / 16bit / mono 的口播,883244 字节,正好 18.4 秒——刚过线。

问题在于:整段脚本一次 TTS 很容易超。一条 60 秒的口播脚本直接合成必然被拒。所以现在的用法是「脚本按句切成 ≤20 秒的段,分别 TTS、分别提交数字人,最后 FFmpeg concat」。

平台侧目前没做自动切段(README 的「已知边界」里老实写了),调用方需要预切。这是下个版本要补的第一件事。

坑 3:改了配置不生效,而且全程零报错

这个坑跟 AI 完全无关,但它是整个项目里我排查最久的一个。

现象:.env 里把 DASHSCOPE_AVATAR_MODEL 从 emo-v1 改成 wan2.2-s2v,docker compose up -d,容器日志显示 Started,一切正常。进去一查,还是 emo-v1。

原因分两层:

  1. Compose 的 environment: 里写 ${VAR:-默认值},插值取值顺序是「shell 环境 > .env 文件」。我早先 source .env 过一次,shell 里留了个 DASHSCOPE_AVATAR_MODEL=emo-v1,每次 compose 解析都被它劫持——.env 改了等于没改。
  2. 更坑的是它连容器都不重建。解析出来的配置和旧容器一模一样,config-hash 不变,up -d 认为"无需变更",只打一行 Started。你看到的"成功",其实是"什么都没干"。

解法:

unset DASHSCOPE_AVATAR_MODEL
docker compose up -d --force-recreate --no-deps api worker

两点反思:

  • 不要用 VAR=值 docker compose up -d 绕过。这次生效了,但下次别人跑 up 又被翻回去,属于埋雷。
  • 这类错误,我的部署预检和冒烟门禁全都抓不到。预检查的是 14 项状态,门禁跑 390 项行为断言,但没有一项校验"模型名配得对不对"——它俩都不属于"状态异常"或"行为错误"。390 项全绿,也可能什么都没测出来。

坑 4:付了钱的操作,绝对不能自动重试

后台作业失败要重试,这是常识。但有一条线必须划清楚:

本地工具步骤(FFmpeg、文件处理)失败可以指数退避重试;付费供应商的提交,永不自动重试。

因为提交超时不等于没提交。供应商那边可能已经建了任务、已经开始计费,你重试一次就是扣两次钱,而且会产出两个不一致的产物。

所以作业失败是三分流的:可重试 / 不可重试 / 需要人工介入。数字人提交属于第二类,失败就停在死信状态,等 API 重放或人工处理。

同理,run 启动前有三道闸门:

闸门触发条件
质量意图降级确认计划的质量档位低于要求,必须显式确认才放行
成本预留闸门执行计划总成本超出 cost_budget,直接拒绝启动
付费供应商授权首次调用付费供应商,需要人工点一次同意

第二道闸门有个我暂时没解决的问题:数字人的成本估算现在返回 0——预算闸门对这条链路暂时没有保护(配额维度是正常的)。这个我在 README 里明确写了,与其等别人发现来提 issue,不如自己先说。

坑 5:队列用数据库实现,比想象中要多想三件事

没用 Celery,直接在 background_jobs 表上做租约队列——想和业务数据同事务,少一个中间件。做完发现有几个细节不想清楚就会出问题:

第一,过期判断必须把 NULL 当过期处理。

-- 租约过期的任务可以被重新领取
lease_expires_at < now() OR lease_expires_at IS NULL

新插入的行 lease_expires_at 是 NULL。如果只写 < now(),这些任务永远不会被领走——而且不会有任何报错,任务队列静静地不动。

第二,领取排序不能加 id。

ORDER BY priority, created_at    -- 不要在这里补 id

同优先级、同一秒创建的任务,如果加 id 兜底排序,多个 Worker 并发领取时会出现系统性偏向:永远是最小的 id 先被抢到,长期下来后面的任务饥饿。不加 id 反而让并发 Worker 自然错开。

第三,付费任务和普通任务不能共用重试策略。 见坑 4。

现在这套队列支持按角色分队列(provider / analysis / media / render),四类 Worker 亲和消费,死信状态可以通过 API 重放。

顺手做了个 skill,让 AI 直接驱动这条流水线

这是我觉得最有意思的部分。

既然整条流水线已经是"HTTP 调用 + 卡在关口等人",那"人"这个角色其实可以交给 AI 工具。所以仓库里带了一个 skills/s2v-pipeline/,给 Claude Code / Codex 这类工具用:

  • 一份 SKILL.md 说清 10 个步骤的顺序和每步该干什么
  • 三份 references 讲流水线阶段、API 端点、故障排查
  • 一个纯标准库的 s2v.py CLI(零依赖,解压即用)

装上之后可以直接说:

把这段稿子做成 15 秒口播,人物用这张图

AI 就会自己建项目 → 建 run → 逐关提交决策 → 生成素材 → 提交数字人 → 合成 → 取片,而且每一步停下来给你看。

更关键的是三层回退:

你要改什么回退层级
画面 / 声音不对素材级重做(重生成图 / 重做 TTS / 重提数字人)
文字 / 方向不对阶段级重跑(rerun --stage)
整个做法都不对重开一条 run

这层设计对"AI 驱动"是必须的。AI 一次做对的概率有限,能不能低成本地改,决定了它到底能不能用。

跑起来

git clone https://github.com/Disoluteness/s2v-oss
cd s2v-oss
cp .env.example .env
docker compose up --build
docker compose exec api alembic upgrade head

http://localhost:18080/docs 是 OpenAPI,/workspace 是自带的工作台。

不想马上花钱接供应商也行——有个 MVP 示例端点,用本地确定性 FFmpeg 媒体把「脚本到 MP4」整条链路走通:

curl -X POST http://localhost:18080/api/mvp/sample-runs \
  -H "Content-Type: application/json" \
  -d '{
    "title": "示例",
    "raw_script": "秋天第一杯奶茶,还没人给你点?别等啦,自己冲就好!",
    "brand_name": "示例品牌",
    "target_duration_seconds": 15
  }'

装 SDK 后一条命令出片:

pip install ./sdk/python
s2v quick "把这段脚本做成 15 秒口播" --download out.mp4

这里有个必须提前知道的事:s2v runs wait 停在 waiting_review 时退出码是 0,不是失败。人(或 AI)本来就是流水线的资源,卡在关口等审批是设计行为。

已知边界

以下照搬 README,不做美化:

  • 没配供应商 key 时,相关工具在预检里显示 unavailable,run 停在关口——设计行为,不是故障
  • 数字人成本估算返回 0,预算闸门对这条链路暂无保护
  • wan2.2-s2v 要求音频 < 20 秒且 < 15MB,平台暂不自动切段
  • /api/assets/{id}/download 对 wav 可能 500(用签名链接 /content 代替)
  • 供应商 4xx 目前统一归类为 input.invalid_or_missing,粒度待细化
  • 无 HA 数据库、无跨区灾备、无 KMS——单机够用,上生产自己评估

最后

项目从零写的(clean-room),设计思路受 OpenMontage「分阶段产物 + 人工关口」启发,但代码、pipeline 定义、schema、prompt、渲染器全部自己实现,仓库里写了明确的致谢和边界声明。

Apache-2.0,随便用。

如果这套"把生成拆成可审核阶段"的思路对你有用,欢迎 star 或直接提 issue 骂设计——尤其是坑 4 那种付费重试的边界,我大概率还有没想到的地方。