把 AI 视频生成写成一次同步 HTTP 请求,Demo 往往能跑,生产环境却很容易在 60 秒网关超时、进程重启或用户重复点击时失控。真正困难的不是"怎么调用模型",而是如何在几分钟甚至更久的等待中,知道任务有没有被重复创建、哪个结果可信、回调能不能重放,以及超时后还能不能恢复。
2026 年 8 月,Vercel AI SDK 给实验性视频接口加入了异步 start/status、轮询和 Webhook 能力。这是一个值得注意的信号:视频生成需要的是可恢复的长任务控制面,而不是把连接一直挂着。本文不连接真实视频供应商,而是用一个通过 6 项测试的 TypeScript 最小实验,把状态机、幂等键、事件去重和终态保护拆开验证。
一、视频生成不是慢一点的普通请求
普通接口通常在一次请求里完成"接受、处理、返回"。视频生成则更像订单履约:先拿到供应商 operation ID,随后多次查询状态,最后得到产物或失败原因。调用方、供应商和回调服务都可能独立重试。
| 风险 | 表面现象 | 真正需要的控制 |
|---|---|---|
| 网关或函数超时 | 请求断开,但供应商仍在生成 | start/status 分离与持久化任务 |
| 用户重复提交 | 同一视频被计费两次 | 业务幂等键和唯一约束 |
| 轮询与 Webhook 同时到达 | 同一成功事件处理两次 | 统一事件归并与事件 ID 去重 |
| 旧轮询晚于新回调 | 成功被旧失败覆盖 | 时间/版本门禁与终态保护 |
| Worker 重启 | 内存中的计时器消失 | 可恢复调度,不依赖单进程等待 |
因此,"请求成功"最多说明供应商接受了任务,不等于视频已经生成,更不等于文件已下载、校验并进入后续剪辑流水线。
二、从 AI SDK 的异步接口看控制面
官方 ai@7.0.50 Release 说明,实验性 VideoModelV4 可以实现 doStart、doStatus 和 handleWebhookOption,experimental_generateVideo 也接受 poll 与 webhook 选项。轮询还允许传入自定义 delay,以适配 durable workflow。
这次能力最早出现在 @ai-sdk/xai@4.0.26 的补丁说明中;当前核验到的后续补丁 @ai-sdk/xai@4.0.27 只更新依赖。版本号会继续变化,可复用的不是某个函数签名,而是下面这层分工:
| 层 | 负责什么 | 不该负责什么 |
|---|---|---|
| Provider 适配层 | 创建任务、查询状态、解析回调 | 决定业务幂等与最终交付 |
| SDK 编排层 | 选择轮询或 Webhook、等待策略 | 代替业务数据库做唯一约束 |
| 业务任务层 | 状态机、幂等、审计、超时、产物验收 | 假设回调只来一次 |
接口是 experimental 也意味着生产代码要固定版本、包一层适配器,并为状态映射写回归测试,避免升级后供应商状态语义悄悄改变。
三、先定义显式状态机
不要只存一个 status: string。最小状态至少应包含 submitted、running、succeeded、failed、cancelled 和 timed_out,并把成功产物和失败原因约束在对应分支里。
ts
type JobState =
| { status: "submitted"; operationId: string; updatedAt: number }
| { status: "running"; operationId: string; updatedAt: number }
| { status: "succeeded"; operationId: string; outputUrl: string; updatedAt: number }
| { status: "failed"; operationId: string; errorCode: string; updatedAt: number }
| { status: "cancelled"; operationId: string; updatedAt: number }
| { status: "timed_out"; operationId: string; updatedAt: number }
timed_out 要成为显式终态,而不是只记一条日志。它表示业务等待预算耗尽,不一定表示供应商已经停止生成。是否继续低频补偿查询、是否尝试取消供应商任务,应由单独策略决定。
四、幂等键要挡在供应商调用之前
最危险的顺序是:先调用供应商,再把 operation ID 写入数据库。只要写库失败,重试就可能再次创建视频。更稳妥的入口是先用业务幂等键占位,再由唯一约束保证同一业务动作只映射到一个本地任务。
ts
function startOrReuse(jobs: readonly JobAggregate[], request: StartRequest) {
const existing = jobs.find(
(job) => job.state.idempotencyKey === request.idempotencyKey,
)
if (existing !== undefined) return { kind: "reused", job: existing }
return { kind: "created", job: createSubmittedJob(request) }
}
实验中的纯函数只证明"相同键复用同一任务"的语义。生产环境还需要数据库唯一索引、事务或原子 upsert。幂等键应来自稳定业务身份,例如 projectId + shotId + renderVersion,不能用每次重试都会变化的随机 UUID。
五、轮询和 Webhook 必须汇入同一个归并器
轮询与 Webhook 不是两套状态更新代码。它们只是事件来源不同,最终都要进入同一个 applyEvent。否则一边做了去重,另一边仍可能覆盖终态。
ts
type TransitionResult =
| { kind: "applied"; aggregate: JobAggregate }
| {
kind: "ignored"
reason: "duplicate" | "stale" | "terminal"
aggregate: JobAggregate
}
function applyEvent(aggregate: JobAggregate, event: VideoJobEvent): TransitionResult
Webhook 的 eventId 应先通过签名校验,再进入去重表;如果供应商没有稳定事件 ID,可以用供应商任务 ID、事件类型和版本号组成可重放指纹。处理成功后再确认消息,失败则允许重试。
六、按"重复、乱序、终态"顺序拒绝坏更新
事件归并器的判断顺序应固定并可测试:先识别重复事件,再拒绝旧观察,最后保护终态。实验使用 observedAt 演示乱序门禁;生产环境更适合供应商单调递增版本号或数据库 compare-and-set。
ts
if (aggregate.seenEventIds.includes(event.eventId)) {
return { aggregate, kind: "ignored", reason: "duplicate" }
}
if (event.observedAt < aggregate.state.updatedAt) {
return { aggregate, kind: "ignored", reason: "stale" }
}
if (isTerminal(aggregate.state)) {
return { aggregate, kind: "ignored", reason: "terminal" }
}
终态保护很关键:任务一旦成功,迟到的失败轮询不能把它改回失败;失败或超时后若要"复活",也应创建新的尝试记录,而不是偷偷改写原任务历史。
七、最小实验验证了什么
实验使用 Bun 1.3.1、TypeScript 7.0.2 和 Biome 2.2.0。测试先失败于缺失实现,补齐最小代码后,幂等复用、合法状态迁移、Webhook 去重、乱序拒绝、终态保护、显式超时和 CLI 场景均通过。
text
Biome: Checked 7 files. No fixes applied.
TypeScript --noEmit: passed
6 pass, 0 fail, 14 expect() calls
CLI: {"appliedEvents":2,"createdJobs":1,"finalStatus":"succeeded","ignoredEvents":2,"reusedJobs":1}
ts
const success = applyEvent(job, webhookSucceeded)
const duplicate = applyEvent(success.aggregate, webhookSucceeded)
const lateFailure = applyEvent(success.aggregate, newerPollFailed)
// duplicate.reason === "duplicate"
// lateFailure.reason === "terminal"
这些结果只能证明本地状态机的示例行为符合测试,不能证明真实供应商稳定,也不能证明 Webhook 来源可信、文件可用或账单正确。
八、接入生产前的检查表
- 业务幂等键有数据库唯一约束,重复请求返回同一任务;
- 供应商 operation ID 在任务表中持久化且可追踪;
- 轮询与 Webhook 进入同一个状态归并器;
- Webhook 验签、防重放,并记录事件 ID;
- 状态更新有版本或 compare-and-set,拒绝乱序覆盖;
- 成功、失败、取消、业务超时都是显式终态;
- Worker 重启后能从数据库恢复调度;
- 轮询有退避、抖动、最大次数和全局并发上限;
- 成功后还要下载、校验 MIME/大小/时长并转存自有对象存储;
- 任务、尝试、事件和产物分表审计,费用能对到业务动作;
- experimental API 固定版本并由适配层隔离;
- 超时不被误报为供应商取消,补偿策略单独配置。
一条稳健的视频流水线,关注的不只是"生成成功率",还包括重复创建率、终态冲突数、回调延迟、轮询次数、超时后晚到成功数和产物验收失败率。只有这些指标可见,长任务才真正可运营。
九、来源与验证边界
- Vercel AI SDK
ai@7.0.50Release - Vercel AI SDK 异步视频接口提交
79e133c - Vercel AI SDK
@ai-sdk/xai@4.0.26Release - Vercel AI SDK
@ai-sdk/xai@4.0.27Release - 本地实验:脱敏 TypeScript 异步视频任务状态机
验证边界 本文通过 Ego 核验了官方 Release 与提交说明,并完成脱敏 TypeScript 状态机实验;没有调用真实视频模型,没有验证任何供应商的成功率、成本、计费、Webhook 签名或产物质量。AI SDK 视频接口仍标记为 experimental,生产接入前应以当日官方文档和锁定版本为准。