「你这个聊天窗口怎么不卡?AI 推理不是都得放服务器上吗?」
同事看我演示完本地 DeepSeek 推理,整个人愣住了。
我告诉他:没有服务器,没有 API 调用,数据连你的电脑都没出过。
这篇文章你能得到什么
- 零成本在浏览器里跑起 DeepSeek-R1(1.5B 量化版)推理
- 用WebGPU调用显卡加速,不依赖云端
- 用Web Worker隔离重计算,页面永不卡死
- 用单例模式让 1GB 模型只加载一次
- 我踩过的5 个坑,全帮你提前踩平
全文代码可直接运行,跟着做,你也能拥有一个纯本地、可离线的 AI 聊天应用。
😅 为什么我非要在浏览器里跑大模型
先说我之前的痛:
- 调 API:按 token 收费,对话一多钱包就疼
- 调 API:网络一抖就超时,生成还要等服务器排队
- 调 API:数据得发到别人服务器,敏感内容没法聊
本地部署?要显卡、要 CUDA、要配环境,直接劝退。
直到我发现一条新路:
大模型 → 浏览器本地 → WebGPU 推理。
- 零服务器、零 API 费用
- 数据不出浏览器,天然隐私
- 加载一次后可离线使用
- 推理跑在你的 GPU 上,速度比想象中快
这就是我做的webgpu-deepseek项目:一个纯浏览器端的 DeepSeek-R1 聊天应用。
🧠 先搞懂数据流:模型是怎么跑进浏览器的
一句话流程:
HuggingFace 模型仓库 → transformers.js(JS 版 Transformers) → 浏览器下载模型文件 → 浏览器缓存(下次免下载) → WebGPU(调用 GPU 加速) → 本地推理,输出结果几个关键角色:
- HuggingFace:AI 圈最火的开源模型社区,各家模型都发在这里
- transformers.js:JS 版本的 transformers 库,负责加载模型、执行推理
- WebGPU:浏览器新特性,让前端能直接调用 GPU
我选的是DeepSeek-R1-Distill-Qwen-1.5B,1.5B 参数,量化后约1GB,是目前浏览器端性价比最高的推理模型之一。
🛠 开工:装依赖 + 搭架构
第一步:装两个依赖
npmi @huggingface/transformersnpmi marked@huggingface/transformers:加载模型 + 执行推理marked:模型输出的是Markdown,得先转成 HTML 才能展示
第二步:想清楚架构
推理是重计算,直接跑在主线程,页面必卡死。
所以用Web Worker把推理隔离出去,主线程只负责 UI:
主线程(React UI) ↕ postMessage 通信 Web Worker(work.js:加载模型 + 推理)Worker 和主线程之间用postMessage收发消息,协议就五个动作:
switch(type){case"check":check();break;// 检测 WebGPUcase"load":load();break;// 加载模型case"generate":stopping_criteria.reset();generate(data);break;// 推理case"interrupt":stopping_criteria.interrupt();break;// 停止生成case"reset":past_key_values_cache=null;stopping_criteria.reset();break;// 重置}🔑 单例模式:让 1GB 模型只加载一次
这是全文我最想讲的设计模式。
单例模式:OOP 面向对象里的 23 种经典设计模式之一,核心就一句话——
一个类在系统中只能实例化一次,全局只有这一个实例。
它专门解决两件事:
- 全局变量问题(instance 到处传,太痛苦)
- 全局状态问题(状态要全局唯一共享)
放到大模型场景,价值直接拉满:
1GB 的模型,加载一次要几秒甚至几分钟。
每次提问都重新加载?直接劝退。
单例模式保证:整个页面生命周期,模型只加载一次,之后一直复用。
看代码,就在work.js里:
classTextGenerationPipeline{staticmodel_id="onnx-community/DeepSeek-R1-Distill-Qwen-1.5B-ONNX";staticasyncgetInstance(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,});returnPromise.all([this.tokenizer,this.model]);}}注意??=:空值合并赋值。
- 第一次调用:实例是空的,走加载逻辑
- 以后每次调用:实例已存在,直接返回
懒加载 + 全局唯一,一次到位。
q4f16是量化精度,device: "webgpu"指定走 GPU。
💬 流式输出 + R1 的思考过程
大模型推理不能干等,要边生成边吐字,体验才对。
用TextStreamer实现流式输出:
conststreamer=newTextStreamer(tokenizer,{skip_prompt:true,skip_special_tokens:true,callback_function,// 每生成一段,发给主线程token_callback_function,// 每个 token 回调,统计速度});R1 还有个灵魂设计:思考过程。
它会先输出<think>...</think>(思考),再输出正式回答。
用两个特殊 token 做状态机:
// 151648: <think>// 151649: </think>const[START_THINKING_TOKEN_ID,END_THINKING_TOKEN_ID]=tokenizer.encode("<think></think>",{add_special_tokens:false},);letstate="thinking";// 'thinking' or 'answering'consttoken_callback_function=(tokens)=>{if(tokens[0]==END_THINKING_TOKEN_ID){state="answering";}};主线程拿到state,就能把「思考」和「回答」分开展示,还能实时算速度(tokens/秒)。
生成时限制max_new_tokens: 2048,并用InterruptableStoppingCriteria支持随时打断。
🧨 我踩的 5 个坑(重点)
坑 1:navigator.gpu 报错,TS 不认识 WebGPU
constIS_WEBGPU_AVAILABLE=!!navigator.gpu;一编译就报错:Property 'gpu' does not exist on type 'Navigator'。
原因:WebGPU 是太新的实验特性,TypeScript 自带类型里还没有它。
当时的应急写法是类型断言:
constIS_WEBGPU_AVAILABLE=!!(navigatorasany).gpu;但不建议到处乱用as any,会把类型系统全部架空。
正确解法:安装类型声明文件:
npmi-D@webgpu/types然后在tsconfig.app.json的types里声明:
{"compilerOptions":{"types":["@webgpu/types"]}}本质:TS 靠.d.ts类型声明文件工作,缺啥补啥。
坑 2:WebGPU 兼容性,不是所有浏览器都能跑
WebGPU 目前Chrome 113+ / Edge默认支持,部分浏览器还得手动开 flag。
所以启动前必须做特性检测:
asyncfunctioncheck(){constadapter=awaitnavigator.gpu.requestAdapter();if(!adapter){thrownewError("WebGPU is not supported (no adapter found)");}}不支持就直接黑屏提示,别让用户一脸懵。
坑 3:模型 1GB,首次下载慢到怀疑人生
首次加载要把模型文件从 HuggingFace 下载到浏览器,1GB 起步,没进度条根本不敢等。
解决:
- 进度回调:
progress_callback实时上报,主线程渲染进度条 - 浏览器缓存:下载一次之后走缓存,二次加载秒开
AutoModelForCausalLM.from_pretrained(this.model_id,{dtype:"q4f16",device:"webgpu",progress_callback,// 上报文件下载进度});坑 4:首轮推理慢到爆炸,其实是 shader 编译
模型加载完了,第一次生成还是卡好久?
因为WebGPU 要现场编译 shader。
解决:加载完用 dummy 输入跑一次,提前编译:
asyncfunctionload(){const[tokenizer,model]=awaitTextGenerationPipeline.getInstance();// 用假输入跑一遍,把 shader 提前编译好constinputs=tokenizer("a");awaitmodel.generate({...inputs,max_new_tokens:1});self.postMessage({status:"ready"});}warmup 一次,之后推理就丝滑了。
坑 5:模型输出乱成一坨,忘了转 Markdown
模型返回的是 Markdown,直接塞进textContent?代码块、加粗全废。
必须用marked转成 HTML 再渲染:
import{marked}from"marked";// 生成完成后,把 markdown 转成 HTMLchat.innerHTML=marked.parse(markdownText);📌 最后
回头看,在浏览器里跑大模型并没有想象中那么科幻:
- transformers.js抹平了模型加载的复杂度
- WebGPU把 GPU 能力直接给到前端
- 单例模式解决重资源重复加载问题
- Web Worker保证页面流畅
- 剩下的,就是踩坑
适合的场景:个人工具、离线应用、隐私敏感场景、不想为 API 付费的玩具。