WebGPU + Transformers.js:把 DeepSeek-R1 塞进浏览器,真香!

谁说前端只能写页面?当浏览器遇上 GPU 加速,1.5B 参数的大模型也能在你电脑上流畅推理,而且全程数据不出设备。这篇实战笔记带你从零搭建一个纯浏览器端的 DeepSeek-R1 对话应用。


1. 先看看我们要做什么

先别急着写代码,你会发现:现在我们完全可以在浏览器里跑一个真正的推理模型,而不只是玩具 Demo。

这个项目基于 HuggingFace 上的 DeepSeek-R1-Distill-Qwen-1.5B-ONNX,配合 Transformers.js 和 WebGPU,实现了:

  • 模型全部在浏览器端下载、加载、推理
  • 利用 GPU 加速,生成速度吊打纯 CPU
  • 使用 Web Worker 保证页面不卡顿
  • Markdown 流式输出,打字机效果

说白了,这就是一个无需后端、完全本地运行的大模型聊天应用。所有数据留在你的电脑上,甚至可以离线使用。


2. 环境准备:该装的轮子一个都不能少

2.1 核心依赖

sql 复制代码
pnpm add @huggingface/transformers marked
pnpm add -D @webgpu/types

我们来拆解一下这三个包分别干了什么事:

  • @huggingface/transformers
    Transformers.js 的本体,相当于 HuggingFace Python 生态的 JavaScript 版本。它能直接从 HuggingFace Hub 下载 ONNX 格式的模型权重,并在浏览器中完成分词、编码、推理的完整流程。没有它,在浏览器里跑大模型几乎是不可能的事。
  • marked
    为什么需要这个包?因为几乎所有大模型的输出都是 Markdown 格式 。你想想看,AI 回复经常包含代码块、加粗、列表、引用 ------ 如果直接返回纯文本,这些结构就很难表达。Markdown 是一种轻量级标记语言,能让模型用最简单的符号表示富文本语义,同时又保持文本可读性。把 Markdown 转成 HTML 展示给用户,就是 marked 这个包的职责。
    用起来也极简:marked.parse(markdownString) 就能得到对应的 HTML。
  • @webgpu/types
    这是 WebGPU 的类型声明文件,开发阶段用,打包后是纯 JS,所以安装为 devDependency。它让 TypeScript 编译器认识 navigator.gpuGPUAdapter 这些实验性 API,避免你到处写 as any

2.2 解决 !!navigator.gpu 报错的两种方法

很多同学第一次写下 !!navigator.gpu 时,编辑器会无情报错:Property 'gpu' does not exist on type 'Navigator'。原因很简单:TypeScript 的内置类型定义里还没有包含 WebGPU 的类型,毕竟这还是个新鲜出炉的规范。这里有两种优雅的解决方法:

方法一(推荐):安装类型声明包

sql 复制代码
pnpm add -D @webgpu/types

然后在 tsconfig.app.json(或你项目中的 tsconfig)里加上:

perl 复制代码
{
  "compilerOptions": { /* ... */ },
  "types": ["vite/client", "@webgpu/types"]
}

重启编辑器后,navigator.gpu 就能被正确识别了。这种方式让代码保持类型安全,不会留下后患。

方法二:类型断言(临时方案)

如果只是快速验证,不想动配置文件,可以这样:

ini 复制代码
const IS_WEBGPU_AVAILABLE = !!(navigator as any).gpu;

as any 告诉 TypeScript:"别管了,我知道自己在干什么"。但滥用 any 会让整个项目的类型防护形同虚设,只在实验阶段或者确实无法安装类型包时使用

💡 金句 :不要因为 TS 报错就滥用 as any,多数时候只是缺了类型声明文件 ------ 装一个类型包,让代码回归安全。


3. 应用骨架:主线程与 Web Worker 的分工

直接在主线程跑模型?那页面肯定会卡成 PPT。我们的架构很清晰:

  • 主线程 (App.jsx) :负责 UI、用户交互,通过 Worker 发指令
  • Worker 线程 (worker.js) :负责模型加载、推理,只通过消息与主线程通信

为什么用 Worker?因为模型下载和推理都是 CPU / GPU 密集操作,放到 Worker 里不会阻塞 UI 渲染,保证了丝滑体验。

3.1 初始化 Worker

scss 复制代码
const worker = useRef(null);

useEffect(() => {
  if (!worker.current) {
    worker.current = new Worker(
      new URL('./worker.js', import.meta.url),
      { type: 'module' }
    );
    worker.current.addEventListener('message', onMessageReceived);
    worker.current.addEventListener('error', onErrorReceived);
    worker.current.postMessage({ type: 'check' }); // 提前检测 WebGPU
  }
}, []);

这里有一行很关键的代码:

go 复制代码
new Worker(new URL('./worker.js', import.meta.url), { type: 'module' })

