「你这个聊天窗口怎么不卡?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 加速)
   → 本地推理,输出结果

几个关键角色:

  1. HuggingFace:AI 圈最火的开源模型社区,各家模型都发在这里
  2. transformers.js:JS 版本的 transformers 库,负责加载模型、执行推理
  3. WebGPU:浏览器新特性,让前端能直接调用 GPU

我选的是 DeepSeek-R1-Distill-Qwen-1.5B,1.5B 参数,量化后约 1GB,是目前浏览器端性价比最高的推理模型之一。

🛠 开工:装依赖 + 搭架构

第一步:装两个依赖

npm i @huggingface/transformers
npm i marked
  • @huggingface/transformers:加载模型 + 执行推理
  • marked:模型输出的是 Markdown,得先转成 HTML 才能展示

第二步:想清楚架构

推理是重计算,直接跑在主线程,页面必卡死

所以用 Web Worker 把推理隔离出去,主线程只负责 UI:

主线程(React UI)
   ↕ postMessage 通信
Web Worker(work.js:加载模型 + 推理)

Worker 和主线程之间用 postMessage 收发消息,协议就五个动作:

switch (type) {
  case "check": check(); break;                          // 检测 WebGPU
  case "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 里:

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]);
  }
}

注意 ??=空值合并赋值

  • 第一次调用:实例是空的,走加载逻辑
  • 以后每次调用:实例已存在,直接返回

懒加载 + 全局唯一,一次到位。

q4f16 是量化精度,device: "webgpu" 指定走 GPU。

💬 流式输出 + R1 的思考过程

大模型推理不能干等,要边生成边吐字,体验才对。

TextStreamer 实现流式输出:

const streamer = new TextStreamer(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 },
);

let state = "thinking"; // 'thinking' or 'answering'

const token_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

const IS_WEBGPU_AVAILABLE = !!navigator.gpu;

一编译就报错:Property 'gpu' does not exist on type 'Navigator'

原因:WebGPU 是太新的实验特性,TypeScript 自带类型里还没有它。

当时的应急写法是类型断言:

const IS_WEBGPU_AVAILABLE = !!(navigator as any).gpu;

但不建议到处乱用 as any,会把类型系统全部架空。

正确解法:安装类型声明文件:

npm i -D @webgpu/types

然后在 tsconfig.app.jsontypes 里声明:

{
  "compilerOptions": {
    "types": ["@webgpu/types"]
  }
}

本质:TS 靠 .d.ts 类型声明文件工作,缺啥补啥。

坑 2:WebGPU 兼容性,不是所有浏览器都能跑

WebGPU 目前 Chrome 113+ / Edge 默认支持,部分浏览器还得手动开 flag。

所以启动前必须做特性检测

async function check() {
  const adapter = await navigator.gpu.requestAdapter();
  if (!adapter) {
    throw new Error("WebGPU is not supported (no adapter found)");
  }
}

不支持就直接黑屏提示,别让用户一脸懵。

坑 3:模型 1GB,首次下载慢到怀疑人生

首次加载要把模型文件从 HuggingFace 下载到浏览器,1GB 起步,没进度条根本不敢等。

解决:

  1. 进度回调progress_callback 实时上报,主线程渲染进度条
  2. 浏览器缓存:下载一次之后走缓存,二次加载秒开
AutoModelForCausalLM.from_pretrained(this.model_id, {
  dtype: "q4f16",
  device: "webgpu",
  progress_callback, // 上报文件下载进度
});

坑 4:首轮推理慢到爆炸,其实是 shader 编译

模型加载完了,第一次生成还是卡好久?

因为 WebGPU 要现场编译 shader

解决:加载完用 dummy 输入跑一次,提前编译:

async function load() {
  const [tokenizer, model] = await TextGenerationPipeline.getInstance();

  // 用假输入跑一遍,把 shader 提前编译好
  const inputs = tokenizer("a");
  await model.generate({ ...inputs, max_new_tokens: 1 });

  self.postMessage({ status: "ready" });
}

warmup 一次,之后推理就丝滑了。

坑 5:模型输出乱成一坨,忘了转 Markdown

模型返回的是 Markdown,直接塞进 textContent?代码块、加粗全废。

必须用 marked 转成 HTML 再渲染:

import { marked } from "marked";

// 生成完成后,把 markdown 转成 HTML
chat.innerHTML = marked.parse(markdownText);

📌 最后

回头看,在浏览器里跑大模型并没有想象中那么科幻:

  • transformers.js 抹平了模型加载的复杂度
  • WebGPU 把 GPU 能力直接给到前端
  • 单例模式 解决重资源重复加载问题
  • Web Worker 保证页面流畅
  • 剩下的,就是踩坑

适合的场景:个人工具、离线应用、隐私敏感场景、不想为 API 付费的玩具。

更多推荐