公司动态

在浏览器里跑 DeepSeek-R1:WebGPU + Transformers.js 实战

📅 2026/8/19 23:27:37
在浏览器里跑 DeepSeek-R1:WebGPU + Transformers.js 实战
最近在折腾一个有意思的项目让 DeepSeek-R1 推理模型直接在浏览器里运行不需要服务器数据不出本机甚至断网后还能用。听起来很酷对吧经过一番踩坑我终于把 HuggingFace 官方示例 完整跑通并梳理出了其中的关键技术和实现细节。这篇文章会带你从零理解这个项目的架构包括如何用Transformers.js在浏览器中加载并推理大模型WebGPU 如何加速推理Web Worker 如何避免阻塞主线程TypeScript 中处理实验性 API 的类型问题流式输出、思考/回答状态机、中断生成等细节如果你也想在自己的前端项目里集成 AI 能力或者单纯对浏览器跑大模型感到好奇这篇实战记录应该能给你一些启发。1. 为什么要在浏览器里跑大模型大模型通常部署在云端前端通过 API 调用。但这种方式有几个痛点数据隐私用户输入会发送到服务器。网络延迟每次请求都要等待服务器响应。成本服务端 GPU 资源昂贵。而浏览器端运行模型可以数据完全本地化隐私有保障加载完成后可离线使用无需服务器部署简单一个静态页面就行当然浏览器端也有局限模型不能太大加载需要时间性能取决于用户设备。好在 WebGPU 的出现让浏览器端推理性能大幅提升配合量化模型如 q4f161.5B 参数级别的模型已经可以流畅运行。2. 技术选型与整体架构这个项目主要依赖huggingface/transformersTransformers.js 库提供 tokenizer 和模型加载/推理 API支持 WebGPU 后端。WebGPU新一代浏览器图形/计算 API比 WebGL 更适合神经网络推理。Web Worker将模型加载和推理放到子线程避免阻塞 UI。React或任意前端框架负责 UI 和状态管理。marked DOMPurify MathJax将模型返回的 Markdown 渲染成安全的 HTML并支持 LaTeX 公式。整体架构如下主线程 (React App) │ ├─ 创建 Worker (worker.js) │ ├─ 加载模型 (Tokenizer LLM) │ ├─ 接收消息 (check / load / generate / interrupt / reset) │ └─ 发送进度 / 状态 / 生成结果 │ ├─ 渲染聊天界面 └─ 处理用户输入主线程和 Worker 之间通过postMessage通信实现了解耦。3. 环境准备与 TypeScript 类型处理3.1 检测 WebGPU 支持在浏览器中判断是否支持 WebGPU 非常简单const IS_WEBGPU_AVAILABLE !!navigator.gpu;但如果你直接在 TypeScript 里写navigator.gpu会报错Property gpu does not exist on type Navigator.这是因为navigator.gpu属于比较新的实验性 APITypeScript 标准库还没收录。解决方法有两种方法一使用类型断言const IS_WEBGPU_AVAILABLE !!(navigator as any).gpu;用as any可以绕过类型检查但会失去类型提示也可能因为any泛滥导致代码质量下降。方法二安装类型声明文件推荐pnpm i -D webgpu/types然后在tsconfig.app.json里加上{ compilerOptions: { types: [vite/client, webgpu/types] } }这样navigator.gpu就有了完整的类型支持写起来更安全。TypeScript 的本质就是通过类型声明文件来扩展标准库的类型理解了这一点以后遇到类似问题就知道怎么解决了。3.2 在 Worker 中使用 WebGPUWorker 里同样需要检测 WebGPU这里用navigator.gpu.requestAdapter()来检查适配器是否可用async function check() { try { const adapter await navigator.gpu.requestAdapter(); if (!adapter) throw new Error(WebGPU is not supported (no adapter found)); } catch (e) { self.postMessage({ status: error, data: e.toString() }); } }如果requestAdapter()返回null说明当前浏览器不支持 WebGPU我们就在主线程显示一个友好的提示。4. 模型加载与单例模式4.1 单例模式确保只加载一次模型文件很大GB 级别加载开销巨大。如果每次生成都重新加载用户会等到天荒地老。因此我们需要单例模式保证 tokenizer 和 model 只实例化一次。tokenizer为分词器负责把用户文本 转换为token id 模型输出再转换为文本输出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]); } }这里使用了空值合并赋值运算符??如果this.tokenizer为null或undefined才执行赋值。因为AutoTokenizer.from_pretrained返回的是 Promise所以第一次调用时会将 Promise 对象赋给this.tokenizer后续调用直接复用同一个 Promise从而避免重复下载和加载。promise.all: 等待分词器和模型全部加载完成才返回4.2 加载进度回调模型下载通常需要较长时间文件几百 MB我们需要给用户展示进度。from_pretrained支持传入progress_callback它会回调每个文件的下载进度。const [tokenizer, model] await TextGenerationPipeline.getInstance((x) { self.postMessage(x); // x 包含 status, file, progress, total 等字段 });x { status: initiate, file: model_q4f16.onnx, total: 10485760 // 文件总字节大小 }主线程接收到这些进度消息后更新进度条 UI。良好的进度反馈是用户体验的关键尤其是面对大文件下载时用户最怕的就是“卡住了”的未知等待。4.3 预热 shaderWebGPU 在第一次执行模型推理时需要编译 shader这会花费几秒钟。如果直接在用户发送消息时编译会明显卡顿。所以加载完成后我们会用一个小输入提前触发编译const inputs tokenizer(a); await model.generate({ ...inputs, max_new_tokens: 1 }); self.postMessage({ status: ready });这样用户开始聊天时推理速度就正常了。5. Web Worker 通信协议主线程和 Worker 之间通过消息传递我们定义了一套简单的协议消息类型方向数据说明check主 → Worker无检查 WebGPU 支持load主 → Worker无加载模型generate主 → Workermessages数组生成回复interrupt主 → Worker无中断生成reset主 → Worker无重置状态loadingWorker → 主提示文本加载中initiateWorker → 主文件信息开始下载某个文件progressWorker → 主进度信息下载进度doneWorker → 主文件信息单个文件下载完成readyWorker → 主无模型就绪startWorker → 主无开始生成updateWorker → 主输出片段、tps 等流式更新completeWorker → 主完整输出生成完成errorWorker → 主错误信息出错主线程根据status字段更新 React 状态const onMessageReceived (e) { switch (e.data.status) { case loading: setStatus(loading); setLoadingMessage(e.data.data); break; case initiate: setProgressItems(prev [...prev, e.data]); break; case progress: /* 更新对应文件进度 */ break; case done: /* 移除已完成文件 */ break; case ready: setStatus(ready); break; case start: /* 追加一条空的 assistant 消息 */ break; case update: /* 追加输出片段 */ break; case complete: setIsRunning(false); break; case error: setError(e.data.data); break; } };Worker 的生命周期组件挂载时创建卸载时移除监听器确保不泄漏。5.1 主线程状态与 ref 管理在App.jsx中React 状态和 ref 各自承担不同职责。理解它们的分工有助于读懂整个数据流。useRef 存储的引用workerWorker 实例。Worker 实例在整个组件生命周期中只需创建一次且其变化不应触发重新渲染所以用useRef保存。chatContainerRef聊天容器 DOM 引用用于实现智能滚动。textareaRef输入框 DOM 引用用于自适应高度调整。useState 管理的状态status模型加载状态null/loading/ready控制页面显示不同区域。error错误信息加载失败时显示。loadingMessage加载提示文本例如“Loading model...”。progressItems下载进度数组每个元素包含file、progress、total等字段用于展示多文件下载进度。isRunning是否正在生成用于禁用输入框和切换发送/停止按钮。messages对话消息数组元素形如{ role: user | assistant, content: string, answerIndex?: number }。input输入框内容。tps/numTokens性能指标展示生成速度。这些状态在 Worker 消息回调中更新驱动 UI 变化。messages的更新采用不可变方式拷贝数组再替换最后一项确保 React 能正确检测到变化并重新渲染。textarea 自适应高度通过useEffect监听input变化调用resizeInput调整高度最小 24px最大 200px。function resizeInput() { if (!textareaRef.current) return; const target textareaRef.current; target.style.height auto; const newHeight Math.min(Math.max(target.scrollHeight, 24), 200); target.style.height ${newHeight}px; }高度设置为auto 让文本框自适应高度不会写死高度这个细节让输入框在内容增多时自动扩展但又不会无限增长。6. 流式生成与状态机6.1 思考/回答状态DeepSeek-R1 是一个推理模型输出分为两个阶段thinking思考和answering回答。思考部分包含推理过程放在think标签内回答部分是最终答案。如何识别阶段切换Worker 中预先编码think/think得到两个特殊的 token idconst [START_THINKING_TOKEN_ID, END_THINKING_TOKEN_ID] tokenizer.encode( think/think, { add_special_tokens: false }, );然后在 token 回调中检测const token_callback_function (tokens) { if (tokens[0] END_THINKING_TOKEN_ID) { state answering; } };当生成的 token 等于结束思考的 token 时状态切换为answering。主线程收到update消息时携带state当state变为answering时记录当前内容的长度作为answerIndex这样前端就能将思考部分和回答部分分开渲染思考部分默认折叠可点击展开。在Chat.jsx中每条 assistant 消息对象可能带有answerIndex属性它表示模型开始输出最终回答的字符位置。分割逻辑如下const thinking answerIndex ? content.slice(0, answerIndex) : content; const answer answerIndex ? content.slice(answerIndex) : ;若answerIndex存在则thinking是思考部分从 0 到 answerIndex-1answer是回答部分从 answerIndex 到结尾。若answerIndex为undefined模型还在思考阶段或未开始回答则整个content都视为思考部分answer为空字符串。UI 上思考部分默认折叠点击“View reasoning”展开回答部分直接渲染。当answer.length 0时表示思考已结束按钮文字变为“View reasoning.”否则显示“Thinking...”。这个设计巧妙地将流式输出中的状态切换转化为字符偏移量实现了思考与回答的分离展示。6.2 流式输出Transformers.js 提供了TextStreamer实现逐 token 输出const streamer new TextStreamer(tokenizer, { skip_prompt: true, // 不输出提示词 skip_special_tokens: true, // 不输出特殊标记 callback_function, // 每生成一段文本回调 token_callback_function, // 每生成一个 token 回调 });callback_function将输出片段通过postMessage发给主线程const callback_function (output) { self.postMessage({ status: update, output, tps, numTokens, state, }); };主线程则通过不断追加字符串实现打字机效果setMessages(prev { const cloned [...prev]; const last cloned.at(-1); const data { ...last, content: last.content output }; if (data.answerIndex undefined state answering) { data.answerIndex last.content.length; } cloned[cloned.length - 1] data; return cloned; });6.3 中断生成用户可能想中途停止生成Transformers.js 提供了InterruptableStoppingCriteriaconst stopping_criteria new InterruptableStoppingCriteria(); // 中断 case interrupt: stopping_criteria.interrupt(); break; // 重置 case reset: stopping_criteria.reset(); break;interrupt()会设置一个标志位模型在每次预测下一个 token 前检查该标志如果为真就停止。主线程收到complete消息后更新 UI。6.4 性能指标 tpstps表示每秒生成的 token 数是衡量推理速度的重要指标。在 token 回调中计算const token_callback_function (tokens) { startTime ?? performance.now(); if (numTokens 0) { tps (numTokens / (performance.now() - startTime)) * 1000; } };主线程展示 tps让用户了解当前的生成速度。6.5 聊天容器的智能滚动流式输出时用户通常希望自动滚动到底部查看最新内容但如果用户正在回看历史消息强制滚动会打断阅读。因此实现了一个“智能滚动”逻辑useEffect(() { if (!chatContainerRef.current || !isRunning) return; const element chatContainerRef.current; if ( element.scrollHeight - element.scrollTop - element.clientHeight STICKY_SCROLL_THRESHOLD ) { element.scrollTop element.scrollHeight; } }, [messages, isRunning]);target.scrollHeight文本框实际内容高度element.scrollTop element.scrollHeight; 因为高度是从顶部0开始计算所以Top等于Height 实现自动滚动这里定义了一个阈值STICKY_SCROLL_THRESHOLD 120像素。只有当用户距离底部小于 120px 时才自动滚动到底部否则保持当前位置避免打断用户阅读。这个细节让聊天体验更加自然。7. Markdown 渲染与安全模型输出是 Markdown 格式需要转换成 HTML 展示。这里使用marked解析 Markdown再用DOMPurify清理潜在的 XSS 风险import { marked } from marked; import DOMPurify from dompurify; function render(text) { text text.replace(/\([[]()])/g, \\$1); const result DOMPurify.sanitize( marked.parse(text, { async: false, breaks: true }), ); return result; }对于数学公式使用better-react-mathjax渲染 LaTeX。MathJax可以识别 Markdown 中的$...$和$$...$$语法。Markdown 让模型输出具有可读性而 DOMPurify 保证了这个过程的安全性。8.1 思考框与回答框1. 分界线answerIndex是怎么来的它不是前端自己算的而是 Worker 在流式生成时告诉主线程的。Worker 里维护了一个状态let state thinking; // 或 answering每次生成 token 时如果检测到END_THINKING_TOKEN_ID即/think对应的 token就把状态切成answeringconst token_callback_function (tokens) { if (tokens[0] END_THINKING_TOKEN_ID) { state answering; } };然后update消息会把state一起发给主线程。主线程在App.jsx里收到update后当发现state answering且当前这条 assistant 消息还没有answerIndex时就把当前内容的长度记录下来case update: { const { output, tps, numTokens, state } e.data; setTps(tps); setNumTokens(numTokens); setMessages((prev) { const cloned [...prev]; const last cloned.at(-1); // 最后一条也就是 AI 正在输出的那条 const data { ...last, content: last.content output, }; // 关键状态切到 answering 时记录 answerIndex if (data.answerIndex undefined state answering) { data.answerIndex last.content.length; } cloned[cloned.length - 1] data; return cloned; }); }所以answerIndex表示思考内容结束、正式回答开始的字符下标。2. 拆分渲染思考框 回答框Message组件拿到带answerIndex的消息后做两段拆分const thinking answerIndex ? content.slice(0, answerIndex) : content; const answer answerIndex ? content.slice(answerIndex) : ;如果还没有answerIndex模型还在思考阶段整段内容都显示在“思考框”里。一旦answerIndex出现前面的就是思考过程后面的才是正式回答。思考框可折叠{thinking.length 0 ( div classNamebg-white dark:bg-gray-800 rounded-lg flex flex-col button classNameflex items-center gap-2 cursor-pointer p-4 hover:bg-gray-50 dark:hover:bg-gray-900 rounded-lg onClick{() setShowThinking((prev) !prev)} BrainIcon className{doneThinking ? : animate-pulse} / span {doneThinking ? View reasoning. : Thinking...} /span span classNameml-auto text-gray-700 {showThinking ? ▲ : ▼} /span /button {showThinking ( MathJax classNameborder-t border-gray-200 dark:border-gray-700 px-4 py-2 dynamic span classNamemarkdown dangerouslySetInnerHTML{{ __html: render(thinking) }} / /MathJax )} /div )}默认是折叠的点击按钮展开里面用render(thinking)把 Markdown 转成 HTML 显示。回答框正式回答{doneThinking ( MathJax classNamemt-2 dynamic span classNamemarkdown dangerouslySetInnerHTML{{ __html: render(answer) }} / /MathJax )}doneThinking就是answer.length 0只有模型开始输出正式回答后回答框才会出现。3. 没开始回答时的占位动画如果thinking.length 0且还没有回答内容说明模型刚开始生成界面上会显示三个跳动的圆点span classNameh-6 flex items-center gap-1 span classNamew-2.5 h-2.5 bg-gray-600 dark:bg-gray-300 rounded-full animate-pulse/span span classNamew-2.5 h-2.5 bg-gray-600 dark:bg-gray-300 rounded-full animate-pulse animation-delay-200/span span classNamew-2.5 h-2.5 bg-gray-600 dark:bg-gray-300 rounded-full animate-pulse animation-delay-400/span /span这就形成了“Thinking... 然后弹出思考框再出现正式回答”的完整交互。8. 踩坑与优化8.1 KV Cache 不能复用我在集成时踩了一个坑想复用past_key_values缓存来加速多轮对话结果第二轮生成直接报错。原因在于generate每次都将完整对话历史重新分词后作为input_ids传入如果再传入之前的past_key_valuesONNX 会认为input_ids只是新增 token导致注意力掩码形状无法广播。解决方案不要复用 KV Cache每次都传完整历史。虽然会损失一些性能但在这个场景下足够用了。8.2 量化模型的选择模型使用了dtype: q4f16量化大幅减小模型体积并加速推理。量化会带来一定精度损失但对于 1.5B 模型来说影响不大。你也可以尝试其他量化级别如q8f16或fp16根据设备性能调整。8.3 进度条优化多个文件同时下载时进度条要能区分不同文件。主线程维护一个progressItems数组每个文件对应一条记录根据file字段更新。9. 总结与展望这个项目展示了浏览器端运行大模型的巨大潜力。通过 WebGPU Transformers.js我们可以在完全离线的环境下运行一个 1.5B 参数的推理模型而且用户体验相当流畅在中高端设备上 tps 可达 20。技术栈的核心思想WebGPU 让浏览器拥有了接近原生的 GPU 计算能力Transformers.js 将 HuggingFace 生态带到了前端Web Worker 保证了 UI 的流畅性TypeScript 类型声明让实验性 API 可用且安全未来浏览器端 AI 会越来越普及也许有一天我们不再需要云端 API所有模型都跑在本地。希望这篇文章能给你一些启发也欢迎去 GitHub 查看完整源码亲手试一试。