我们逐个参数拆解一下:

  • new URL('./worker.js', import.meta.url)
    URL 构造函数接收两个参数:第一个是相对路径 './worker.js',第二个是基准 URL import.meta.url(当前 JS 模块的完整 URL,比如 http://localhost:5173/src/App.jsx)。它会解析出一个新的绝对 URL:http://localhost:5173/src/worker.js。这样做的好处是,无论打包工具(Vite、Webpack)怎么处理模块路径,都能准确定位 Worker 文件,避免路径错误。
  • { type: 'module' }
    Worker 构造函数的第二个参数,表示这个 Worker 将作为 ES Module 执行。这意味着在 worker.js 里可以直接使用 import 语句(比如 import { AutoTokenizer } from ...),而传统的 Web Worker 默认只支持 importScripts()。这个配置项是现代前端工程化的必备选项。

3.2 主线程消息处理

我们约定一套消息状态码:

javascript 复制代码
const onMessageReceived = (e) => {
  switch (e.data.status) {
    case 'loading':    // 模型开始下载
    case 'initiate':   // 单个文件开始下载
    case 'progress':   // 下载进度
    case 'done':       // 单个文件完成
    case 'ready':      // 模型全部就绪
    case 'start':      // 推理开始
    case 'update':     // 流式生成的新 token
    case 'complete':   // 推理完成
    case 'error':      // 出错了
  }
}

这种设计让 UI 只用根据状态做展示,而真正的重活都藏在 Worker 里。


4. Worker 核心:单例 + 流水线

4.1 为什么用单例模式?

大模型的初始化非常昂贵 ------ 下载模型文件、构建分词器、预热推理 pipeline。这个 pipeline 我们全局只需要一份,每次对话复用即可。单例模式正好解决这个问题:

  • 保证只有一个实例
  • 延迟初始化(第一次调用才加载)
  • 避免重复下载模型
javascript 复制代码
class TextGenerationPipeline {
  static model_id = 'onnx-community/DeepSeek-R1-Distill-Qwen-1.5B-ONNX';

  static async getInstance(progress_callback = null) {
    this.tokenizer ??= AutoTokenizer.from_pretrained(this.model_id, {
      progress_callback,
    });

    return Promise.all([this.tokenizer]);
  }
}

我们把这段代码解剖一下:

  • static model_id

    静态属性,存储 HuggingFace 上的模型仓库 ID。Transformers.js 会通过这个 ID 去远程拉取对应的分词器配置、tokenizer.json 等文件。

  • static async getInstance(progress_callback = null)

    静态方法,负责创建并返回单例。参数 progress_callback 是一个可选的回调函数,用于接收模型下载过程中的进度信息(如文件名称、已下载百分比)。调用方可以传入一个回调,比如:

    scss 复制代码
    (x) => self.postMessage(x)

    这样下载进度就能实时发送给主线程。

  • this.tokenizer ??= ...

    这是空值合并赋值运算符,等价于:

    kotlin 复制代码
    if (this.tokenizer === null || this.tokenizer === undefined) {
      this.tokenizer = AutoTokenizer.from_pretrained(...)
    }

    它确保 from_pretrained 只会执行一次 ------ 后续调用 getInstancethis.tokenizer 已经有值,直接复用,不会重复下载。

  • AutoTokenizer.from_pretrained(this.model_id, { progress_callback })

    这是 Transformers.js 提供的一个智能工厂方法,能根据模型 ID 自动匹配并下载对应的分词器。

分词器的核心使命

大模型内部处理的根本不是文字,而是一串整数 ID(token IDs)。分词器就是"文字 ↔ 数字序列"的双向翻译器。

  • 文本 → Token IDs :把用户输入的 "你好,世界" 切成 [你好, ,, 世界],再映射为模型词汇表中的编号,比如 [101, 102, 103]
  • Token IDs → 文本:把模型推理输出的 token 序列逐个解码回人类可读的文字。
  • 特殊标记 :自动添加对话模板需要的 <s></s><|user|><|assistant|> 等控制符。

一句话:没有分词器,模型就是个听不懂人话、也说不出人话的哑巴。

AutoTokenizer.from_pretrained() 为什么能自动匹配?

你只需要传入 HuggingFace 上的模型仓库 ID,它就会:

  1. 从远程仓库下载 tokenizer.jsontokenizer_config.json 等文件。
  2. 根据配置文件自动选择正确的分词器类型(BPE、WordPiece、Unigram 等)。
  3. 加载词汇表、合并规则、特殊 token 映射。
  4. 返回一个可以直接调用 encode() / decode() 的实例。

整个过程对开发者黑盒,你不用关心模型内部的分词细节,这也是"Auto"的含义。

4.2 下载进度反馈

from_pretrained 的第二个参数里可以传入 progress_callback,它能收到每个文件的下载进度。我们把进度数据通过 postMessage 发回主线程,页面就能展示"Loading model... 45%"这样友好的提示。

php 复制代码
async function load() {
  self.postMessage({ status: 'loading', data: 'Loading model...' });

  const [tokenizer] = await TextGenerationPipeline.getInstance((x) => {
    self.postMessage(x);
  });

  // 下载完成后通知主线程
  self.postMessage({ status: 'ready' });
}

💡不要让你的用户对着空白页猜进度,一个进度条能极大提升等待体验。


5. 让 WebGPU 飞起来:模型推理的加速器

WebGPU 不仅仅用来画三角形,它对通用计算(GPGPU)的支持让浏览器里的 AI 推理成为可能。我们需要在 Worker 里检查设备是否支持 WebGPU:

javascript 复制代码
async function check() {
  try {
    const adapter = await navigator.gpu.requestAdapter();
    if (!adapter) throw new Error('No adapter found');
    // 可选:检测 shader-f16 特性等
  } catch (e) {
    self.postMessage({ status: 'error', data: e.toString() });
  }
}

这行代码是整个 WebGPU 世界的入口

ini 复制代码
const adapter = await navigator.gpu.requestAdapter();

它的执行流程如下:

  1. 浏览器向操作系统请求一个 GPU 适配器(物理显卡的抽象)。
  2. 操作系统返回一个可用的 GPU 句柄(如果存在)。
  3. 浏览器封装成 GPUAdapter 对象,包含该 GPU 的特性、限制、队列族等信息。
  4. 如果系统没有独立显卡(比如虚拟机),或者浏览器不支持 WebGPU,requestAdapter() 会返回 null

拿到 adapter 之后能干嘛?

  • 调用 adapter.requestDevice() 创建一个 GPUDevice,这才是你真正干活的"虚拟 GPU 终端"。
  • 检查 adapter.features,看看是否支持 shader-f16timestamp-query 等高级特性。
  • 查看 adapter.limits,了解最大绑定组数量、最大缓冲区大小等硬件限制。

所以这行代码不是简单的一句"获取 GPU",而是浏览器与显卡握手的起点,后续所有并行计算、着色器执行、显存分配都由这个 adapter 派生的 device 完成。

如果用户浏览器不支持 WebGPU(比如旧版 Firefox 或未开启相关 flag),我们就友好地显示提示页面。这也是我们 App 里 IS_WEBGPU_AVAILABLE 的判断依据。


6. 踩坑合集与性能优化建议

  • 安装 @webgpu/types 并在 tsconfig 的 types 里加入 "@webgpu/types"
  • 或临时使用 (navigator as any).gpu 做运行时检测(不推荐用于生产)

6.2 模型下载太慢

  • 模型文件存放在 HuggingFace,首次加载会下载大约几个 GB 的数据(1.5B 的量化版大约 1~2GB)
  • 浏览器会缓存这些文件(通过 Service Worker 或 HTTP 缓存),第二次打开速度起飞
  • 可以考虑将模型托管到国内 CDN,但要注意跨域策略

6.3 推理速度与内存占用

  • WebGPU 对显存的使用有限制,大模型可能需要 shader-f16 等特性支持
  • 推理时 Worker 占用的内存可以通过 navigator.deviceMemory 做个粗略判断,给低配设备降级提示

7. 一点思考:前端工程师的 AI 新使命

过去我们说"前端搞 AI"总觉得离自己很远,要么得学 Python,要么得调云端 API。但现在 WebGPU + Transformers.js 的组合让浏览器成为最好的 AI 应用运行环境之一

  • 隐私优先:数据不离开设备,适合企业内网、医疗、法律等场景
  • 零部署成本:一个静态页面就能跑,没有服务器开销
  • 离线可用:模型一次加载后,随时随地都能推理

说白了,以后的前端技能树里,一定会多一条"端侧模型部署与优化" 。现在开始接触 WebGPU 和 Transformers.js,就是在为未来铺路。

浏览器不再是内容的展示层,它正在成为通用计算平台 ------ 而 WebGPU 就是那把打开新世界大门的钥匙。

相关推荐
xiaominlaopodaren1 小时前
three.js地图数学基础(四):瓦片金字塔
javascript·gis·three.js
mONESY1 小时前
🔥 React Hooks 进阶实战:受控组件 + 性能优化一站式掌握
javascript
Synmbrf2 小时前
flv播放设置hasAudio为true黑屏
javascript
Eloudy2 小时前
一键构建 pytorch cpp lib 前端和 wheel
人工智能·pytorch·gpu
看到我请叫我铁锤2 小时前
vue编写web端在线预览文档
前端·javascript·vue.js
鬼手点金2 小时前
FreeLLMAPI 介绍
llm·github·nvidia·apikey·freellmapi·agnes ai·日日新
ssshooter2 小时前
为什么明明只有一个 12px 的小元素,父容器却有 63px 高?
前端·javascript·面试
breeze jiang3 小时前
React + WebGPU 在浏览器运行 DeepSeek:从 Worker 通信到流式生成
前端·javascript·react.js
AINative软件工程3 小时前
LLM 应用的 Feature Flag 工程实践:Prompt、模型与 AI 行为的生产安全灰度
后端·llm·ai编程