WebGPU+WebLLM:浏览器跑DeepSeek-R1 React实战

发布时间:2026/9/19 2:03:46
WebGPU+WebLLM:浏览器跑DeepSeek-R1 React实战 一次线下演示翻车的经历让我彻底下定决心把模型搬进浏览器里跑。当时会议室网络抽风调用云端接口的聊天窗口一直转圈台下几十号人盯着屏幕等回复那种尴尬我现在还记得。会后我就开始琢磨端侧 AI 这件事如果模型能直接跑在用户的设备上不依赖网络往返那演示、内测、私有数据处理这些场景就都稳了。这篇笔记就是那段时间折腾 DeepSeek-R1、WebGPU、React、TS 和 Tailwind 的完整记录从环境判断、模型选型、推理线程拆分到界面渲染和性能踩坑全部是我自己跑通并验证过的路径。适合已经会写 React 和 TS、想往端侧 AI 方向迈一步的前端同学也适合想把大模型塞进客户端产品的工程师。1. 端侧跑大模型到底图什么先把动机和边界想清楚1.1 DeepSeek-R1 蒸馏系列为什么适合放进浏览器最开始我想直接塞一个满血大模型进浏览器后来发现这想法不现实。浏览器能拿到的显存是用户设备共享出来的消费级笔记本的集成显卡可能就分到几百 MB 到 1 GB 出头独显好一点也就 4 到 8 GB。满血模型的权重动辄几十 GB光加载就把显存撑爆。所以真正能落地的是蒸馏后的小参数版本DeepSeek-R1 的蒸馏系列正好踩在这个甜区上常见的有 1.5B、7B、8B 这几个量级经过 4 位量化之后7B 左右的模型权重可以压到 4 GB 上下1.5B 更是能压到 1 GB 以内普通独显笔记本就能扛住。选它还有一个很重要的原因R1 蒸馏版保留了较强的推理链能力做数学题、逻辑题、结构化输出时表现比同尺寸的通用小模型更稳。端侧场景里我们往往不指望它写长篇小说而是希望它能做意图识别、表单抽取、简单的多步推理这时候会想一下再回答的能力就很值钱。我这里说的蒸馏版指的是用 R1 生成的数据去微调小底座模型得到的版本底座可能是 Qwen 也可能是 Llama 系选型时要看清楚底座是谁因为这直接影响后面 tokenizer 和对话模板的配置。1.2 端侧推理的收益与代价延迟、隐私、显存的三角关系很多人一上来只盯着省钱和隐私两个词但实际用下来端侧的收益和代价是一个三角关系你得按场景取舍。我把它们列成表格更直观维度端侧推理云端 API首次响应需下载并编译模型首屏慢网络好时很快后续响应无网络往返稳定可预期受网络波动影响大数据流向数据不出设备数据出设备运行成本用户设备承担服务方按量付费显存占用占用用户显存低端设备吃紧无离线可用支持不支持看清楚这张表你就明白了端侧最大的代价是首次加载和显存占用。我第一次跑 7B 量化模型光是下载加编译就花了将近两分钟用户如果在这个阶段关掉页面体验直接崩掉。所以后来我在产品里把模型下载做成了带进度条的独立流程跟对话界面解耦用户可以先看界面再等模型就绪。这一步的取舍很关键别指望用户会傻等一个白屏。隐私这块是真的香。我们有个内部场景要处理合同草稿之前走云端接口需要走审批流程现在模型在本地跑数据一步都不出浏览器合规部门直接放行。如果你手上有类似的敏感数据场景端侧推理几乎是唯一解。1.3 硬件门槛自查别让用户在第一步就被劝退端侧 AI 项目最容易翻车的地方不是代码而是硬件。上线前一定要做能力探测而不是等用户点了按钮才报错。我的做法是三层检查先查navigator.gpu是否存在再申请适配器看是否返回空最后读一下适配器的 limits 看显存和 buffer 上限够不够。export async function probeWebGPU(): Promise{ ok: boolean; reason?: string; adapterInfo?: GPUAdapterInfo; } { if (!(gpu in navigator) || !navigator.gpu) { return { ok: false, reason: 当前浏览器未开启 WebGPU }; } const adapter await navigator.gpu.requestAdapter({ powerPreference: high-performance, }); if (!adapter) { return { ok: false, reason: 未能获取 GPU 适配器可能是驱动或策略限制 }; } const info adapter.info ?? (await (adapter as any).requestAdapterInfo?.()); return { ok: true, adapterInfo: info }; }拿到adapterInfo之后我会在界面上直接展示显卡型号让用户心里有数。如果探测失败就降级到轻量模式只让用户用 1.5B 的小模型或者干脆引导到云端模式。这比让用户点了开始然后卡死要体面得多。注意requestAdapterInfo在新版规范里已经被adapter.info取代写兼容代码时两个都要兜住不要只用一个。2. WebGPU 通道打通让浏览器真正调用到 GPU 算力2.1 WebGPU 和 WebGL 的本质差异通用计算才是关键很多人以为 WebGPU 就是 WebGL 的升级版其实两者的定位差得很远。WebGL 是为图形渲染设计的它的抽象层级很高你要做通用计算得把数据伪装成纹理用片段着色器绕着弯算这就是所谓的 GPGPU 黑科技。WebGPU 直接把计算着色器compute shader暴露出来配合 storage buffer 和 workgroup写起来就是正经的并行计算。对跑大模型来说这个差异是决定性的。Transformer 推理里大量的矩阵乘、归一化、注意力计算全是规整的并行任务天生适合 compute shader。没有原生计算能力就得靠渲染管线模拟性能和可控性都差一大截。所以 WebGPU 不是让画面更好看而是让浏览器第一次具备真正意义上的本地并行计算能力。顺带说一句WebGPU 的能力不止服务大模型。像 splat.js 这类纯 JavaScript 加 WebGPU 的 3D 高斯泼溅方案就是拿同一套计算通道去做点云渲染的。所以你学 WebGPU 的这套适配器申请、设备管理、buffer 传输的知识换到别的 GPU 计算项目里一样能用投资回报率很高。2.2 适配器申请、设备丢失与降级策略WebGPU 初始化有两步绕不开先requestAdapter再requestDevice。适配器代表物理或逻辑 GPU设备才是你真正提交命令的句柄。这里有几个细节新手很容易踩。其一是powerPreference桌面双显卡机器上写high-performance能优先选独显写low-power会优先集显跑模型当然要独显。其二是设备丢失处理GPU 驱动崩溃、系统休眠、显存不足都可能触发device.lost这时候必须能感知到并恢复。const device await adapter.requestDevice({ requiredLimits: { maxStorageBufferBindingSize: adapter.limits.maxStorageBufferBindingSize, maxBufferSize: adapter.limits.maxBufferSize, }, }); device.lost.then((info) { console.error(GPU 设备丢失:, info.reason, info.message); // 通知上层重建引擎而不是让界面一直卡在加载态 onDeviceLost?.(info); });实测下来显存不足导致的设备丢失最隐蔽因为它不报错只是推理突然变慢或者直接中断。我的经验是给引擎设一个显存水位线模型加载后如果可用显存低于某个阈值就主动提示用户切小模型别硬撑。2.3 实测不同浏览器下的初始化差异记录我把手上的几台机器都跑了一遍记录了一些差异供你参考环境WebGPU 状态备注桌面 Chrome 新版默认开启体验最完整桌面 Edge默认开启行为与 Chrome 基本一致桌面 Firefox需看版本与平台部分平台默认开启移动端 Safari较新系统版本支持需留意显存与热限移动端 Chrome平台差异大低端机经常拿不到适配器移动端的坑尤其多。我在安卓低端机上测过适配器能申请到但一旦加载稍微大点的模型设备立刻热得发烫然后帧率暴跌、推理速率断崖式下降。所以移动端我一般只放 1.5B 甚至更小的模型并且加上仅在使用时加载、闲置即卸载的策略别让 GPU 长时间挂着。3. 推理引擎接入选型WebLLM、transformers.js 与自建算子的取舍3.1 为什么我最终选了 WebLLM 的预编译模型浏览器里跑大模型有三条路。第一条是用 transformers.js它基于 ONNX Runtime Web能加载 HuggingFace 上导出的 ONNX 模型生态庞大但 WebGPU 后端的算子覆盖和性能优化没到极致遇到大模型容易卡在算子不支持上。第二条是自己在 WebGPU 上写算子控制力最强但工作量巨大除非你是做推理框架的否则别碰。第三条是用 WebLLM它基于 MLC 编译栈把模型预编译成 WebGPU 能直接吃的形态开箱即用。我选 WebLLM 的原因是省心。它把权重量化、算子融合、KV cache 管理这些脏活都做完了你只需要提供一个 model id 就能拉起引擎。DeepSeek-R1 蒸馏系列在社区里已经有对应的量化编译版本命名一般长这样DeepSeek-R1-Distill-Qwen-7B-q4f16_1-MLC结尾的q4f16_1表示 4 位权重量化、激活用 fp16、group size 为 1。如果官方预编译列表里暂时没有你要的版本也可以用 MLC 的工具链自己编译但那是另一个话题了。import * as webllm from mlc-ai/web-llm; const engine await webllm.CreateMLCEngine( DeepSeek-R1-Distill-Qwen-7B-q4f16_1-MLC, { initProgressCallback: (report) { // report.progress 是 0~1 的浮点数 setLoadingProgress(report.progress); setLoadingText(report.text); }, } );initProgressCallback一定要接上否则用户面对的就是一个没有任何反馈的加载界面非常劝退。我会把下载和编译两个阶段分开显示下载阶段显示百分比和剩余体积编译阶段显示正在预热显卡让用户知道进度到哪了。3.2 量化等级对精度和显存的影响量化是端侧落地的命门。常见的有 q4f16 和 q4f32 两种前者权重 4 位、激活 fp16后者激活 fp32。数值上后者精度略高但显存和带宽开销更大端侧我一般优先选 q4f16实在有精度问题再考虑调高。量化带来的精度损失在小模型上更明显。7B 量化后做常规对话、抽取任务问题不大但涉及精确计算或者长链推理时偶尔会冒出错答案。我的做法是在 prompt 里明确要求分步思考并且把 temperature 压到 0.2 到 0.4 之间减少随机性。量化格式权重位宽7B 大致显存适用场景q4f16_14 位4 GB 左右日常对话、抽取q4f32_14 位5 GB 以上对精度敏感的任务q3f16_13 位3 GB 左右低端设备兜底3.3 模型缓存到 Cache Storage 的落地细节模型权重动辄几个 GB每次刷新页面都重新下载谁也受不了。WebLLM 内部会用浏览器的 Cache Storage 缓存权重但这个缓存是跟源站绑定的换域名、清缓存、隐私模式都会失效。我在产品里做了两件事一是在首次加载完成后主动提示模型已就绪下次打开更快二是在缓存被清空时给出明确反馈而不是让用户以为是卡死。提示调试阶段频繁改端口或域名会导致缓存反复失效看起来像每次都重新下载。固定一个本地域名再调试能省下大量等待时间。还有一点要注意浏览器对 Cache Storage 的配额是动态的磁盘空间紧张时可能回收。所以别把模型一定在本地当成硬保证代码里永远要留一条缓存失效则重新下载的路径。4. React TS 工程骨架把推理线程和 UI 线程彻底拆开4.1 Web Worker 加消息通道主线程绝不能碰推理这是我踩过最深的坑。一开始我图省事直接在组件里调推理接口结果模型一跑起来页面直接假死按钮点不动、滚动卡成幻灯片。原因是推理是 CPU 和 GPU 密集型任务哪怕有 GPU 加速前后处理、tokenizer 解码这些环节还是占主线程一旦执行就把渲染阻塞了。正确的做法是把引擎整个放进 Web Worker。主线程只负责收集用户输入和渲染结果推理全在 Worker 里跑两者用消息传递通信。这样即使推理在满载界面依然能流畅响应用户点停止生成能立刻生效。// worker.ts import * as webllm from mlc-ai/web-llm; let engine: webllm.MLCEngine | null null; self.onmessage async (e: MessageEvent) { const msg e.data; if (msg.type init) { engine await webllm.CreateMLCEngine(msg.modelId, { initProgressCallback: (r) { self.postMessage({ type: progress, progress: r.progress, text: r.text }); }, }); self.postMessage({ type: ready }); return; } if (msg.type generate engine) { const chunks await engine.chat.completions.create({ messages: msg.messages, temperature: msg.temperature, stream: true, }); for await (const chunk of chunks) { const delta chunk.choices[0]?.delta?.content ?? ; if (delta) self.postMessage({ type: chunk, id: msg.id, delta }); } self.postMessage({ type: done, id: msg.id }); } };Worker 里用流式接口逐块吐 token主线程收到一块就追加一块这样用户看到的是打字机效果而不是等全部生成完再一次性刷出来。4.2 用 TS 泛型约束消息协议别让消息类型失控主线程和 Worker 之间的消息是最容易失控的地方。消息类型一多收到一个没见过的字段就会静默出错。我的做法是用可辨识联合类型把协议写死再配一个带类型收窄的处理器。type InferMessage | { type: init; modelId: string } | { type: generate; id: string; messages: ChatMessage[]; temperature?: number } | { type: progress; progress: number; text: string } | { type: chunk; id: string; delta: string } | { type: done; id: string } | { type: error; id?: string; message: string }; function handle(msg: InferMessage) { switch (msg.type) { case chunk: appendDelta(msg.id, msg.delta); break; case done: finalize(msg.id); break; case error: showError(msg.message); break; } }这里有个实用技巧用泛型包一层消息发送器把type字段和剩余的 payload 分开推导发送侧就能自动校验字段完整性。function postT extends InferMessage[type]( type: T, payload: OmitExtractInferMessage, { type: T }, type ) { self.postMessage({ type, ...payload }); }这样写好处是改了协议类型编译器会立刻把所有用错的地方标出来比运行时才发现问题强太多。4.3 流式 token 的状态管理选型流式输出对状态管理是个考验。每来一个 token 就要更新界面如果用最朴素的 setState一秒几十次更新会把 React 的重渲染压得很重。我试过几种方案最后的选择是按数据量分级短对话用useReducer聚合把多次 delta 合并成一次 dispatch长对话或者需要跨组件共享的场景用 Zustand因为它的选择器订阅粒度更细只有订阅了对应会话的组件才会重渲染。一个关键细节是别把每个 token 都单独存成数组元素那会让状态结构越来越碎。我的做法是维护一个当前正在生成的会话在内存里用字符串拼接每帧或者每隔几十毫秒同步一次到状态树界面拿到的是拼接好的整体文本。这样更新频率降下来打字机效果反而更顺滑。5. Tailwind 撑起的 AI 对话界面流式渲染与视觉细节5.1 打字机效果背后的重排优化打字机效果看着简单实现不好会拖垮性能。最直观的做法是每来一个 token 就更新一次 DOM文本变长后整段都要重新布局长回复时会明显掉帧。我做了两件事来缓解。第一是把输出区域做成固定高度加滚动容器新内容从底部追加避免整页重排。第二是用 Tailwind 的contain相关工具类把渲染范围框住让浏览器知道这块区域可以独立布局。div classh-[60vh] overflow-y-auto contain-layout contain-paint px-4 py-3 div classprose prose-invert max-w-none whitespace-pre-wrap break-words {content} /div /divwhitespace-pre-wrap保留换行break-words防止长链接撑破布局这两个组合基本能覆盖大部分流式文本的排版问题。滚动到底部我用的是scrollIntoView({ behavior: smooth })但要加个判断只有用户当前就在底部时才自动滚动否则用户往上翻看历史会被强行拽回底部体验极差。5.2 流式 Markdown 渲染的常见坑模型输出经常带 Markdown但流式渲染 Markdown 有两个经典问题。第一个是代码块没闭合。当模型刚吐出还没吐完语言标识时渲染器可能把它当成行内代码界面会闪一下。我的处理是在渲染前做个简单的补全统计未闭合的代码块数量如果为奇数就补一个闭合标记。第二个是表格和列表在半截状态下会抖动这个没有完美解只能接受轻微跳动或者干脆用更宽松的降级策略生成中先按纯文本展示生成完再渲染 Markdown。从安全角度讲渲染 Markdown 一定要做清洗禁止内联脚本和危险属性。别直接dangerouslySetInnerHTML塞原始字符串那样一旦 prompt 被注入就麻烦大了。我一般用成熟的 Markdown 渲染库并开启它的安全模式。5.3 深色主题与运行指标展示端侧 AI 界面我基本都做深色主题原因很实际长时间盯着屏幕浅色背景更累眼而且深色更能突出代码块。Tailwind 做深色很简单用dark:前缀配合根节点的类切换即可我更倾向用 CSS 变量定义语义化颜色这样换主题时不用改一堆类名。运行指标展示也很值得做。我会在对话界面角落放一个小面板显示当前模型、量化等级、首 token 延迟、生成速度和显存占用。用户看到这些数据会更有掌控感出问题时你也能让他截个图快速定位。指标面板用 Tailwind 的网格布局几行就摆好别做得太花哨重点是信息密度。6. 实测数据与踩坑排查链路6.1 首 token 延迟和生成速度的实测记录我在几台设备上跑了同一段 prompt记录了一些数字仅供参考因为具体结果跟驱动、散热、后台负载都有关设备档位模型首 token 延迟生成速度独显笔记本7B q4f161~2 秒每秒十几到二十几个 token轻薄本集显1.5B q4f161 秒以内每秒二三十个 token中端手机1.5B q4f162~4 秒每秒十个上下可以看到首 token 延迟主要是 prefill 阶段算出来的prompt 越长越慢。如果你的场景 prompt 里要带一大段上下文首 token 延迟会很可观这时候要考虑做 prompt 精简或者缓存 KV。生成速度则跟模型大小强相关7B 明显比 1.5B 慢但回答质量也更好怎么选还是看场景。6.2 显存溢出和设备丢失的排查链路这类问题最烦人因为浏览器给的信息很少。我把自己的排查步骤整理成一条链路你可以照着走。第一步复现并记录。打开浏览器开发者工具的性能面板看推理时显存水位和帧率变化。同时记录触发崩溃前的操作是加载新模型还是长对话。第二步缩小模型。换 1.5B 跑同样流程如果正常基本可以确认是显存问题。这时候考虑降量化等级从 q4f16 换到更低或者给对话历史做截断别让上下文无限增长。第三步检查泄漏。KV cache 和对话历史是最容易堆积的两块。如果你的代码把每次生成的完整输出都留在内存里长会话很快就会把显存吃光。要定期清理不再使用的会话。第四步处理设备丢失。前面提过device.lost的 promise 必须接上收到后重建引擎并给用户明确提示而不是让界面卡在生成中。注意别在device.lost回调里直接重新加载模型那可能触发二次崩溃。先释放旧资源提示用户再让用户手动触发重载更稳妥。6.3 移动端和跨浏览器的特殊处理移动端有两个绕不开的问题内存限制和热限。移动浏览器的标签页内存上限比桌面低很多模型稍大就被系统回收页面直接白屏。我的策略是移动端只放小模型并且在visibilitychange事件里监听页面切后台切走就暂停推理、释放资源回来再按需重建。跨浏览器还有一堆小差异。有的浏览器需要用户手势之后才能申请 GPU 设备所以初始化最好绑定在开始对话这个点击事件里别在页面加载时自动跑。还有的浏览器首次创建引擎会触发额外的编译等待要在界面上给出明确的状态别让用户以为死机了。另外提一句我在等模型编译的空档里会顺手做代码分割优化把推理引擎、Markdown 渲染这些重依赖用动态import()拆出去首屏只加载界面骨架。这么做能让首屏很快出来用户看到界面就觉得这应用挺快然后再慢慢等模型。这个体感上的小把戏实际用下来效果很好。整个项目从最初的翻车演示到后来能在各种设备上稳定跑起来前后折腾了差不多一个月。真正花时间的不是写代码而是搞清楚硬件边界、把推理和渲染解耦、以及处理各种环境差异。如果你也想做端侧 AI我的建议是先从一个最小可跑的 demo 开始把模型加载和单轮对话跑通再逐步加流式、加界面、加多轮。一上来就搭大框架很容易在某个环节卡住然后放弃。