
聊一个我最近一直在折腾的事在浏览器里直接跑 DeepSeek-R1 的推理。先说清楚完整版 R1 是六百多亿参数的 MoE 模型普通电脑那点内存连模型文件都放不下更别说单靠浏览器去做端侧推理。但我真正跑通的是 DeepSeek 官方蒸馏出来的小尺寸版本再配合 WebGPU 把算子丢到 GPU 上执行叠一层 INT4 量化最后在 Chrome 里实现了几十 token/s 的生成速度。整个项目的核心组合是 WebGPU Transformers.js目标很纯粹不让数据离开本地、不用租 GPU 服务器打开网页就能和模型对话。这套方案适合两类人一类是想在浏览器里做 AI Demo 但不想承担服务器成本的前端工程师另一类是做大模型应用但被数据隐私卡住的开发者。你不需要懂底层的 CUDA 或设备端算子只需要 JavaScript 基础跟着文章走完就能在本地跑起一个完整的 R1 蒸馏模型推理页面。顺便提一句我在做推理过程可视化的时候还接入了 splat.js 这套纯 JavaScript WebGPU 的 3D 高斯泼溅处理方案把 token 流渲染成了三维点云效果相当酷这部分在后面的进阶章节会展开聊。1. 为什么非要把大模型装进浏览器1.1 端侧推理解决的不只是省钱问题一说起大模型大家默认就是云端的 API 服务一来是因为模型太大二来是 GPU 贵。但把推理搬到浏览器端侧我体验下来有几个实实在在的收益不是赶时髦。第一个是隐私。数据不出浏览器意味着你的问题、Prompt、对话内容全部留在本地硬件上。我做过一个内部知识库问答的原型很多材料是没脱敏的业务数据客户明确要求不能过第三方 API。这种场景下端侧推理几乎是唯一解不是省不省钱的问题而是能不能做的问题。第二个是离线可用。只要模型文件缓存过一次断网状态下照样能推理。去客户现场做演示、在飞机上临时调试网络条件再差也不影响输出。有些项目干脆就是内网环境根本没有访问外部的通道。第三个是成本结构的变化。云端推理的账单是按 token 算的服务一旦上线就是持续支出。端侧方案把成本变成了“一次性下载模型文件”之后每次调用都只是本地电费。对 Demo、教学、原型验证这些小流量场景这个账非常划算。不过端侧也有很明显的短板模型规模做不大、算力受硬件限制、推理速度上限有限。所以它不是要替代云端而是在“隐私敏感 交互密度高 并发量低”这个特定区间里非常能打。1.2 DeepSeek-R1 的现实约束蒸馏模型加量化DeepSeek-R1 这条线和其他大模型不太一样官方把 R1 的推理能力蒸馏到了很多小尺寸模型里覆盖 1.5B、7B、8B、14B 等不同参数规模。这意味着你不需要硬啃原版那个巨型 MoE拿一个蒸馏小模型就能获得接近原版的推理风格典型特征就是思考过程里会生成think标签包裹的中间推理内容。但即使是最小的 R1 蒸馏模型想做端侧部署也得过两关。第一关是模型格式。浏览器里跑神经网络需要的是 ONNX 或者 GGUF 这类通用格式Hugging Face 上的原始 safetensors 权重不能直接用。需要先做格式转换这一步在后面的章节详细讲。第二关是模型大小。以 1.5B 参数量为例FP32 权重约占 6GB 内存浏览器页面根本扛不住。必须量化把 FP32 压缩到 INT8 或 INT41.5B 模型可以压到 0.9GB 左右7B 模型压到 4GB 左右。这个体积对现代笔记本来说就完全可以接受了。我最终选择的是 1.5B 蒸馏版加 INT4 量化理由是生成速度更稳定内存占用可控而且推理过程那种“先思考再回答”的风格保留得很好。7B 版本我也试过能出结果但速度明显下降适合追求质量的场景不适合流畅交互。2. 核心技术底座解密WebGPU 与 Transformers.js 如何协同工作2.1 WebGPU把浏览器变成 GPU 的可编程前端很多人对 WebGPU 的认知还停留在“更快的 WebGL”其实完全不是一个层面的东西。WebGL 本质上是给 GPU 发一些画三角形盒子的指令而 WebGPU 暴露了现代 GPU 的通用计算能力你可以写 compute shader让 GPU 做矩阵乘法、卷积、图算法这类通用计算任务不再局限于渲染。大模型推理恰好是 GPU 通用计算的高频场景。Transformer 每一层都在做矩阵乘法而矩阵乘法天然适合 GPU 并行加速。这也是为什么浏览器里跑大模型第一选择就是 WebGPU 而不是 WebGL 或纯 WASM。WebGPU 还有两个很实际的工程优势。一个是内存管理可以显式控制 buffer 的创建和释放比 WebGL 那种隐式状态机清晰得多。另一个是 Shader 语言换成了 WGSL类型安全性和可调试性都比 GLSL 强。虽然写起来门槛高了一些但对于已经封装好的推理库来说WebGPU 只是底层执行后端使用者甚至不太需要直接接触。2.2 Transformers.js 与 ONNX Runtime 的配合方式Transformers.js 是 Hugging Face 官方维护的 JavaScript 版本 Transformers 库它做的事情和 Python 端的 transformers 几乎一样加载模型、调用 pipeline、输出结果。API 设计也保持了统一风格比如pipeline(text-generation, modelName)这一行代码就能加载一个语言模型。它底层真正干活的其实是 ONNX Runtime Web。这个运行时负责把 ONNX 格式的模型图翻译成可执行的算子然后根据你在设备端的选择决定把这些算子跑在 CPU 上WASM 后端还是 GPU 上WebGPU 后端。Transformers.js 就是中间这层适配器让开发者不用关心 ONNX Runtime 的复杂接口。我在代码里只需要这样声明import { pipeline } from huggingface/transformers; const generator await pipeline(text-generation, models/r1-distill-qwen-1.5b-onnx, { device: webgpu, dtype: q4, });device: webgpu告诉 Transformers.js 优先使用 WebGPU 执行计算dtype: q4告诉它在加载权重时按 4bit 量化读取。这两个参数是整个性能优化最核心的开关后面会展开讲。2.3 为什么不直接用 WebLLM 或者 GGUF.js市面上其实还有几套浏览器端大模型推理方案我也都快速评估过。最常拿来比较的是 WebLLM它底层走的是 MLC 那套 TVM 编译路线对硬件的针对性优化做得很深支持更复杂的模型。但我的项目选 Transformers.js有几个很实际的原因。第一是生态一致性。Transformers.js 和 Python 端的 Transformers 保持着镜像 API训练、转换、推理全在同一个生态里出了问题去社区提问响应速度也快。第二是模型来源方便Hugging Face 上有大量已经转成 ONNX 的模型可以直接复用不用自己处理导出链。第三是中间层可控性WebLLM 把很多底层细节封装得过死自定义一个算子或接入第三方可视化模块反而麻烦。我还是用一张表格对比三套方案的差异方案底层后端模型格式优势劣势Transformers.jsONNX Runtime WebONNXAPI 统一、生态成熟、上手快算子覆盖不如专用引擎深WebLLMTVM WebGPU编译后的 MLCEngine优化激进、支持复杂模型工程链复杂、调试困难GGUF.js llama.cpp WASMWASM/WebGPUGGUF直接复用 llama.cpp 权重JS 封装较薄、灵活性一般最终我选 Transformers.js 还有一个私心它支持我后面接入 splat.js 做三维可视化因为推理过程的中间数据可以通过回调直接拿到 JS 侧自由度很高。3. 模型准备阶段选型、转换与量化3.1 选模型的判断逻辑参数规模与显存预算的平衡选模型不能只盯着参数量还要把你的硬件预算先算清楚。浏览器端侧的可用内存很大程度上取决于机器物理内存和浏览器进程限制。Chrome 单个页面能稳定使用的内存大概在 2-4GB 之间超过这个值很容易触发标签页崩溃。模型体积可以粗略算参数量乘权重位宽再除以 8 得到字节数。模型参数量FP324字节INT81字节INT40.5字节1.5B6GB1.5GB0.75GB7B28GB7GB3.5GB14B56GB14GB7GB表里只是权重体积还没算 KV Cache 和中间激活值。KV Cache 和上下文长度直接相关上下文越长Cache 越大。所以我给 1.5B INT4 配了 4096 长度的上下文内存峰值控制在 1.2GB 左右给浏览器留足了余量。7B INT4 虽然理论可行但 3.5GB 权重加 KV Cache 很容易顶到 4GB 以上我只在 16GB 内存的机器上跑通过了。选 1.5B 还有一个重要原因R1 蒸馏小模型在数学题、逻辑推理这类任务上保留了很强的思考能力虽然知识覆盖不如大模型但作为“会推理的端侧模型”已经够能打了。3.2 把 safetensors 权重转成 ONNX浏览器里不能直接装 safetensors 格式的权重Transformers.js 需要 ONNX 格式。如果你的模型没有现成的 ONNX 版本需要用 Python 环境离线转一次。推荐用 Hugging Face 官方工具链 optimumpip install optimum onnx onnxruntime optimum-cli export onnx --model deepseek-ai/DeepSeek-R1-Distill-Qwen-1.5B --task text-generation r1-distill-qwen-onnx/这个命令会把模型导出到r1-distill-qwen-onnx/目录里面会包含model.onnx和配套的分词器 JSON 文件。导出的 ONNX 模型默认是 FP32体积比较大还不能直接上浏览器需要继续量化。有一点要提醒转换时确认任务类型必须传--task text-generation否则导出模型时会把注意力层和位置编码给丢掉生成结果会完全乱掉。我第一次就踩了这个坑。3.3 把 ONNX 模型量化到 INT4ONNX Runtime 的量化工具链比较成熟可以用onnxruntime.quantization做 INT8 / INT4 量化。工具库的名字有点误导它支持的精度其实很宽可以做到 4bit。from onnxruntime.quantization import quantize_static, QuantType, CalibrationMethod from onnxruntime.quantization.shape_inference import quant_pre_process # 先做 shape inference避免量化时算子不支持 quant_pre_process(r1-distill-qwen-onnx/model.onnx, model_preprocessed.onnx) # 用静态量化需要准备一小段校准数据集 quantize_static( model_preprocessed.onnx, model_q4.onnx, calibration_data_readercalibration_reader, quant_formatQuantType.QInt4, per_channelTrue, activation_typeQuantType.QInt8, )per_channelTrue是一个容易被忽略的参数。逐通道量化比逐张量量化精度损失小很多尤其是 4bit 这种低位宽不用 per-channel 的话输出质量会明显下降。量化完成后model_q4.onnx大约 0.9GB这个文件才是真正部署到浏览器里的核心权重文件。把它和分词器的两个 JSON 文件tokenizer.json、tokenizer_config.json放到同一个静态目录模型准备阶段就结束了。4. 浏览器工程落地从空目录到第一段输出4.1 浏览器准备与环境检测不是所有浏览器都支持 WebGPU。目前支持比较完整的是 Chrome / Edge 113Safari 18 也开始默认开放。Firefox 需要在 about:config 里手动开启底层开关不建议作为主力测试环境。建议在页面加载前先做一次能力检测async function checkWebGPU() { if (!navigator.gpu) { alert(当前浏览器不支持 WebGPU请升级到最新版 Chrome 或 Edge); return false; } try { const adapter await navigator.gpu.requestAdapter(); const device await adapter.requestDevice(); return true; } catch (e) { alert(WebGPU 初始化失败请检查显卡驱动或浏览器设置); return false; } }这一步不能省。我遇到过不少用户打开页面后页面白屏排查半天发现是浏览器版本太老WebGPU 接口整个不存在。4.2 工程初始化Vite Transformers.js我用 Vite 搭了一个最轻量的页面不需要后端框架。工程结构足够简单适合作为后续项目的模板。npm create vitelatest r1-browser-demo -- --template vanilla cd r1-browser-demo npm install huggingface/transformers主页面只需要三块内容一个文本输入框、一个生成按钮、一个输出区域。重点是控制在按钮点击后加载模型不要页面一打开就初始化模型否则白屏时间太长用户体验很差。模型文件放在public/models/目录下Vite 会把public里的内容原样拷贝到构建后的根目录这样 Transformers.js 可以直接从相对路径加载模型import { pipeline, env } from huggingface/transformers; env.allowLocalModels true; env.localModelPath ./models/;4.3 加载模型并生成第一段文本核心代码如下let generator null; async function loadModel() { const status document.querySelector(#status); status.textContent 正在加载模型第一次需要下载约 0.9GB 文件请耐心等待...; generator await pipeline(text-generation, r1-distill-qwen-onnx, { dtype: q4, device: webgpu, progress_callback: (progress) { if (progress.status progress) { status.textContent 模型加载中${(progress.loaded / progress.total * 100).toFixed(1)}%; } }, }); status.textContent 模型已就绪可以开始推理; } async function generate() { const inputText document.querySelector(#input).value; const output await generator(inputText, { max_new_tokens: 512, do_sample: true, temperature: 0.7, top_p: 0.95, repetition_penalty: 1.1, }); document.querySelector(#output).textContent output[0].generated_text; }第一次运行时会逐个下载模型分片文件progress_callback会实时返回下载进度。加载完成后后面的推理就不需要网络了。4.4 模型文件托管与 CORS 的隐藏要求浏览器加载本地模型文件不是随便放一个路径就行的。Transformers.js 底层会发起 fetch 请求并且需要服务器支持 Range 请求因为模型文件拆成了多个分片并发下载。如果你用python -m http.server这种开发服务器默认是支持 Range 的但用某些静态文件服务器时要确认配置。如果是生产环境模型文件必须放在同源的静态目录里。跨域加载模型要额外配置 CORS 请求头否则浏览器会直接拦掉分片请求。我在本地调试时图省事想从 Hugging Face CDN 直接加载模型结果被 CORS 卡了很久最后还是老老实实把模型文件下载到本地。另外提醒一句Vite 开发服务器不能直接加载node_modules里的模型文件必须放在public目录这个路径问题是新手最容易踩的。5. 性能实测与调优记录5.1 测试环境与方法测试机器配置是一台 Windows 笔记本CPU 是 i7-12700HGPU 是 RTX 3060 Laptop 6GB 显存浏览器用的 Chrome 126。模型是 1.5B INT4 量化版上下文长度设置为 4096。测试提示词用了一道数学题“一个数列前两项是 1 和 1从第三项开始每项等于前两项之和求第 10 项。”这类题目 R1 蒸馏版会先写一段think推理过程再给答案。5.2 实测数字与观察我记录了两种运行模式的数据运行模式首 token 延迟平均生成速度内存峰值WebGPU INT4约 2 秒35-45 token/s1.1-1.3 GBWebGPU INT8约 3 秒20-25 token/s1.8-2.0 GBWASM CPU INT4约 8 秒6-9 token/s1.2 GB对比非常明显。WebGPU 后端和 WASM 后端的差距接近 5 倍所以如果你的浏览器支持 WebGPU一定要选这个后端。INT4 比 INT8 快了接近一倍质量损失在短文本生成里几乎感知不到我最终锁定了 INT4。RTX 3060 的 6GB 显存完全跑得动 1.5B 模型实测过程中显存没有被占满。但如果是只有核显的轻薄本WebGPU 会用集显执行计算速度会明显下降大概在 15-20 token/s 左右还能接受。5.3 影响生成速度的几个关键因素实际调试下来影响速度的因素远不止“显卡够不够好”。第一个是上下文长度。我建议不要一次性把max_new_tokens拉太长。每生成一个新 token整个 KV Cache 都会重新计算一次上下文越长单步推理越慢。512 是一个交互体验不错的默认值长文本生成建议分段处理。第二个是 Shader 编译预热。WebGPU 第一次执行模型算子时有一个编译 WGSL Shader 的过程会卡几秒钟。这个是在页面第一次生成时就发生后面再推理就快了。建议在页面加载后先跑一个“预发热身”的短对话把 Shader 编译的延迟提前消耗掉。第三个是并发请求。Transformers.js 目前不支持同一个模型实例并发跑多个长生成任务如果用户点了按钮后没有禁用按钮连续触发多次生成会互相阻塞。处理方式很简单生成期间把按钮置灰。下面这段是我在真实测试里总结出来的推荐参数组合// 质量优先模式 { max_new_tokens: 1024, do_sample: true, temperature: 0.6, top_p: 0.9, repetition_penalty: 1.1, } // 速度优先模式 { max_new_tokens: 256, do_sample: false, }do_sample: false会强制走贪心解码输出变稳定但略机械。如果做演示、追求速度贪心解码更稳妥不会突然生成一个奇怪的长尾词。6. 进阶玩法用 splat.js 把推理过程做成 3D 可视化6.1 可视化思路把 token 对应到三维空间跑通文本推理之后我开始琢磨能不能让推理过程“看得见”。传统的做法是把注意力权重画成热力图只能看到 Transformer 内部的 attention 分布形式太平面了。我想做一个更直观的版本把生成过程中每个 token 的隐藏状态抽取出来映射到三维空间用点云的形式动态展示模型“思路”的变化轨迹。具体做法不复杂。在 Transformers.js 的生成回调里把每个 token 的 logits 或者 hidden state 推到一个数组里const embeddings []; await generator(inputText, { max_new_tokens: 128, callback_function: (beams) { const currentToken beams[0].output_token_ids.at(-1); const embedding beams[0].token_embeddings?.at(-1); if (embedding) { embeddings.push(embedding); } }, });拿到每个 token 的 embedding 后用 PCA 或者 UMAP 降维到三维坐标。浏览器端直接跑 UMAP 比较吃力PCA 是线性的计算量小对 Demo 完全够用。6.2 splat.js纯 JS WebGPU 的 3D 高斯泼溅处理方案点云拿到手之后渲染是另一个问题。普通点的渲染是 Billboard 粒子视觉比较单调。这里我用了最近社区里很火的高斯泼溅方案 splat.js。splat.js 是一个纯粹的 JavaScript WebGPU 实现的 3D 高斯泼溅渲染器它的核心能力是把空间中的一组带位置、颜色和协方差信息的高斯点通过 WebGPU 管线实时渲染出来。和传统点云相比高斯泼溅的效果是“一团模糊光晕”而不是“一个硬点”用于展示 token 之间的聚集关系特别合适。token 在语义上相近它们在三维空间里就聚成一片光晕语义跳转时就是一个大跨度位移视觉上非常直观。引入方式非常轻量import splat from ./splat.js; const scene new splat.Scene(renderer.adapter); const cloud new splat.GaussianCloud(pointData); scene.add(cloud); // 每一帧更新点云数据 cloud.updatePositions(embeddings);embeddings就是前面收集的 token 向量降维后的三维坐标。使用 splat.js 的时候不需要关心 WebGPU 的内部实现传坐标数组进去就行它会自动在渲染循环里更新点云。6.3 工程落地与避坑把 splat.js 接入演示页后实际效果确实惊艳但也踩了几个坑。第一个坑是数据规模控制。如果每生成一个 token 就渲染一个高斯点上下文一长点云数量就爆炸。我把策略改成每 4 个 token 合并成一个关键帧点只展示“思路关键节点”这样可以保持 60 帧流畅。第二个坑是主线程开销。WebGPU 推理和 splat.js 渲染都在争 GPU 资源如果同一时间既在生成 token 又在渲染动画会出现明显卡顿。我的处理是生成过程中暂停动画等一批 token 生成完毕后一次性更新点云。这样虽然少了实时感但整体流畅度好很多。第三个坑是三维场景的语义误导。点云里的“距离”是词向量空间的距离不是文本行数。刚开始我把点云纵向坐标当作 token 的序号结果生成顺序在视觉上变成了一条斜线很容易误导观众。后来去掉了坐标轴让点云纯粹展示语义分布反而清晰得多。这个可视化方案特别适合做技术分享的演示素材也比干巴巴的控制台输出有感染力。如果你原来对 splat.js 的认知就是三维场景重建可以多关注一下它在数据可视化上的潜力高斯泼溅不是只能渲染照片级场景。7. 现场踩坑实录九个常见问题与排查方法这一节是我花时间最多的地方。把浏览器端跑大模型的坑按出现频率列出来并附上解决思路能帮你少走不少弯路。现象根因排查与解决页面提示 WebGPU 不可用浏览器版本太旧或硬件不支持升级 Chrome/Edge 到 113检查 GPU 驱动模型加载卡在 1% 或 0%文件下载请求被 CORS 拦截确认模型文件和页面同源检查静态服务器 Range 支持加载模型时内存直接爆掉量化参数没传对模型按 FP32 加载检查 dtype 参数是否真正生效看网络面板下载量生成速度极慢每秒不到 10 token回退到了 WASM/CPU 后端打开 DevTools Console 看 device 信息确认 adapter 支持 WebGPU输出内容完全乱码转换模型时少了 task 参数用 optimum 重新导出带--task text-generationWebGPU 初始化成功但推理崩溃WGSL Shader 编译内存溢出降低上下文长度关闭其他标签页释放 GPU 内存文本重复、生成死循环温度太高或没有重复惩罚设置 temperature 0.6-0.7repetition_penalty 1.1页面在 Chrome 正常Safari 白屏Safari 版本或 WebGPU 实现差异检查 Safari 版本暂时只用 Chrome/Edge 做演示首次推理卡顿 5-10 秒Shader 编译预热页面加载后先跑一段短文本预热把缓冲时间提前7.1 隐蔽的问题模型路径和缓存策略除了表格里的常见问题还有一个容易被忽略的点浏览器的 HTTP 缓存策略。模型文件每个分片有几十 MB如果服务器返回的缓存头不正确用户每次刷新页面都要重新下载全部文件。我最终在生产环境里给模型文件加了Cache-Control: max-age31536000, immutable响应头。模型权重不会频繁更新一年缓存可以接受。开发环境则保持默认不缓存方便及时替换新权重。如果你用的是public目录里的文件Vite 在构建时不会重命名模型文件这反而是好事因为浏览器可以按原始 URL 命中缓存。不要用那种带 content hash 的文件名模型分片之间互相引用旧 URL 会出问题。7.2 关于 token 器和特殊符号的一点提示R1 系列模型的分词器里有think这种特殊 token属于内容的一部分。Transformers.js 加载模型时会自动识别分词器 JSON 里的特殊 token 配置不需要手动处理。但如果你用的是自己转换的模型要检查tokenizer_config.json里是否把think正确声明为 special token否则生成时会被拆成几个普通 token输出格式会很奇怪。我遇到过一种更隐蔽的问题think标签在正常推理中会输出但在某些浏览器私密模式下特殊 token 的显示样式会和普通文本混在一起看不出来模型是否进入了思考状态。建议在 UI 层面对think和/think做一次样式解析让思考状态可视化这个细节对演示非常有帮助。最后分享一个小技巧整个项目做下来我最大的体会是浏览器端跑大模型并没有想象中那么遥远但也没有网上某些演示看起来那么轻松真正的门槛在工程整合不在单点技术。WebGPU 把算力门槛降低了Transformers.js 把模型加载门槛降低了量化把体积门槛降低了三个东西叠在一起才让“R1 放进浏览器”变成了一件可重复落地的事。如果你准备自己动手我建议按照“模型量化 → 页面跑通 → 性能调优 → 可视化增强”的顺序推进。不要一开始就纠结要不要上 splat.js 这种高阶玩法先把最朴素的文本生成跑通再逐步往上加东西。还有一个小技巧调试 WebGPU 后端时打开 Chrome 的chrome://gpu先确认 WebGPU 已经被启用再确认显卡不是被强制切到了软件渲染。很多“生成太慢”的案例最后查出来是浏览器把 GPU 禁用代码再优化也没用。希望这个项目也能帮你在浏览器里跑出一条真正属于你自己的 R1 推理链路。