在浏览器里运行 DeepSeek-R1:从 WebGPU 检测到模型加载(一)
大语言模型一定要部署在服务器上,再由前端调用接口吗?对于体积合适、经过量化并转换为 ONNX 格式的模型,答案是否定的。浏览器可以下载模型,把模型缓存到本地,再通过 WebGPU 使用显卡完成推理。
这一篇先不急着生成文本,而是把最基础也最容易卡住的一段链路打通:
- 搭建 React、TypeScript 与 Vite 工程;
- 检查浏览器是否支持 WebGPU;
- 用 Web Worker 隔离模型任务;
- 通过 Transformers.js 下载分词器与模型;
- 展示多文件下载进度;
- 预热模型,确认推理管线真正可用。
完成这一部分以后,模型会完全运行在浏览器中。页面不需要把对话内容发送给后端;模型第一次加载较慢,后续则可以利用浏览器缓存。
一、整个应用是怎样协作的
这个应用可以分为三层:
text
React 主线程
├─ 展示加载页、进度条和聊天界面
├─ 收集用户输入
└─ 通过 postMessage 与 Worker 通信
│
▼
Web Worker
├─ 检查 WebGPU
├─ 加载 tokenizer 和 model
├─ 执行文本生成
└─ 把进度和生成结果发回主线程
│
▼
Transformers.js + ONNX Runtime Web + WebGPU
└─ 在浏览器本地执行模型推理
这里最关键的设计不是某一个 API,而是职责分离。React 主线程只处理界面,模型下载、预热和推理交给 Worker。大模型计算时间较长,如果全部塞进主线程,输入框、滚动和按钮反馈都容易受到影响。
二、工程依赖与 TypeScript 配置
项目使用的核心运行时依赖如下:
json
{
"dependencies": {
"@huggingface/transformers": "3.7.1",
"better-react-mathjax": "^3.0.1",
"dompurify": "^3.4.14",
"marked": "^15.0.5",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"tailwindcss": "^4.3.3"
}
}
第一篇真正会用到的是 @huggingface/transformers。它提供分词器、模型加载、流式输出和停止条件等能力。marked、DOMPurify 与 MathJax 会在第三篇处理模型输出时使用。
WebGPU 仍然是一个相对新的浏览器能力。即使浏览器运行时已经存在 navigator.gpu,TypeScript 也不一定认识对应类型。直接用类型断言绕过检查当然可以:
ts
const available = !!(navigator as any).gpu;
但 any 会同时放弃这一段代码的类型保护。更合适的方式是安装 WebGPU 类型声明,并在应用侧 TypeScript 配置中启用它:
bash
pnpm i -D @webgpu/types
json
{
"compilerOptions": {
"target": "es2023",
"lib": ["ES2023", "DOM"],
"types": ["vite/client", "@webgpu/types"],
"moduleResolution": "bundler",
"jsx": "react-jsx",
"noEmit": true
},
"include": ["src"]
}
类型声明只服务于开发阶段。TypeScript 最终会被编译为 JavaScript,浏览器是否真的支持 WebGPU,仍然需要运行时检查。
三、WebGPU 检测要分成两层
页面渲染前可以先做一次轻量判断:
ts
const IS_WEBGPU_AVAILABLE = !!navigator.gpu;
如果不存在 navigator.gpu,页面直接提示当前浏览器不支持 WebGPU。下面省略支持 WebGPU 时的主体界面,只保留能力分支:
tsx
return IS_WEBGPU_AVAILABLE ? (
<div className="flex flex-col h-screen">
{/* 模型加载区、聊天区和输入区 */}
</div>
) : (
<div className="fixed w-screen h-screen bg-black text-white">
WebGPU is not supported by this browser :(
</div>
);
不过,"对象存在"不代表一定能拿到可用的 GPU 适配器。因此 Worker 中还需要一次实际检测:
js
async function check() {
try {
const adapter = await navigator.gpu.requestAdapter();
if (!adapter) {
throw new Error("WebGPU is not supported (no adapter found)");
}
} catch (error) {
self.postMessage({
status: "error",
data: error.toString(),
});
}
}
两层检查各有作用:主线程的布尔判断用于快速切换界面;requestAdapter() 则验证 Worker 所在环境能否获得真正可用的适配器。检测失败后,Worker 不直接操作 DOM,而是把错误发回 React。
四、为什么用 Web Worker
在 React 组件中,用 useRef 保存 Worker:
tsx
const worker = useRef<Worker | null>(null);
useRef 的值跨渲染保持不变,而且更新它不会触发页面重新渲染,很适合保存 Worker 这种命令式对象。
组件挂载后创建 Worker:
tsx
useEffect(() => {
if (!worker.current) {
worker.current = new Worker(
new URL("./worker.js", import.meta.url),
{ type: "module" },
);
worker.current.postMessage({ type: "check" });
}
const onMessageReceived = (event: MessageEvent) => {
// 根据 event.data.status 更新界面
};
const onErrorReceived = (event: ErrorEvent) => {
console.error("Worker error:", event);
};
worker.current.addEventListener("message", onMessageReceived);
worker.current.addEventListener("error", onErrorReceived);
return () => {
worker.current?.removeEventListener("message", onMessageReceived);
worker.current?.removeEventListener("error", onErrorReceived);
};
}, []);
new URL("./worker.js", import.meta.url) 会以当前模块为基准解析 Worker 地址,构建工具也能识别这项依赖。type: "module" 让 Worker 可以使用 ESM 的 import 语法。
这里还要注意两点:
- 空依赖数组表示初始化逻辑只在组件挂载阶段执行;
- 清理函数移除监听器,避免组件再次挂载后重复接收同一条消息。
主线程和 Worker 之间约定了一套很小的消息协议。主线程发出 check、load、generate、interrupt、reset;Worker 返回 loading、initiate、progress、done、ready、start、update、complete、error。
比起让双方传递含义不明的字符串,把消息拆成 type/status + data,状态流会清楚很多。
五、用单例管理分词器和模型
Hugging Face 是开源模型常见的发布社区,国内也常见 ModelScope。Transformers.js 可以根据模型 ID 从模型社区取得资源,并在 Web 环境中加载和执行 NLP 模型。这里使用的模型 ID 是 onnx-community/DeepSeek-R1-Distill-Qwen-1.5B-ONNX。
模型和分词器都不是应该反复创建的普通对象。它们下载和初始化成本高,一次加载后应该持续复用。
设计模式是针对特定问题总结出的设计方案,关注的是如何组织职责,而不是某一段固定实现。这里适合使用单例模式:在 Worker 生命周期内只保留一套模型与分词器,避免把昂贵对象当成普通局部变量反复创建。
Worker 中可以用一个静态类实现延迟单例:
js
import {
AutoTokenizer,
AutoModelForCausalLM,
} from "@huggingface/transformers";
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,
});
this.model ??= AutoModelForCausalLM.from_pretrained(this.model_id, {
dtype: "q4f16",
device: "webgpu",
progress_callback,
});
return Promise.all([this.tokenizer, this.model]);
}
}
这段代码包含几个值得展开的点。
1. from_pretrained 返回 Promise
浏览器需要异步获取配置、分词器和模型分片,不能同步返回完整对象。因此这里保存的首先是 Promise,而不是已经加载完成的模型。
这反而有一个好处:如果模型还没下载完,第二次调用 getInstance() 也会拿到同一个 Promise,不会再次发起一套重复下载。
2. ??= 只在空值时赋值
空值合并赋值运算符只在左侧为 null 或 undefined 时执行赋值:
js
this.model ??= createModel();
它和"每次都创建模型"完全不同,也不会把 false、0、空字符串等非空值误判为需要初始化。这里借助 ??=,分词器和模型在 Worker 生命周期内都只初始化一次。
3. 分词器和模型并行等待
js
return Promise.all([this.tokenizer, this.model]);
分词器与模型的加载互不依赖,Promise.all 会统一等待二者完成,返回顺序仍与传入顺序一致。因此后面可以直接写:
js
const [tokenizer, model] =
await TextGenerationPipeline.getInstance();
4. 明确模型运行参数
模型加载时指定了:
js
{
dtype: "q4f16",
device: "webgpu"
}
device: "webgpu" 明确选择浏览器的 WebGPU 后端;dtype: "q4f16" 对应当前应用采用的模型数据类型配置。应用使用的是 DeepSeek-R1-Distill-Qwen-1.5B-ONNX,模型 ID 是下载与适配的唯一来源,不需要自己拼接多个资源地址。
六、把模型下载进度传回 React
模型往往由多个资源组成,所以加载进度不能只用一个数字表示。progress_callback 收到进度事件后,Worker 原样把事件转发给主线程:
js
const [tokenizer, model] =
await TextGenerationPipeline.getInstance((event) => {
self.postMessage(event);
});
React 根据 status 维护一个进度项数组:
tsx
switch (event.data.status) {
case "initiate":
setProgressItems((prev) => [...prev, event.data]);
break;
case "progress":
setProgressItems((prev) =>
prev.map((item) =>
item.file === event.data.file
? { ...item, ...event.data }
: item,
),
);
break;
case "done":
setProgressItems((prev) =>
prev.filter((item) => item.file !== event.data.file),
);
break;
}
这里必须使用函数式状态更新。模型文件可能并发下载,进度回调也会快速连续触发。prev 是 React 交给当前更新函数的最新状态,避免多个回调都抓住同一份旧数组。
三种事件正好对应一个进度项的生命周期:
text
initiate:加入列表
progress:按 file 更新
done:从列表移除
进度组件本身很简单:
tsx
function formatBytes(size: number) {
const index =
size === 0 ? 0 : Math.floor(Math.log(size) / Math.log(1024));
return (
+(size / Math.pow(1024, index)).toFixed(2) +
["B", "kB", "MB", "GB", "TB"][index]
);
}
function Progress({ text, percentage, total }) {
percentage ??= 0;
return (
<div className="w-full bg-gray-100 rounded-lg overflow-hidden">
<div
className="bg-blue-400 whitespace-nowrap px-1 text-sm"
style={{ width: `${percentage}%` }}
>
{text} ({percentage.toFixed(2)}%
{isNaN(total) ? "" : ` of ${formatBytes(total)}`})
</div>
</div>
);
}
percentage ??= 0 保证进度尚未上报时也能安全渲染。文件总大小有效时,再通过 formatBytes 转成更容易阅读的单位。
七、下载完成不等于立刻可用:还要预热
完整加载函数如下:
js
async function load() {
self.postMessage({
status: "loading",
data: "Loading model...",
});
const [tokenizer, model] =
await TextGenerationPipeline.getInstance((event) => {
self.postMessage(event);
});
self.postMessage({
status: "loading",
data: "Compiling shaders and warming up model...",
});
const inputs = tokenizer("a");
await model.generate({ ...inputs, max_new_tokens: 1 });
self.postMessage({ status: "ready" });
}
预热过程只输入一个简单字符,并只生成一个新 token。它的目的不是获得有意义的回答,而是让模型走通一次生成过程并完成着色器编译。只有这一步结束后,Worker 才发送 ready。
页面状态也因此很自然:
text
null -> 展示模型介绍和 Load model 按钮
loading -> 展示加载提示及每个文件的进度
ready -> 展示聊天界面并允许输入
error -> 展示 Worker 返回的错误
按钮点击时只做两件事:通知 Worker 加载,并立刻切换页面状态,避免用户重复点击。
tsx
<button
onClick={() => {
worker.current?.postMessage({ type: "load" });
setStatus("loading");
}}
disabled={status !== null || error !== null}
>
Load model
</button>
八、本篇小结
到这里,一条可靠的浏览器模型加载链路已经建立起来:
- TypeScript 通过
@webgpu/types理解 WebGPU 类型; - 主线程做快速能力判断,Worker 通过
requestAdapter()做实际检查; - React 只负责交互,耗时工作留在 Worker;
- 静态类、Promise 和
??=共同保证模型只加载一次; - 多文件进度通过消息协议同步到页面;
- 一次最小生成完成 WebGPU 预热,最后再进入
ready状态。
下一篇将进入真正的推理流程:对话模板如何转成模型输入,怎样流式返回 token,如何区分"思考过程"和"最终回答",以及停止生成与 KV Cache 在当前实现中分别处于什么状态。