前言

最近我在做一个名为 webgpu-deepseek 的项目,目标是让 DeepSeek 模型直接运行在浏览器中,也就是端侧模型。它和常见的 AI 聊天页面有一个很大的区别:传统网页通常把用户问题发送给服务器,由服务器上的 GPU 调用模型,再把答案返回给浏览器;这个项目则希望把模型下载到浏览器本地,利用 Transformers.js、Web Worker 和 WebGPU 在用户设备上完成推理。

这个方向同时连接了前端与 AI 两套知识体系。前端部分涉及 React、TypeScript、Vite、Web Worker 和浏览器 API,AI 部分则涉及 Hugging Face、模型文件、Tokenizer、推理管线与 GPU 加速。直接从模型加载代码开始抄,很容易出现“页面跑起来了,但不知道每一层为什么存在”的情况。因此,这个系列不会急着堆功能,而是按照项目实际开发顺序逐步推进。

这是系列的第一部分。我们会从模型来源和依赖安装开始,一步步写出 WebGPU 判断、Worker 创建、消息监听和模型加载入口。每讲清一个概念,项目就向前推进一步;模型生成、流式输出和中断任务会沿用这套通信结构继续扩展。

我平时会通过《你不知道的 JavaScript》补语言基础,通过掘金等技术社区了解工程实践,关注 AI 开发者的分享,再去 GitHub 阅读真实项目源码。学习过程中最重要的一步,是把理解后的内容重新输出到社区:只有能够把执行流程讲清楚,才说明自己不只是“见过这段代码”。

这一步的目标:让浏览器成为模型的运行环境,让 React 负责交互,让 Web Worker 负责耗时任务,让 WebGPU 负责并行计算。

1. 项目要解决什么问题

1.1 为什么尝试在浏览器中运行模型

常见的大模型应用采用服务端推理:浏览器把问题发送到接口,服务器加载模型、执行推理,再把结果返回。它的优点是模型能力强、硬件由平台统一管理,但也意味着应用依赖网络与服务器资源。

浏览器本地推理提供了另一条路线。模型首次下载完成后,可以缓存在用户设备中,再次打开页面时不一定需要重新下载全部文件。推理过程发生在本地,也能减少部分数据离开设备的需求。不过,这条路线同样有明确限制:模型文件较大、首次加载慢、浏览器内存有限,而且 WebGPU 兼容性仍需要认真检查。

对比维度 服务端模型推理 浏览器本地模型推理
模型运行位置 云服务器或远程 GPU 用户浏览器和本地 GPU
首次使用成本 通常无需下载模型 需要下载模型文件
网络依赖 每次请求通常都需要网络 模型缓存后可减少远程下载
数据流向 输入一般需要发送到服务器 推理可以留在用户设备
硬件控制 服务端统一配置 受用户浏览器和显卡能力影响
适合模型 可以承载较大的模型 更适合压缩、量化或蒸馏模型

这个项目选择 DeepSeek-R1-Distill-Qwen-1.5B,正是因为端侧环境更关注模型体积和设备承受能力。1.5B 表示模型大约具有 15 亿参数;“Distill”表示它通过蒸馏方式继承更大推理模型的部分能力。它仍然不是一个很小的网页资源,所以第一次下载和初始化会比较慢。

1.2 Hugging Face、ModelScope 与模型社区

Hugging Face 是 AI 开源生态中最有影响力的模型社区之一。模型厂商和开发者可以在 Hub 上发布模型权重、Tokenizer、配置文件、模型说明与使用示例。应用不需要把全部模型文件提交进前端代码仓库,只要知道模型 ID,就可以按需访问对应仓库。

国内开发者也经常使用 ModelScope(魔搭社区)。两者都承担模型托管、发现和协作的作用,但具体模型格式、生态工具和下载方式可能不同。这个项目选择 Hugging Face 与 Transformers.js 的组合,因此代码围绕 Hugging Face Hub 的模型组织方式展开。

模型进入浏览器的大致链路如下:

DeepSeek-R1-Distill-Qwen-1.5B
        ↓ 导出适合浏览器推理的 ONNX 文件
