核心摘要
生产级 AI 视频 API 是一套工作流编排。先持久化 Job,再提交可能计费的 Attempt;超时进入不确定状态;Webhook 与轮询共同对账;资产转存后才标记可交付。可靠性来自状态不变量,而不是模型排名。
目录
- 核心要点
- 先定义用户契约
- 把供应商能力当作版本化证据
- 拆分任务尝试事件与资产
- 使用单调状态机
- 提交任务但不制造重复生成
- 签名 Webhook 与轮询对账并用
- 按失败类型决定是否重试
- 接管生成资产的生命周期
- 控制配额与有效成片成本
- 观测整个控制面
- 运行 Go 参考实现
- 建立发布门禁
- 常见问题
- 总结
核心要点
- 立即返回本地任务 ID:视频生成应采用
202 Accepted、任务队列和状态查询资源,不能阻塞用户请求等待成片。 - 调用供应商前先落库:同一个事务内创建 Job、预留预算、创建第一次 Attempt,并写入 Outbox 事件。
- 显式表示提交结果未知:请求超时可能发生在供应商已接受付费任务之后,直接重试可能生成两次。
- 回调不是唯一真相来源:必须基于原始请求体验签、按事件 ID 去重、单调更新状态,并轮询长时间未更新的任务。
- 临时下载地址不等于资产系统:输出必须经过来源限制、媒体校验、流式下载、校验和计算,再进入自有存储。
- 成本要计算到可交付结果:只看单次 API 报价会忽略重试、审核失败、用户放弃和资产转存失败。
本文只解决 AI 视频生成 API 的工程接入。画质与业务通过率应使用独立的 AI 视频评测协议;盲测结果不能代替账号、地区、配额和可靠性验收。
先定义用户契约
面向用户的 API 应承诺一个持久工作流,而不是照搬任意供应商的请求结构。API 网关接收一项逻辑需求,生成本地任务 ID,并让调用方通过稳定的状态资源查询进度。
创建结果至少包含:
| 字段 | 含义 | 契约 |
|---|---|---|
job_id |
本系统不可变的任务标识 | 在任何供应商调用之前生成 |
state |
本地任务状态 | 初始为 accepted,不能根据 HTTP 连接状态推断 |
status_url |
任务查询地址 | 更换供应商后仍保持不变 |
estimated_class |
粗粒度成本或延迟档位 | 不承诺精确价格和完成时间 |
request_id |
全链路排查标识 | 可以对外暴露,但不能包含凭证 |
本地事务提交成功后立即返回,不等待生成,也不向前端暴露供应商临时链接。鉴权、取消、配额、保留和错误语义因此由产品掌控。
在实现前先区分三个概念:
- Job(任务):用户的一次逻辑请求,重试和切换供应商后仍是同一个任务。
- Attempt(尝试):一次可能产生费用的供应商提交。
- Asset(资产):经过校验并保存在自有存储中的输出副本。
如果把三者混成一条记录,团队很容易把重试统计成新用户任务,也容易把“供应商生成成功”误当成“用户已经拿到视频”。
把供应商能力当作版本化证据
Adapter 负责翻译当前供应商契约,路由策略则读取版本化能力注册表。产品名、模型别名、限额、地区、价格和预览状态变化很快,不应散落在业务分支里。
当前一手文档展示了四种不同的服务面:
| 服务面 | 异步契约 | 完成路径 | 对工程设计的影响 |
|---|---|---|---|
| Google Gemini API 视频生成 | 视频生成返回 Long-running Operation | 可轮询 Operation;Google 的 Webhook 文档也说明了长任务事件交付 | 模型选择、Operation 标识和回调配置要分开保存 |
| OpenAI Videos API | Create 返回可查询状态的视频任务 | 轮询或接收 video.completed、video.failed 事件,再下载结果 |
文档说明下载地址最长约一小时过期,因此转存属于完成链路 |
| 可灵图生视频 API | 提交后进入 submitted、processing、succeed 或 failed |
可提供 callback_url 与唯一 external_task_id,也可查询状态 |
外部任务 ID 可用于对账;文档说明结果文件 30 天后清理 |
| Runway API | 创建后返回 Task ID | 轮询或使用 SDK 的等待能力 | 保存 API 版本头,并按 上线指南处理输入校验、429 与 503 |
这张表不是模型排名。它说明稳定抽象应是“拥有远端身份的异步 Attempt”,而不是写死“某模型负责草稿、某模型负责终稿”。路由前应按目标账号核对具体 Endpoint、模型、地区、输入模式、时长、分辨率、审核行为、并发限制和生效价格。
能力注册表建议包含:
| 维度 | 应记录的值 |
|---|---|
| 身份 | 供应商、Endpoint、模型别名、供应商模型 ID |
| 可用性 | 地区、账号档位、预览或正式可用状态 |
| 输入 | 文本、图片、参考视频及媒体限制 |
| 输出 | 可选时长、画幅、分辨率、音频行为 |
| 操作 | 轮询、Webhook、取消、按外部键查询 |
| 限制 | 账号并发、速率限制、请求体大小 |
| 计费 | 币种、计费单位、价格生效时间 |
| 治理 | 保留期、来源元数据、内容策略版本 |
每次 Attempt 都要保存当时选中的快照。否则数月后的故障复盘无法区分“供应商模型发生变化”和“本系统出现回归”。
拆分任务尝试事件与资产
最小可运营数据模型至少需要五类记录,因为它们回答的是不同问题。
| 记录 | 必备字段 | 回答的问题 |
|---|---|---|
video_job |
租户、请求幂等键、能力需求、状态、版本 | 用户要求了什么,现在能看到什么? |
video_attempt |
Job ID、供应商、模型、稳定外部键、Remote ID、价格快照 | 哪一次可能计费的执行发生了? |
outbox |
聚合 ID、事件类型、载荷、租约时间、投递次数 | 本地提交后,还有哪个外部动作必须执行? |
provider_event_inbox |
供应商、Event ID、原始 Body 哈希、接收时间 | 这条回调是否已经影响过状态? |
video_asset |
对象 URI、字节数、媒体类型、校验和、来源、保留策略 | 系统是否真正拥有可用输出? |
关键不变量应落实为数据库约束,而不只是写在设计文档里:
(tenant_id, client_idempotency_key)对逻辑任务唯一。(job_id, attempt_number)唯一。- 供应商支持幂等键时,
(provider, provider_idempotency_key)唯一。 - 获得远端身份后,
(provider, remote_id)唯一。 - 事件收件箱中的
(provider, event_id)唯一。 - 没有已提交的 Asset 记录,Job 不得进入
ready。
敏感输入需要单独治理。可以保存 Prompt 哈希用于去重和审计,但不能把完整 Prompt、源媒体签名 URL、输出临时 URL 或凭证写入指标和普通日志。若复现能力要求保留 Prompt 或输入资产,应加密存储、按租户隔离权限,并配置删除周期。
使用单调状态机
单调状态机用于阻止延迟回调和并发 Worker 把任务状态倒退。各供应商状态应先在 Adapter 边界归一化,再由唯一的 Compare-and-Set 迁移函数处理。
最重要的边界是 succeeded 与 ready。前者只表示供应商报告已有输出,后者才表示本系统已经校验并保存输出。面向用户的成功率必须使用 ready。
状态迁移要遵守以下规则:
- 任务进入终态后,延迟到达的
running不能让它倒退。 - 数据库更新必须携带期望版本号或允许的前置状态集合。
- 重复事件返回成功,但不重复执行副作用。
- 未识别的供应商状态应保留原始值供排查,不能凭空映射到本地终态。
- “取消”首先是请求;只有供应商确认后,才能判断计算和计费是否停止。
提交任务但不制造重复生成
安全的提交链路会在网络调用前提交本地意图,并把超时当作知识不完备问题:此时系统不知道远端是否已经接受任务。
建议按以下顺序执行:
- 校验内容策略、输入媒体、租户并发和保守成本上限。
- 在同一数据库事务中创建 Job 与 Attempt、预留预算,并写入
dispatch_requestedOutbox。 - Worker 以租约方式领取 Outbox 记录。
- 使用该 Attempt 稳定的供应商幂等键或
external_task_id发起提交。 - 成功时保存 Remote ID 和价格快照。
- 请求发送后连接中断时,进入
submit_unknown。 - 创建新 Attempt 前,必须先用原外部键对账。
事务 Outbox 解决“数据库已提交,但消息尚未发布时进程崩溃”的窗口。它无法让外部供应商调用天然变成 Exactly-once。现实目标是 At-least-once 投递、幂等本地副作用,以及供应商支持下的远端去重。
submit_unknown 需要专门的运营策略:
| 供应商能力 | 安全动作 |
|---|---|
| Create 支持幂等键 | 按供应商契约使用完全相同的键重试 |
| 支持按外部任务 ID 查询 | 用原 ID 查询,并绑定恢复出的远端任务 |
| 支持基于可靠请求元数据搜索 | 在有限时间窗口内谨慎对账 |
| 没有可靠去重或查询能力 | 停止自动重发,继续有限轮询或转人工处理 |
重试时更换幂等键会产生新 Attempt;不同 Prompt 共用一个键则会破坏身份。键应从不可变的 Attempt 身份生成,不能使用 Worker 第几次投递作为组成部分。
签名 Webhook 与轮询对账并用
Webhook 负责降低完成通知延迟,轮询负责在消息交付失败时恢复正确性。两条路径必须复用同一套状态迁移代码。
若供应商采用 Standard Webhooks 规范,签名内容为 webhook-id.webhook-timestamp.raw_body。必须在 JSON 解析前读取原始字节;拒绝超出短时间窗的消息;用常量时间比较签名;密钥轮换期间允许新旧密钥重叠;并通过唯一 Event ID 防止重放产生副作用。
Webhook 接收端在返回 2xx 前只做有限工作:
- 限制请求体大小并检查 Content-Type。
- 对原始请求体执行时间戳和签名验证。
- 在唯一约束下把事件写入持久收件箱。
- 以事务方式投递后续处理。
- 返回成功。
不要在回调请求中下载视频、调用其他供应商或执行质量评测。Google 的 Webhook 文档说明,非 2xx 的交付可能持续重试最长 24 小时;处理器越慢,故障期间重复流量越大。
轮询计划应持久化,而不是在 Web 进程里开启固定死循环。记录 next_reconcile_at,远端任务仍活跃时逐步扩大间隔。回调长时间未到、Worker 重启恢复、以及判定任务卡死前,都应触发轮询;增加随机抖动,避免部署或供应商恢复时所有任务同步请求。
按失败类型决定是否重试
重试策略必须先回答三个问题:上一次操作是否可能已经被接受、再次尝试是否可能改变结果、剩余预算是否允许。
| 失败类型 | 示例 | 默认处理 |
|---|---|---|
| 本地校验失败 | 媒体、时长或画幅不支持 | 提交前拒绝,不重试 |
| 鉴权或权限失败 | 401、403 |
打开熔断并告警,不逐任务重试 |
| 内容策略拒绝 | 供应商标记内容不允许 | 保存原因分类,不盲目切换供应商 |
| 容量或限流 | 429、文档约定的 503 |
遵守 Retry-After,使用有上限的 Full Jitter,保持 Attempt 身份 |
| 明确未发送 | 本地队列租约在请求前失效 | 重新投递 Outbox |
| 提交结果未知 | 请求字节可能发出后超时或断连 | 先对账,不自动创建新键 |
| 生成终态失败 | 供应商返回 Failed | 只有错误分类可恢复且预算足够时才重试 |
| 资产下载失败 | URL 过期、大小不符、校验失败 | 刷新元数据或重试转存,不先重新生成 |
RFC 9110 规定 Retry-After 可以是延迟秒数,也可以是 HTTP 日期。两种格式都要解析,并以任务截止时间为上限,再随机化 Worker 唤醒时刻。重试预算应同时限制尝试次数、累计等待时间和预计费用。
供应商 Fallback 是新的语义 Attempt,不是传输层重试。另一个模型可能改变构图、审核、时长或音频结果。产品策略必须明确这种变化是否可接受,资产来源中要如实记录,并重新预留预算。
接管生成资产的生命周期
供应商返回成功不代表资产已可靠交付,因为输出地址通常有有效期。OpenAI 文档说明视频下载链接最长约一小时过期;可灵文档说明结果文件 30 天后清理。因此,转存应及时执行,而且不能依赖用户是否在线。
资产 Worker 应执行:
- 只接收可信 Adapter 针对预期 Remote Task 产出的 URL。
- 强制 HTTPS,并通过受限出口客户端下载。
- 仅允许预期域名,或对每次重定向和最终解析地址重新校验,防止 SSRF。
- 流式读取并设置字节数与时间上限,不能把整段视频一次性读入内存。
- 校验声明值与实际媒体类型、尺寸、时长和业务限制。
- 在流式写入时计算密码学校验和。
- 先写临时对象,确认上传完整后再提升为正式对象。
- 原子提交 Asset 记录与
ready状态。
Asset 应保存供应商、模型身份、Attempt ID、Prompt 编译器版本、源媒体哈希、内容策略结果、校验和,以及能在媒体处理链路中保留的来源凭据。生成式 AI 的输出仍需接受普通媒体安全、权利审核和数据保留治理。
不要把供应商签名 URL 当成永久字段。它可能通过日志和客服系统泄露,也可能在用户打开前过期,甚至授予超出预期的访问权限。对外应返回由产品鉴权、底层指向自有对象存储的地址。
控制配额与有效成片成本
成本控制从提交前开始,到资产进入 ready 才结束。“先用便宜模型出草稿”不是普适结论,因为草稿未必能预测终稿,额外尝试也可能比直接生成更贵。
成本账本采用两阶段处理:
- 根据能力、时长、分辨率和当前价格快照,预留一个保守上限。
- 供应商返回实际用量或 Attempt 进入契约明确的终态后,释放或结算预留额。
- 延迟到达的用量记录进入对账,但不能改写历史价格快照。
至少要设置四类相互独立的限制:
| 限制 | 防止的问题 |
|---|---|
| 租户活跃 Attempt 并发 | 单一租户占满全部供应商槽位 |
| 租户每日预留金额 | 突发请求产生无上限账单 |
| 单 Job 重试与 Fallback 预算 | 无法生成的 Prompt 在供应商间循环 |
| 供应商与模型并发 | 429 风暴和队列坍塌 |
成本指标应在结果边界计算:
单条可交付视频成本 = 所有 Attempt 实际费用之和 / ready Job 数
每可交付秒成本 = 所有 Attempt 实际费用之和 / ready Asset 总时长
有效产出率 = ready Job 数 / 可计费 Attempt 数
按供应商、模型、输入模式、时长、分辨率、租户档位和 Prompt 编译器版本拆分指标。在受控工作负载中比较完整 Attempt 账本、审核通过率和实际交付资产之前,不应对外宣称固定节省比例。
观测整个控制面
可观测系统必须说明任务停在哪里,而不只是统计供应商 Endpoint 报错数量。
需要跟踪:
- 从
accepted到领取 Outbox 的队列等待时间 - 提交延迟与
submit_unknown比例 - 供应商接受率、运行率、终态失败率和策略拒绝率
- 按能力与供应商拆分的完成延迟及分位数
- Webhook 验签失败、重复率、消息年龄和处理积压
- 轮询发现状态变化的次数与卡住任务数
- 资产下载、校验和持久化失败率
- 预留与实际结算差异、每可交付秒成本
- 用户取消、放弃和人工处理量
一条 Trace 应贯穿 API、Outbox、Worker、供应商 Attempt、Webhook、轮询器、资产转存和用户通知。Span 可以包含 Job ID、Attempt ID、供应商、模型快照、Remote ID、状态版本和 Event ID,但不能包含 Prompt、凭证、签名 URL 和源媒体。
SLO 应围绕系统可控制的结果定义。例如,统计符合条件的 Accepted Job 在能力对应截止时间前进入 ready 的比例。供应商生成时间、内部排队时间和资产转存时间需要拆开,故障发生时才能明确责任边界。
运行 Go 参考实现
下面这套仅使用 Go 标准库的实现覆盖两条关键控制面路径:从不确定提交中恢复,以及幂等、单调地处理供应商事件;同时按原始请求体和有限时间窗验证 Standard Webhooks 签名。
内存 Map 只用于让示例容易编译。生产环境应替换为数据库事务、唯一约束、Outbox 和事件收件箱。
生产 Adapter 必须把“请求字节可能已经离开当前进程”之后发生的网络错误统一映射为 ErrOutcomeUnknown。只有能证明请求尚未发送,或供应商明确拒绝,才可以进入普通失败分支。
创建 controller.go:
package videoworkflow
import (
"context"
"crypto/hmac"
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"errors"
"net/http"
"strconv"
"strings"
"sync"
"time"
)
var (
ErrOutcomeUnknown = errors.New("provider may have accepted the request")
ErrRemoteNotFound = errors.New("provider job not found")
ErrInvalidState = errors.New("invalid state transition")
)
type State string
const (
StateAccepted State = "accepted"
StateDispatching State = "dispatching"
StateSubmitUnknown State = "submit_unknown"
StateSubmitted State = "submitted"
StateRunning State = "running"
StateSucceeded State = "succeeded"
StateReady State = "ready"
StateFailed State = "failed"
)
var stateOrder = map[State]int{
StateAccepted: 10,
StateDispatching: 20,
StateSubmitUnknown: 25,
StateSubmitted: 30,
StateRunning: 40,
StateSucceeded: 50,
StateReady: 60,
StateFailed: 60,
}
type Job struct {
ID string
IdempotencyKey string
RemoteID string
State State
Version int64
Failure string
}
type Provider interface {
Submit(context.Context, string) (string, error)
LookupByIdempotencyKey(context.Context, string) (string, bool, error)
}
type Controller struct {
mu sync.Mutex
provider Provider
jobs map[string]Job
jobByRemote map[string]string
seenEvents map[string]struct{}
}
func NewController(provider Provider) *Controller {
return &Controller{
provider: provider,
jobs: make(map[string]Job),
jobByRemote: make(map[string]string),
seenEvents: make(map[string]struct{}),
}
}
func (c *Controller) Create(jobID string) Job {
c.mu.Lock()
defer c.mu.Unlock()
if job, ok := c.jobs[jobID]; ok {
return job
}
job := Job{
ID: jobID,
IdempotencyKey: "video:" + jobID + ":attempt:1",
State: StateAccepted,
Version: 1,
}
c.jobs[jobID] = job
return job
}
func (c *Controller) Dispatch(ctx context.Context, jobID string) error {
c.mu.Lock()
job, ok := c.jobs[jobID]
if !ok || job.State != StateAccepted {
c.mu.Unlock()
return ErrInvalidState
}
job.State = StateDispatching
job.Version++
c.jobs[jobID] = job
c.mu.Unlock()
remoteID, err := c.provider.Submit(ctx, job.IdempotencyKey)
c.mu.Lock()
defer c.mu.Unlock()
job = c.jobs[jobID]
if errors.Is(err, ErrOutcomeUnknown) {
job.State = StateSubmitUnknown
job.Failure = err.Error()
job.Version++
c.jobs[jobID] = job
return err
}
if err != nil {
job.State = StateFailed
job.Failure = err.Error()
job.Version++
c.jobs[jobID] = job
return err
}
c.bindRemote(&job, remoteID)
c.jobs[jobID] = job
return nil
}
func (c *Controller) ReconcileUnknownSubmit(
ctx context.Context,
jobID string,
) error {
c.mu.Lock()
job, ok := c.jobs[jobID]
c.mu.Unlock()
if !ok || job.State != StateSubmitUnknown {
return ErrInvalidState
}
remoteID, found, err := c.provider.LookupByIdempotencyKey(
ctx,
job.IdempotencyKey,
)
if err != nil {
return err
}
if !found {
return ErrRemoteNotFound
}
c.mu.Lock()
defer c.mu.Unlock()
job = c.jobs[jobID]
if job.State != StateSubmitUnknown {
return nil
}
c.bindRemote(&job, remoteID)
c.jobs[jobID] = job
return nil
}
func (c *Controller) bindRemote(job *Job, remoteID string) {
job.RemoteID = remoteID
job.State = StateSubmitted
job.Failure = ""
job.Version++
c.jobByRemote[remoteID] = job.ID
}
func (c *Controller) ApplyProviderEvent(
eventID string,
remoteID string,
incoming State,
) error {
if incoming != StateRunning &&
incoming != StateSucceeded &&
incoming != StateFailed {
return ErrInvalidState
}
c.mu.Lock()
defer c.mu.Unlock()
jobID, ok := c.jobByRemote[remoteID]
if !ok {
return ErrRemoteNotFound
}
if _, duplicate := c.seenEvents[eventID]; duplicate {
return nil
}
c.seenEvents[eventID] = struct{}{}
job := c.jobs[jobID]
if job.State == StateReady ||
job.State == StateSucceeded ||
job.State == StateFailed {
return nil
}
if stateOrder[incoming] <= stateOrder[job.State] {
return nil
}
job.State = incoming
job.Version++
c.jobs[jobID] = job
return nil
}
func VerifyStandardWebhook(
secret string,
header http.Header,
body []byte,
now time.Time,
tolerance time.Duration,
) error {
messageID := header.Get("webhook-id")
timestamp := header.Get("webhook-timestamp")
signatures := header.Get("webhook-signature")
if messageID == "" || timestamp == "" || signatures == "" {
return errors.New("missing webhook signature headers")
}
seconds, err := strconv.ParseInt(timestamp, 10, 64)
if err != nil {
return errors.New("invalid webhook timestamp")
}
delta := now.Sub(time.Unix(seconds, 0))
if delta < -tolerance || delta > tolerance {
return errors.New("webhook timestamp outside tolerance")
}
encodedSecret := strings.TrimPrefix(secret, "whsec_")
key, err := base64.StdEncoding.DecodeString(encodedSecret)
if err != nil {
return errors.New("invalid webhook secret")
}
signed := make([]byte, 0, len(messageID)+len(timestamp)+len(body)+2)
signed = append(signed, messageID...)
signed = append(signed, '.')
signed = append(signed, timestamp...)
signed = append(signed, '.')
signed = append(signed, body...)
mac := hmac.New(sha256.New, key)
_, _ = mac.Write(signed)
expected := mac.Sum(nil)
for _, candidate := range strings.Fields(signatures) {
versionAndValue := strings.SplitN(candidate, ",", 2)
if len(versionAndValue) != 2 || versionAndValue[0] != "v1" {
continue
}
actual, decodeErr := base64.StdEncoding.DecodeString(versionAndValue[1])
if decodeErr == nil &&
subtle.ConstantTimeCompare(actual, expected) == 1 {
return nil
}
}
return errors.New("webhook signature mismatch")
}
参考实现已通过故障注入测试:供应商接受后响应丢失、迟到状态事件、正文被篡改和签名时间戳过期。示例刻意不提供供应商 HTTP Payload,因为每个 Adapter 都应依据当前官方契约生成,不能复制一个虚构的“通用视频端点”。
建立发布门禁
只要故障注入破坏了状态不变量,生产发布就应失败。Adapter 应使用脱敏后的供应商 Fixture 测试,完整链路则在非生产账号执行。
| 门禁 | 注入方式 | 必须得到的证据 |
|---|---|---|
| 重复创建 | 并发发送相同 Client Key | 只有一个逻辑 Job 和一个初始 Attempt |
| 不确定提交 | 供应商接受后丢弃响应 | 对账找到一个 Remote Task,没有盲目重发 |
| 回调重放 | 重复发送同一 Event | 只有一条 Inbox 记录和一次状态副作用 |
| 回调乱序 | 先发 succeeded,再发延迟的 running |
状态停留在 succeeded 或更后阶段 |
| 回调丢失 | 屏蔽终态 Webhook | Poller 在修复目标内发现终态 |
| Worker 崩溃 | 分别在领取 Outbox 后、收到远端响应后终止 | 租约恢复后继续执行,不产生重复副作用 |
| 供应商限流 | 返回两种格式的 Retry-After |
延迟受尊重,且有上限、抖动和预算 |
| 恶意资产地址 | 重定向到内网地址或超过大小限制 | 下载被阻断,Job 不进入 ready |
| 输出链接过期 | 返回已过期临时 URL | 刷新元数据或重试转存,不重新生成 |
| 预算耗尽 | 用完 Job 的重试预留额 | 停止新 Attempt,并给出可解释终态 |
用户体验门禁还应确认:刷新不重复创建,恢复会话后状态一致,错误能区分输入、策略、容量、生成和资产交付问题,且不暴露凭证。
常见问题
生产应用应该怎样接入 AI 视频生成 API?
先持久化 Job 并返回 ID,再由 Outbox Worker 提交 Attempt。Webhook 与轮询通过同一状态机更新进度,资产 Worker 完成转存后才进入 ready,因此用户契约不依赖供应商 Schema。
提交视频任务时超时应该怎么处理?
只要请求可能已经到达供应商,就标记 submit_unknown,保留原 Attempt 和幂等键并查询远端。无法可靠查询时进入有限排查或人工处理,不能换键自动重发。
视频生成应该使用 Webhook 还是轮询?
两者都要使用。Webhook 降低通知延迟,轮询修复丢失或无效回调;两条路径共用同一个幂等迁移函数,并对轮询设置退避和抖动。
如何避免任务重复和重复计费?
用租户级 Key 去重逻辑请求,用稳定 Key 标识每次 Attempt,并通过唯一约束落实。Webhook Event 写入 Inbox 去重;结果未知时先对账,不能直接创建新 Attempt。
什么情况下生成视频才算可交付?
输出经过来源、重定向、媒体属性、大小和校验和检查,并写入自有存储后才可交付。供应商的 succeeded 只会启动资产转存阶段。
总结
可靠的 AI 视频生成依赖供应商中立的控制面。先于网络调用持久化本地意图,分离 Job 与 Attempt,显式建模不确定结果,用 Webhook 与轮询对账,并让状态更新保持幂等和单调。随后管住资产下载、实际费用结算和结果级 SLO,才能把“能生成一段视频”升级为“能稳定交付视频”。
相邻的 多模态流水线工程指南讲解输入校验与 Adapter;文生视频术语补充生成机制。