WebGPU DeepSeek项目实战(二):用单例模式加载Tokenizer并转发下载进度
- 前言
- [1. 从 `case "load"` 接上模型加载入口](#1. 从
case "load"接上模型加载入口) -
- [1.1 App 如何发出加载命令](#1.1 App 如何发出加载命令)
- [1.2 Worker 如何把命令交给 `load()`](#1.2 Worker 如何把命令交给
load())
- [2. 进入加载函数前,先理解 Singleton 单例模式](#2. 进入加载函数前,先理解 Singleton 单例模式)
-
- [2.1 什么是单例模式](#2.1 什么是单例模式)
- [2.2 用 `Popup` 看懂标准单例写法](#2.2 用
Popup看懂标准单例写法) - [2.3 为什么 Tokenizer 适合使用这种思路](#2.3 为什么 Tokenizer 适合使用这种思路)
- [3. `TextGenerationPipeline` 是什么](#3.
TextGenerationPipeline是什么) -
- [3.1 它从哪里来](#3.1 它从哪里来)
- [3.2 `static model_id` 保存了什么](#3.2
static model_id保存了什么) - [3.3 它和标准单例有什么区别](#3.3 它和标准单例有什么区别)
- [4. 逐行拆解 `getInstance()`](#4. 逐行拆解
getInstance()) -
- [4.1 `progress_callback = null` 是什么](#4.1
progress_callback = null是什么) - [4.2 静态方法中的 `this` 指向谁](#4.2 静态方法中的
this指向谁) - [4.3 `??=` 为什么能避免重复加载](#4.3
??=为什么能避免重复加载) - [4.4 `AutoTokenizer.from_pretrained()` 是什么](#4.4
AutoTokenizer.from_pretrained()是什么) - [4.5 `Promise.all(this.tokenizer)` 为什么返回数组](#4.5
Promise.all([this.tokenizer])为什么返回数组)
- [4.1 `progress_callback = null` 是什么](#4.1
- [5. Tokenizer 到底是什么,为什么加载模型前需要它](#5. Tokenizer 到底是什么,为什么加载模型前需要它)
-
- [5.1 Tokenizer 负责文本与数字之间的转换](#5.1 Tokenizer 负责文本与数字之间的转换)
- [5.2 加载 Tokenizer 不等于加载 LLM](#5.2 加载 Tokenizer 不等于加载 LLM)
- [6. 逐行拆解 `load()` 与回调参数 `x`](#6. 逐行拆解
load()与回调参数x) -
- [6.1 为什么先发送 `loading`](#6.1 为什么先发送
loading) - [6.2 `const tokenizer` 在等待什么](#6.2
const [tokenizer]在等待什么) - [6.3 `x` 是什么,从哪里来](#6.3
x是什么,从哪里来) - [6.4 `console.log()` 和 `self.postMessage()` 分别做什么](#6.4
console.log()和self.postMessage()分别做什么)
- [6.1 为什么先发送 `loading`](#6.1 为什么先发送
- [7. App 如何接住加载状态](#7. App 如何接住加载状态)
-
- [7.1 `case "loading"` 为什么要更新两个 State](#7.1
case "loading"为什么要更新两个 State) - [7.2 React 条件渲染如何切换界面](#7.2 React 条件渲染如何切换界面)
- [7.3 `initiate`、`progress` 和 `done` 为什么先保留](#7.3
initiate、progress和done为什么先保留)
- [7.1 `case "loading"` 为什么要更新两个 State](#7.1
- [8. Worker 中的 WebGPU 检查仍然负责什么](#8. Worker 中的 WebGPU 检查仍然负责什么)
- [9. 把这一节的完整执行流程串起来](#9. 把这一节的完整执行流程串起来)
- [10. 本节两份完整代码](#10. 本节两份完整代码)
-
- [10.1 `worker.js` 完整版](#10.1
worker.js完整版) - [10.2 `App.tsx` 完整版](#10.2
App.tsx完整版)
- [10.1 `worker.js` 完整版](#10.1
- 总结
前言
上一篇从 Hugging Face、Transformers.js 和 WebGPU 的关系讲起,在 React 中创建了 Web Worker,并通过 check 命令完成 WebGPU 能力检查。代码最后停在了 worker.js 的 case "load":页面已经能把"加载"命令发给 Worker,但 Worker 还没有真正执行加载任务。
这一篇就从这个入口继续向下推进。点击 Load model 后,Worker 会进入 load(),使用 Transformers.js 加载 DeepSeek 模型对应的 Tokenizer(分词器),并把下载状态转发给 React 页面。
这里会遇到一个很重要的设计问题:模型相关资源体积大、初始化成本高,如果每次调用 load() 都重新创建和下载,不仅浪费时间,还可能在多个请求同时到达时触发重复任务。因此,在讲 TextGenerationPipeline.getInstance() 之前,需要先理解它背后的 Singleton 单例模式。
这一节的核心链路:用户点击按钮 → App 发送
load→ Worker 调用单例资源管理器 → Transformers.js 加载 Tokenizer → 下载事件通过x回到 App。
需要提前明确:这一篇加载的是 Tokenizer,不是大模型权重,也还没有执行文本生成。Tokenizer 是模型处理自然语言前必须准备好的工具,模型权重与 WebGPU 推理会在后续流程中继续接入。
1. 从 case "load" 接上模型加载入口
1.1 App 如何发出加载命令
页面上的按钮负责把用户操作转换成 Worker 能理解的命令:
ts
<button
onClick={() => {
worker.current.postMessage({ type: "load" });
setStatus("loading");
}}
disabled={status !== null || error !== null}
>
Load model
</button>
点击按钮后会连续发生两件事:
worker.current.postMessage({ type: "load" })把一个普通 JavaScript 对象发送给 Worker。setStatus("loading")立即把 React 页面切换到加载状态,让用户点击后马上得到界面反馈。
消息中的 type 相当于命令名称。Worker 收到消息后,通过下面的代码取出 type 和 data:
javascript
const { type, data } = e.data;
这是对象解构,等价于:
javascript
const type = e.data.type;
const data = e.data.data;
这次 App 只发送了 type,所以 data 是 undefined,但统一保留这个字段后,以后的生成命令就可以把用户输入一起传给 Worker。
1.2 Worker 如何把命令交给 load()
Worker 使用 switch 根据 type 分发任务:
javascript
self.addEventListener("message", async (e) => {
const { type, data } = e.data;
switch (type) {
case "check":
check();
break;
case "load":
load();
break;
case "generate":
break;
case "interrupt":
break;
case "reset":
break;
}
});
case "load" 之所以调用 load(),是因为 App 发来的命令就是 { type: "load" }。这里的几个 case 不是 JavaScript 或 Transformers.js 强制规定的,而是项目自己设计的消息协议:
| 命令 | 作用 |
|---|---|
check |
检查浏览器能否提供 WebGPU Adapter |
load |
准备模型相关资源,这一篇先加载 Tokenizer |
generate |
后续接收输入并生成文本 |
interrupt |
后续中断正在进行的生成任务 |
reset |
后续重置生成状态或相关资源 |
这次真正向前推进的是 load。其他命令继续保留接口,不提前添加与这一节无关的实现。
2. 进入加载函数前,先理解 Singleton 单例模式
2.1 什么是单例模式
在面向对象程序中,调用一次 new 通常就会创建一个新对象:
javascript
const a = new Popup();
const b = new Popup();
console.log(a === b); // false
a 和 b 来自同一个类,但它们是两个不同的对象。如果某类资源在整个系统中只需要创建一次,就可以使用 单例模式统一管理。
单例模式:让一个类只创建并共享一个实例,其他代码通过统一入口取得这个实例,而不是在各处随意
new。
单例模式通常包含三个关键点:
| 关键点 | 含义 |
|---|---|
| 唯一实例 | 类把创建好的对象保存在静态属性中 |
| 统一入口 | 外部通过 getInstance() 获取对象 |
| 延迟创建 | 第一次使用时才创建,后续直接复用 |
"延迟创建"也叫 Lazy Loading(懒加载)。它并不是程序启动时就把所有资源准备好,而是等真正有人使用时再初始化。
2.2 用 Popup 看懂标准单例写法
先看一个与模型无关的基础示例:
javascript
class Popup {
// 静态属性属于类本身,不属于某个 Popup 对象。
static ins;
// 外部通过统一入口获取实例。
static getInstance() {
// 只有第一次调用时,Popup.ins 才是空值。
if (!Popup.ins) {
Popup.ins = new Popup();
}
// 后续调用直接返回已经创建的对象。
return Popup.ins;
}
open(url) {
window.open(url, "_blank");
}
}
const a = Popup.getInstance();
const b = Popup.getInstance();
console.log(a === b); // true
逐行看它的职责:
static ins用来保存唯一实例。因为它是静态属性,所以可以通过Popup.ins访问,不需要先执行new Popup()。static getInstance()是获取实例的统一入口。静态方法同样直接属于类,可以写成Popup.getInstance()。if (!Popup.ins)只在第一次调用时成立,因此new Popup()只执行一次。- 第二次调用时,
Popup.ins已经有值,方法直接返回原来的对象,所以a === b为true。
这里"单例"的是 Popup 对象,并不是浏览器标签页。即使 a 和 b 指向同一个 Popup 实例,多次调用 a.open() 仍然可能打开多个页面。单例模式控制的是 对象创建次数,不会自动限制对象内部方法的执行次数。
2.3 为什么 Tokenizer 适合使用这种思路
Tokenizer 不是一个简单数字或字符串。它需要根据模型 ID 查找配置、下载词表和 Tokenizer 文件,再把这些文件解析成可调用的分词器。这个过程包含网络请求、缓存读取和对象初始化,明显比创建普通对象更昂贵。
如果不做管理,每次加载都直接执行:
javascript
AutoTokenizer.from_pretrained(model_id);
那么多次调用就可能重复启动相同的异步任务。项目希望得到的行为是:
- 第一次调用时开始加载 Tokenizer。
- 加载过程中再次调用时,等待同一个加载任务。
- 加载完成后再次调用时,复用同一个结果。
这就是单例思想在模型资源管理中的实际价值:昂贵资源只初始化一次,后续任务共享它。
3. TextGenerationPipeline 是什么
3.1 它从哪里来
TextGenerationPipeline 不是 Transformers.js 自动提供的类,而是项目在 worker.js 中自己定义的:
javascript
class TextGenerationPipeline {
static model_id = "onnx-community/DeepSeek-R1-Distill-Qwen-1.5B-ONNX";
static async getInstance(progress_callback = null) {
// Tokenizer 的加载逻辑会写在这里。
}
}
类名中的三个单词分别表达了它的定位:
| 单词 | 在这里的含义 |
|---|---|
TextGeneration |
后续要服务于文本生成任务 |
Pipeline |
把分词器、模型和生成步骤组织成一条处理流水线 |
class |
用类的静态属性和静态方法集中管理这些资源 |
虽然名字叫"文本生成流水线",这一节只把 Tokenizer 接入。流水线是后续资源的容器,不代表模型推理已经发生。
3.2 static model_id 保存了什么
javascript
static model_id = "onnx-community/DeepSeek-R1-Distill-Qwen-1.5B-ONNX";
model_id 是 Hugging Face Hub 上的模型仓库 ID:
onnx-community是仓库所属组织。DeepSeek-R1-Distill-Qwen-1.5B-ONNX是仓库名称。
Transformers.js 会根据这个 ID 找到仓库中的 Tokenizer 配置和词表文件。将它写成 static 后,getInstance() 可以通过 this.model_id 使用它,不需要先创建 TextGenerationPipeline 对象。
3.3 它和标准单例有什么区别
标准 Popup 示例把整个类的对象保存在 Popup.ins 中,而项目没有执行:
javascript
new TextGenerationPipeline();
它真正缓存的是类上的 Tokenizer 加载 Promise:
javascript
this.tokenizer ??= AutoTokenizer.from_pretrained(...);
因此,它使用的是单例模式的核心思路,但管理目标稍有不同:
| 对比项 | Popup 基础示例 |
TextGenerationPipeline |
|---|---|---|
| 被复用的内容 | Popup 类实例 |
Tokenizer 的加载 Promise |
| 第一次调用 | new Popup() |
AutoTokenizer.from_pretrained() |
| 保存位置 | Popup.ins |
TextGenerationPipeline.tokenizer |
| 统一入口 | Popup.getInstance() |
TextGenerationPipeline.getInstance() |
| 后续调用 | 返回同一个对象 | 等待或取得同一份 Tokenizer |
理解这个区别很重要:getInstance() 这个名字来自单例模式,但这段代码的重点不是创建 TextGenerationPipeline 实例,而是保证模型相关资源不会被重复加载。
4. 逐行拆解 getInstance()
有了单例基础,再看项目中的完整方法:
javascript
static async getInstance(progress_callback = null) {
this.tokenizer ??= AutoTokenizer.from_pretrained(this.model_id, {
// 下载进度回调函数
progress_callback,
});
return Promise.all([this.tokenizer]);
}
4.1 progress_callback = null 是什么
progress_callback 是调用者传进来的函数,用于接收文件加载进度。如果调用时没有传函数,它的默认值就是 null:
javascript
TextGenerationPipeline.getInstance();
加载时如果需要追踪进度,就传入一个回调:
javascript
TextGenerationPipeline.getInstance((x) => {
console.log(x);
});
Transformers.js 在下载开始、下载过程中和下载结束时调用这个函数,并把进度对象作为参数传回来。
4.2 静态方法中的 this 指向谁
getInstance() 前面有 static,所以它是类的静态方法。通过下面的方式调用时:
javascript
TextGenerationPipeline.getInstance(...);
方法内部的 this 指向 TextGenerationPipeline 这个类。因此:
javascript
this.model_id
就是:
javascript
TextGenerationPipeline.model_id
同理,this.tokenizer 表示保存在类上的 Tokenizer 资源。第一次赋值后,其他调用也能从同一个类上取得它。
4.3 ??= 为什么能避免重复加载
??= 叫作 空值合并赋值运算符 。只有左边是 null 或 undefined 时,才会执行右边并赋值。
javascript
this.tokenizer ??= AutoTokenizer.from_pretrained(...);
可以按下面的逻辑理解:
javascript
if (this.tokenizer === null || this.tokenizer === undefined) {
this.tokenizer = AutoTokenizer.from_pretrained(...);
}
第一次调用时,this.tokenizer 还不存在,所以会执行 from_pretrained()。第二次调用时,它已经保存了值,右侧不会再次执行。
这里保存的不只是最终 Tokenizer,而是 from_pretrained() 一调用就返回的 Promise。这带来一个非常实用的效果:即使第一次下载还没结束,第二次调用也会取得同一个 Promise,等待同一个下载任务,而不会再发起一次加载。
| 调用时机 | this.tokenizer 的状态 |
代码行为 |
|---|---|---|
| 第一次调用 | undefined |
执行 from_pretrained() 并保存 Promise |
| 下载过程中再次调用 | 正在进行的 Promise | 复用并等待同一个 Promise |
| 下载完成后再次调用 | 已兑现的 Promise | 直接取得同一份加载结果 |
这正是注释中"LLM 只需要初始化一次,后面可以一直用"的设计意图。不过落实到这一篇的代码上,更准确的说法是:Tokenizer 的加载任务只创建一次。模型本身还没有在这里初始化。
4.4 AutoTokenizer.from_pretrained() 是什么
先看导入:
javascript
import {
AutoTokenizer,
} from "@huggingface/transformers";
AutoTokenizer 来自 @huggingface/transformers,也就是 Transformers.js。它的作用是根据模型仓库的配置,自动选择并创建与该模型匹配的 Tokenizer。
javascript
AutoTokenizer.from_pretrained(this.model_id, {
progress_callback,
});
这段调用只需要抓住两个参数:
| 参数 | 来源 | 作用 |
|---|---|---|
this.model_id |
TextGenerationPipeline 的静态属性 |
告诉 Transformers.js 去哪个模型仓库加载文件 |
{ progress_callback } |
getInstance() 的参数 |
下载状态变化时通知项目代码 |
对象中的简写:
javascript
{
progress_callback,
}
等价于:
javascript
{
progress_callback: progress_callback,
}
前一个 progress_callback 是配置项名称,后一个是函数变量。因为两者同名,JavaScript 允许省略冒号后面的部分。
from_pretrained 可以简单理解为:按照预训练模型仓库里的配置,把匹配的 Tokenizer 文件加载并初始化好。 首次访问可能从 Hugging Face 下载文件,浏览器缓存可用时则可以复用缓存。
4.5 Promise.all([this.tokenizer]) 为什么返回数组
from_pretrained() 是异步操作,因此 this.tokenizer 是一个 Promise。下面这行会等待数组中的异步任务全部完成:
javascript
return Promise.all([this.tokenizer]);
这里只有一个任务,所以最终结果的结构是:
javascript
[tokenizer]
调用处才能用数组解构取得第一个结果:
javascript
const [tokenizer] = await TextGenerationPipeline.getInstance(...);
执行顺序可以压缩成四步:
getInstance()检查类上是否保存了 Tokenizer Promise。- 没有就调用
from_pretrained(),有就直接复用。 Promise.all()等待加载结束,并返回结果数组。const [tokenizer]从数组第一项取得 Tokenizer。
使用数组形式也给流水线保留了统一组织多个异步资源的结构。不过在这一节中,数组里确实只有 Tokenizer,不需要把它误解成模型也被一起加载了。
5. Tokenizer 到底是什么,为什么加载模型前需要它
5.1 Tokenizer 负责文本与数字之间的转换
大模型不能直接理解"你好"或"请写一个斐波那契函数"这样的字符串。模型实际接收的是一串数字 ID。Tokenizer 的职责,就是按照模型训练时使用的规则把文本切分成 Token,再把 Token 映射成数字。
text
用户文本
↓ Tokenizer 编码
Token ID 数组
↓ 大模型计算
输出 Token ID
↓ Tokenizer 解码
可阅读文本
例如,一段文本可能被拆成词、子词、标点或其他片段。具体怎样切分不是项目随意决定的,而是由模型配套的 Tokenizer 规则决定。因此,DeepSeek 模型必须使用与它匹配的 Tokenizer,不能随便拿另一个分词器替换。
5.2 加载 Tokenizer 不等于加载 LLM
这两个动作处在同一条推理流水线中,但负责不同事情:
| 组件 | 负责什么 | 是否生成答案 |
|---|---|---|
| Tokenizer | 文本与 Token ID 之间的编码、解码 | 否 |
| LLM 模型 | 根据输入 Token 进行神经网络计算并预测后续 Token | 是 |
AutoTokenizer.from_pretrained() 读取的是 Tokenizer 相关配置和文件。它使用了模型仓库 ID,所以看起来像在"加载模型",但此时并没有把 DeepSeek 的 ONNX 权重载入推理会话,也没有让 WebGPU 开始计算。
这一阶段的意义是先准备模型的输入输出转换器。后续用户输入问题时,Tokenizer 会把问题编码成模型能接收的数据;模型输出数字后,还要通过 Tokenizer 解码成人能阅读的答案。
6. 逐行拆解 load() 与回调参数 x
TextGenerationPipeline 管理好 Tokenizer 后,load() 负责调用它,并把过程中的状态送回页面:
javascript
async function load() {
self.postMessage({
status: "loading",
data: "Loading model...",
});
const [tokenizer] = await TextGenerationPipeline.getInstance((x) => {
// Transformers.js 每次报告文件加载进度时都会执行这里。
console.log(x, "//////////////");
self.postMessage(x);
});
}
6.1 为什么先发送 loading
javascript
self.postMessage({
status: "loading",
data: "Loading model...",
});
Worker 不能直接修改 React 页面,所以它先发送一条自定义消息,告诉 App:加载函数已经开始执行。
status: "loading"用于选择 App 中对应的case。data: "Loading model..."是准备显示在页面上的提示文字。
这里的 self 指 Worker 自身的全局作用域。Worker 中没有 window 和 document,不能直接获取按钮或修改 DOM,但可以通过 self.postMessage() 与主线程通信。
6.2 const [tokenizer] 在等待什么
javascript
const [tokenizer] = await TextGenerationPipeline.getInstance(...);
getInstance() 返回 Promise.all() 的结果,所以外层使用 await 等待。等 Tokenizer 初始化完成后,返回值是数组,[tokenizer] 取出数组第一项。
这行代码执行结束时,可以确定 Tokenizer 已经可用。不过在这段 load() 中,变量 tokenizer 还没有继续参与编码任务。它的作用先是确保加载流程真正等待到完成,为后续接入模型和文本生成做好准备。
6.3 x 是什么,从哪里来
javascript
(x) => {
console.log(x, "//////////////");
self.postMessage(x);
}
x 只是回调函数的参数名,可以改成 progress、event 或 progressInfo。它不是项目提前声明的固定变量,而是 Transformers.js 调用 progress_callback 时传进来的进度对象。
它的来源链路如下:
text
load() 传入回调函数
↓
getInstance(progress_callback) 接收函数
↓
from_pretrained(..., { progress_callback }) 得到函数
↓
Transformers.js 加载文件并调用 progress_callback(进度对象)
↓
进度对象进入参数 x
进度对象会随着加载阶段变化。例如,它可能表示开始处理某个文件:
javascript
{
status: "initiate",
name: "onnx-community/DeepSeek-R1-Distill-Qwen-1.5B-ONNX",
file: "tokenizer.json"
}
下载过程中可能包含进度信息:
javascript
{
status: "progress",
file: "tokenizer.json",
progress: 42.6,
loaded: 426000,
total: 1000000
}
文件处理结束后则会出现 done 状态。具体文件名、大小和进度值由实际加载过程决定,上面的对象只用于说明字段结构。
6.4 console.log() 和 self.postMessage() 分别做什么
javascript
console.log(x, "//////////////");
self.postMessage(x);
两行代码使用的是同一个进度对象,但目的不同:
| 代码 | 作用 |
|---|---|
console.log(x, "//////////////") |
在开发者工具中观察 Transformers.js 传回了什么 |
self.postMessage(x) |
把进度对象原样转发给 React 主线程 |
斜杠字符串只是调试时的视觉分隔符,不参与业务逻辑。真正建立 Worker 与页面联系的是 self.postMessage(x)。
7. App 如何接住加载状态
7.1 case "loading" 为什么要更新两个 State
App 注册了 Worker 的 message 事件:
ts
const onMessageReceived = (e) => {
switch (e.data.status) {
case "loading":
setStatus("loading");
setLoadingMessage(e.data.data);
break;
case "initiate":
break;
case "progress":
break;
case "done":
break;
case "ready":
break;
case "start":
break;
case "update":
break;
case "complete":
break;
case "error":
setError(e.data.data);
break;
}
};
当 Worker 发送:
javascript
{
status: "loading",
data: "Loading model...",
}
App 就进入 case "loading"。两次状态更新分别解决不同问题:
ts
setStatus("loading");
它保存的是加载阶段,用于决定页面应该显示欢迎区还是加载区。
ts
setLoadingMessage(e.data.data);
它保存的是 Worker 发来的提示文字,也就是 "Loading model..."。React State 变化后会重新渲染,页面才会显示这段文字。
status决定"显示哪一块界面",loadingMessage决定"加载界面显示什么内容"。
按钮点击时已经调用过一次 setStatus("loading"),这样界面可以立刻切换;Worker 再发回 loading,表示后台函数确实已经开始,同时补上传给用户看的提示文字。两者并不冲突。
7.2 React 条件渲染如何切换界面
欢迎内容使用下面的条件:
ts
{status === null && messages.length === 0 && (
<div>
{/* 项目介绍与 Load model 按钮 */}
</div>
)}
加载内容使用另一个条件:
ts
{status === "loading" && (
<div className="w-full max-w-[500px] text-left mx-auto p-4 bottom-0 mt-auto">
<p className="text-center mb-1">{loadingMessage}</p>
</div>
)}
因此,状态变化会带来清晰的页面切换:
text
status === null
→ 显示项目介绍和 Load model 按钮
用户点击按钮
→ status 变成 loading
→ 欢迎内容隐藏,加载区域出现
Worker 发回 loadingMessage
→ 页面显示 Loading model...
7.3 initiate、progress 和 done 为什么先保留
Transformers.js 的进度回调不只发送一种状态。加载文件时,项目会收到以下三类关键事件:
status |
表示什么 | 可以怎样用于页面 |
|---|---|---|
initiate |
某个文件开始加载 | 创建一条文件下载记录 |
progress |
文件正在下载 | 更新百分比、已下载大小等信息 |
done |
某个文件处理完成 | 将对应文件标记为完成 |
load() 已经通过 self.postMessage(x) 把这些事件送到了 App,但三个 case 的函数体仍然为空,所以页面暂时只显示 Loading model...,不会显示具体下载百分比。
ready、start、update 和 complete 则服务于后面的模型就绪与文本生成阶段。它们保留了消息入口,但不在这一篇提前实现。
8. Worker 中的 WebGPU 检查仍然负责什么
加载 Tokenizer 的同时,原来的 check() 继续负责检查 WebGPU:
javascript
async function check() {
try {
const adapter = await navigator.gpu.requestAdapter();
if (!adapter) {
throw new Error("WebGPU is not supported (no adapter found)");
}
} catch (e) {
self.postMessage({
status: "error",
data: e.toString(),
});
}
}
关于 DOM、BOM、Adapter 和 Device,这几个概念需要放在一起理解:
- DOM(Document Object Model) 以
document为入口,用来访问和修改页面节点。Worker 不能使用 DOM。 - BOM(Browser Object Model) 包含浏览器环境提供的对象,例如
navigator。Worker 环境仍可通过navigator访问 WebGPU 能力。 - GPUAdapter 是浏览器对可用 GPU 适配器的抽象入口。
requestAdapter()没拿到结果时,项目就抛出错误。 - GPUDevice 会在后续通过 Adapter 请求得到,真正的 WebGPU 计算与资源创建会围绕 Device 展开。
职责边界:check() 检查 GPU 能力,load() 先加载 Tokenizer。Tokenizer 本身处理文本,不使用 GPU 做模型推理。
9. 把这一节的完整执行流程串起来
整个过程可以整理成一条从 UI 到资源加载、再回到 UI 的闭环:
text
1. App 创建 module Worker
↓
2. App 发送 { type: "check" }
↓
3. Worker 请求 GPUAdapter,失败则返回 error
↓
4. 用户点击 Load model
↓
5. App 发送 { type: "load" },并把 status 设为 loading
↓
6. Worker 的 case "load" 调用 load()
↓
7. load() 先发送 { status: "loading", data: "Loading model..." }
↓
8. TextGenerationPipeline.getInstance() 检查 tokenizer Promise
↓
9. 第一次调用 from_pretrained(),后续调用复用同一 Promise
↓
10. Transformers.js 把 initiate / progress / done 对象传给 x
↓
11. Worker 使用 self.postMessage(x) 原样转发
↓
12. App 根据 e.data.status 进入对应 case
这条链路里有两套协议,不能混在一起:
| 方向 | 识别字段 | 示例 |
|---|---|---|
| App → Worker | type |
{ type: "load" } |
| Worker → App | status |
{ status: "loading", data: "Loading model..." } |
type 表示"请 Worker 做什么",status 表示"Worker 做到哪一步"。这套区分会继续支撑后续的生成、流式更新、中断和重置。
10. 本节两份完整代码
10.1 worker.js 完整版
下面把单例资源管理、WebGPU 检查、Tokenizer 加载和消息分发放到一起。
javascript
import {
// Transformers.js 提供的自动分词器。
AutoTokenizer,
} from "@huggingface/transformers";
/**
* 使用单例思路延迟加载流水线资源。
* 这一节先管理 Tokenizer,模型权重尚未接入。
*/
class TextGenerationPipeline {
// Hugging Face 模型仓库 ID,静态属性属于类本身。
static model_id = "onnx-community/DeepSeek-R1-Distill-Qwen-1.5B-ONNX";
// 外部通过统一入口取得资源。
static async getInstance(progress_callback = null) {
// ??= 只会在 this.tokenizer 为 null 或 undefined 时执行右侧。
// from_pretrained 返回 Promise,所以加载过程中再次调用也会复用同一任务。
this.tokenizer ??= AutoTokenizer.from_pretrained(this.model_id, {
// Transformers.js 下载文件时会调用这个进度回调。
progress_callback,
});
// 等待 Tokenizer 完成,并以数组形式返回。
return Promise.all([this.tokenizer]);
}
}
// Worker 不能访问 document 或直接修改 DOM,
// 但可以通过 navigator 检查 WebGPU。
async function check() {
try {
// GPUAdapter 是浏览器对可用 GPU 适配器的抽象入口。
const adapter = await navigator.gpu.requestAdapter();
if (!adapter) {
throw new Error("WebGPU is not supported (no adapter found)");
}
// 可继续检查 16 位浮点着色器能力。
// fp16_supported = adapter.features.has("shader-f16")
} catch (e) {
self.postMessage({
status: "error",
data: e.toString(),
});
}
}
async function load() {
// 先通知主线程:加载函数开始执行。
self.postMessage({
status: "loading",
data: "Loading model...",
});
// getInstance 返回数组,所以使用 [tokenizer] 取得第一项。
const [tokenizer] = await TextGenerationPipeline.getInstance((x) => {
// x 是 Transformers.js 传入的文件加载进度对象。
console.log(x, "//////////////");
// Worker 不操作页面,把进度对象原样转发给 App。
self.postMessage(x);
});
}
// 统一接收来自 App 的命令。
self.addEventListener("message", async (e) => {
const { type, data } = e.data;
switch (type) {
case "check":
check();
break;
case "load":
load();
break;
case "generate":
break;
case "interrupt":
break;
case "reset":
break;
}
});
10.2 App.tsx 完整版
App 继续负责 Worker 生命周期、消息接收和页面状态。下面的代码展示这一节对应的完整界面流程。
ts
import { useEffect, useState, useRef } from "react";
// 把 navigator.gpu 转成布尔值,用于决定是否显示项目页面。
const IS_WEBGPU_AVAILABLE = !!navigator.gpu;
const STICKY_SCROLL_THRESHOLD = 120;
const EXAMPLES = [
"Solve the equation x^2 - 3x + 2 = 0",
"Lily is three times older than her son. In 15 years, she will be twice as old as him. How old is she now?",
"Write python code to compute the nth fibonacci number.",
];
function App() {
// 保存 Worker 对象;useRef 更新不会触发页面重新渲染。
const worker = useRef(null);
// 保存加载阶段、错误信息和加载提示。
const [status, setStatus] = useState(null);
const [error, setError] = useState(null);
const [loadingMessage, setLoadingMessage] = useState("");
// 后续保存用户消息和模型输出。
const [messages, setMessages] = useState([]);
useEffect(() => {
// 组件初始化时只创建一个 Worker。
if (!worker.current) {
worker.current = new Worker(new URL("./worker.js", import.meta.url), {
// 允许 Worker 使用 import 导入 npm 模块。
type: "module",
});
// Worker 创建后先检查 WebGPU。
worker.current.postMessage({ type: "check" });
}
// 接收 Worker 通过 self.postMessage() 发来的状态。
const onMessageReceived = (e) => {
switch (e.data.status) {
case "loading":
// 保存加载阶段与提示文字,触发 React 重新渲染。
setStatus("loading");
setLoadingMessage(e.data.data);
break;
case "initiate":
// 某个文件开始加载。
break;
case "progress":
// 某个文件的下载进度发生变化。
break;
case "done":
// 某个文件加载完成。
break;
case "ready":
// 后续表示模型相关资源已经可以使用。
break;
case "start":
// 后续表示文本生成开始。
break;
case "update":
// 后续接收流式生成内容。
break;
case "complete":
// 后续表示一次生成结束。
break;
case "error":
// 保存 Worker 发回的错误信息。
setError(e.data.data);
break;
}
};
// 接收 Worker 自身执行时抛出的错误。
const onErrorReceived = (e) => {
};
worker.current.addEventListener("message", onMessageReceived);
worker.current.addEventListener("error", onErrorReceived);
}, []);
return (
IS_WEBGPU_AVAILABLE ? (
<div className="flex flex-col h-screen mx-auto items justify-end text-gray-800 dark:text-gray-200 bg-white dark:bg-gray-900">
{status === null && messages.length === 0 && (
<div className="h-full overflow-auto scrollbar-thin flex justify-center items-center flex-col relative">
<div className="flex flex-col items-center mb-1 max-w-[400px] text-center">
<img
src="logo.png"
width="80%"
height="auto"
className="block drop-shadow-lg bg-transparent"
></img>
<h1 className="text-4xl font-bold mb-1">
DeepSeek-R1 WebGPU
</h1>
<h2 className="font-semibold">
A next-generation reasoning model that runs locally in your
browser with WebGPU acceleration.
</h2>
</div>
<div className="flex flex-col items-center px-4">
<p className="max-w-[510px] mb-4">
<br />
You are about to load{" "}
<a
href="https://huggingface.co/onnx-community/DeepSeek-R1-Distill-Qwen-1.5B-ONNX"
target="_blank"
rel="noreferrer"
className="font-medium underline"
>
DeepSeek-R1-Distill-Qwen-1.5B
</a>
, a 1.5B parameter reasoning LLM optimized for in-browser
inference. Everything runs entirely in your browser with{" "}
<a
href="https://huggingface.co/docs/transformers.js"
target="_blank"
rel="noreferrer"
className="underline"
>
🤗 Transformers.js
</a>{" "}
and ONNX Runtime Web, meaning no data is sent to a server. Once
loaded, it can even be used offline. The source code for the demo
is available on{" "}
<a
href="https://github.com/huggingface/transformers.js-examples/tree/main/deepseek-r1-webgpu"
target="_blank"
rel="noreferrer"
className="font-medium underline"
>
GitHub
</a>
.
</p>
{error && (
<div className="text-red-500 text-center mb-2">
<p className="mb-1">
Unable to load model due to the following error:
</p>
<p className="text-sm">{error}</p>
</div>
)}
<button
className="border px-4 py-2 rounded-lg bg-blue-400 text-white hover:bg-blue-500 disabled:bg-blue-100 cursor-pointer disabled:cursor-not-allowed select-none"
onClick={() => {
// 告诉 Worker 开始加载。
worker.current.postMessage({ type: "load" });
// 立即切换页面状态,给用户即时反馈。
setStatus("loading");
}}
disabled={status !== null || error !== null}
>
Load model
</button>
</div>
</div>
)}
{status === "loading" && (
<>
<div className="w-full max-w-[500px] text-left mx-auto p-4 bottom-0 mt-auto">
<p className="text-center mb-1">{loadingMessage}</p>
</div>
</>
)}
</div>
) : (
<div className="fixed w-screen h-screen bg-black z-10 bg-opacity-[92%] text-white text-2xl font-semibold flex justify-center items-center text-center">
WebGPU is not supported
<br />
by this browser :(
</div>
)
);
}
export default App;
代码中的 STICKY_SCROLL_THRESHOLD、EXAMPLES、messages、setMessages 以及生成阶段的状态入口,会在聊天与流式输出继续推进时发挥作用。它们不是 Tokenizer 加载逻辑的一部分,因此这一篇不提前展开其实现。
总结
这一篇从 case "load" 继续,把 App 的点击事件、Worker 的加载函数和 Transformers.js 的 Tokenizer 串成了第一条资源加载链路。为了理解 TextGenerationPipeline.getInstance(),我们先用 Popup 示例拆解了单例模式:静态属性保存资源,静态方法提供统一入口,第一次调用时创建,后续调用直接复用。
项目没有创建一个 TextGenerationPipeline 对象,而是使用 this.tokenizer ??= 把 AutoTokenizer.from_pretrained() 返回的 Promise 缓存在类上。这样不但加载完成后可以复用 Tokenizer,即使多个调用在下载过程中接连到达,也会等待同一个异步任务。progress_callback 则把 Transformers.js 的文件状态交给参数 x,Worker 再通过 self.postMessage(x) 原样转发给 App。
最后要记住这一阶段的准确边界:Tokenizer 负责把文本转换成模型需要的 Token ID,也负责把输出 Token 解码成文本,但它本身不会生成答案。initiate、progress 和 done 已经能到达 React 消息监听器,下一步可以在这些 case 中保存文件状态并绘制真实下载进度,再继续接入模型权重和推理流程。