在 Cloudflare Workers 上跑 Modal 的 Whisper 转录
Cloudflare Workers 既跑不了多分钟的 GPU 任务,也开不了 Modal gRPC SDK 的长连接 —— 于是 ScribeToAny 把 Modal 当成普通 HTTP 端点,fire-and-forget 触发,再让 GPU 机器通过签名 webhook 回调。本文讲清这套异步设计、对账兜底,以及那些真正咬人的边界情况。
ScribeToAny 做音视频转录。整个 Web 应用跑在 Cloudflare Workers 上;而转录本身 —— Whisper,外加一段可选的翻译 —— 跑在 Modal 的 GPU 上。让这两个运行时协作,反倒是整个系统里最有意思的部分,因为最直观的那种做法,在 Workers 上根本行不通。
下面是我们实际的接法,连同踩到的真实边界。
约束
Worker 不是服务器。它在一次请求上被唤醒,拿到很小的 CPU 预算,然后被要求尽快返回。它没有一个常驻进程去看护一个要跑几分钟的任务,也开不了 Modal Python SDK 用来调用 Function.spawn() 的那条 gRPC 连接。两个死结,指向同一个结论:
- 你不能在 Worker 里跑转录。一段十分钟的播客,不是一个请求范围内能干完的活。
- 你甚至不能用 Modal 的常规客户端去启动这个任务。没有 gRPC,没有 Python,没有长连接的 socket。
那个天真的版本 —— "await 转录、然后把结果返回" —— 在第一条上就死了。所以这套设计从第一行代码起就必须是异步的,而 Worker 要做的事,被压缩成了寥寥几个亚秒级的 HTTP 调用。
答案的形状
别把 Modal 当 SDK,把它当一个 HTTP 端点。Modal 允许你暴露一个 Web 端点:一旦被命中,它就**派生(spawn)**出真正的 GPU 函数,然后立刻返回。于是整个流程变成:
- 浏览器把媒体文件直接上传到 R2(不经过 Worker)。
- Worker 预签名出一个读取 URL,写入一条
queued的任务行,朝 Modal 的 Web 端点发一个 POST。Modal 用一个 call id 应答,并在后台开始 GPU 工作。 - 引擎干完(或失败,或只是想汇报进度)之后,朝 Worker 上的一个 webhook 回 POST,用共享密钥签名。
- 前端轮询任务行,一旦翻成
done就点亮。
Worker 全程只做三件快事:预签名、spawn、apply-callback。没有一件在等 GPU。这就是全部的窍门 —— 剩下的工作,是让它在真实世界里活下来:webhook 会丢,回调会重复送达。
上传不碰 Worker
媒体文件从不流经 Worker —— 那会撑爆 CPU 预算,还什么都换不来。浏览器拿到一个预签名的 R2 PUT,直接上传。我们意图优先(intent-first)地签发它:在签名之前就写一条 pending 行,这样一个中途被放弃的上传,仍然留下痕迹,供我们事后清扫。
// createUploadUrl(server function)—— 节选
await db.insert(userFiles).values({ id, userId, r2Key, status: 'pending' /* … */ });
const uploadUrl = await presignR2Url(r2Key, 'PUT', PRESIGN_PUT_TTL);
return { fileId: id, uploadUrl };
PUT 成功后,客户端再调 finalizeUpload,把 pending → uploaded,并用 R2 的 HEAD 校正文件大小(永远别信客户端报的字节数)。始终到不了 uploaded 的行,由一个 cron 清扫任务回收 —— 下文再说。
触发任务
transcribeFile 就是异步交接发生的地方。它检查配额,预签名一个 GET(让引擎能把音频从 R2 读回去),在一个原子并发守卫后插入 queued 任务,然后 spawn:
const audioUrl = await presignR2Url(file.r2Key, 'GET', PRESIGN_GET_TTL);
// … 插入 queued 任务行(原子守卫) …
await spawnTranscription({ jobId, audioUrl, /* mode, language, targetLang … */ });
return { jobId, status: 'queued' };
spawn 本身故意做得很笨:一个 fetch,而且它在 Modal 接受任务的那一刻就 resolve —— 不是转录完成的时候。
const res = await fetch(MODAL_TRANSCRIBE_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
// Modal Proxy Auth —— token id/secret,不放进 body
'Modal-Key': MODAL_KEY,
'Modal-Secret': MODAL_SECRET,
},
body: JSON.stringify({
job_id: jobId,
callback_url: `${APP_URL}/api/transcripts/webhook`,
audio_url: audioUrl,
model_size, beam_size, language,
target_lang: targetLang, // null ⇒ 完全不挂翻译这一段
}),
});
if (!res.ok) throw new Error(`Failed to start transcription (${res.status})`);
两点值得点名。回调 URL 是在请求里交给引擎的,所以引擎永远不必知道我们的拓扑。而签名密钥是预先共享的(一个等于我们 MODAL_WEBHOOK_SECRET 的 Modal secret)—— 它在任何方向的请求体里都从不出现。
回调:先验签,再幂等地落库
有意思的事现在都发生在 webhook 里。第一步,认证它。引擎用 HMAC-SHA256 对原始 body 签名,把 hex 摘要放在 X-Webhook-Signature 里。Workers 上没有 Node 的 crypto,所以这里用 WebCrypto,而且比较是恒定时间的:
export async function verifyWebhookSignature(rawBody: string, signature: string | null) {
if (!secret || !signature) return false;
const key = await crypto.subtle.importKey(
'raw', new TextEncoder().encode(secret),
{ name: 'HMAC', hash: 'SHA-256' }, false, ['sign'],
);
const sig = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(rawBody));
const expected = [...new Uint8Array(sig)].map((b) => b.toString(16).padStart(2, '0')).join('');
return timingSafeEqual(expected, signature.trim().replace(/^sha256=/, '').toLowerCase());
}
你必须对原始字节做哈希,而不是一个重新序列化过的对象 —— JSON.parse 再 JSON.stringify 会重排 key、改动空白,你的签名就永远对不上。所以 handler 先 await request.text(),验签通过后才去 parse。
然后落库。这里最重要的性质是幂等,因为一个你没能足够快返回 200 的 webhook 会被重试,而一次重试的回调绝不能被重复落库。规则只有一行:任务一旦终态,重复的就忽略。
if (job.status === 'done' || job.status === 'failed') {
return { ok: true, applied: false }; // 已终态 —— 空操作
}
HTTP 状态码是特意选来引导引擎重试行为的:
| 情况 | 响应 | 为什么 |
|---|---|---|
| 签名错误/缺失 | 401 | 直接拒绝 |
未知 job_id | 200 | ack,好让引擎停止重试一个我们永远不会有的任务 |
| 已落库(或已终态) | 200 | 成功 |
| 我们自己的 DB 抛错 | 500 | 请引擎重试 —— 回调是合法的,只是我们自己搞砸了 |
第三行是微妙的那个。未知任务不是一个要往上抛的错误;它是一封死信,而你能做的最厚道的事,就是 ack 它,让发送方放弃。
那些咬人的边界
上面这条 happy path 大概只占三分之一的代码。剩下的,全是异步契约里一方会凭空消失时、各种出错情况的兜底。
丢失的 webhook。 如果引擎崩了,或者回调被丢了,任务就会永远卡在 transcribing。所以有一个 cron 任务来对账:任何静默超过超时阈值的任务,被标记为 failed;同一次 tick 还会清扫那些始终没 finalize 的孤儿 R2 上传。Workers 的 cron 触发器天生适合干这个 —— 正是这个兜底,让那条乐观的异步路径可以放心依赖。
绝不误杀健康任务。 我们还有一个存活探针,能去问 Modal 某个 call 是否还在跑。它返回一个特意设计成三值的答案 —— running、一个终态、或者 null 意为**"不知道"(探针没配、请求失败、无法解析)。调用方必须把 null 当作没有信息**,别去动这个任务。要是把"我探不到"塌缩成"任务死了",那么探针一有一分钟抖动,对账器就会开始处决健康的任务。
segments 存 R2,不进数据库。 一个终态回调不会内联携带转录文本。引擎把 segments 以 {job_id}.tsv 写进 R2;done webhook 只带元数据(语言、时长、成本、耗时)。应用按需读那份 TSV,再由它生成 SRT/VTT/TXT/PDF。把成千上万条字幕行挡在 D1 之外,让回调保持小、任务表保持窄。
一个任务里的两个时钟。 加上那段可选翻译,单个任务现在要在不同时间点完成两件事。转录可以已经 done,而翻译还在跑。所以翻译更新是在终态早返回之前就落库的 —— 否则,一个在转录完成之后才到的翻译 ping,会撞进"已终态、空操作"那条分支,翻译行就会永远卡在 queued。两条独立的腿,一行任务记录,而这两个检查的先后顺序是承重的。
为什么这其实是个好搭配
很容易把上面这一切读成"在跟平台对着干"。并不是。一旦转录被挪出 Worker,Worker 留下来做的每件事,恰恰都是 Workers 模型擅长的:短小、无状态、I/O 密集的 HTTP handler,配一个 cron 兜底,加一个持久化存储(D1 + R2)在它们之间保存状态。GPU 机器干 GPU 的活;边缘干边缘的活;一个签名 webhook 加一次幂等落库,就是那道接缝。
那个看起来致命的约束 —— "你不能在这儿跑这个任务" —— 最后反倒催生了一个干净的设计。Worker 从不阻塞,任务扛得住一次丢失的回调,一次重试的 webhook 是空操作。这就是整个系统。
ScribeToAny 基于 TanStack Start + React,跑在 Cloudflare Workers(D1 + R2)上,转录引擎在 Modal。想试试产品那一侧的话,浏览器内的免费字幕/媒体转换器完全在客户端跑 —— 不上传、不注册。