调用 AI 视频 API 在演示中看似简单:

  1. 发送提示。
  2. 等待响应。
  3. 展示视频。

在生产环境中,模型很少在原始 HTTP 请求中返回视频。它会创建一个远程任务,给你一个外部任务 ID,经历提供商特定的状态流转,最终返回一个媒体 URL 或错误。你的应用必须在这一过程中保持用户状态、账单状态、审核状态与提供商状态的一致性。

本文为类似 MICT(一个 AI 图像与短视频工作区)这样的产品提供了一个与提供商无关的参考架构。代码示例是示意性的,并非 MICT 生产实现的逐字描述。有用的部分不是某个特定的 API 调用,而是围绕该调用的边界集合。

真实的工作流是一个状态机

一个可靠的生成请求包含的阶段远多于“加载”和“完成”:

request
  -> authenticate
  -> validate model settings
  -> moderate input
  -> calculate cost
  -> create and persist local task
  -> reserve or deduct credits
  -> create provider task
  -> attach external task ID
  -> poll provider
  -> normalize provider state
  -> moderate output
  -> complete or fail
  -> refund when required

Enter fullscreen mode Exit fullscreen mode

将其视为显式的状态机能让每一层共享一套词汇。通常一套精简的内部集合就足够了:

type GenerationStatus =
  | "running"
  | "processing"
  | "completed"
  | "failed";

Enter fullscreen mode Exit fullscreen mode

提供商可以在边缘保留自己的词汇:

function mapProviderState(state: string): GenerationStatus {
  switch (state) {
    case "waiting":
    case "queuing":
      return "running";
    case "generating":
      return "processing";
    case "success":
      return "completed";
    case "fail":
      return "failed";
    default:
      return "running";
  }
}

Enter fullscreen mode Exit fullscreen mode

重要的设计选择是:产品其余部分永远不会针对每个提供商的拼写做分支。适配器只做一次。

保持本地任务 ID 与外部任务 ID 分离

在调用提供商前,先创建并持久化自己的任务。

const taskId = createLocalTaskId();

await persistGeneration({
  taskId,
  userId,
  status: "running",
});

const externalTask = await provider.createTask(providerModel, input);

await attachExternalTaskId({
  taskId,
  externalTaskId: externalTask.id,
});

Enter fullscreen mode Exit fullscreen mode

本地 ID 属于你的产品,用于:

  • 授权检查;
  • 生成历史;
  • 积分交易;
  • 支持参考;
  • 分析;
  • 幂等性。

外部 ID 属于提供商,仅在查询或取消远程任务时使用。

当提供商发生变更、临时替换某个提供商,或支持工单引用一个从未收到外部任务 ID 的本地任务时,这种分离就很重要。

在资金变动前验证设置

不同的视频模型接受不同的模式、时长、分辨率、宽高比、输出数量和音频设置组合。不要让客户端随意创造这些组合。

将能力保存在服务端模型配置中:

type ModelConfig = {
  modes: Array<"t2v" | "i2v">;
  durations: number[];
  resolutions: string[];
  aspectRatios: string[];
  calculateCredits(input: {
    duration: number;
    resolution: string;
    outputCount: number;
  }): number;
};

Enter fullscreen mode Exit fullscreen mode

然后在审核、计费或提交给提供商前拒绝不支持的值:

if (!config.modes.includes(mode)) {
  throw new RequestError("Unsupported generation mode");
}

if (!config.resolutions.includes(resolution)) {
  throw new RequestError("Unsupported resolution");
}

Enter fullscreen mode Exit fullscreen mode

客户端验证对反馈有用,而服务端验证是保护账单和数据的边界。

将审核放在生成之前

输入审核应在创建提供商任务和扣除积分之前进行。

const moderation = await moderateRequest({
  userId,
  model,
  mode,
  prompt,
  imageUrl,
});

if (!moderation.allowed) {
  return {
    ok: false,
    code: moderation.code,
    creditsCharged: false,
  };
}

Enter fullscreen mode Exit fullscreen mode

这可以避免为被自己策略拒绝的请求支付上游提供商费用,也能让 UI 区分策略决策与技术故障。

输出审核是另一个关口。提供商可能成功生成你的应用不应发布的媒体。输出在通过该关口前不应变为 completed

provider success
  -> extract result URL
  -> moderate result
  -> completed

provider success
  -> extract result URL
  -> output blocked
  -> failed with a policy-safe message

Enter fullscreen mode Exit fullscreen mode

不要把输入审核、提供商策略错误和输出审核合并成一个通用的“生成失败”提示。它们有不同的重试规则和支持路径。

使积分操作幂等

AI 生成产品中最具破坏性的 bug 往往是财务状态 bug:

  • 提供商拒绝请求,但积分仍被扣除;
  • 轮询执行两次,退款也执行两次;
  • 提供商任务已启动,但数据库写入失败;
  • 浏览器在网络超时后重试请求。

每笔扣费都应有一个稳定的来源键:

await decreaseCredits({
  userId,
  amount: creditCost,
  sourceType: "generation_charge",
  sourceId: taskId,
});

Enter fullscreen mode Exit fullscreen mode

退款应引用同一个来源:

await refundCreditsBySource({
  sourceType: "generation_charge",
  sourceId: taskId,
  transactionType: "refund",
});

Enter fullscreen mode Exit fullscreen mode

数据库应对相关来源或事务键强制唯一性。应用层 if (!refunded) 检查有帮助,但在并发轮询下还不够。

你至少需要两条退款路径:

  1. 提供商任务无法创建。
  2. 提供商接受任务,但后来达到终结失败。
