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 中不能直接使用 window、document 操作页面,因此它通过:
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 做了什么
static 让 getInstance 属于类本身,因此可以直接调用:
js
TextGenerationPipeline.getInstance();
??= 做了什么
js
this.model ??= loadModel();
只有 this.model 为 null 或 undefined 时才加载。第一次调用会下载和初始化,后续调用复用原来的 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