核心摘要

生产级 AI 视频 API 是一套工作流编排。先持久化 Job,再提交可能计费的 Attempt;超时进入不确定状态;Webhook 与轮询共同对账;资产转存后才标记可交付。可靠性来自状态不变量,而不是模型排名。

目录

核心要点

  • 立即返回本地任务 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、字节数、媒体类型、校验和、来源、保留策略 系统是否真正拥有可用输出?

关键不变量应落实为数据库约束,而不只是写在设计文档里:

  1. (tenant_id, client_idempotency_key) 对逻辑任务唯一。
  2. (job_id, attempt_number) 唯一。
  3. 供应商支持幂等键时,(provider, provider_idempotency_key) 唯一。
  4. 获得远端身份后,(provider, remote_id) 唯一。
  5. 事件收件箱中的 (provider, event_id) 唯一。
  6. 没有已提交的 Asset 记录,Job 不得进入 ready。

敏感输入需要单独治理。可以保存 Prompt 哈希用于去重和审计,但不能把完整 Prompt、源媒体签名 URL、输出临时 URL 或凭证写入指标和普通日志。若复现能力要求保留 Prompt 或输入资产,应加密存储、按租户隔离权限,并配置删除周期。

使用单调状态机

单调状态机用于阻止延迟回调和并发 Worker 把任务状态倒退。各供应商状态应先在 Adapter 边界归一化,再由唯一的 Compare-and-Set 迁移函数处理。

flowchart TD A["accepted"] -->|"本地事务提交"| B["dispatch_pending"] B -->|"Worker 获取 Outbox 租约"| C["submitting"] C -->|"Remote ID 已保存"| D["submitted"] C -->|"响应结果不确定"| E["submit_unknown"] E -->|"查询找到已有任务"| D E -->|"无法安全恢复身份"| F["manual_review"] D -->|"供应商开始生成"| G["running"] D -->|"供应商终态失败"| H["failed"] G -->|"供应商输出存在"| I["succeeded"] G -->|"供应商终态失败"| H I -->|"开始转存"| J["ingesting"] J -->|"自有资产提交成功"| K["ready"] J -->|"校验或复制失败"| L["asset_failed"] L -->|"有限次数重试"| J

最重要的边界是 succeeded 与 ready。前者只表示供应商报告已有输出,后者才表示本系统已经校验并保存输出。面向用户的成功率必须使用 ready。

状态迁移要遵守以下规则:

  • 任务进入终态后,延迟到达的 running 不能让它倒退。
  • 数据库更新必须携带期望版本号或允许的前置状态集合。
  • 重复事件返回成功,但不重复执行副作用。
  • 未识别的供应商状态应保留原始值供排查,不能凭空映射到本地终态。
  • “取消”首先是请求;只有供应商确认后,才能判断计算和计费是否停止。

提交任务但不制造重复生成

安全的提交链路会在网络调用前提交本地意图,并把超时当作知识不完备问题:此时系统不知道远端是否已经接受任务。

建议按以下顺序执行:

  1. 校验内容策略、输入媒体、租户并发和保守成本上限。
  2. 在同一数据库事务中创建 Job 与 Attempt、预留预算,并写入 dispatch_requested Outbox。
  3. Worker 以租约方式领取 Outbox 记录。
  4. 使用该 Attempt 稳定的供应商幂等键或 external_task_id 发起提交。
  5. 成功时保存 Remote ID 和价格快照。
  6. 请求发送后连接中断时,进入 submit_unknown。
  7. 创建新 Attempt 前,必须先用原外部键对账。

事务 Outbox 解决“数据库已提交,但消息尚未发布时进程崩溃”的窗口。它无法让外部供应商调用天然变成 Exactly-once。现实目标是 At-least-once 投递、幂等本地副作用,以及供应商支持下的远端去重。

submit_unknown 需要专门的运营策略:

