React + WebGPU 在浏览器运行 DeepSeek:从 Worker 通信到流式生成

React + WebGPU 在浏览器运行 DeepSeek:从 Worker 通信到流式生成

本文基于 webgpu-deepseek 项目源码整理,重点解释模型如何在浏览器中加载、推理和返回结果。源码静态阅读,运行未验证;实际 WebGPU 兼容性、模型下载情况和生成速度需要在目标环境单独确认。

你会得到什么

这个项目不是简单地在页面里调用一个模型,而是拆成了三层:

  • React 主线程:负责输入框、聊天列表和加载进度。
  • Web Worker:负责下载模型、初始化 WebGPU 和执行推理。
  • Transformers.js:负责 tokenizer、模型加载和文本生成。

核心判断是:模型生命周期和页面交互要分开管理,缓存和流式消息是浏览器端运行大模型的关键。

1. 先看完整调用链

text 复制代码
main.tsx
  ↓ 挂载 App
App.tsx
  ↓ 创建 Worker,发送 check/load/generate
worker.js
  ↓ 检测 WebGPU
  ↓ 加载 tokenizer 和 model
  ↓ TextStreamer 流式生成
  ↓ postMessage 返回状态和文本
App.tsx
  ↓ 更新 React state
Chat.jsx
  ↓ Markdown、HTML 安全清理、数学公式渲染

主线程和 Worker 之间不是直接调用函数,而是约定消息格式:

消息类型 Worker 行为 页面用途
check 检查 WebGPU 适配器 判断能力
load 下载并初始化模型 显示加载进度
generate 生成回答 显示流式文本
interrupt 中断生成 响应停止按钮
reset 清理缓存和中断状态 开始新的状态

2. 为什么模型放进 Web Worker

App.tsx 创建了一个 module Worker:

js 复制代码
worker.current = new Worker(new URL("./worker.js", import.meta.url), {
  type: "module",
});
worker.current.postMessage({ type: "check" });

页面主线程擅长处理 DOM 和用户交互,但模型下载、WebGPU 初始化和推理都可能是耗时任务。Worker 可以把这些工作放到后台线程,主线程只接收结果并更新 UI。

Worker 中不能直接使用 windowdocument 操作页面,因此它通过:

js 复制代码
self.postMessage({
  status: "update",
  output,
});

把结果发送给 React。

这里有一个需要重点记住的地方:postMessage 不是普通函数调用。主线程发送的是一份消息数据,Worker 再根据 type 判断要做什么。

3. WebGPU 检查分两步

页面中有快速判断:

js 复制代码
const IS_WEBGPU_AVAILABLE = !!navigator.gpu;

它只说明浏览器是否提供了 navigator.gpu 属性。

Worker 中还会继续请求适配器:

js 复制代码
const adapter = await navigator.gpu.requestAdapter();
if (!adapter) {
  throw new Error("WebGPU is not supported (no adapter found)");
}

可以把两者理解为:

  • !!navigator.gpu:有没有 WebGPU 入口。
  • requestAdapter():能不能找到实际可用的 GPU 适配器。

所以第一个判断为 true,并不代表后续模型推理一定成功。浏览器版本、显卡驱动、模型格式和显存都可能影响结果。

4. TextGenerationPipeline 如何避免重复加载

项目用一个类统一管理 tokenizer 和模型:

js 复制代码
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]);
  }
}

static 做了什么

staticgetInstance 属于类本身,因此可以直接调用:

js 复制代码
TextGenerationPipeline.getInstance();

??= 做了什么

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

只有 this.modelnullundefined 时才加载。第一次调用会下载和初始化,后续调用复用原来的 Promise 或模型对象。

这体现了"单例式缓存"思想:模型初始化成本高,生成多次回答时不应该反复加载。

两个参数的含义

js 复制代码
dtype: "q4f16",
device: "webgpu",

源码意图是使用量化数据类型降低资源压力,并让模型运行在 WebGPU 设备上。具体兼容性和性能不能只靠静态代码判断,本文不把它们描述成已验证结果。

5. 从聊天消息到模型输入

用户消息最终通过:

js 复制代码
const inputs = tokenizer.apply_chat_template(messages, {
  add_generation_prompt: true,
  return_dict: true,
});

转换为模型需要的输入。

messages 是聊天结构,例如:

js 复制代码
[
  { role: "user", content: "请解释 Web Worker" },
]

模型真正处理的不是这段普通字符串,而是 tokenizer 转换后的 token 数据。

add_generation_prompt: true 的作用是补充生成提示,让模型知道接下来应该由 assistant 回答。

6. TextStreamer 为什么能实现流式输出

模型生成不是一次性返回全部文本,而是不断生成 token。项目配置了:

js 复制代码
const streamer = new TextStreamer(tokenizer, {
  skip_prompt: true,
  skip_special_tokens: true,
  callback_function,
  token_callback_function,
});

