浏览器也能跑大模型: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 对话推理。
数据链路
css
HuggingFace 模型仓库
→ Transformers.js 远程下载(首次 ~1.5GB)
→ 浏览器 IndexedDB 缓存
→ ONNX Runtime Web + WebGPU 本地推理
→ 流式输出 Markdown → marked 转 HTML → 页面渲染
项目文件树
ruby
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 | 模型实例全局唯一,避免重复加载 |
marked |
AI 返回的 Markdown 转 HTML 渲染 |
@webgpu/types |
TypeScript 对 navigator.gpu 的类型声明 |
| React 19 + Vite 8 + TailwindCSS 4 | 界面层 |
依赖与版本
json
{
"@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 为什么要独立线程?
问题
大模型推理是计算密集型操作------一次前向传播涉及数十亿次矩阵运算。如果放在主线程:
markdown
用户在输入框打字
→ 刚好撞上模型推理
→ 60fps → 0fps
→ 页面卡死 💀
解决方案
Web Worker 在独立线程跑模型,和 UI 线程互不干扰:
js
// 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
}
}, []);
js
// 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
lua
主线程 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 是最自然的抽象。
代码实现
js
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 就绪;后续可扩展加载其他组件(如生成器) |
为什么不是普通变量?
js
// ❌ 坏做法:每次 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。直接写代码:
ts
// ❌ TypeScript 报错:Property 'gpu' does not exist on type 'Navigator'
const IS_WEBGPU_AVAILABLE = !!navigator.gpu;
TS 的类型定义里 Navigator 没有 gpu 这个属性,编译都过不了。
两种解法
快速绕过(不推荐):
ts
// as any 忽略类型检查------能用,但污染了其他地方的代码
!!(navigator as any).gpu
根本解决(项目采用):
bash
pnpm i -D @webgpu/types
然后在 tsconfig.app.json 中配置:
json
{
"compilerOptions": {
"types": ["@webgpu/types"] // TypeScript 认识 navigator.gpu 了
}
}
安装后,TS 自动获得完整的 WebGPU 类型提示:
ts
// ✅ 完美工作,有代码补全
const adapter: GPUAdapter = await navigator.gpu.requestAdapter();
Worker 中的检测逻辑
js
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 生命周期管理
ts
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 分派:
| status | UI 行为 |
|---|---|
loading |
显示加载文案(如"正在下载 tokenizer...") |
initiate |
开始下载某个模型文件 |
progress |
更新进度条 |
done |
单个文件下载完成 |
ready |
所有文件就绪,可开始对话 |
start |
推理开始 |
update |
流式输出新 token |
complete |
生成结束 |
每个状态对应不同的 UI 展示,构成一个清晰的状态机。
3. WebGPU 兜底提示
ts
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 ------ 模型推理核心
js
async function load() {
self.postMessage({ status: "loading", data: "Loading model..." });
const [tokenizer] = await TextGenerationPipeline.getInstance((x) => {
self.postMessage(x); // 进度回调 → 主线程
});
self.postMessage({ status: "ready" });
}
核心流程:
scss
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 在提交时取值:
jsx
const textareaRef = useRef(null);
const handleSubmit = () => {
const comment = textareaRef.current.value; // 提交时才读
};
这是非受控组件模式。选择它的原因:用户输入过程中不需要实时校验或格式化文本,因此没必要每次按键都触发 state 更新和重渲染。提交时一次性读取即可------性能最优。
总结
回顾你从这篇文章中收获的:
- WebGPU 不是框架专属 --- 浏览器原生
navigator.gpu就能用,配合 ONNX Runtime Web 跑大模型 - Web Worker 是大模型前端的标配 --- 计算密集型任务必须移出主线程,否则 UI 卡死
- Singleton 是模型加载的最优解 --- 1.5GB 权重的实例绝不能重复创建
- 实验性 API 的类型处理 ---
as any只是临时方案,正解是安装@webgpu/types - 架构先于代码 --- 主线程/Worker 通信、单例管理、状态机驱动 UI,这些设计决策比写代码更重要
下一步可以扩展的方向:补全 generate 分支实现流式推理对话、增加对话历史管理、优化首包延迟。
你觉得在浏览器端跑大模型,最大的痛点是什么?是首包加载速度、显存占用,还是模型精度?欢迎在评论区交流 👏
完整项目代码
项目地址:Gitee 仓库
bash
# 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 默认开启