供应商能力 安全动作
Create 支持幂等键 按供应商契约使用完全相同的键重试
支持按外部任务 ID 查询 用原 ID 查询,并绑定恢复出的远端任务
支持基于可靠请求元数据搜索 在有限时间窗口内谨慎对账
没有可靠去重或查询能力 停止自动重发,继续有限轮询或转人工处理

重试时更换幂等键会产生新 Attempt;不同 Prompt 共用一个键则会破坏身份。键应从不可变的 Attempt 身份生成,不能使用 Worker 第几次投递作为组成部分。

签名 Webhook 与轮询对账并用

Webhook 负责降低完成通知延迟,轮询负责在消息交付失败时恢复正确性。两条路径必须复用同一套状态迁移代码。

sequenceDiagram participant P as 供应商 participant W as Webhook 边缘服务 participant I as 事件收件箱 participant Q as 工作队列 participant C as 状态控制器 participant R as 对账器 P->>W: 签名头与原始请求体 W->>W: 大小检查、时间窗检查、验签 W->>I: 按供应商与 Event ID 唯一写入 I->>Q: 投递归一化事件 W-->>P: 快速返回 2xx Q->>C: Compare-and-Set 状态迁移 R->>P: 轮询超过对账时间的 Attempt P-->>R: 当前远端状态 R->>C: 调用同一迁移函数

若供应商采用 Standard Webhooks 规范,签名内容为 webhook-id.webhook-timestamp.raw_body。必须在 JSON 解析前读取原始字节;拒绝超出短时间窗的消息;用常量时间比较签名;密钥轮换期间允许新旧密钥重叠;并通过唯一 Event ID 防止重放产生副作用。

Webhook 接收端在返回 2xx 前只做有限工作:

  1. 限制请求体大小并检查 Content-Type。
  2. 对原始请求体执行时间戳和签名验证。
  3. 在唯一约束下把事件写入持久收件箱。
  4. 以事务方式投递后续处理。
  5. 返回成功。

不要在回调请求中下载视频、调用其他供应商或执行质量评测。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 应执行:

  1. 只接收可信 Adapter 针对预期 Remote Task 产出的 URL。
  2. 强制 HTTPS,并通过受限出口客户端下载。
  3. 仅允许预期域名,或对每次重定向和最终解析地址重新校验,防止 SSRF。
  4. 流式读取并设置字节数与时间上限,不能把整段视频一次性读入内存。
  5. 校验声明值与实际媒体类型、尺寸、时长和业务限制。
  6. 在流式写入时计算密码学校验和。
  7. 先写临时对象,确认上传完整后再提升为正式对象。
  8. 原子提交 Asset 记录与 ready 状态。

Asset 应保存供应商、模型身份、Attempt ID、Prompt 编译器版本、源媒体哈希、内容策略结果、校验和,以及能在媒体处理链路中保留的来源凭据。生成式 AI 的输出仍需接受普通媒体安全、权利审核和数据保留治理。

不要把供应商签名 URL 当成永久字段。它可能通过日志和客服系统泄露,也可能在用户打开前过期,甚至授予超出预期的访问权限。对外应返回由产品鉴权、底层指向自有对象存储的地址。

控制配额与有效成片成本

成本控制从提交前开始,到资产进入 ready 才结束。“先用便宜模型出草稿”不是普适结论,因为草稿未必能预测终稿,额外尝试也可能比直接生成更贵。

成本账本采用两阶段处理:

  1. 根据能力、时长、分辨率和当前价格快照,预留一个保守上限。
  2. 供应商返回实际用量或 Attempt 进入契约明确的终态后,释放或结算预留额。
  3. 延迟到达的用量记录进入对账,但不能改写历史价格快照。

至少要设置四类相互独立的限制:

限制 防止的问题
租户活跃 Attempt 并发 单一租户占满全部供应商槽位
租户每日预留金额 突发请求产生无上限账单
单 Job 重试与 Fallback 预算 无法生成的 Prompt 在供应商间循环
供应商与模型并发 429 风暴和队列坍塌

成本指标应在结果边界计算:

text
单条可交付视频成本 = 所有 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:

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;文生视频术语补充生成机制。

一手资料与延伸阅读