Hugging Face 模型仓库
        ↓ Transformers.js 根据模型 ID 请求文件
浏览器首次下载
        ↓ 写入浏览器缓存
Tokenizer + 模型初始化
        ↓ WebGPU 执行并行计算
文本生成等 NLP 任务

这里要区分“模型社区”和“模型运行库”。Hugging Face Hub 负责保存和分发模型,Transformers.js 才是浏览器中加载、执行模型的工具。可以把 Hub 理解为仓库,把 Transformers.js 理解为把仓库内容取下来并运行的引擎。

1.3 Transformers.js 在链路中的位置

@huggingface/transformers 是 Transformers.js 的 npm 包。它提供 JavaScript 版本的 Transformer 模型加载与推理能力,让前端可以通过模型 ID 远程访问 Hugging Face 模型,并执行文本生成、文本分类、特征提取、语音识别等 NLP 或多模态任务。

Transformers.js 默认可以从 Hugging Face Hub 下载适配的 ONNX 模型,并在浏览器支持时使用缓存。第一次加载需要传输模型、Tokenizer 和配置文件,所以耗时明显;再次加载可以尝试复用浏览器缓存,因此通常会更快。但浏览器缓存仍受存储额度、清理策略和站点来源影响,不能把它理解成永远不会丢失的本地文件。

WebGPU 则位于执行链路的后半段。它允许网页以更接近现代 GPU 的方式提交计算任务,适合机器学习中的矩阵运算。Transformers.js 可以借助 ONNX Runtime Web 把部分模型计算交给 WebGPU,从而避免完全依赖 CPU。

模型 ID 解决“去哪里找模型”,Transformers.js 解决“怎样加载并运行模型”,浏览器缓存解决“避免每次重新下载”,WebGPU 解决“怎样更高效地计算”。

相关概念可以结合 Transformers.js Pipeline 官方文档Transformers.js WebGPU 指南MDN WebGPU API 继续核对。

2. 项目依赖与工程工具

2.1 运行时依赖分别负责什么

package.json 中的运行时依赖如下。我们先认识每个依赖在整条链路中的职责,再在对应功能出现时使用它,避免刚开始就把所有 API 堆进代码。

"dependencies": {
  "@huggingface/transformers": "3.7.1",
  "@tailwindcss/vite": "^4.3.3",
  "better-react-mathjax": "^2.0.3",
  "dompurify": "^3.2.3",
  "marked": "^15.0.5",
  "react": "^18.3.1",
  "react-dom": "^18.3.1",
  "tailwindcss": "^4.3.3"
}
依赖 作用 在项目中的位置
@huggingface/transformers JavaScript 版本的 Transformers 库,负责加载 Tokenizer、模型并执行推理 Worker 中的模型管线
react 使用组件、状态和 Hooks 构建交互界面 App.tsx
react-dom 把 React 组件挂载到浏览器 DOM main.tsx
tailwindcss 提供原子化 CSS 类 页面 className
@tailwindcss/vite 把 Tailwind 接入 Vite 构建流程 vite.config.ts
marked 把 Markdown 文本解析成 HTML 模型回答渲染
dompurify 清理 HTML 中潜在的不安全内容 Markdown HTML 安全处理
better-react-mathjax 在 React 页面中显示数学公式 模型公式渲染

marked 的作用尤其容易被误解。大模型返回内容时经常使用 Markdown,因为 Markdown 能自然表达标题、代码块、加粗、列表和引用。浏览器最终显示的是 HTML,因此需要先完成格式转换。

例如,模型返回:

# 一元二次方程

解析后对应的 HTML 结构是:

<h1>一元二次方程</h1>

Markdown 比直接生成 HTML 更简洁,也更适合流式文本。但解析得到 HTML 后不能盲目信任内容,因此项目同时安装了 dompurify。前者负责“转换”,后者负责“清理”,两者职责不同。

2.2 开发依赖与 TypeScript 工具链

开发依赖不会直接成为页面业务功能,但它们决定了项目如何开发、检查和构建。

