浏览器也能跑大模型:WebGPU + Transformers.js 本地运行 DeepSeek-R1

148 阅读7分钟

浏览器也能跑大模型:WebGPU + Transformers.js 本地运行 DeepSeek-R1

借助 WebGPU 加速和 Transformers.js,在浏览器中零后端部署运行 15 亿参数的推理模型。

前言

大模型离前端远吗?以前可能隔着服务端 API、GPU 集群、按 token 计费——但现在已经不一样了。这篇文章带你从 0 搭建一个完全在浏览器里跑的 DeepSeek-R1 推理应用:模型下载到本地、WebGPU 加速推理、全程数据不出浏览器。

适合读者:对 AI 感兴趣的前端开发者,有 React 基础即可。

读完你会收获:

  • WebGPU 在浏览器中的实际应用场景
  • Transformers.js 如何下载、加载并运行 ONNX 格式的 LLM
  • 为什么 Web Worker + 单例模式是本地推理的最佳拍档
  • TypeScript 对实验性浏览器 API 的类型处理

文末附有完整可运行项目代码。

项目概览

一句话:在浏览器中用 WebGPU 运行 DeepSeek-R1-Distill-Qwen-1.5B 模型,实现本地 NLP 对话推理。

数据链路

HuggingFace 模型仓库
  → Transformers.js 远程下载(首次 ~1.5GB)
    → 浏览器 IndexedDB 缓存
      → ONNX Runtime Web + WebGPU 本地推理
        → 流式输出 Markdown → marked 转 HTML → 页面渲染

项目文件树

deepseek-r1-webgpu/
├── index.html                 # 入口
├── package.json               # 依赖:React 19 + Vite + TailwindCSS 4
├── vite.config.ts             # Vite 配置
├── src/
│   ├── main.tsx               # React 挂载点
│   ├── App.tsx                # 主界面 + WebGPU 检测 + 模型加载状态
│   ├── App.css                # 样式(TailwindCSS 下基本只是辅助)
│   ├── index.css              # 全局:@import "tailwindcss"
│   └── worker.js              # Web Worker:模型加载 + 推理(核心)
└── public/
    └── logo.png               # DeepSeek Logo

核心技术栈

技术作用
@huggingface/transformers浏览器端模型加载与推理
WebGPU调用本地 GPU 加速矩阵运算
Web Worker模型推理在独立线程,不冻结 UI
Singleton Pattern模型实例全局唯一,避免重复加载
markedAI 返回的 Markdown 转 HTML 渲染
@webgpu/typesTypeScript 对 navigator.gpu 的类型声明
React 19 + Vite 8 + TailwindCSS 4界面层

依赖与版本

{
  "@huggingface/transformers": "3.7.1",
  "@tailwindcss/vite": "^4.3.3",
  "marked": "^15.0.5",
  "react": "^19.2.7",
  "react-dom": "^19.2.7",
  "tailwindcss": "^4.3.3",
  "@webgpu/types": "^0.1.71"
}

核心知识点一:Web Worker 为什么要独立线程?

问题

大模型推理是计算密集型操作——一次前向传播涉及数十亿次矩阵运算。如果放在主线程:

用户在输入框打字
  → 刚好撞上模型推理
    → 60fps → 0fps
      → 页面卡死 💀

解决方案

Web Worker 在独立线程跑模型,和 UI 线程互不干扰:

// App.tsx - 主线程创建 Worker
const worker = useRef(null);

useEffect(() => {
  if (!worker.current) {
    worker.current = new Worker(
      new URL("./worker.js", import.meta.url),
      { type: "module" }  // Worker 内支持 ESM import
    );
    worker.current.postMessage({ type: "check" }); // 检测 WebGPU
  }
}, []);
// worker.js - 独立线程中跑
self.addEventListener("message", async (e) => {
  const { type } = e.data;
  switch (type) {
    case "check":  check();  break;  // 检测 WebGPU 支持
    case "load":   load();   break;  // 下载并加载模型
    case "generate":         break;  // 推理生成
  }
});

通信机制:postMessage

