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

115 阅读10分钟

谁说前端只能写页面?当浏览器遇上 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 核心依赖

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 的类型,毕竟这还是个新鲜出炉的规范。这里有两种优雅的解决方法:

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

pnpm add -D @webgpu/types

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

{
  "compilerOptions": { /* ... */ },
  "types": ["vite/client", "@webgpu/types"]
}

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

方法二:类型断言(临时方案)
如果只是快速验证,不想动配置文件,可以这样:

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

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
  }
}, []);

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

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 主线程消息处理

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

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 我们全局只需要一份,每次对话复用即可。单例模式正好解决这个问题:

  • 保证只有一个实例
  • 延迟初始化(第一次调用才加载)
  • 避免重复下载模型
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 是一个可选的回调函数,用于接收模型下载过程中的进度信息(如文件名称、已下载百分比)。调用方可以传入一个回调,比如:

    (x) => self.postMessage(x)
    

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

  • this.tokenizer ??= ...
    这是空值合并赋值运算符,等价于:

    if (this.tokenizer === null || this.tokenizer === undefined) {
      this.tokenizer = AutoTokenizer.from_pretrained(...)
    }
    

    它确保 from_pretrained 只会执行一次 —— 后续调用 getInstance 时 this.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%”这样友好的提示。

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:

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 世界的入口

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. 踩坑合集与性能优化建议

6.1 TypeScript 报错 navigator.gpu 不存在

  • 安装 @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 就是那把打开新世界大门的钥匙。