依赖 主要作用
vite 提供开发服务器、模块处理和生产构建
typescript 在开发阶段进行静态类型检查,最终代码仍会构建为 JavaScript
@vitejs/plugin-react 让 Vite 正确处理 React 与 TSX
@webgpu/types 为 TypeScript 补充 WebGPU 类型声明
@types/react@types/react-dom 提供 React 相关类型
@types/node 为 Vite 配置等 Node.js 环境代码提供类型
eslint@eslint/js 检查常见代码质量问题
typescript-eslint 让 ESLint 理解 TypeScript 语法
eslint-plugin-react-hooks 检查 React Hooks 使用规则
eslint-plugin-react-refresh 检查 React 热更新相关约束
globals 为 ESLint 提供浏览器等环境的全局变量定义

Vite 插件配置来自项目现有的 vite.config.ts

import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'

export default defineConfig({
  plugins: [
    react(),
    tailwindcss()
  ],
})

react() 让 Vite 处理 React,tailwindcss() 让构建器识别 Tailwind。插件数组体现的是构建阶段的处理链,而不是页面运行后才执行的业务代码。

3. 整体架构:主线程与 Worker 分工

3.1 为什么模型任务不能全部放在 App 中

JavaScript 主线程既要执行代码,也要响应点击、输入、滚动和页面渲染。如果模型下载、Tokenizer 处理和推理持续占用主线程,界面就可能卡顿甚至长时间无响应。

Web Worker 提供独立于主线程的执行环境。它不能直接操作 DOM,但可以处理计算,并通过消息与主线程通信。这非常适合浏览器端 AI:

模块 主要职责 不负责什么
App.tsx 显示页面、保存 UI 状态、响应点击、展示错误和进度 不直接承担模型推理
worker.js 检查 WebGPU、加载模型、预热模型,未来执行生成任务 不直接操作页面 DOM
WebGPU 提供 GPU 计算入口 不管理 React 状态和页面组件

可以把 App 看成前台,把 Worker 看成后厨。前台不能直接进入后厨执行函数,只能发送一张带有 type 的任务单;后厨完成阶段性工作后,再用带有 status 的消息通知前台。

3.2 两套消息:命令与状态

App 发给 Worker 的消息表示“要做什么”,所以使用 type

命令类型 意图
check 检查 WebGPU 和 GPU Adapter
load 加载模型并完成预热
generate 生成文本
interrupt 中断生成
reset 重置任务或模型状态

Worker 发给 App 的消息表示“做到哪一步”,所以使用 status

状态 意图
loading 模型正在加载或预热
initiate 开始处理某个下载文件
progress 文件下载进度发生变化
done 某个文件下载完成
ready 模型可以使用
start 开始生成
update 返回一段流式文本
complete 生成完成
error 发生错误

二者共同组成项目的通信协议:

App 创建 Worker
        ↓
App 发送 { type: "check" }
        ↓
Worker 执行 check()
        ↓
Worker 请求 GPU Adapter
        ↓
检查失败时返回 { status: "error", data: 错误信息 }
        ↓
App 更新 error 并重新渲染页面

这种结构的意义是 解耦。App 不需要了解模型内部每一步怎样执行,只需要认识状态;Worker 不需要了解页面长什么样,只需要认识命令。

4. App.tsx:主线程如何搭起交互入口

4.1 先做一次快速 WebGPU 检查

进入 App.tsx 后,我们先判断浏览器有没有暴露 WebGPU 入口:

const IS_WEBGPU_AVAILABLE = !!navigator.gpu;

代码刚写下时,部分编辑器会立刻在 gpu 下方标红:

类型“Navigator”上不存在属性“gpu”

这里需要先判断错误来自哪里。浏览器运行 JavaScript 时,会检查 navigator 对象上是否真的存在 gpu;TypeScript 在开发阶段检查的却是 Navigator 接口声明。WebGPU 比较新,当编辑器使用的 TypeScript 或 DOM 类型没有包含这项声明时,就会出现“浏览器可能支持,但类型系统不认识”的情况。

第一种解决方法是使用 as any

const IS_WEBGPU_AVAILABLE = !!(navigator as any).gpu;

