谁说前端只能写页面?当浏览器遇上 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.gpu、GPUAdapter这些实验性 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',第二个是基准 URLimport.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 ??= ...这是空值合并赋值运算符,等价于:
kotlinif (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,它就会:
- 从远程仓库下载
tokenizer.json、tokenizer_config.json等文件。 - 根据配置文件自动选择正确的分词器类型(BPE、WordPiece、Unigram 等)。
- 加载词汇表、合并规则、特殊 token 映射。
- 返回一个可以直接调用
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();
它的执行流程如下:
- 浏览器向操作系统请求一个 GPU 适配器(物理显卡的抽象)。
- 操作系统返回一个可用的 GPU 句柄(如果存在)。
- 浏览器封装成
GPUAdapter对象,包含该 GPU 的特性、限制、队列族等信息。 - 如果系统没有独立显卡(比如虚拟机),或者浏览器不支持 WebGPU,
requestAdapter()会返回null。
拿到 adapter 之后能干嘛?
- 调用
adapter.requestDevice()创建一个GPUDevice,这才是你真正干活的"虚拟 GPU 终端"。 - 检查
adapter.features,看看是否支持shader-f16、timestamp-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 就是那把打开新世界大门的钥匙。