在浏览器里运行 DeepSeek-R1:从 WebGPU 检测到模型加载(一)

在浏览器里运行 DeepSeek-R1:从 WebGPU 检测到模型加载(一)

大语言模型一定要部署在服务器上,再由前端调用接口吗?对于体积合适、经过量化并转换为 ONNX 格式的模型,答案是否定的。浏览器可以下载模型,把模型缓存到本地,再通过 WebGPU 使用显卡完成推理。

这一篇先不急着生成文本,而是把最基础也最容易卡住的一段链路打通:

  1. 搭建 React、TypeScript 与 Vite 工程;
  2. 检查浏览器是否支持 WebGPU;
  3. 用 Web Worker 隔离模型任务;
  4. 通过 Transformers.js 下载分词器与模型;
  5. 展示多文件下载进度;
  6. 预热模型,确认推理管线真正可用。

完成这一部分以后,模型会完全运行在浏览器中。页面不需要把对话内容发送给后端;模型第一次加载较慢,后续则可以利用浏览器缓存。

一、整个应用是怎样协作的

这个应用可以分为三层:

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。它提供分词器、模型加载、流式输出和停止条件等能力。markedDOMPurify 与 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 :&#40;
  </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 之间约定了一套很小的消息协议。主线程发出 checkloadgenerateinterruptreset;Worker 返回 loadinginitiateprogressdonereadystartupdatecompleteerror

比起让双方传递含义不明的字符串,把消息拆成 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. ??= 只在空值时赋值

空值合并赋值运算符只在左侧为 nullundefined 时执行赋值:

js 复制代码
this.model ??= createModel();

它和"每次都创建模型"完全不同,也不会把 false0、空字符串等非空值误判为需要初始化。这里借助 ??=,分词器和模型在 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>

八、本篇小结

到这里,一条可靠的浏览器模型加载链路已经建立起来:

  1. TypeScript 通过 @webgpu/types 理解 WebGPU 类型;
  2. 主线程做快速能力判断,Worker 通过 requestAdapter() 做实际检查;
  3. React 只负责交互,耗时工作留在 Worker;
  4. 静态类、Promise 和 ??= 共同保证模型只加载一次;
  5. 多文件进度通过消息协议同步到页面;
  6. 一次最小生成完成 WebGPU 预热,最后再进入 ready 状态。

下一篇将进入真正的推理流程:对话模板如何转成模型输入,怎样流式返回 token,如何区分"思考过程"和"最终回答",以及停止生成与 KV Cache 在当前实现中分别处于什么状态。

相关推荐
八角丶1 小时前
Node.js 事件循环详解(实验驱动)
前端·node.js
deli0070071 小时前
汉诺塔益智小游戏:浏览器里说句话,码道 WebUI 一键生成+部署上线
前端·ai编程
用户2181697049301 小时前
Flutter (二十四) 音频
前端
愚公搬代码1 小时前
【愚公系列】《Web应用安全》012-Behinder工具的使用
前端·安全
aixingpan1 小时前
aixingpan.cn API开发文档:api_docs_errors接口指南
前端·php
苹果二1 小时前
【案例说明】能源行业中融合知识图谱、LLM与AI Agent的知识工程实践
人工智能·ai智能体·pem·phm·能源行业·知识工程·pqm
陈童学哦1 小时前
NestJS集成LangChain两条路:封装库躺平 vs 手动硬撸
人工智能
markvivv1 小时前
【译】适合在RTX 5090、DGX Spark或类似机器上可运行的最佳新模型是什么?
大数据·人工智能·spark
caimouse1 小时前
ReactOS 窗口系统分析(31):TextOutW 文本输出全链路 — 从用户函数到显示缓冲区的旅程
网络·人工智能·计算机视觉