any 是 TypeScript 的任意类型。navigator as any 相当于告诉 TypeScript:“这里暂时不要继续检查 navigator 的属性。”这样可以马上消除红线,适合验证问题是否只是缺少类型声明。

any 会同时放弃拼写、参数和返回值检查。如果项目中到处使用 any,类型会沿着变量和函数继续传播,TypeScript 的保护能力也会逐渐消失。因此,这个办法适合临时排查,不适合作为长期方案。

第二种解决方法是安装 WebGPU 类型声明:

pnpm i -D @webgpu/types

-D 表示开发依赖。类型声明只参与编辑器提示和 TypeScript 检查,不会成为浏览器运行模型时需要下载的业务代码。

安装后,在 tsconfig.app.jsontypes 中加入:

"types": ["vite/client", "@webgpu/types"]

完成这一步,TypeScript 就能理解 Navigator.gpuGPUAdapterGPUDevice。于是代码可以保留清晰的原始写法:

const IS_WEBGPU_AVAILABLE = !!navigator.gpu;
解决方式 优点 代价 适用场景
(navigator as any).gpu 快速、无需安装依赖 放弃这一段类型检查 临时验证和排查
@webgpu/types 保留完整 WebGPU 类型提示 需要安装并配置类型包 项目开发的稳定方案

navigator.gpu 是 WebGPU 的入口,!! 把它明确转换成布尔值:存在时得到 true,不存在时得到 false。这个判断适合快速决定页面显示哪个分支,但它不能保证一定能拿到可用的 GPU Adapter,所以我们还会在 Worker 中调用 requestAdapter() 做进一步检查。

4.2 用 useRef 保存 Worker,用 useState 保存页面状态

Worker 实例被放进 useRef

const worker = useRef(null);

useRef 返回一个长期存在的对象,真正的值保存在 worker.current。组件重新渲染时,这个引用不会像普通局部变量一样重新丢失;修改它也不会触发页面渲染。这正符合 Worker 实例的特点:需要长期保存,但创建完成本身不要求页面刷新。

项目中与模型加载有关的状态包括:

const [status, setStatus] = useState(null);
const [error, setError] = useState(null);
const [loadingMessage, setLoadingMessage] = useState("");
const [progressItems, setProgressItems] = useState([]);
const [isRunning, setIsRunning] = useState(false);

沿着模型加载链路,我们先使用 statuserrorloadingMessage,分别保存加载阶段、错误信息和提示文字。下载列表与生成统计会在相应交互出现时再接入页面。

状态 目的
status 判断模型处于未加载、加载中还是可用状态
error 保存 Worker 或 WebGPU 返回的错误信息
loadingMessage 保存“正在加载模型”“正在预热”等提示文本

useStateuseRef 最本质的区别是:状态更新会触发 React 重新渲染,引用更新不会。 页面要显示的内容放进 state,外部对象实例放进 ref。

4.3 useEffect 中只创建一次 Worker

创建 Worker 的逻辑位于 useEffect

useEffect(() => {
  if (!worker.current) { // 只实例化一次
    // html5 新特性
    worker.current = new Worker(new URL("./worker.js", import.meta.url), {
      type: "module", // 前端不是默认支持esm
    });
    // 消息通信
    worker.current.postMessage({ type: "check" }); // Do a feature check
  }

useEffect(..., []) 表示这段副作用逻辑在组件挂载后执行。创建 Worker 属于 React 渲染之外的操作,所以不放在 JSX 中。

if (!worker.current) 用来避免重复实例化。new URL("./worker.js", import.meta.url) 让 Vite 知道 Worker 文件相对于当前模块的位置,生产构建时 Vite 会单独处理这个资源。type: "module" 表示 Worker 使用 ES Module 模式,Transformers.js 也可以从这个模块环境中接入。

Worker 创建后,App 立即发送:

worker.current.postMessage({ type: "check" });

这不是直接调用 Worker 的 check(),而是向另一个线程投递一条消息。Worker 收到 type: "check" 后,再在自己的环境中调用对应函数。

4.4 接收 loading 与 error 状态

App 通过 message 事件接收 Worker 返回的数据。先处理加载提示和错误这两个直接影响页面的状态:

case "loading":
  // Model file start load: add a new progress item to the list.
  setStatus("loading");
  setLoadingMessage(e.data.data);
  break;

case "error":
  setError(e.data.data);
break;

当 Worker 返回 loading 时,App 保存加载状态和提示语;当 Worker 返回 error 时,App 保存错误信息。调用这些 setter 后,React 会重新渲染页面。

监听器注册代码为:

worker.current.addEventListener("message", onMessageReceived);
worker.current.addEventListener("error", onErrorReceived);

message 表示 Worker 主动发回的业务消息;error 表示 Worker 脚本自身发生未处理错误。这里先把两个监听入口分开,避免业务失败和 Worker 崩溃混成同一种事件。

4.5 页面如何触发 load 命令

浏览器支持 WebGPU 时,页面会显示加载按钮:

<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.current.postMessage({ type: "load" });
    setStatus("loading");
  }}
  disabled={status !== null || error !== null}