try {
  const external = await provider.createTask(modelId, input);
  await attachExternalTaskId({ taskId, externalTaskId: external.id });
} catch (error) {
  await refundByGenerationSource(taskId);
  throw error;
}

Enter fullscreen mode Exit fullscreen mode

轮询期间的终结失败:

if (status === "failed") {
  await refundByGenerationSource(taskId);
  await markGenerationFailed(taskId, publicErrorMessage);
}

Enter fullscreen mode Exit fullscreen mode

当退款幂等时,重复轮询就不再那么可怕。

轮询应该枯燥

WebSocket 可以提升感知响应性,但对于耗时数秒至数分钟的提供商任务,轮询往往是最稳健的首选实现。

状态端点应:

  1. 验证请求;
  2. 加载本地生成记录;
  3. 验证该记录属于当前用户;
  4. 对本地终结状态立即返回;
  5. 仅对非终结任务查询提供商;
  6. 规范化结果;
  7. 更新本地状态;
  8. 返回小型且稳定的响应。
type StatusResponse = {
  status: GenerationStatus;
  progress?: number;
  resultUrl?: string;
  errorCode?: string;
  errorMessage?: string;
};

Enter fullscreen mode Exit fullscreen mode

客户端不需要提供商的原始载荷。原始载荷会泄露实现细节,并让前端行为依赖不稳定的第三方模式。

合理的浏览器循环如下:

async function waitForGeneration(taskId: string) {
  while (true) {
    const result = await getStatus(taskId);

    if (result.status === "completed") return result;
    if (result.status === "failed") throw new Error(result.errorMessage);

    await delay(5000);
  }
}

Enter fullscreen mode Exit fullscreen mode

生产代码还应在组件卸载时停止轮询、在隐藏标签页中暂停或减速,并设置客户端最大等待时间。即使浏览器停止询问,任务仍可在服务端继续。

防御性地解析提供商结果

即使是同一提供商,不同模型返回的结果形状也可能不同:

{ "resultUrls": ["https://..."] }

Enter fullscreen mode Exit fullscreen mode

{ "video_url": "https://..." }

Enter fullscreen mode Exit fullscreen mode

{ "output": [{ "url": "https://..." }] }

Enter fullscreen mode Exit fullscreen mode

将结果提取放在适配器内,仅接受非空字符串:

function extractResultUrl(raw: string): string | undefined {
  const result = JSON.parse(raw);
  const candidates = [
    result.resultUrls?.[0],
    result.urls?.[0],
    result.output?.[0]?.url,
    result.videoUrl,
    result.video_url,
  ];

  return candidates.find(
    (value): value is string =>
      typeof value === "string" && value.length > 0
  );
}

Enter fullscreen mode Exit fullscreen mode

提供商返回 success 但没有可用结果 URL,并不意味着用户体验已完成。应将任务保持为非终结状态,或转入明确诊断的失败路径。

不要让晚到的成功覆盖失败

异步系统可能产生尴尬的顺序:

  1. 任务看似成功;
  2. 输出审核开始;
  3. 另一个进程将生成标记为失败;
  4. 晚到的更新试图将其标记为完成。

使用条件更新:

update generations
set status = 'completed', result_url = $1
where task_id = $2
  and status <> 'failed';

Enter fullscreen mode Exit fullscreen mode

如果更新影响零行,则重新加载当前状态并返回该状态。终结安全决策应优先于晚到的成功事件。

保留错误类别

用户需要针对不同错误采取不同行动:

错误类别 用户行动
积分不足 充值或选择更便宜的设置
提供商不可用 稍后重试
输入策略阻止 修改请求
提供商策略阻止 修改请求
输出策略阻止 仅当决策似乎错误时联系支持
未知技术故障 重试或携带任务 ID 联系支持

返回稳定的错误代码,并单独维护用户安全消息:

throw new RequestError(
  "We could not start this generation right now.",
  "provider_unavailable",
  { creditsCharged: false }
);

Enter fullscreen mode Exit fullscreen mode

不要在浏览器中暴露上游账户余额、内部模型路由或原始策略消息。

测试什么

仅测试一条快乐路径的视频是不够的。需要测试状态转换:

  • 无效模型设置不扣费;
  • 输入审核拒绝不扣费;
  • 提供商创建失败仅退款一次;
  • 提供商终结失败仅退款一次;
  • 重复状态轮询不重复退款;
  • 一个用户不能读取另一个用户的任务;
  • 提供商成功但无 URL 不变为完成;
  • 输出审核失败不能被晚到的成功覆盖;
  • 本地终结状态不再查询提供商;
  • 未知提供商状态保持可恢复。

最佳测试针对不变式:

one accepted generation <= one charge
one failed charged generation <= one refund
completed => usable result URL
failed-by-policy => result URL is not released
task owner => only user allowed to read status

Enter fullscreen mode Exit fullscreen mode

更大的教训

AI 视频界面是一个附带创意 UI 的分布式系统。模型调用只是其中一步。当验证、授权、计费、轮询、审核和故障恢复等每一步都有明确的边界时,产品才值得信赖。

从一个小型内部状态机开始。将提供商词汇留在边缘。为每笔扣费提供稳定来源。使退款幂等。将审核视为生命周期的一部分,而非预检复选框。然后让轮询有意地变得枯燥。

这个基础不如生成演示光鲜,但它才是让演示变成产品的关键。


免责声明:本文为 MICT 准备,仅作为该架构的产品背景被引用一次。AI 工具辅助了起草和编辑;代码、声明和最终文本均在发布前经过审查。