从零到一:在浏览器里跑 DeepSeek-R1,WebGPU 大模型推理全链路解析(二)
前言
大家好,我是 Daijvnzhao。最近我在做一个项目------把 DeepSeek-R1 大模型完整跑在浏览器里,纯本地推理,数据绝不离开你的电脑。过程中踩了不少坑,也学到了很多底层知识。
本文按照项目的实际流程,从模型下载到推理输出,把每个关键知识点讲透。不会铺开所有边角内容,聚焦核心链路和底层原理。
一、整体架构:一个模型如何从云端跑到浏览器里
整个链路五步走:
markdown
HuggingFace 模型仓库
↓
Transformers.js 远程下载 ONNX 模型文件
↓
浏览器本地缓存(下次无需重新下载)
↓
WebGPU 推理加速(GPU Compute Shader 并行矩阵运算)
↓
Web Worker 异步执行(主线程不卡,UI 流畅)
↓
React 主线程渲染(进度条 → 聊天界面 → Markdown + 数学公式)
核心依赖:
- @huggingface/transformers:JS 版 Transformers,负责模型下载和推理调度
- marked + DOMPurify:Markdown → 安全 HTML
- MathJax:LaTeX 数学公式渲染
二、HuggingFace:AI 界的 GitHub
HuggingFace 是目前 AI 圈最活跃的开源模型社区。Meta 的 LLaMA、Google 的 Gemma、Mistral、国内 DeepSeek 和 Qwen,会把训练好的模型发布到这里。它本质上是模型文件的 GitHub,托管的不止代码,更多的是几百 MB 到几百 GB 的模型权重。
本项目使用 DeepSeek-R1-Distill-Qwen-1.5B-ONNX:
- DeepSeek-R1:深度求索出品的推理模型,擅长数学和逻辑推理
- Distill-Qwen-1.5B:用 Qwen 做基座、蒸馏成 1.5B 参数的小模型
- ONNX:开放神经网络交换格式,跨框架跨平台
1.5B 参数意味着不需要数据中心级 GPU,普通电脑的集成显卡就能跑------这是浏览器端推理的前提。
国内有阿里的 ModelScope(魔搭),但 HuggingFace 生态更成熟。Transformers.js 基于 HuggingFace 格式设计,选它最自然。
三、Transformers.js:把 HuggingFace 搬进浏览器
worker.js 中的两个核心 import:
javascript
import {
AutoTokenizer, // 分词器:文本 → token id 序列
AutoModelForCausalLM, // 因果语言模型:token id 序列 → 逐 token 生成
} from "@huggingface/transformers";
"Causal"(因果)是什么意思?
因果语言模型的核心是注意力掩码:每个 token 只能看到自己前面的 token,不能"偷看"未来。
arduino
输入序列:[我, 爱, 北京, __]
↓ ↓ ↓
预测时: token "我" 只能看到 "我"
预测时: token "爱" 只能看到 "我", "爱"
预测时: token "北京" 只能看到 "我", "爱", "北京"
这就是"因果"------只能根据上文预测下文
对比 BERT 的 AutoModelForMaskedLM(完形填空),BERT 可以看到前后所有 token(双向注意力)。Transformer 原始论文中,Encoder 用双向注意力,Decoder 用因果注意力。GPT、DeepSeek、LLaMA 等生成式模型只用 Decoder,所以归为 Causal LM。
from_pretrained() 底层做了什么
arduino
AutoModelForCausalLM.from_pretrained("onnx-community/DeepSeek-R1-...")
这个调用展开后经历了五个步骤:
- 构造请求 URL :拼出 HuggingFace CDN 上
config.json、tokenizer.json、onnx/model.q4f16.onnx等文件的地址 - HTTP Range 分段下载 :
.onnx文件几百 MB,浏览器用Range: bytes=0-1048575分段请求,每完成一个 chunk 就调用progress_callback - ONNX Runtime Web 解析计算图 :ONNX 文件本质是 protobuf 序列化的计算图,每层算子描述为
node { op_type: "MatMul", input: [...], output: [...] },ORT 将其解析为内存中的图结构 - 注册 WebGPU 后端算子:ORT 的 WebGPU EP(Execution Provider)将每个算子映射到预先写好的 WGSL Shader 模板
- 返回 Pipeline 对象 :提供给上层调用
.generate()方法
整个过程是异步的,文件大、耗时长,这也是为什么后面要用单例模式------绝不能重复执行。
四、WebGPU 检测与 TypeScript 类型困境
App.tsx 开头的浏览器能力检测:
ini
const IS_WEBGPU_AVAILABLE = !!navigator.gpu;
!! 的本质
!! 不是特殊语法,是两个 ! 的叠加。第一个 ! 把值转布尔并取反,第二个再取反:
javascript
!!undefined → !true → false
!!{} → !false → true
如果浏览器不支持 WebGPU,navigator.gpu 是 undefined,!!undefined = false,界面直接展示 "WebGPU is not supported by this browser"。
TypeScript 的类型问题
直接写 navigator.gpu,TS 会报 Property 'gpu' does not exist on type 'Navigator'。因为 WebGPU 是 2023 年才在 Chrome 113 默认开启的新 API,TS 内置的 Navigator 类型还没收录。
本项目安装了 @webgpu/types:
perl
"@webgpu/types": "^0.1.71"
TypeScript 的 interface 是开放式(Declaration Merging) ------同名 interface 自动合并属性。@webgpu/types 内部声明了 interface Navigator { readonly gpu: GPU },安装后无需任何 import,navigator.gpu 就有了完整类型提示。^0.1.71 中版本号 0.x.x 表示 WebGPU 规范尚未到 1.0 正式版。
五、Web Worker:主线程不卡顿的秘诀
大模型推理是计算密集型任务,跑在主线程会让 UI 完全冻结。
Worker 的创建
go
worker.current = new Worker(new URL("./worker.js", import.meta.url), {
type: "module", // 前端打包工具默认不支持 ESM,需显式声明
});
new URL("./worker.js", import.meta.url):Vite/Webpack 需要这种写法才能在打包时正确解析 worker 文件路径type: "module":Worker 默认只能用importScripts(),加这个标志后可直接用import/export
线程通信
主线程和 Worker 通过 postMessage 通信。数据被浏览器结构化克隆(Structured Clone),不是共享内存,两端互不干扰。大型数据(如模型权重)浏览器会走 Transferable 优化,接近零拷贝。
css
主线程 Worker
│ │
│── { type: "check" } ──→ │ 检测 WebGPU
│── { type: "load" } ──→ │ 开始加载模型
│ ←── { status: "initiate" } 文件开始下载
│ ←── { status: "progress" } 下载进度更新
│ ←── { status: "ready" } 就绪
六、单例模式与 ??= 运算符
kotlin
class TextGenerationPipeline {
static model_id = "onnx-community/DeepSeek-R1-Distill-Qwen-1.5B-ONNX";
// 单例模式:llm 只需要初始化一次,后面可以一直用,实例化开销很大
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", // 4-bit 存储 + float16 计算
device: "webgpu", // 使用 GPU 推理
progress_callback,
});
return Promise.all([this.tokenizer, this.model]);
}
}
??= 的精确语义
kotlin
this.model ??= someAsyncOperation();
// 等价于:
if (this.model === null || this.model === undefined) {
this.model = someAsyncOperation();
}
| 运算符 | 触发赋值的条件 |
|-----------|-----------------------------|-------|-----------------------------------------------------------------|
| `x | | = y` | x 为任意 falsy 值(false, 0, "", null, undefined, NaN) |
| x ??= y | x 仅为 null 或 undefined |
from_pretrained() 返回 Promise(永远是 truthy),两个运算符在这里行为一致。但 ??= 语义更精确------"只有没初始化过才去下载"。这是防御性编码习惯。
为什么必须用单例
from_pretrained() 做的事情极其昂贵:HTTP 下载几百 MB、解析 ONNX、注册 GPU 算子。整个过程可能几十秒到几分钟。如果用普通实例化,每次调用都重新执行,用户要反复等待。**单例保证不管调用多少次,下载和初始化只发生一次。**这是 GOF 23 种设计模式中最常用的之一------用于管理全局状态、避免重复初始化。
Promise.all 并行
kotlin
return Promise.all([this.tokenizer, this.model]);
分词器和模型文件并行下载 。浏览器对同域名的 HTTP/2 连接是多路复用的,并行能充分利用带宽。如果写成两个 await 串行,总耗时就是两者之和,浪费一半等待时间。
七、dtype: "q4f16":量化的底层原理
这是整个项目中最关键的配置。
为什么需要量化
原始权重是 float32------每个参数 4 字节。1.5B × 4 = 6 GB,浏览器根本扛不住。
量化的思路:用更少的比特存储,计算时反量化回来。
逐字符拆解
css
q4f16
││││
│││└── f16 → 计算精度:forward pass 时用 float16 运算
││└─── 4 → 存储精度:每权重 4 个比特(0.5 字节)
│└──── f → 浮点格式运算
└───── q → Quantized(量化版)
格式对比
| 格式 | 每权重 | 模型大小 | 精度损失 | 适用场景 |
|---|---|---|---|---|
fp32 |
4 字节 | ~6 GB | 基准 | 服务器 GPU |
fp16 |
2 字节 | ~3 GB | < 0.1% | 高端显卡 |
q8f16 |
1 字节 | ~1.5 GB | < 0.5% | 台式机 |
q4f16 |
0.5 字节 | ~750 MB | < 1% | 普通浏览器 |
存储减少 87.5%,精度损失不到 1%------这是浏览器跑大模型的王牌配置。
线性量化怎么做的
ONNX 文件里不仅存了 4-bit 权重,还存了每层的量化参数:
ini
量化公式:w_float16 = scale × (w_int4 - zero_point)
w_int4 ∈ {0, 1, ..., 15} ← 4-bit 只能表示 16 个离散值
w_float16 ∈ [-2.3, 1.8] ← 真实的浮点权重范围
scale = (max - min) / 15 ← 步长
zero_point = -min / scale ← 零点偏移
举个例子,某层权重范围是 -1.0, 2.0:
ini
scale = 3.0 / 15 = 0.2
zero_point = 1.0 / 0.2 = 5
存储(4-bit) → 反量化后(float16)
0 → 0.2 × (0 - 5) = -1.0
5 → 0.2 × (5 - 5) = 0.0
15 → 0.2 × (15 - 5)= 2.0
16 个离散值均匀覆盖了整个权重范围。对神经网络来说,权重的精确值不如相对大小重要,16 个档位已经足够。
数据流全链路
arduino
[HuggingFace CDN] 4-bit 量化权重
│ HTTP 分段下载
▼
[浏览器 ArrayBuffer] 4-bit packed
│ GPU buffer upload
▼
[GPU VRAM] WebGPU Storage Buffer(4-bit packed)
│ WGSL Compute Shader 中:
├─ 读取 packed 值
├─ 查 scale/zero_point 表
├─ 解包 + 反量化 → float16
├─ MatMul / Attention 矩阵运算
└─ 输出 float16 logits
▼
[推理结果] token 概率分布 → 采样 → 下一个 token
为什么用 GPU 而不是 CPU
矩阵乘法本质是"每个输出元素独立计算",天然适合 GPU 并行:
yaml
CPU(8核): 约 30 GFLOPS → 1 token / 3-10 秒
GPU(集显): 约 2000 GFLOPS → 1 token / 50-200 毫秒
差距不在频率,在并行度。GPU 有 1000+ 着色器核心同时工作。
八、模型预热:为什么要用 "a" 跑一遍
模型文件下载完成后,Worker 里有一段关键逻辑:
php
self.postMessage({
status: "loading",
data: "Compiling shaders and warming up model...",
});
// Run model with dummy input to compile shaders
// a 分词
const inputs = tokenizer("a");
// 调用 generate 方法生成文本
// max_new_tokens 生成的文本长度
await model.generate({ ...inputs, max_new_tokens: 1 });
self.postMessage({ status: "ready" });
为什么需要预热
ONNX Runtime Web 第一次调用 generate() 时,需要把模型每一层算子惰性编译为 WGSL Shader。这个过程包括:
- MatMul 着色器编译(约 800ms)
- Attention 着色器编译(约 400ms)
- 其他算子编译(约 300ms)
- GPU 缓冲区分配(约 100ms)
总计约 1.6 秒。如果不预热,这 1.6 秒会叠加在用户输入第一个问题的首次响应上------"我发了消息,怎么等了两秒才有反应?"体验很差。
预热后,这 1.6 秒发生在用户点 "Load model" 之后、看到输入框之前,用户此时在看加载进度条,完全无感知。
为什么是 "a" 而不是别的
- 短:仅 1 个 token + BOS 特殊标记,GPU 计算量极低
- 合法:任何模型词表里肯定有字母 "a"
- 无意义:输出什么都不重要,我们只关心推理管线跑通没
max_new_tokens: 1 是关键------不加它模型会按默认值(通常 256 或 512)连续生成一长串无意义文本,白费时间。1 个 token 刚好走一遍完整的 forward pass,触发所有 Shader 编译。
九、进度回调与 React 函数式 setState
模型由多个文件组成(tokenizer.json、config.json、onnx/model.q4f16.onnx 等)。下载过程中,Worker 对每个文件分别发送 initiate → progress → done 三条消息:
yaml
Worker → 主线程:
{ status: "initiate", file: "tokenizer.json", progress: 0, total: 1.2MB }
{ status: "progress", file: "tokenizer.json", progress: 50%, total: 1.2MB }
{ status: "initiate", file: "model.q4f16.onnx", progress: 0, total: 752MB }
{ status: "progress", file: "tokenizer.json", progress: 100%, total: 1.2MB }
{ status: "done", file: "tokenizer.json" }
{ status: "progress", file: "model.q4f16.onnx", progress: 45%, total: 752MB }
{ status: "done", file: "model.q4f16.onnx" }
App.tsx 中用 switch 实现三段式状态机:
javascript
case "initiate":
// 多个文件并发下载时进度回调频繁触发
// 给函数为了获得最新状态
setProgressItems((prev) => [...prev, e.data]);
break;
case "progress":
// Model file progress: update one of the progress items.
setProgressItems((prev) =>
prev.map((item) => {
if (item.file === e.data.file) {
return { ...item, ...e.data };
}
return item;
}),
);
break;
case "done":
// Model file loaded: remove the progress item from the list.
setProgressItems((prev) =>
prev.filter((item) => item.file !== e.data.file),
);
break;
case "ready":
setStatus("ready");
break;
三条 case 对应三种数组操作:
scss
initiate → 追加新文件 (prev) => [...prev, e.data]
progress → 原地更新进度 (prev) => prev.map(...)
done → 删除完成项 (prev) => prev.filter(...)
为什么必须用函数式 (prev) => 而不是直接传值
根源在 useEffect 的依赖数组是 []:
javascript
useEffect(() => {
const onMessageReceived = (e) => {
// 这个函数在 useEffect body 中定义
// useEffect 只执行一次,所以 onMessageReceived 也只创建一次
// 它闭包中捕获的 progressItems = [](初始值),永远不会变
case "initiate":
// ❌ setProgressItems([...progressItems, e.data])
// progressItems 永远是 [] → 每次都 [...[], 新数据] → 只保留最后一个
// ✅ setProgressItems((prev) => [...prev, e.data])
// prev 由 React 内部传入,保证是最新值
break;
};
}, []); // ← 空依赖 = 闭包永久锁死在初始值
React 内部的执行流程:
scss
// 两条 "initiate" 消息几乎同时到达,还没 re-render
// 第 1 条:加入队列
updateQueue.push((prev) => [...prev, { file: "tokenizer.json" }])
// 第 2 条:加入队列
updateQueue.push((prev) => [...prev, { file: "model.onnx" }])
// React commit 阶段,逐个执行:
prev = [] // 初始值
prev = updateQueue[0](prev) → [tokenizer.json]
prev = updateQueue[1](prev) → [tokenizer.json, model.onnx]
// 最终渲染:两个文件都在 ✅
如果用值形式,两个闭包读到的 progressItems 都是 [],第二个直接覆盖第一个------数据丢了。
Progress 组件
arduino
function formatBytes(size) {
const i = size == 0 ? 0 : Math.floor(Math.log(size) / Math.log(1024));
return +(size / Math.pow(1024, i)).toFixed(2) * 1
+ ["B", "kB", "MB", "GB", "TB"][i];
}
export default function Progress({ text, percentage, total }) {
percentage ??= 0; // null/undefined 时默认 0%
return (
<div className="w-full bg-gray-100 dark:bg-gray-700 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>
);
}
formatBytes 用换底公式 Math.log(size) / Math.log(1024) 一次性算出最合适的单位索引(B/kB/MB/GB/TB),O(1) 复杂度,比 while 循环高效。
percentage ??= 0 保证 initiate 时 progress 为 undefined 也不显示 NaN%。进度条动画由 CSS width 过渡驱动------写入 GPU 合成层,不触发 layout,不卡主线程。
十、useEffect cleanup 与 StrictMode
erlang
useEffect(() => {
// ...
worker.current.addEventListener("message", onMessageReceived);
worker.current.addEventListener("error", onErrorReceived);
return () => {
worker.current.removeEventListener("message", onMessageReceived);
worker.current.removeEventListener("error", onErrorReceived);
};
}, []);
为什么必须 cleanup
React 18 的 StrictMode 在开发环境会故意双重挂载组件来暴露副作用问题:
markdown
StrictMode 开发模式执行顺序:
1. 挂载组件 → body 执行 → addEventListener(第 1 次绑定)
2. 模拟卸载 → cleanup 执行 → removeEventListener(清理掉)
3. 再挂载 → body 执行 → addEventListener(重新绑定,始终只有 1 次)
如果没有 cleanup(第 2 步什么都不做),第 3 步会加上第二个监听器------同一条 Worker 消息触发两次回调,每一条进度更新都导致重复渲染。
removeEventListener 的匹配要求
arduino
worker.current.removeEventListener("message", onMessageReceived);
removeEventListener 需要完全相同的函数引用 。这就是为什么 onMessageReceived 必须在 useEffect 内部定义------同一个闭包引用被 body 和 cleanup 共享,能精确匹配。
StrictMode 是开发环境专用工具,生产构建完全不受影响。它是一个零成本的代码质量守门员------双重调用帮你提前发现不纯的副作用。cleanup 已经补好,建议在
main.tsx中保留<StrictMode>包装。
十一、输入交互:条件判断的三段式按钮
ini
{isRunning ? (
<div className="cursor-pointer" onClick={onInterrupt}>
<StopIcon ... />
</div>
) : input.length > 0 ? (
<div className="cursor-pointer" onClick={() => onEnter(input)}>
<ArrowRightIcon className="bg-gray-800 dark:bg-gray-100 text-white dark:text-black ..." />
</div>
) : (
<div>
<ArrowRightIcon className="bg-gray-200 dark:bg-gray-600 text-gray-50 dark:text-gray-800 ..." />
</div>
)}
决策树:
lua
isRunning?
├── true → StopIcon(■ 停止按钮)------正在生成中,可中断
└── false → input.length > 0?
├── true → ArrowRightIcon 亮色(→ 可发送)
└── false → ArrowRightIcon 灰色(→ 禁用态)
键盘事件与门
javascript
onKeyDown={(e) => {
if (
input.length > 0 && // ① 输入不为空
!isRunning && // ② 没有正在生成
e.key === "Enter" && // ③ 按下的是 Enter
!e.shiftKey // ④ 没有同时按 Shift
) {
e.preventDefault(); // 阻止默认换行
onEnter(input); // 触发发送
}
}}
四个条件构成与门:
ini
input.length > 0 ──┐
!isRunning ────────┤
e.key === "Enter" ─┼─ AND → onEnter(input)
!e.shiftKey ───────┘
任何一个不满足都不发送。Shift+Enter 放行默认行为(换行),这是聊天应用的标准交互模式。
十二、Chat 组件:Markdown + XSS 防护 + 数学公式 + 思考过程
依赖分工
- marked:Markdown 文本 → HTML 字符串(AIGC 输出天然是 Markdown,含代码块、加粗、列表等)
- DOMPurify :剥离危险标签(
<script>、onerror、javascript:等),防御 XSS - MathJax :渲染 LaTeX 公式(
$x^2$、$$\frac{a}{b}$$)
安全渲染链路
arduino
function render(text) {
// 修复 marked 对括号转义的已知问题
text = text.replace(/\([[]()])/g, "\\$1");
const result = DOMPurify.sanitize(
marked.parse(text, {
async: false,
breaks: true, // 单换行也转 <br>
}),
);
return result;
}
数据流:
scss
Markdown 文本
→ marked.parse() // Markdown → HTML
→ DOMPurify.sanitize() // 剥离 XSS 攻击向量
→ dangerouslySetInnerHTML // 注入 DOM
→ MathJax 二次渲染 // 识别 $...$ 中 LaTeX 公式
dangerouslySetInnerHTML 如其名------直接塞 HTML 进 DOM。模型可能幻觉生成 <script>alert(1)</script>,不加 DOMPurify 就是 XSS 漏洞。
思考过程的折叠展示
DeepSeek-R1 输出包含"思考过程"和"最终回答"两部分。Message 组件通过 answerIndex 切分:
ini
const thinking = answerIndex ? content.slice(0, answerIndex) : content;
const answer = answerIndex ? content.slice(answerIndex) : "";
思考过程默认折叠,点击 "View reasoning." 展开------类似 ChatGPT 的 "Thinking" 区域。BrainIcon 在思考中播放脉冲动画(animate-pulse),给用户"模型正在工作"的视觉反馈。思考完成后 doneThinking 变 true,图标停止脉冲。
三点加载动画
当模型还在计算、content 为空时,显示三个依次闪烁的圆点:
css
<span className="h-6 flex items-center gap-1">
<span className="w-2.5 h-2.5 ... animate-pulse"></span>
<span className="w-2.5 h-2.5 ... animate-pulse animation-delay-200"></span>
<span className="w-2.5 h-2.5 ... animate-pulse animation-delay-400"></span>
</span>
三个点通过不同的 animation-delay 制造波浪脉冲效果------和 ChatGPT 的加载动画逻辑一致。
十三、整体状态机流转
status 是贯穿整个 App 的核心状态变量:
bash
null ──→ "loading" ──→ "ready" ──→ (未来: "generating")
│ │
│ └── 显示进度条
│ inititate → progress → done
│
└── 显示欢迎页 + Load model 按钮
当 status 变化时,条件渲染自动切换:
| status | 界面 |
|---|---|
null |
欢迎页 + 模型介绍 + Load 按钮 |
"loading" |
进度条列表 + 加载文案 |
"ready" |
聊天区域 + 输入框(可交互) |
"error" |
红色错误提示 |
(未来)"generating" |
流式输出中,stop 按钮亮起 |
十四、总结
本文覆盖了从 HuggingFace 到浏览器推理的完整链路:
| 层级 | 知识点 | 核心价值 |
|---|---|---|
| 模型获取 | HuggingFace + Transformers.js | 浏览器直接加载开源模型 |
| 硬件加速 | WebGPU Compute Shader | 1000+ GPU 核心并行,比 CPU 快 50 倍 |
| 模型压缩 | q4f16 线性量化 |
6GB → 750MB,精度损失 < 1% |
| 性能优化 | 单例模式 + 模型预热 | 只下载一次,提前编译 Shader |
| 并发处理 | Web Worker + postMessage | 计算离主线程,UI 不冻结 |
| 状态管理 | 函数式 setState | (prev) => [...prev, x] 解决闭包陷阱 |
| 代码质量 | useEffect cleanup + StrictMode | 清理副作用,零成本 bug 检测 |
| 安全渲染 | DOMPurify + MathJax | 防 XSS + 数学公式排版 |
| 工程规范 | @webgpu/types + 声明合并 |
TypeScript 类型安全 |
这个项目的流式生成部分(generate / update / complete)还在实现中,后续会继续补充。有问题欢迎在掘金评论区交流!