>
  Load model
</button>

点击按钮会完成两件事:

  • 使用 postMessage({ type: "load" }) 通知 Worker 加载模型。
  • 使用 setStatus("loading") 立即更新页面状态。

disabled={status !== null || error !== null} 表示只要进入某个状态,或者出现错误,按钮就不能再次点击。这样可以避免用户连续触发多个模型加载任务。

当浏览器不支持 WebGPU 时,三元表达式会切换到全屏提示页面。也就是说,页面渲染并不负责深度检测 GPU,只负责根据能力判断与状态选择合适的 UI。

5. worker.js:后台线程如何接住任务

5.1 Worker 环境为什么使用 self

Worker 没有页面 DOM,也不能直接使用 document 操作组件。它拥有自己的全局作用域,通常通过 self 注册事件和发送消息:

self.addEventListener("message", async (e) => {
  const { type, data } = e.data;

  switch (type) {
    // 检查webgpu是否支持
    case "check":
      check();
      break;
    // 加载模型
    case "load":
      break;
    // 生成文本
    case "generate":
      break;
    // 中断生成
    case "interrupt":
      break;
    // 重置模型
    case "reset":
      break;
  }
});

e.data 就是 App 通过 postMessage 发送的对象。这里先取出 type,再通过 switch 把消息分配到不同函数。我们先让 checkload 进入真实函数;讲到生成、中断和重置时,再沿着相同的命令结构填入对应逻辑,不需要把所有任务塞进一个事件回调。

5.2 check:从接口存在到拿到 GPU Adapter

Worker 中的检查函数是:

async function check() {
  try {
    // window 
    // DOM Document Object Model  document
    // BOM Browser Object Model navigator 
    // adapter 是 GPU 适配器的抽象,
    // 后续所有 WebGPU 计算/渲染操作都通过 device 执行
    const adapter = await navigator.gpu.requestAdapter();
    if (!adapter) {
      // 抛出错误
      throw new Error("WebGPU is not supported (no adapter found)");
    }
    // fp16_supported = adapter.features.has("shader-f16")
  } catch (e) {
    self.postMessage({
      status: "error",
      data: e.toString(),
    });
  }
}

navigator.gpu.requestAdapter() 会异步请求一个 GPUAdapter。Adapter 可以理解为浏览器对可用 GPU 的抽象入口,后续还可以通过它申请 GPUDevice、读取特性与限制。

如果没有合适的 Adapter,结果可能为空,代码主动抛出错误。错误被 catch 捕获后,Worker 不操作页面,而是把错误发回 App:

self.postMessage({
  status: "error",
  data: e.toString(),
});

这样,硬件检查留在 Worker,用户提示留在 React,职责不会混在一起。

6. TypeScript 配置如何承接 WebGPU 类型

6.1 类型依赖只服务于开发阶段

在第 4 节安装 @webgpu/types 后,浏览器并不会因此获得 WebGPU。这个依赖补充的是 TypeScript 对 WebGPU API 的描述,让编辑器知道 gpu 上有哪些方法、requestAdapter() 返回什么,以及 GPUAdapterGPUDevice 之间是什么关系。

