多厂商 AI 调度:统一混乱的供应商生态
单一供应商是脆弱的。
当你的应用依赖某个 AI 厂商的 API,你就把命运交给了他们的稳定性、定价策略和服务质量。厂商 API 超时、限流、下线,你的应用就停摆。
多厂商调度的核心不是贪多,而是分散风险。但它带来了新的复杂度:每个厂商的 API 不同,参数不统一,返回格式各异。如何在这种混乱中建立秩序?
核心挑战:差异性
AI 厂商的 API 差异体现在三个层面。关于分布式 Workers 架构的整体设计,参见 purikura-workers-architecture。
参数命名
Fal.ai 用 prompt,KIEAI 用 text,OpenAI 可能用 input。同样的功能,不同的字段名。
参数结构
有的厂商用扁平结构 { width: 1920, height: 1080 },有的用嵌套对象 { resolution: { width: 1920, height: 1080 } }。
响应格式
Webhook 回调的数据结构完全不同。Fal.ai 返回 output.video.url,KIEAI 返回 data.result_url。
这些差异不是小问题。如果直接在业务代码中处理每个厂商的特殊情况,代码会变成 if-else 地狱。每次添加新厂商,你要改动十几处地方。
有些厂商提供官方 SDK,但这不解决问题。
SDK 只是把 HTTP 调用封装了一层,API 差异依然存在。更重要的是,SDK 增加了依赖。当你需要支持 10 个厂商时,你要安装 10 个 SDK,版本冲突和依赖地狱会让你崩溃。
直接使用 HTTP 请求让你保持控制权。所有厂商用统一的方式调用,统一的方式处理响应。
统一抽象:UniversalModel
解决方案是建立统一的抽象层。所有厂商的差异被封装在适配器(Adapter)中,业务代码只看到统一的接口。
interface UniversalModel {
id: string
name: string
type: 'image' | 'video'
vendor: string
parameters: {
prompt: string
resolution?: { width: number; height: number }
duration?: number
// ... 统一的参数定义
}
}
前端只需要知道 UniversalModel,不需要关心是哪个厂商。用户选择模型后,系统自动找到对应的适配器,转换参数,调用 API。
适配器的职责:
- 参数转换 - 把统一参数转成厂商特定格式
- 请求构建 - 构造 HTTP 请求(headers、body、auth)
- 响应解析 - 把厂商响应转成统一格式
- 错误映射 - 把厂商错误码转成统一的错误类型
💡 Click the maximize icon to view in fullscreen
这种设计的好处是扩展性。添加新厂商时,你只需要实现一个新的适配器,不需要修改业务代码。测试也更容易,每个适配器可以独立测试。
关键流程:生成、恢复、退款
AI 生成是异步的、不可靠的、需要容错的。
💡 Click the maximize icon to view in fullscreen
核心点:
- 立即返回 - 不等待 AI 完成,避免超时
- 原子扣款 - 积分扣除和任务创建在同一事务中
- 状态追踪 - 每个状态变化都记录在数据库
关于异步处理的更多思考,参见 queue-reliability-boundaries。
但生成可能失败。厂商 API 超时、限流、内部错误。这时需要两个关键机制。
僵尸任务恢复
任务卡在 processing 状态超过 30 分钟,可能是 Webhook 丢失或厂商没有回调。定时任务每 15 分钟扫描一次,向厂商查询真实状态,同步到数据库。
但不能无限重试。如果 24 小时内查询超过 20 次仍未完成,标记为不可恢复,触发退款。
僵尸任务恢复有个困境:厂商 API 本身可能不稳定。
如果每次查询都失败,你是继续重试还是放弃?继续重试可能浪费资源,放弃可能错失真正完成的任务。
目前的策略是:20 次重试 + 24 小时窗口。这个数字是经验值,不是理论最优解。实际运行中需要根据厂商的真实可靠性调整。
自动退款 失败任务自动触发退款,返还用户积分。退款也可能失败(数据库连接问题、并发冲突),所以用指数退避重试(详见 exponential-backoff-timeout)。
💡 Click the maximize icon to view in fullscreen
死信队列(DLQ)是最后的安全网。如果退款重试全部失败,任务进入 DLQ,等待人工处理。DLQ 不是失败的标志,而是对自动化边界的承认。
积分系统:统一的度量
不同厂商的定价模型不同。有的按秒计费,有的按分辨率计费,有的按请求次数计费。如何统一?
答案是积分系统。用户购买积分,生成消耗积分。积分是抽象的货币,屏蔽了厂商定价的差异。
积分计算规则:
function calculateCredits(model: UniversalModel, params: GenerationParams) {
const baseCost = model.base_credit_cost;
const resolutionMultiplier = getResolutionMultiplier(params.resolution);
const durationMultiplier = params.duration / 5; // 每 5 秒为基准
return Math.ceil(baseCost * resolutionMultiplier * durationMultiplier);
}
这个公式不是理论推导的,而是在实际运营中调整的。基础成本基于厂商 API 的实际费用,乘数根据用户接受度和竞争对手定价调整。
积分系统还解决了另一个问题:预扣款。在任务提交时扣除积分,而不是完成时。这避免了用户余额不足导致的任务失败。如果任务失败,退款流程返还积分。
积分比货币灵活。
货币涉及支付通道、税务、退款流程。积分是内部虚拟货币,充值时发生一次真实货币交易,之后都是积分流动。
这让定价调整更灵活。你可以随时修改积分消耗规则,而不需要改动支付系统。你可以给新用户赠送积分,而不需要真实退款。
关于统一积分系统的设计,参见 unified-credit-system-design。
可观测性:全链路追踪
分布式系统的调试是噩梦。一个请求经过前端、API Gateway、队列、AI 处理器、厂商 API,最后通过 Webhook 回调。任何一个环节出错,如何定位?
答案是全链路追踪。每个请求生成一个 traceId,它随着调用链传播。
// 前端生成 traceId
const traceId = `client_${Date.now()}_${randomId()}`;
// API Gateway 接收并创建新的 trace 上下文
const serverTraceId = traceManager.createTrace('video_generation', {
clientTraceId: traceId,
userId: user.id
});
// 队列消息包含 traceId
await queue.send({
jobId,
payload,
traceId: serverTraceId
});
// AI 处理器继承 trace
const span = traceManager.createSpan(traceId, 'vendor_api_call');
每个关键操作都创建一个 Span,记录开始时间、结束时间、状态、错误信息。Span 之间通过 traceId 关联,形成调用树。
当用户报告问题时,你搜索 traceId,看到整个请求的生命周期:什么时候创建、什么时候入队、什么时候调用厂商、什么时候回调、哪里出错。
全链路追踪有成本。每个 Span 都要写入数据库或日志系统,高并发时会产生大量数据。
权衡方案:
- 只追踪关键路径(生成、退款、恢复)
- 采样记录(只记录部分请求)
- 内存追踪 + 定期持久化(失败时才写数据库)
目前 Purikura 使用内存追踪,只在任务失败或进入 DLQ 时持久化。这在性能和可观测性之间取得了平衡。
关于追踪技术的更多讨论,参见 call-chain-tracing。
数据库:单一真相源
所有状态的权威来源是数据库。
ai_vendors - 厂商配置 存储厂商的基本信息、API 端点、认证方式。
ai_models - 模型配置 每个模型关联一个厂商,定义参数、积分成本、支持的功能。
ai_generations - 任务记录 每个生成请求创建一条记录,包含用户、模型、参数、状态、结果。
ai_generation_logs - 事件日志 记录任务的每次状态变化,用于审计和调试。
ai_generation_dlq - 死信队列 记录自动处理失败的任务,等待人工介入。
ai_generation_retry_log - 重试日志 记录每次重试的时间、原因、结果。
这些表不只是存储,更是系统的契约。所有组件通过数据库协调状态,避免了分布式状态不一致的问题。
数据库的角色不只是存储,更是协调者。当多个 Worker 并发操作同一条记录时,数据库的事务和锁确保了一致性。
架构的本质
多厂商调度的复杂度不在技术细节,而在对不确定性的管理。
厂商 API 会超时、会限流、会下线。Webhook 会丢失。网络会分区。这些都是现实,不是异常。
架构设计不是消除不确定性,而是让系统在不确定性中依然可靠。重试机制、僵尸任务恢复、自动退款、死信队列,这些机制不是完美的,但它们定义了系统在混乱中的行为边界。
关于从意图到架构的思考过程,参见 from-intent-to-architecture。
好的架构不是一次性设计的,而是在约束条件下持续演化的结果。Purikura 的多厂商调度从单一厂商开始,每次遇到问题就添加新的保护层。现在它支持多个厂商,但代价是更高的复杂度。
这个权衡是否值得?取决于你的业务需要多高的可靠性。如果单点故障不可接受,多厂商是必然选择。
最后更新:2025-11-06
Related Posts
Articles you might also find interesting
分布式 Workers 的解耦设计
通过微服务架构和队列系统,实现高可用的 AI 任务处理。从单体到分布式,每个设计决策都是对复杂度的权衡。
Studio 系统架构:从状态机到端到端流程
深入 Studio 系统的状态管理中心、组件协调机制和 AI 生成的完整数据流,理解前后端集成的设计逻辑
统一积分系统的设计实践
从多套积分到单一积分池的架构演进,以及背后的原子性、一致性设计
定价界面优化的三层方法
数据诚实、决策引导、视觉精调——三层递进的优化方法。从移除虚假功能到帮助用户选择,再到像素级修复,每一步都在解决真实问题
适配器模式:对现实的妥协
当 PayPro 要求 IP 白名单而 Stripe 不需要,当一个按秒计费另一个按请求计费,架构设计不是消除约束——而是管理约束。适配器模式不是优雅设计,而是对现实混乱的务实投降。
离屏渲染:照片捕获为什么需要独立的 canvas
实时流与静态合成的本质冲突,决定了系统必须分离。理解这种分离,就理解了架构设计中最重要的原则。
集中式配置:让 Reddit 组件脱离重复泥潭
当同一份数据散落在多个文件中,维护成本呈指数级增长。集中式配置不是技术选择,而是对抗熵增的必然手段。
让文档跟着代码走
文档过时是熵增的必然。对抗衰败的方法不是更频繁的手工维护,而是让文档"活"起来——跟随代码自动更新。三种文档形态,三种生命周期。
双重导出管道的架构选择
在用户生成内容场景中,速度与质量的权衡决定了导出架构。理解两种不同管道的设计逻辑,能够更准确地把握产品体验的边界。
Purikura的页面系统
通过五层分层继承复用架构,实现零代码修改的页面生成系统。从类型定义到页面渲染,每一层专注单一职责,实现真正的数据驱动开发。
重复数据的迁移实践:从 N 个文件到 1 个真相源
当同一份 Reddit posts 配置散落在多个文件中,维护成本以文件数量指数增长。迁移到集中式配置不是技术选择,而是对复杂度的清算。
在运行的系统上生长新功能
扩展不是推倒重来,而是理解边界,找到生长点。管理层作为观察者和调节器,附着在核心系统上,监测它,影响它,但不改变它的运行逻辑。
分层修复
生产问题没有银弹。P0 止血,P1 加固,P2 优化。优先级不是排序,而是在不确定性下的决策框架。
Studio 前端架构:从画布到组件的设计思考
深入 Purikura Studio 前端架构设计,探讨 DOM-based 画布、状态管理和组件化的实践经验
Context 驱动的认证状态管理
认证系统的核心不在登录按钮,而在状态同步。如何让整个应用感知用户状态变化,决定了用户体验的流畅度。
第三方回调的状态映射完整性
KIE.AI 视频生成的三个修复揭示了一个本质问题:回调不只是接收结果,是建立状态映射。没有 vendor_task_id,系统就失去了追溯能力。