其中:

  • callback_function:获得已经转换好的文本片段,并发送给主线程。
  • token_callback_function:每生成 token 时统计数量和速度。
  • skip_prompt:不重复显示输入提示词。
  • skip_special_tokens:隐藏特殊 token。

发送给页面的消息大致是:

js 复制代码
self.postMessage({
  status: "update",
  output,
  tps,
  numTokens,
  state,
});

React 收到 update 后,把 output 追加到最后一条 assistant 消息,因此用户能看到逐步生成的回答。

7. 思考过程和答案如何区分

代码通过编码 <think></think>,拿到开始和结束 token:

js 复制代码
const [START_THINKING_TOKEN_ID, END_THINKING_TOKEN_ID] =
  tokenizer.encode("<think></think>", {
    add_special_tokens: false,
  });

当生成到结束思考 token 时:

js 复制代码
if (tokens[0] == END_THINKING_TOKEN_ID) {
  state = "answering";
}

前端根据 answerIndex 把内容拆成 thinking 和 answer,并允许用户展开或收起思考过程。

8. 页面渲染为什么需要 DOMPurify

Chat.jsx 的渲染链是:

text 复制代码
模型 Markdown 文本
  ↓ marked.parse
HTML 字符串
  ↓ DOMPurify.sanitize
安全一些的 HTML
  ↓ dangerouslySetInnerHTML
插入 React 页面

关键代码:

js 复制代码
const result = DOMPurify.sanitize(
  marked.parse(text, {
    async: false,
    breaks: true,
  }),
);

Markdown 转 HTML 后,如果直接使用 dangerouslySetInnerHTML,就需要考虑危险 HTML 内容。项目先使用 DOMPurify 清理,这是一个重要的安全边界。

另外,MathJax 负责数学公式显示,适合模型回答方程、代码解释等内容。

9. 模型加载与生成的两个阶段

加载阶段

text 复制代码
发送 load
  ↓
发送 loading
  ↓
getInstance 下载 tokenizer 和 model
  ↓
发送下载进度
  ↓
用简单输入生成 1 个 token 进行预热
  ↓
发送 ready

预热的目的,是提前触发模型和 WebGPU 的初始化工作,让正式提问时少承担一部分首次初始化成本。实际耗时和效果需要运行验证。

生成阶段

text 复制代码
发送 generate
  ↓
reset stopping_criteria
  ↓
准备 chat template
  ↓
model.generate
  ↓
TextStreamer 持续发送 update
  ↓
发送 complete

用户点击停止时,发送 interrupt,Worker 调用:

js 复制代码
stopping_criteria.interrupt();

这是一种由生成过程主动检查停止条件的中断设计。

10. 排错清单

现象 优先检查
navigator.gpu 类型警告 是否安装并配置 @webgpu/types;不要长期依赖 as any
Worker 无法加载 new URL 引用的文件名是否和 src 中实际文件一致
Failed to resolve import package.json 是否声明对应依赖,包管理器是否混用
页面一直不能输入 Worker 是否发送 ready,主线程是否正确设置 status
只有完整结果没有实时输出 TextStreamer 是否传入 streamer,是否处理 update
Markdown 渲染异常 marked 输入、反斜杠处理和 MathJax 配置
HTML 安全风险 是否先调用 DOMPurify.sanitize
停止按钮无效 stopping_criteria 是否传给 model.generate

结语

这个项目最值得迁移的设计不是某一个 API,而是职责划分:React 处理交互,Worker 管理重任务,模型类负责资源生命周期,消息状态负责跨线程反馈。理解这条调用链后,再学习 WebGPU、tokenizer 或流式生成,都会更容易定位问题。

建议下一步按以下顺序实践:先单独完成 Worker 的消息往返,再接入 tokenizer,最后接入模型和流式 UI。本文代码和项目运行结果均未验证,部署前应补做依赖安装、构建、浏览器 WebGPU 能力和模型加载检查。

标签: React, WebGPU, Transformers.js, Web Worker

相关推荐
a1117761 小时前
家居生成器 Cartoon 开源项目 3D web
前端·开源
小林ixn1 小时前
前端卡顿终结者:用 useRef 把 Web Worker 请进 React 项目
前端·react.js·前端框架
sunly_1 小时前
React 三个重要概念:Ref、Props、State 详解
前端·javascript·react.js
weixin_431600441 小时前
前端对接 SSE 的两种常见方式
前端·后端·学习·ai·sse·nest.js
Mh1 小时前
如何使用GSAP实现一个 `pinned` 滚动楼层叙事?
前端·javascript·css
Larcher7 小时前
React Router 不只是页面跳转:从 SPA 路由到权限守卫的完整实践
javascript·后端
Larcher8 小时前
大模型为什么每次回答都不一样?一文搞懂 Temperature、Top K 与 Top P
javascript·后端
用户9385156350710 小时前
从零在浏览器里跑 DeepSeek-R1:WebGPU + Transformer.js 全链路实战
前端·设计模式·typescript
CodeSheep11 小时前
稚晖君公司人事大变动,来了!
前端·后端·程序员