很多类型包采用 @types/xxx 命名,所以第一次遇到问题时很容易猜成 @types/webgpu。项目实际安装的是 @webgpu/types

pnpm i -D @webgpu/types

TypeScript 只参与开发和构建检查。Vite 打包后,浏览器执行的是 JavaScript;能否真正使用 WebGPU,仍由浏览器版本、系统、显卡驱动和安全上下文决定。

6.2 tsconfig.json 决定类型检查范围

tsconfig.json 是 TypeScript 项目的配置入口。它决定编译目标、模块解析方式、需要加载的类型、是否生成文件以及检查强度。项目把浏览器代码和 Vite 配置拆成 tsconfig.app.jsontsconfig.node.json,再由根配置引用。

配置 在项目中的作用
target: "es2023" 按较新的 JavaScript 语法目标进行检查
lib: ["ES2023", "DOM"] 加载语言能力和浏览器 DOM 类型
types: ["vite/client", "@webgpu/types"] 加入 Vite 与 WebGPU 类型
moduleResolution: "bundler" 按 Vite 这类打包器的方式解析模块
jsx: "react-jsx" 使用 React JSX 转换方式
noEmit: true TypeScript 负责检查,不直接输出 JS,由 Vite 完成构建
include: ["src"] 检查 src 下的应用源码
strictNullChecks: false 允许较宽松的 null 类型处理
noImplicitAny: false 允许部分参数被推断为 any

这套配置先保证框架推进时能够识别 WebGPU。等 App 与 Worker 的消息结构完全稳定,再为 typestatusdata 定义明确类型,逐步提高检查强度。

TypeScript 不会增强浏览器的运行能力,它增强的是开发阶段的可理解性和错误发现能力。

7. 把整个框架重新串起来

7.1 页面启动后的真实执行顺序

把前面的代码连起来,页面启动后的执行流程如下:

  1. main.tsx 创建 React 根节点并渲染 App
  2. App.tsx 通过 navigator.gpu 做快速能力判断。
  3. 组件挂载后,useEffect 创建模块化 Worker。
  4. App 发送 { type: "check" }
  5. Worker 匹配 case "check" 并执行 requestAdapter()
  6. 检查失败时,Worker 返回 { status: "error" }
  7. App 调用 setError(),React 重新渲染错误提示。
  8. 检查通过后,用户可以点击 Load model
  9. App 发送 { type: "load" },Worker 在 case "load" 接住命令。
  10. 执行模型加载的 load() 从下一节开始实现,这里先停在消息入口。

这条链路中最重要的不是某一个 API,而是 跨线程状态流

用户操作
  ↓
React 事件
  ↓
App postMessage(type)
  ↓
Worker switch(type)
  ↓
WebGPU / 模型任务
  ↓
Worker postMessage(status)
  ↓
App switch(status)
  ↓
setState
  ↓
React 更新页面

7.2 App.tsx 框架完整版

前面按照 WebGPU 判断、Worker 创建、消息监听和按钮交互逐步写代码,合在一起就是下面的 App.tsx。为了让本篇主线更集中,示例问题、滚动引用、Token 统计等暂时不参与交互的变量没有放进这份汇总代码;下面每一段都能在前文找到对应解释。

import { useEffect, useState, useRef } from "react";

// 快速判断浏览器是否暴露 WebGPU 入口。
// @webgpu/types 让 TypeScript 能识别 navigator.gpu。
const IS_WEBGPU_AVAILABLE = !!navigator.gpu;

