WebGPU 模型加载的完整实现
基于 Vite + React + transformers.js,用 WebGPU 在浏览器端加载 1.5B 参数模型的完整实现记录
DeepSeek-R1 是近年来最受关注的开源推理模型之一。通常,运行这样一个模型需要 Python 环境、CUDA 驱动、一块还不错的显卡------或者至少一台云服务器。但 WebGPU 改变了这个前提。
WebGPU 是浏览器暴露的 GPU 编程接口,让 JavaScript 可以直接调度显卡的计算资源。配合 Hugging Face 的 transformers.js 库,你可以在一个普通网页里加载 ONNX 格式的语言模型,用 GPU 执行推理,不需要任何后端服务器1。
这篇文章记录了 dpsk-webgpu 项目的搭建过程:从技术选型、Worker 线程架构,到 WebGPU 检测和模型下载的完整实现。项目目前完成了模型加载和进度展示,推理部分还在开发中------但加载流程本身就是整件事的地基,值得单独写清楚。
技术选型
项目用 Vite 脚手架创建,技术栈是 React 19 + TypeScript,UI 用 Tailwind CSS v4。核心依赖只有一个包:@huggingface/transformers(即 transformers.js v4)1。
transformers.js 是 Hugging Face 官方维护的 JavaScript 库,API 设计和 Python 端的 transformers 高度一致。你同样用 from_pretrained 加载模型,用 AutoTokenizer 做分词。区别在于模型文件是 ONNX 格式,推理通过 WebGPU 后端执行。模型权重直接缓存在浏览器的 Cache Storage 里,第二次加载不需要重新下载。
JSON
json
// package.json
{
"dependencies": {
"@huggingface/transformers": "^4.2.0",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"tailwindcss": "^4.3.3"
}
}
为什么需要 Worker 线程
模型下载和推理是计算密集型操作。如果直接在 React 组件里调用 transformers.js 的 API,整个主线程会被阻塞------用户看到的是一个完全冻结的页面,连进度条都刷不动。
Web Worker 是浏览器原生的多线程方案。把模型加载和推理逻辑放到 worker.js 里,主线程只负责渲染 UI 和响应用户操作。两个线程通过 postMessage 互相通信:主线程发指令给 Worker,Worker 把进度和状态回传给主线程。
这个项目的架构因此分成两层:
- App.tsx(主线程) :管理 UI 状态,创建 Worker,监听 Worker 回传的消息
- worker.js(Worker 线程) :检测 WebGPU,加载模型,执行推理
搭建 Worker 通道
App.tsx 用 useRef 持有 Worker 实例,在 useEffect 中完成创建、监听和清理三件事。
TSX
javascript
// src/App.tsx
const worker = useRef<Worker | null>(null);
useEffect(() => {
worker.current = new Worker(
new URL('./worker.js', import.meta.url),
{ type: "module" }
);
// 监听 Worker 回传的消息
worker.current?.addEventListener("message", onMessage);
// 先检测 GPU,通过后再触发下载
worker.current?.postMessage({ type: "check" });
return () => {
worker.current?.removeEventListener("message", onMessage);
worker.current?.terminate();
};
}, []);
useRef 的作用是让 Worker 实例在组件的整个生命周期内保持引用,不会因为重渲染而丢失。new URL('./worker.js', import.meta.url) 是 Vite 推荐的写法,保证构建后路径正确2。type: "module" 让 Worker 以 ES 模块方式运行,这样才能在里面使用 import 语法引入 transformers.js。
清理逻辑不能省。
removeEventListener移除消息监听,terminate终止 Worker 线程。少了terminate,Worker 会继续占用内存;少了removeEventListener,已终止的 Worker 上残留的监听器可能触发意外错误。
三个状态撑起整个 UI
组件只需要三个状态变量:
TSX
scss
// src/App.tsx
const [message, setMessage] = useState("正在检测 GPU...");
const [progress, setProgress] = useState<number | null>(null);
const [error, setError] = useState(false);
message 是当前提示文字,progress 是下载百分比(null 表示不在下载),error 控制文字是否变红。三个变量覆盖了加载流程中所有可能的 UI 状态。
Worker 通过 postMessage 发回的数据带有一个 status 字段,主线程用 switch/case 分发处理:
TSX
kotlin
// src/App.tsx --- onMessage 回调
const onMessage = (event: MessageEvent) => {
const data = event.data;
switch (data.status) {
case "webgpu-check":
setMessage(data.message);
setError(!data.supported);
if (data.supported) {
// GPU 可用,才发起模型下载
worker.current?.postMessage({ type: "load" });
}
break;
case "loading":
setMessage(data.message);
break;
case "download":
setProgress(data.progress);
break;
case "ready":
setMessage("模型下载完成 ✅");
setProgress(null);
break;
case "error":
setMessage(data.message);
setError(true);
break;
}
};
这里有一个关键设计:WebGPU 检测通过后,主线程才发 load 指令触发模型下载。这个顺序保证了不会在不支持 WebGPU 的浏览器里白跑一趟几百 MB 的下载。
WebGPU 检测
worker.js 收到 check 指令后,调用 checkWebGPU 函数。检测逻辑分两步:
JavaScript
php
// src/worker.js --- checkWebGPU
async function checkWebGPU() {
try {
if (!navigator.gpu) {
throw new Error("浏览器没有 WebGPU");
}
const adapter = await navigator.gpu.requestAdapter();
if (!adapter) {
throw new Error("没有找到可用的 GPU adapter!");
}
self.postMessage({
status: "webgpu-check",
supported: true,
message: "GPU可用",
});
} catch (err) {
self.postMessage({
status: "webgpu-check",
supported: false,
message: "GPU不可用",
});
}
}
先检查 navigator.gpu 是否存在------这是 WebGPU API 的入口,不存在说明浏览器根本不支持3。然后调用 requestAdapter() 请求一个 GPU 适配器,这一步会实际和显卡驱动打交道。如果拿不到 adapter,说明虽然有 API 但硬件或驱动不满足要求。
检测结果通过 postMessage 回传给主线程,携带 supported 布尔值和一条人类可读的 message。
模型加载与进度回传
模型加载封装在 TextGenerationPipeline 类里,用静态属性实现单例模式:
JavaScript
javascript
// src/worker.js --- TextGenerationPipeline
class TextGenerationPipeline {
static modelId = "onnx-community/DeepSeek-R1-Distill-Qwen-1.5B-ONNX";
static tokenizer = null;
static model = null;
static async getInstance(progressCallback = null) {
this.tokenizer ??= AutoTokenizer.from_pretrained(this.modelId, {
progress_callback: progressCallback,
});
this.model ??= AutoModelForCausalLM.from_pretrained(this.modelId, {
dtype: "q4f16",
device: "webgpu",
progress_callback: progressCallback,
});
return Promise.all([this.tokenizer, this.model]);
}
}
modelId 指向 Hugging Face 上的 onnx-community/DeepSeek-R1-Distill-Qwen-1.5B-ONNX------DeepSeek-R1 蒸馏版 Qwen-1.5B 的 ONNX 转换,体积小、速度快,适合浏览器端运行4。
两个关键参数值得展开:
dtype: "q4f16"--- 4 比特量化加 16 位浮点。模型权重被压缩到原来的四分之一,1.5B 参数的模型下载量控制在几百 MB 以内,大多数设备都能扛住device: "webgpu"--- 告诉 transformers.js 用 WebGPU 后端执行推理,而不是回退到 WASM。WASM 模式虽然兼容性更好,但速度差了一个数量级
??= 运算符做的是逻辑空赋值:只有左侧为 null 或 undefined 时才执行右侧的赋值。tokenizer 和 model 各加载一次,后续调用直接复用已加载的实例,避免重复下载。
loadModel 函数负责把 transformers.js 的进度回调翻译成 Worker 消息:
JavaScript
php
// src/worker.js --- loadModel
async function loadModel() {
try {
self.postMessage({
status: "loading",
message: "下载模型中..."
});
await TextGenerationPipeline.getInstance((progress) => {
// transformers.js 的"下载进度"消息状态名是 "progress"(带 progress 数字字段)
// "download" 只是"开始下载"的提示,没有进度字段,别用它
if (progress.status === "progress" && Number.isFinite(progress.progress)) {
self.postMessage({
status: "download",
progress: progress.progress,
});
}
});
ready = true;
self.postMessage({ status: "ready" });
} catch (err) {
self.postMessage({
status: "error",
message: err instanceof Error ? err.message : String(err),
});
}
}
一个容易踩的坑: transformers.js 的
progress_callback会收到多种状态的消息。其中status为"progress"的才带有progress数值字段,表示下载百分比。还有一个"download"状态,但它只是"开始下载"的通知,没有进度数值。直接用"download"状态的progress字段会拿到undefined,进度条永远不动。
模型加载完成后,ready 标志位设为 true,Worker 发回 ready 消息。主线程收到后隐藏进度条,显示"模型下载完成"。
通信全流程
把主线程和 Worker 之间的消息往来画成时序图,整个流程一目了然:
Mermaid源码
worker.js (Worker 线程)App.tsx (主线程)worker.js (Worker 线程)App.tsx (主线程)loop下载过程隐藏进度条,显示完成显示错误,不触发下载altGPU 可用GPU 不可用postMessage({type:"check"})checkWebGPU(){status:"webgpu-check", supported:true}postMessage({type:"load"})loadModel(){status:"loading", message:"下载模型中..."}{status:"download", progress:xx}{status:"ready"}{status:"webgpu-check", supported:false}
100%
消息格式刻意保持简单:每条消息只有一个 status 字段加上若干数据字段。主线程的 switch/case 能直接分发,不需要复杂的路由逻辑。这种设计的好处是扩展容易------后续加推理功能时,只需要增加 "start"、"update"、"complete" 几个状态分支就行。
进度条的实现
UI 部分用 Tailwind 写了一个进度条,结构很简单:
TSX
css
// src/App.tsx --- JSX 渲染
<h1>dpsk-r1 webgpu 学习版</h1>
<p className={error ? "text-red-500" : ""}>{message}</p>
{progress !== null && (
<div>
{/* 灰色底槽 */}
<div className="w-full h-4 bg-gray-200 rounded-full overflow-hidden">
{/* 蓝色填充,宽度 = 下载百分比 */}
<div className="h-4 bg-blue-500 rounded-full transition-all"
style={{ width: `${progress}%` }} />
</div>
<p className="mt-1 text-sm">下载中 {Math.round(progress)}%</p>
</div>
)}
外层 div 是灰色底槽,内层 div 的宽度绑定到 progress 百分比,transition-all 让宽度变化有平滑过渡。progress 为 null 时整个区块不渲染------这是下载未开始或已完成的信号。
实际运行时,界面大概长这样:
Plain Text
erlang
┌──────────────────────────────────────────┐
│ │
│ dpsk-r1 webgpu 学习版 │
│ │
│ 下载模型中... │
│ │
│ ████████████████░░░░░░░░░░ 67% │
│ 下载中 67% │
│ │
└──────────────────────────────────────────┘
走到哪里了
项目的 generate 分支目前是模拟的,并没有真正调用模型做推理:
JavaScript
lua
// src/worker.js --- generate(模拟)
if (type === "generate" && ready) {
self.postMessage({ status: "start" });
self.postMessage({ status: "update", output: `模拟回答:${text}` });
self.postMessage({ status: "complete" });
}
它只是把用户输入原样回显。下一步的工作是接入 tokenizer.encode 和 model.generate,实现真正的文本生成。UI 层也需要补上输入框和对话区域------目前的界面只有一个标题、一行状态文字和一个进度条,够用来验证加载流程,但离可用的聊天界面还有距离。
不过基础已经搭好了。Worker 通道、状态管理、WebGPU 检测、模型加载与进度回传------这些基础设施在接入推理时不需要重写,只需要在 generate 分支里填上真正的推理逻辑。到那一步,这个项目就从一个"加载 Demo"变成一个能真正对话的浏览器端 AI 应用。
Footnotes
- Hugging Face, Transformers.js Documentation. huggingface.co/docs/transf... ↩ ↩2
- Vite, Web Worker 支持. vite.dev/guide/featu... ↩
- MDN Web Docs, WebGPU API. developer.mozilla.org/en-US/docs/... ↩
- onnx-community, DeepSeek-R1-Distill-Qwen-1.5B-ONNX. huggingface.co/onnx-commun... ↩