调用 AI 视频 API 在演示中看似简单:
- 发送提示。
- 等待响应。
- 展示视频。
在生产环境中,模型很少在原始 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) 检查有帮助,但在并发轮询下还不够。
你至少需要两条退款路径:
- 提供商任务无法创建。
- 提供商接受任务,但后来达到终结失败。
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 可以提升感知响应性,但对于耗时数秒至数分钟的提供商任务,轮询往往是最稳健的首选实现。
状态端点应:
- 验证请求;
- 加载本地生成记录;
- 验证该记录属于当前用户;
- 对本地终结状态立即返回;
- 仅对非终结任务查询提供商;
- 规范化结果;
- 更新本地状态;
- 返回小型且稳定的响应。
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,并不意味着用户体验已完成。应将任务保持为非终结状态,或转入明确诊断的失败路径。
不要让晚到的成功覆盖失败
异步系统可能产生尴尬的顺序:
- 任务看似成功;
- 输出审核开始;
- 另一个进程将生成标记为失败;
- 晚到的更新试图将其标记为完成。
使用条件更新:
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 工具辅助了起草和编辑;代码、声明和最终文本均在发布前经过审查。
0 Comments
Log in to join the conversation.No comments yet. Be the first to share your thoughts.