function App() {
  // 保存 Worker 实例。修改 ref 不会触发 React 重新渲染。
  const worker = useRef(null);

  // 保存模型加载状态、错误信息和 Worker 返回的加载文案。
  const [status, setStatus] = useState(null);
  const [error, setError] = useState(null);
  const [loadingMessage, setLoadingMessage] = useState("");

  useEffect(() => {
    // Worker 只创建一次,避免组件渲染时反复创建后台线程。
    if (!worker.current) {
      worker.current = new Worker(new URL("./worker.js", import.meta.url), {
        // 使用模块化 Worker,为导入 Transformers.js 做准备。
        type: "module",
      });

      // App 不直接调用 check(),而是给 Worker 发送 check 命令。
      worker.current.postMessage({ type: "check" });
    }

    // 接收 Worker 主动返回的业务状态。
    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 主动上报业务错误时,把错误保存进 state。
          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">
        {/* error 有内容时才渲染错误提示。 */}
        {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>
    ) : (
      // 浏览器没有暴露 navigator.gpu 时显示全屏提示。
      <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 :&#40;
      </div>
    )
  );
}

export default App;

App.tsx 的主线只有三件事:创建 Worker、发送命令、把 Worker 状态变成 React 状态loadingMessage 在消息到达时被保存,等加载进度界面讲到它时,再把这段状态渲染到 JSX;这里不提前加入新的 UI。

7.3 worker.js 框架完整版

worker.js 与 App 保持相反方向的职责:它接收 type 命令,在这一节执行 WebGPU 检查,再通过 status 把错误信息发回页面。load 命令只保留消息入口,不在这里展开模型加载。

// Worker 没有 DOM,不能直接操作页面。
// 它通过 self 接收和发送消息,通过 navigator 访问 WebGPU。
async function check() {
  try {
    // 请求 GPU Adapter,它是浏览器对可用 GPU 的抽象入口。
    const adapter = await navigator.gpu.requestAdapter();

    if (!adapter) {
      throw new Error("WebGPU is not supported (no adapter found)");
    }

    // 可以继续通过 adapter.features 检查 shader-f16 等能力。
    // fp16_supported = adapter.features.has("shader-f16")
  } catch (e) {
    // Worker 不操作 React 页面,只把错误信息发回 App。
    self.postMessage({
      status: "error",
      data: e.toString(),
    });
  }
}

// Worker 统一监听 App 发来的命令。
self.addEventListener("message", async (e) => {
  const { type, data } = e.data;

  switch (type) {
    case "check":
      // 检查 WebGPU。
      check();
      break;

    case "load":
      // 下一节从这里进入模型加载。
      break;

    case "generate":
      // 文本生成命令沿用同一套消息协议。
      break;

    case "interrupt":
      // 中断生成命令沿用同一套消息协议。
      break;

    case "reset":
      // 重置命令沿用同一套消息协议。
      break;
  }
});

把两份代码并排看,交互关系会非常清楚:App.tsx 使用 worker.current.postMessage() 发送 checkloadworker.js 使用 switch(type) 接住命令。这一节让 check 进入函数并在失败时返回 errorload 到达对应 case 后暂停,下一节再让它进入模型加载函数。

这就是浏览器端模型应用最重要的一条骨架:UI 事件变成 Worker 命令,Worker 任务变成状态消息,状态消息再变成 React 页面变化。

总结

这篇文章从模型为什么能进入浏览器讲起,顺着 Hugging Face、Transformers.js、浏览器缓存和 WebGPU 串起端侧推理链路。写下 const IS_WEBGPU_AVAILABLE = !!navigator.gpu 时,我们先遇到 TypeScript 不认识 gpu 的问题,再比较 navigator as any@webgpu/types 两种解法:前者适合临时绕过检查,后者通过正式类型声明保留编辑器提示,因此项目选择安装依赖并在 tsconfig.app.json 中加入 WebGPU 类型。

接着,App.tsx 使用 useRef 保存 Worker,使用 useState 保存页面状态,通过 postMessage 发送 checkload 命令;worker.js 使用 switch(type) 分发任务,通过 requestAdapter() 检查 GPU,并在失败时把 error 发回 React。文章末尾的两份完整代码把这条交互链集中呈现:App 管界面与状态,Worker 管后台任务,WebGPU 提供 GPU 入口。

至此,系列第一部分把模型应用的运行边界和通信方式建立起来。下一部分会从 case "load" 继续,实现 Worker 中的 load(),再进入 Transformers.js、模型 ID、Tokenizer、下载进度和浏览器缓存。

更多推荐