用浏览器 WebGPU跑DPSK大模型(1) - 模型的下载和前端下载进度的显示

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 推荐的写法,保证构建后路径正确2type: "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 模式虽然兼容性更好,但速度差了一个数量级

??= 运算符做的是逻辑空赋值:只有左侧为 nullundefined 时才执行右侧的赋值。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 让宽度变化有平滑过渡。progressnull 时整个区块不渲染------这是下载未开始或已完成的信号。

实际运行时,界面大概长这样:

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.encodemodel.generate,实现真正的文本生成。UI 层也需要补上输入框和对话区域------目前的界面只有一个标题、一行状态文字和一个进度条,够用来验证加载流程,但离可用的聊天界面还有距离。

不过基础已经搭好了。Worker 通道、状态管理、WebGPU 检测、模型加载与进度回传------这些基础设施在接入推理时不需要重写,只需要在 generate 分支里填上真正的推理逻辑。到那一步,这个项目就从一个"加载 Demo"变成一个能真正对话的浏览器端 AI 应用。


Footnotes

  1. Hugging Face, Transformers.js Documentation. huggingface.co/docs/transf... ↩2
  2. Vite, Web Worker 支持. vite.dev/guide/featu...
  3. MDN Web Docs, WebGPU API. developer.mozilla.org/en-US/docs/...
  4. onnx-community, DeepSeek-R1-Distill-Qwen-1.5B-ONNX. huggingface.co/onnx-commun...
相关推荐
夏幻灵1 小时前
Vue 2 与 Vue 3 在应用初始化上的设计差异与技术演进
前端·javascript·vue.js
xiaoshuai10242 小时前
编译管不着的跨层 bug:用 4 道脚本闸守住 API/SQL/权限/迁移
架构
悟空瞎说2 小时前
Three.js 架构实践:用 MVC 模式组织你的 3D 应用
前端·javascript
光影少年2 小时前
react navite本地存储:AsyncStorage、MMKV、文件存储
前端·react native·react.js
王林不想说话2 小时前
JavaScript 从入门到进阶:一篇覆盖核心知识、运行机制与工程实践
javascript
windliang2 小时前
Claude Code 源码分析(十一):一段会话怎样保存、恢复与续写
前端·javascript·人工智能
Dr.kangder2 小时前
嵌入式面试总结(八)——大小端
嵌入式硬件·面试·职场和发展·架构·嵌入式
李白客2 小时前
分布式集群与数据库产业:从单机到集群的架构跃迁与市场重构
数据库·分布式·架构
画中有画3 小时前
Kappa 架构在大数据实时处理系统中的应用
大数据·架构