用户点击了一个按钮。请求卡住了。十秒钟的沉默。他们点击了备用按钮。现在,两个任务正针对同一个意图在运行。最终会导致重复的副作用、重复扣费,以及一场让你忙活一整个下午的数据烂摊子。
这不是前端 Bug。React 中的禁用按钮或防抖计时器救不了你。第一个请求已经在进行中了。网络只是吞掉了响应。如果你的后端将每个传入的请求都视为全新的指令,那么重试就会变成负担。你需要从 API 设计和数据库模式(schema)层面来解决这个问题。
解决方案始于一个简单的结构化拆分。
将“任务”与“尝试”分离
将“任务”(Job)视为用户意图的持久化记录。它记录了所有者、参数、目标供应商以及确切的意图。“尝试”(Attempt)则是为了实现该意图而进行的具体尝试。
想象一家打印店。你递交了一个文件,他们给了你一张 45 号票据。这张票据就是“任务”。店员尝试使用喷墨打印机,结果卡纸了,这是第一次“尝试”。他们把文件移到激光打印机上,这是第二次“尝试”。在整个过程中,45 号票据从未改变。如果打印店每尝试一种打印机就开一张新票据,你就会支付三次费用并收到三份不想要的副本。
你的数据库应该镜像这种逻辑。一张表存储任务,另一张表存储尝试。任务行保持不变,而尝试记录在其下方不断累积。
这种分离赋予了你控制权。它还为你提供了一个可以附加“幂等键”(idempotency key)的地方,使其能够经受住网络波动。
为每个任务要求幂等键
每一个创建任务的 POST 请求都必须携带一个唯一的幂等键。这个键属于用户,而不是会话(session)。将所有者 ID 与该键结合,然后在两列上强制执行唯一的数据库约束。
为什么要使用数据库约束?因为在插入之前通过应用代码检查是否存在,是一个潜在的竞态条件。两个完全相同的请求可能会在同一微秒的间隙中溜过去。让数据库来充当执行者。如果用户两次发送相同的所有者 ID 和键,第二次请求会触发唯一性冲突,然后你只需返回现有的任务即可。两个请求都会获得相同的任务 ID,不会启动重复的工作。
务必严格限制作用域。如果有人重用了键但更改了输入负载(payload),请返回冲突错误。幂等键必须与确切的意图绑定,而不仅仅是与用户绑定。相同的键配上不同的输入意味着客户端逻辑混乱,你的系统应该拒绝它,而不是去猜测其意图。
保护状态转换
一次“尝试”是一个状态转换,而不是一个新任务。如果之前的尝试仍处于“启动中”或“未知”状态,你的 API 必须拒绝产生新的尝试。
超时是问题的根源。当供应商请求超时时,客户端看到的是失败,但服务器端的进程可能仍在运行。GPU 集群可能仍在处理你的推理请求,容器可能仍在向对象存储写入数据。如果你将超时的尝试标记为“失败”并立即发起第二次尝试,你就是在拿重复的副作用进行豪赌。
将超时视为“未知”状态,而非“失败”状态。在早期的尝试达到终态(terminal state)或被带外(out-of-band)进程明确取消之前,请阻塞新的尝试。这种停顿可能会让人感到不适,它迫使用户等待,但也防止了两个工作进程同时修改同一个下游资源的混乱局面。
使用“比较并交换”解决竞态
最难的问题出现在多个尝试同时完成时。也许你的系统针对主供应商发起了第一次尝试;在十秒钟的沉默后,它针对备用供应商发起了第二次尝试。现在,两次尝试都完成了。你不能让两者都将结果写入同一个任务行。
使用“比较并交换”(Compare-and-Swap)逻辑。在任务行中添加一个版本号。当一次尝试完成时,它会运行一个带有条件的更新操作:
- 当前版本必须与该尝试开始时读取的版本一致。
- 不能有其他尝试已经占用了结果槽位。
- 如果两者都通过,则写入结果并增加版本号。
用 SQL 表达,它看起来像是一个带有条件的更新语句:WHERE id = $1 AND version = $2 AND completed_by IS NULL。如果更新返回的行数为零,说明另一个尝试已经胜出。迟到的请求必须被忽略。丢弃其结果。不要合并,不要追加。直接丢弃该工作。一个覆盖了早期胜出者的迟到结果会导致数据损坏,唯一安全的做法就是将其舍弃。
这能优雅地处理逆序完成的情况。尝试 A 先发出请求,但 30 秒后才返回。尝试 B 后发出请求,但 5 秒后就返回了。尝试 B 赢得了比较并交换 (compare-and-swap)。尝试 A 的更新影响了零行数据。你的系统记录了这次竞态条件,忽略了过时的负载,然后继续运行。
测试脆弱点
在常规路径测试中,你无法发现这些 Bug。你的测试套件需要针对这些脆弱点进行测试。
- 模拟双击。两个具有相同幂等键的并发 POST 请求必须返回相同的 Job ID。
- 使用不匹配的输入发送相同的键。预期会得到冲突响应。如果参数不同,系统绝不能在不报错的情况下返回现有的 Job。
- 触发超时。验证 Job 进入的是“未知状态”而非“失败状态”,并且在歧义消除之前,系统会阻止进一步的尝试。
- 强制两次尝试以逆序完成。确认第二个返回的尝试会失败,即使第一个发出的尝试是官方的主要提供商。
这些测试并非为了应对边缘情况而准备的奢侈品。它们是你的 API 与系统其他部分达成的契约。
在进行故障转移前验证提供商意图
如果你运行的是多提供商设置,你可能会倾向于将不同的 AI 模型视为可互换的插槽。它们共享相同的代码路径、相同的 HTTP 客户端和相同的 JSON Schema。但这并不意味着它们的行为完全一致。
一个模型可能会幻觉出一个顶层键。另一个模型可能会忽略你的系统提示词格式。Schema 验证可以捕获语法错误,但它会放行那些你的业务逻辑无法解析的响应。提供商可能会返回有效的 JSON,但它对你的提示词模板的处理方式却是错误的。
在允许自动模型切换之前,运行针对特定提供商的测试。确认备用模型在低温度 (low temperature) 下确实遵循你的输出结构。验证你的提示词通过该提供商的分词器 (tokenizer) 后能正确渲染。使用真实输入测试完整的往返流程。只有当你证明了备用模型共享相同的运行契约时,自动故障转移才是安全的。
每个意图仅保留一个 Job
备用路径是好事,但不受控制的备用路径倍增则是 Bug。你技术栈的每一层都需要评估是否已经处理过完全相同的任务。负载均衡器、API 处理程序、数据库和 Worker 必须都遵循相同的标识。
构建你的系统,使重试和备用方案在同一个稳定的 Job 下表现为新的尝试。使用基于数据库的幂等键来锁定该 Job。守护状态转换。让尝试进行竞态。让且仅让一个胜出。只有这样,你才能防止用户的一次点击演变成一个周末的数据清理工作。
