
最近我把 DeepSeek-R1 的蒸馏小模型塞进了浏览器里整条链路是这样的浏览器加载模型权重WebGPU 做 GPU 推理加速React TypeScript 管界面逻辑Tailwind 负责样式输出。模型推理完全发生在本地没有后端服务也不经过任何远程 API打开页面就能得到一个有推理能力的对话助手。这不是一个玩具 Demo。它解决了一个很实在的问题你不需要买显卡、不需要租服务器、也不需要处理 API Key 和计费模型缓存好之后断网都能聊。数据不出本机对于隐私敏感场景、内网环境、以及想研究大模型推理原理的同学来说端侧方案值得花时间搞明白。这篇文章会按我实际动手的顺序拆开讲先讲为什么这么选型、架构怎么设计再讲模型量化、WebGPU 推理、React 前端的状态管理与流式渲染最后是我踩过并且排查掉的一堆问题。代码基于 WebLLM Vite React 18 TypeScript 5 Tailwind CSS 3核心 API 我尽量贴了完整代码照着跑应该能通。1. 项目定位与技术选型为什么要做纯浏览器端的大模型应用1.1 端侧推理解决的三类真实问题先聊一个最容易被忽略的问题为什么要费劲把模型跑到浏览器里直接在服务器上部署一个 API 不香吗我自己的答案有三点。第一是隐私。对话内容完全留在本机不经过第三方服务。这就好比你自己写日记揣在兜里而不是把日记本交给别人保管。医疗、法务、企业内部文档这类敏感场景数据不出设备是硬需求就算你的前端页面最终要部署到公网只要推理在端侧发生服务端就永远接触不到用户的对话原文。第二是成本。没有按 token 计费一说一次性的模型下载之后随便聊。个人开发者做小工具、离线应用、内网产品这种模式非常友好。第三是延迟和可用性。本地推理没有网络 RTT也没有服务端限流网络断了照样能用配合 PWA 甚至可以做成完全离线的桌面级体验。对应的代价也很明确模型规模受硬件和浏览器内存限制推理速度取决于用户的 GPU不同浏览器对 WebGPU 的支持程度还有差异。所以项目里选模型不能贪大要选“蒸馏 量化”之后能在浏览器里跑得动、且质量还能看的版本。这个平衡是整个项目的核心后面我会详细讲。1.2 技术栈选型的完整思路选型其实是被问题驱动的。我要跑的是 R1 这种带推理链路的对话模型那么推理引擎必须支持流式输出、KV Cache、采样参数调整这些能力UI 层要处理大量异步状态和流式文本需要好用的状态管理和声明式渲染整个项目类型复杂普通 JavaScript 很容易在模型参数和消息结构上出错。所以最后定下来的组合是DeepSeek-R1 蒸馏版保留 R1 的思维链输出风格体积可以压缩到浏览器能承受的范围。WebGPU这是关键。WebGL 本质是图形 API要在上面做通用计算得把计算伪装成纹理渲染效率低还难写WebGPU 提供 compute shader 和 storage buffer是真正面向 GPU 通用计算的 Web 标准大模型的矩阵乘法和注意力计算靠它才能跑得快。React TypeScript聊天界面天然是流式异步场景React 的声明式渲染能省掉大量手动 DOM 操作TS 给引擎配置、消息结构、运行状态这套复杂类型做静态兜底重构的时候敢放手改。Tailwind CSS对话应用 UI 迭代特别频繁Tailwind 的原子类方便快速调样式暗色主题支持也好后面要换主题色时只改几个 class 就行。选型时我其实也考虑过纯 WASM 在 CPU 上推理的方案。它的兼容性更好但速度实在不行。以 7B 量化模型为例CPU 上可能只有 2-4 token/s用户等一句话要等半分钟基本不可用。换成 GPU 能到 8-15 token/s体验差距巨大。所以只要目标设备有支持 WebGPU 的浏览器优先上 WebGPU 是正确的。1.3 整体架构与数据流项目的整体架构可以拆成四层模型层、推理引擎层、状态管理层、UI 层。模型层是 DeepSeek-R1 蒸馏版的量化权重运行前从 CDN 分片下载到浏览器缓存在 IndexedDB 里推理引擎层用 WebLLM内部是 MLC Engine它负责把模型编译成针对当前 GPU 的计算 shader并提供聊天补全 API状态管理层我用 Zustand 维护消息列表、引擎状态和采样参数UI 层就是 React 组件负责渲染流式文本、推理过程和设置面板。数据流是这样走的用户输入消息提交到 Zustand store → React 组件调用引擎的流式 API → 引擎在 Worker 线程里跑 WebGPU 推理逐 token 返回结果 → 回调里把新 token 放到 store 的当前回复字段 → React 组件增量渲染。引擎放在 Worker 里非常关键不然 GPU 推理时的数据传输和解析会阻塞主线程页面直接卡死。2. 核心细节解析DeepSeek-R1、量化与 WebGPU 推理链路2.1 DeepSeek-R1 蒸馏模型的选用逻辑原版 DeepSeek-R1 是一个 671B 参数的 MoE 模型推理一次需要海量显存普通人压根跑不起更别说浏览器。官方也很清楚这一点所以发布了蒸馏版本把 R1 的能力迁移到更小的开源基座模型上包括 Qwen 系列的 1.5B、7B、14B、32B以及 Llama 系列的 8B、70B。蒸馏版保留了 R1 最有辨识度的东西先输出完整的推理过程reasoning content再给出最终答案。这个特性做产品时很有话题性用户能看到模型“想”的过程信任感和互动感都会好很多。在浏览器环境里我建议从 1.5B 到 8B 这个区间选再往上走对内存和 GPU 的要求就超出普通用户设备的水平了。我自己打包测试的型号大体上是这几类模型参数量Q4 量化后体积推荐 GPU 显存参考速度RTX 3060 级别R1-Distill-Qwen-1.5B1.5B约 0.9GB2GB 以上30-60 token/sR1-Distill-Qwen-7B7B约 4.1GB8GB 以上8-15 token/sR1-Distill-Llama-8B8B约 4.7GB8GB 以上7-12 token/sR1-Distill-Qwen-14B14B约 8GB16GB 以上3-6 token/s速度数据受 GPU 型号、驱动和浏览器版本影响很大上面的数只是参考。我的经验是先用 1.5B 把全链路跑通代码和交互都稳定了再根据目标设备换更大的模型。一上来就追 7B/8B容易在模型加载和显存问题上耗尽耐心。2.2 量化方案与显存估算模型要进浏览器绕不开量化。原理不复杂模型权重通常是 FP16 或 BF16 存储一个参数占 2 字节1.5B 参数的模型光权重就要约 3GB7B 就是 14GB加载进浏览器内存不现实。量化就是把权重压成更低的位数4-bit 量化后一个参数约 0.5 字节1.5B 模型压缩到约 0.75GB7B 约 3.5GB这才落到浏览器可接受的范围。WebLLM 的模型命名里会带量化格式比如q4f16_1表示权重用 4-bit 量化存储计算时部分中间结果保持 FP16。这种格式在体积和推理质量之间比较平衡。实际测试下来4-bit 量化对蒸馏小模型的回答质量损失是能接受的普通人感知不到明显降智。但不要只算权重体积显存还要给 KV Cache 和激活值留空间。KV Cache 的大小和层数、注意力头数、上下文长度直接相关粗略估算时按上下文 token 数乘一个系数算。我建议跑 7B 模型时至少留出 4-5GB 显存余量不然生成长文本到一半就会 OOM。所以设置面板里我特意加了一个“最大生成长度”的开关用户可以根据自己设备调整。2.3 WebGPU 在推理链路里做了什么很多人对 WebGPU 的理解是“新一点的 WebGL”不准确。WebGL 是图形光栅化 API你把矩阵乘法搬上去只能靠 fragment shader 硬模拟写起来痛苦性能浪费也大。WebGPU 提供了真正的 compute pipeline可以写通用计算 shaderWGSL用 GPU 大规模并行做矩阵乘法、attention 计算、采样这些大模型推理的核心算子。整个调用链路是先在 JS 侧拿到 GPU adapter 和 device然后创建计算管线把模型权重放到 storage buffer 里每层 Transformer 的前向计算对应一组 dispatch。WebLLM 这个项目复杂就复杂在它用 TVM 的堆栈把模型编译成了针对当前 GPU 的 shader还做了算子融合、缓存管理、KV Cache 复用这些不是我们自己手写 WGSL 能搞定的所以实际开发时直接调 WebLLM 的 API 而不是自己写推理内核是明智的选择。浏览器对 WebGPU 的支持这两年已经比较稳了Chrome 和 Edge 113 及以上默认开启Firefox 和 Safari 还在推进。后面我会给一段兼容性检测代码跑不了的时候至少能优雅地告诉用户原因。2.4 模型加载、缓存与运行时 API模型权重不是打进代码包里的而是在运行时从 CDN 分片下载。WebLLM 会从远端 URL 加载 safetensors 权重文件并把下载好的文件编译缓存到 IndexedDB 里。首次加载慢后面就会快很多。加载过程有进度回调单位是 MB。我在界面上做了一个进度条把加载进度和状态展示出来否则用户打开页面干等半分钟会以为网页坏了。运行时引擎核心 API 就是聊天补全和 OpenAI 的接口风格很像传入 messages 数组返回模型输出。流式模式下引擎每次吐一个增量 chunk我们用 for-await 循环收数据一个个拼接到当前回复上。Engine 还提供了runtimeStats()这类方法能拿到生成速度和显存占用调试性能时很有用。3. 实操过程从空目录到可跑的聊天应用3.1 初始化项目与依赖安装脚手架直接用 Vite 的 react-ts 模板干净利落npm create vitelatest deepseek-webgpu-demo -- --template react-ts cd deepseek-webgpu-demo npm install npm install mlc-ai/web-llm zustand react-markdown remark-gfm npm install -D tailwindcss/typographyTailwind 装完后初始化配置文件记得把 content 扫描路径配上npx tailwindcss init -ptailwind.config.ts大致是这样import type { Config } from tailwindcss; export default { darkMode: class, content: [./index.html, ./src/**/*.{ts,tsx}], theme: { extend: { colors: { surface: hsl(var(--surface)), surface-hover: hsl(var(--surface-hover)), }, }, }, plugins: [require(tailwindcss/typography)], } satisfies Config;tailwindcss/typography是后面渲染 Markdown 时给正文加排版用的不加的话模型输出的标题、列表、代码块会挤成一团。3.2 推理引擎封装Worker、进度回调与类型引擎建议放在 Worker 里这样 GPU 推理、数据解析、解码这些耗时的操作不会卡住主线程。先建一个 Worker 文件// src/engine/worker.ts import { WebWorkerMLCEngineHandler } from mlc-ai/web-llm; const handler new WebWorkerMLCEngineHandler(); self.onmessage (msg: MessageEvent) { handler.onmessage(msg); };主线程这边封装一个 Engine 模块负责创建 Worker 引擎、管理模型切换// src/engine/engine.ts import { CreateWebWorkerMLCEngine } from mlc-ai/web-llm; import type { ChatCompletionMessageParam, InitProgressReport, MLCEngineInterface, } from mlc-ai/web-llm; export type ChatStatus idle | loading-model | ready | generating | error; const worker new Worker(new URL(./worker.ts, import.meta.url), { type: module, }); export async function createEngine( modelId: string, onProgress: (progress: InitProgressReport) void ): PromiseMLCEngineInterface { const engine await CreateWebWorkerMLCEngine(worker, modelId, { initProgressCallback: onProgress, }); return engine; }注意new URL(./worker.ts, import.meta.url)这个写法是必须的Vite 要靠它识别 Worker 入口并做打包处理。很多新手在这里直接用字符串路径结果开发环境跑得起来一打包就报 Worker 找不到。模型切换时要记得先卸载旧的再创建新的export async function switchModel( engine: MLCEngineInterface, modelId: string, onProgress: (progress: InitProgressReport) void ): PromiseMLCEngineInterface { await engine.unload(); await engine.reload(modelId, { initProgressCallback: onProgress }); return engine; }unload会释放显存和内存不调用的话连续切换两三次模型浏览器直接崩给你看。3.3 React 界面状态管理、消息流与流式渲染状态管理用 Zustand代码少、心智负担轻关键是不像 Redux 那样要写一堆样板代码// src/stores/chatStore.ts import { create } from zustand; export interface ChatMessage { id: string; role: user | assistant; content: string; reasoning?: string; } interface ChatState { messages: ChatMessage[]; status: ChatStatus; currentText: string; currentReasoning: string; settings: { temperature: number; topP: number; maxTokens: number; systemPrompt: string; }; addMessage: (msg: ChatMessage) void; setStatus: (status: ChatStatus) void; updateCurrent: (delta: string, reasoningDelta?: string) void; commitCurrent: () void; }流式生成的核心逻辑在聊天组件里。调用引擎的流式接口用 for-await 逐个消费 chunk再更新到 store// src/components/ChatWindow.tsx核心片段 const asyncChunkGenerator await engine.chat.completions.create({ messages: [ { role: system, content: settings.systemPrompt }, ...history, { role: user, content: input }, ], temperature: settings.temperature, top_p: settings.topP, max_tokens: settings.maxTokens, stream: true, stream_options: { include_usage: true }, }) as AsyncIterableChatCompletionChunk; setStatus(generating); for await (const chunk of asyncChunkGenerator) { const delta chunk.choices[0]?.delta; if (!delta) continue; if (delta.reasoning_content) { updateCurrent(, delta.reasoning_content); } if (delta.content) { updateCurrent(delta.content); } }这里有个细节R1 蒸馏模型会先吐 reasoning_content推理过程再吐 content最终答案。我在消息卡片里做了一个折叠区用户点开能看到模型的思考过程默认收起不干扰正文阅读。这个交互做完之后整个应用明显区别于普通问答机器人有 R1 的味道了。流式输出还有一个容易踩的坑不要在 React 组件里用 setState 直接拼 token。每个 token 触发一次 setState7B 模型十几 token/s肉眼可见地闪。我用 Zustand 的 store 统一管理增量更新React 组件订阅 store 里对应的字段渲染由库来调度帧率稳定很多。3.4 设置面板采样参数与模型切换设置面板放在侧边栏包含模型选择、温度、top_p、最大生成长度、系统提示词这几个配置项。温度控制随机性值越高回答越发散低一点则更确定top_p 控制采样的候选集合大小最大生成长度主要用来控制显存和响应时间。这几个参数直接透传给引擎代码不复杂但对用户体验影响很大。模型切换我用了一个下拉框列表里预置几档模型 ID从 1.5B 到 8B按设备能力选。切换的流程是先置状态为 loading-model然后走switchModel流程期间展示进度条。这块逻辑要做得稳因为用户大概率会手痒去切一个大模型然后机器带不动这时候你要给清晰的错误提示而不是白屏。3.5 Tailwind 样式与暗色主题对话界面的样式重点在消息气泡和排版。用户消息右对齐助手消息左对齐助手消息里用prose prose-invert类渲染 Markdown。这个prose类来自 typography 插件会自动给标题、列表、代码块、引用加一套好看的排版样式不用自己手写一堆 CSS。暗色主题用 Tailwind 的darkMode: class策略在 html 根节点切换.dark类即可。我习惯用 CSS 变量定义表面色、边框色、文本色这些基础 token再映射到 Tailwind 的配置里这样换主题时只改几个变量就行。另外一个 Tailwind 的老坑必须提动态拼接类名不会生效。比如className{\bg-${color}-500} 这种写法Tailwind 构建时扫描不到完整类名编译出来的 CSS 里不会有对应样式。要么写完整的映射表要么用 safelist 显式声明。我在模型徽章颜色上踩过一次后来改成枚举映射表才解决。4. 常见问题与排查技巧实录4.1 浏览器兼容性与 WebGPU 检测白屏和权限问题的根源WebGPU 是分浏览器支持的新特性我上线测试时遇到的第一个问题就是用户白屏。检测代码很简单export function checkWebGPUSupport(): string | null { if (!(gpu in navigator)) { return 当前浏览器不支持 WebGPU请使用 Chrome/Edge 113 及以上版本; } return null; }但有gpu属性不代表一定能用。硬件加速被关闭、显卡驱动有问题、浏览器内部 flag 被改过都可能导致requestAdapter()返回 null。所以更稳妥的做法是异步验证export async function verifyWebGPU(): Promiseboolean { if (!(gpu in navigator)) return false; const adapter await navigator.gpu.requestAdapter(); return adapter ! null; }加载页上我会先跑这两个检查不通过就显示一个带详细提示的占位界面而不是黑屏或白屏。4.2 模型下载慢、断流与缓存问题模型文件不大但也不算小1.5B 的 Q4 量化包也有接近 1GB首次加载在弱网环境下是个煎熬过程。我遇到最典型的问题是下载到一半进度条不动了看起来像卡死。排查思路是确认进度回调里的数值是否还在变化如果是 CDN 不稳定可以在引擎配置里切换到备用模型文件地址或者走自建文件服务。WebLLM 会把模型和编译产物缓存到 IndexedDB二次加载会快很多。但有些用户浏览器开了隐私模式或者自动清理站点数据缓存会被清掉表现为每次打开都在重新下载这个没法完全避免至少要在 UI 上提示一下模型体积和缓存逻辑让用户有预期。4.3 显存溢出与推理卡顿从 OOM 到速度优化生成到一半页面崩溃或者长时间无响应大概率是显存不够。我的排查顺序是先换更小模型排除基础性能问题再把最大生成长度调小观察是否在长文本阶段崩溃最后检查是不是同时开了多个 GPU 应用抢显存。浏览器本身对显存没有强制配额所以 OOM 的表现往往不是报错而是页面整个黑掉或者 GPU 进程重启非常迷惑。推理速度慢的话先确认不是 CPU 回退。WebLLM 在 WebGPU 不可用时会尝试用 WASM 在 CPU 上跑速度掉到个位数 token/s 甚至更夸张。看engine.runtimeStats()的输出如果显示当前后端是 CPU 而不是 WebGPU就回到 4.1 的兼容性排查。另外如果机器有核显和独显浏览器可能选错 GPU可以在浏览器设置里让站点优先使用高性能 GPU。4.4 流式输出与界面不同步Token 丢字和渲染闪烁流式输出最常见的诡异问题是界面上的文字偶尔会跳一下或者丢一两个字。这多半不是模型问题而是增量更新逻辑写错了。用for await消费 chunk 时如果某个 chunk 里delta.content是 null比如只在最后返回 usage 信息的 chunk直接就会往字符串里拼一个 null。我的处理是每个 chunk 都判断一下内容字段是否存在再更新。界面闪动的问题则是 React 渲染和流式写入的节奏不匹配。我做了两个优化一是在消息还没生成完时当前回复卡片用单独的字段承载生成结束后一次性提交到消息列表避免列表项不断被重建二是把推理过程的折叠区展开/收起状态独立管理不跟着文本流一起触发重渲染。4.5 TypeScript 类型边界问题流式 chunk 和引擎状态WebLLM 的类型定义更新的比较勤流式接口的返回类型在不同版本里可能不一样。我在开发时遇到过for await拿到的 chunk 和类型不匹配的情况解决思路是定义一个自己的窄类型interface StreamChunk { choices?: Array{ delta?: { content?: string; reasoning_content?: string; }; }; usage?: { prompt_tokens: number; completion_tokens: number; }; }把引擎返回的数据先断言成这个结构再消费既保住了字段安全又把对上游类型的强依赖解耦了。项目里所有跟引擎交互的地方我都尽量通过封装的类型来中转不要到处直接 import 引擎包的类型这样升级依赖时改动面小很多。4.6 本地开发与构建部署的坑最后说两个和开发构建相关的坑。Vite 开发环境下Worker 和 WASM 的加载一般没问题但如果你在 build 之后发现 Worker 找不到或者引擎初始化失败先检查base路径配置。部署在子路径时Worker 脚本和模型文件的 URL 解析都会出问题base: ./能解决大部分相对路径问题。另一个是安全上下文。WebGPU 要求页面在安全上下文HTTPS 或 localhost里运行如果你部署到纯 HTTP 的生产环境很可能 WebGPU 直接被禁用。内网部署时尤其要注意这点HTTP 访问的页面navigator.gpu是 undefined排查方向完全不一样。这个项目折腾下来我最大的体会是端侧 AI 的技术栈已经比自己想象中成熟太多了。模型蒸馏、量化、WebGPU 推理引擎这些本来以为是“服务器专用硬件”才能玩的东西现在普通浏览器加一块中端 GPU 就能跑起来。踩坑主要集中在前端工程和浏览器兼容性上反而是模型本身的推理质量要比预期好得多。如果你也想自己搞一套我的建议是从最小的闭环开始1.5B 模型 最朴素的聊天框先跑通流式渲染再逐步加模型切换、推理过程展示、设置面板这些增强功能。步子迈大了真的会卡在显存和兼容性问题上半天下不来。后续可以玩的方向也很多比如在端侧给模型挂本地文件做简单检索增强或者接上语音合成做有声对话都不需要改推理内核纯在上层做文章就行。