围绕异步生成任务的边界状态处理、网络故障恢复、轮询与回调策略,以及结果一致性保障等工程难题展开。
过去一个月,我一直在做一个异步视频任务的 AI 生成产品。
我原本以为模型集成是最困难的部分。
向 AI 服务商发送请求并获取任务 ID 相对直接。但更难的工程工作在以下情况出现:服务商响应缓慢、网络在关键时刻失败、用户刷新页面、或者已经扣费之后。
本文涵盖了我在将 AI API 转化为能够在不确定性中恢复的工作流程时学到的架构经验。
从高层看,一个异步生成请求看起来很简单:
Client
→ Generation API
→ Local task record
→ AI provider
→ Polling or webhook
→ Result reconciliation
→ Persistent result
愉快路径很容易:
验证请求。
提交 provider 任务。
轮询直到成功。
生产系统很少一直停留在愉快路径上。
有趣的问题出现在这些步骤之间。
生成任务不仅仅是 pending、success 或 failed。
还有另一个重要的状态:
submission_unknown
想象向一个 provider 发送 POST 请求。provider 接受了它并开始生成,但响应因为网关超时而丢失了。
从应用的角度看,可能有两种现实:
自动重试请求可能产生第二次付费生成。
将其视为普通失败可能给用户退款,而昂贵的 provider 任务仍在运行。
因此系统需要区分确认的拒绝和不确定的提交。
一个概念性的状态模型可能长这样:
type GenerationState =
| 'created'
| 'submitting'
| 'pending'
| 'succeeded'
| 'failed'
| 'submission_unknown';
具体名称不重要。重要的是保留不确定性,而不是将每个结果强行归类为成功或失败。
用户会双击按钮。浏览器会重试请求。移动连接会断开。UI 状态在刷新后会被恢复。
生成端点必须假设同一个逻辑提交可能到达多次。
每个客户端提交应携带一个稳定的幂等键:
type GenerateRequest = {
prompt: string;
model: string;
idempotencyKey: string;
};
在服务端,该键应解析为一个用户的一个本地任务:
const existingTask = await findTaskByIdempotencyKey({
userId,
idempotencyKey,
});
if (existingTask) {
return existingTask;
}
数据库唯一性约束仍然是必要的,因为两个请求可能同时通过初始查找。
检查现有任务。
尝试用唯一键创建任务。
如果插入在竞态中失败,加载并返回获胜的任务。
永远不要对同一个逻辑提交扣费两次。
幂等性不仅仅是防止双击的保护。它是整个工作流程中实现恢复的基础。
轮询是一种观察机制。它不是任务本身。
浏览器可能在三十秒后停止轮询,但 provider 任务可能仍在正常运行。
这个区别很重要:
Polling window ended ≠ Generation task failed
如果客户端将每次轮询超时视为终端失败,当用户重试时可能会提交另一个任务。
更好的方法是保留原始任务 ID 并稍后恢复观察:
async function resumeTask(taskId: string) {
const localTask = await loadTask(taskId);
if (isTerminal(localTask.status)) {
return localTask;
}
return observeExistingTask(taskId);
}
刷新页面、重新打开应用、或从另一台设备返回,都应尽可能继续跟踪同一个任务。
UI 可以说明观察已暂停,而不说生成已失败。
一个任务可能通过多条路径达到终态:
async function reconcileTask(input: {
taskId: string;
providerStatus: ProviderStatus;
source: 'poll' | 'webhook' | 'recovery';
}) {
// Load the current task.
// Ignore an already-finalized task.
// Persist the result or failure once.
// Refund credits at most once.
// Return the canonical local state.
}
没有共享对账,竞态条件很可能发生。
Webhook 可能在前景轮询仍活跃时完成任务。两条路径都可能尝试保存结果或发起退款。
因此终端转换必须是幂等的:
if (task.finalizedAt) {
return task;
}
在实践中,简单的应用层检查可能不够。数据库约束或事务性更新应强制这一不变量。
许多观察者可能发现结果,但只有一个转换应该最终确定它。
前端需要显示预期的积分成本,但不能决定最终扣费。
生成成本可能取决于:
服务端应解析并验证真实请求参数,然后计算权威成本:
const options = parseGenerationOptions(request.body);
const creditCost = calculateCreditCost({
model: options.model,
duration: options.duration,
resolution: options.resolution,
outputCount: options.outputCount,
});
前端应尽可能使用相同的共享定价定义,但服务端仍须重新计算扣费。
否则,过时的客户端或被修改的请求会在显示定价和实际账单之间产生差距。
退款需要同样的纪律。
退款应绑定到唯一任务和交易原因,这样重复的恢复尝试就不能对同一任务退款两次。
任务恢复不仅仅是后端问题。
刷新后,前端需要足够的信息来重建用户正在做的事情:
仅保存 provider 任务 ID 是不够的。
Provider 知道如何生成输出,但不一定知道你的产品应如何呈现用户的意图。
本地任务记录应保持为应用的真相来源。Provider 状态是用于更新该记录的外部证据。
这种分离也防止内部 provider 标识符和实现细节泄露到用户界面。
在构建这个工作流程之前,我认为核心工程问题是模型集成。
现在我看到三个不同的层:
Provider API 只解决了第一层。
产品在第二层和第三层构建。
AI 工具可以加快实现速度,但也更容易在底层状态模型可靠之前添加功能。
最有价值的工作不是添加另一个模型。而是定义当多件事同时失败时什么必须保持为真。
对于异步 AI 产品,可靠性来自保持标识和不确定性:
API 调用可能是可见的功能。
恢复模型才是真正的产品。
Disclosure: This article is based on my own implementation experience and was reviewed against the system's actual behavior. AI was used to help organize the structure and edit the English draft.