主线程                          Worker 线程
  │                                │
  │── postMessage({type:"load"})──→│  触发模型加载
  │                                │
  │←── postMessage({status:"loading"},"正在下载...")──┤
  │←── postMessage({status:"progress"}, 45%) ──────┤
  │←── postMessage({status:"ready"}) ──────────────┤

postMessage 是浏览器原生 API,数据通过结构化克隆传递,无需引入任何第三方消息库。

核心知识点二:Singleton 单例模式——模型只加载一次

为什么必须用单例?

DeepSeek-R1-Distill-Qwen-1.5B 的 ONNX 权重文件约 1.5GB。如果意外调用了两次 load():

  • 下载 2 × 1.5GB = 3GB 流量
  • GPU 显存分配 2 份 = 浏览器 OOM 崩溃
  • 磁盘缓存冗余

模型是你全局唯一的稀缺资源,Singleton 是最自然的抽象。

代码实现

class TextGenerationPipeline {
  static model_id = "onnx-community/DeepSeek-R1-Distill-Qwen-1.5B-ONNX";

  static async getInstance(progress_callback = null) {
    // ??= 含义:如果 this.tokenizer 为 null/undefined,则执行右侧赋值
    // 第二次调用 getInstance() 时,直接跳过,返回已缓存的实例
    this.tokenizer ??= AutoTokenizer.from_pretrained(this.model_id, {
      progress_callback,  // 下载进度回调 → 实时更新 UI
    });

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

逐行拆解

代码作用
static model_id模型标识,挂在类上而非实例上,全局唯一
static getInstance()工厂方法,不管调几次,保证只创建一个实例
this.tokenizer ??=逻辑空赋值:第二次调用时 this.tokenizer 已有值,右侧的 from_pretrained 不执行
progress_callback下载进度(如 45%)回调给主线程更新 UI
Promise.all([...])等待 tokenizer 就绪;后续可扩展加载其他组件(如生成器)

为什么不是普通变量?

// ❌ 坏做法:每次 import worker.js 都执行
const tokenizer = await AutoTokenizer.from_pretrained(...);

// ❌ 坏做法:全局变量,无封装
let _tokenizer = null;
function getTokenizer() {
  if (!_tokenizer) _tokenizer = await AutoTokenizer.from_pretrained(...);
  return _tokenizer;
}

// ✅ 做法:用 static 挂类上,语义清晰
class TextGenerationPipeline {
  static tokenizer = null;
  static async getInstance() {
    this.tokenizer ??= AutoTokenizer.from_pretrained(...);
    return this.tokenizer;
  }
}

类的 static 属性天然表达"属于这个类型,不属于某个实例"的语义,比裸变量更内聚;后续添加其他模型组件(如生成器)时,统一通过 getInstance 管理。

核心知识点三:WebGPU 检测与 TypeScript 类型处理

问题

navigator.gpu 是 Chrome 113+ 才引入的实验性 API。直接写代码:

// ❌ TypeScript 报错:Property 'gpu' does not exist on type 'Navigator'
const IS_WEBGPU_AVAILABLE = !!navigator.gpu;

TS 的类型定义里 Navigator 没有 gpu 这个属性,编译都过不了。

两种解法

快速绕过(不推荐):

// as any 忽略类型检查——能用,但污染了其他地方的代码
!!(navigator as any).gpu

根本解决(项目采用):

pnpm i -D @webgpu/types

然后在 tsconfig.app.json 中配置:

{
  "compilerOptions": {
    "types": ["@webgpu/types"]  // TypeScript 认识 navigator.gpu 了
  }
}

安装后,TS 自动获得完整的 WebGPU 类型提示:

// ✅ 完美工作,有代码补全
const adapter: GPUAdapter = await navigator.gpu.requestAdapter();

Worker 中的检测逻辑

async function check() {
  const adapter = await navigator.gpu.requestAdapter();
  if (!adapter) {
    throw new Error("WebGPU is not supported (no adapter found)");
  }
  self.postMessage({ status: "ready" });
}

requestAdapter() 请求 GPU 访问权限:返回 null 说明浏览器不支持或驱动不可用;返回 adapter 对象说明环境就绪。

逐组件拆解

App.tsx —— 主界面与状态机

App.tsx 有三个职责:

1. Web Worker 生命周期管理

useEffect(() => {
  worker.current = new Worker(
    new URL("./worker.js", import.meta.url),
    { type: "module" }
  );
  worker.current.postMessage({ type: "check" }); // 启动即检测

  worker.current.addEventListener("message", onMessageReceived);
  return () => worker.current.removeEventListener("message", onMessageReceived);
}, []);

用 useRef 存 Worker 引用,确保整个组件生命周期只有一个 Worker 实例。首次渲染时创建、注册消息监听。

2. 状态机驱动 UI

onMessageReceived 按 e.data.status 分派:

statusUI 行为
loading显示加载文案(如"正在下载 tokenizer...")
initiate开始下载某个模型文件
progress更新进度条
done单个文件下载完成
ready所有文件就绪,可开始对话
start推理开始
update流式输出新 token
complete生成结束

每个状态对应不同的 UI 展示,构成一个清晰的状态机。

3. WebGPU 兜底提示

const IS_WEBGPU_AVAILABLE = !!navigator.gpu;

return IS_WEBGPU_AVAILABLE
  ? (/* 正常界面 */)
  : (
    <div className="fixed w-screen h-screen ...">
      WebGPU is not supported by this browser :(
    </div>
  );

WebGPU 不可用时直接全屏提示,不展示加载按钮。

worker.js —— 模型推理核心

async function load() {
  self.postMessage({ status: "loading", data: "Loading model..." });

  const [tokenizer] = await TextGenerationPipeline.getInstance((x) => {
    self.postMessage(x); // 进度回调 → 主线程
  });

  self.postMessage({ status: "ready" });
}

核心流程:

load() 被调用
  → postMessage("loading") 通知 UI
    → TextGenerationPipeline.getInstance(进度回调)
      → AutoTokenizer.from_pretrained(model_id, { progress_callback })
        → 下载模型文件(每次进度更新回调 postMessage)
          → 分词器创建完成
            → postMessage("ready") 通知 UI

为什么 textarea 要用 ref 取值?

在 CommentBox.jsx 中,<textarea> 没有绑定 value + onChange,而是通过 useRef 在提交时取值:

const textareaRef = useRef(null);
const handleSubmit = () => {
  const comment = textareaRef.current.value; // 提交时才读
};

这是非受控组件模式。选择它的原因:用户输入过程中不需要实时校验或格式化文本,因此没必要每次按键都触发 state 更新和重渲染。提交时一次性读取即可——性能最优。

总结

回顾你从这篇文章中收获的:

  1. WebGPU 不是框架专属 — 浏览器原生 navigator.gpu 就能用,配合 ONNX Runtime Web 跑大模型
  2. Web Worker 是大模型前端的标配 — 计算密集型任务必须移出主线程,否则 UI 卡死
  3. Singleton 是模型加载的最优解 — 1.5GB 权重的实例绝不能重复创建
  4. 实验性 API 的类型处理 — as any 只是临时方案,正解是安装 @webgpu/types
  5. 架构先于代码 — 主线程/Worker 通信、单例管理、状态机驱动 UI,这些设计决策比写代码更重要

下一步可以扩展的方向:补全 generate 分支实现流式推理对话、增加对话历史管理、优化首包延迟。

你觉得在浏览器端跑大模型,最大的痛点是什么?是首包加载速度、显存占用,还是模型精度?欢迎在评论区交流 👏

完整项目代码

项目地址:Gitee 仓库

# 1. 克隆仓库
git clone git@gitee.com:dcx2758/ai_doubao_dcx.git

# 2. 进入项目目录
cd ai_doubao_dcx/ai/webgpu-deepseek/deepseek-r1-webgpu

# 3. 安装依赖(pnpm 或 npm 均可)
pnpm install
# 或
npm install

# 4. 启动开发服务器
pnpm run dev
# 或
npm run dev

# 5. 浏览器打开 http://localhost:5173
# 需要 Chrome 113+ / Edge 113+,WebGPU 默认开启