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

最近在折腾一个有意思的项目:让 DeepSeek-R1 推理模型直接在浏览器里运行 ,不需要服务器,数据不出本机,甚至断网后还能用。听起来很酷对吧?经过一番踩坑,我终于把 HuggingFace 官方示例 完整跑通,并梳理出了其中的关键技术和实现细节。

这篇文章会带你从零理解这个项目的架构,包括:

  • 如何用 Transformers.js 在浏览器中加载并推理大模型
  • WebGPU 如何加速推理
  • Web Worker 如何避免阻塞主线程
  • TypeScript 中处理实验性 API 的类型问题
  • 流式输出、思考/回答状态机、中断生成等细节

如果你也想在自己的前端项目里集成 AI 能力,或者单纯对浏览器跑大模型感到好奇,这篇实战记录应该能给你一些启发。

1. 为什么要在浏览器里跑大模型?

大模型通常部署在云端,前端通过 API 调用。但这种方式有几个痛点:

  • 数据隐私:用户输入会发送到服务器。
  • 网络延迟:每次请求都要等待服务器响应。
  • 成本:服务端 GPU 资源昂贵。

而浏览器端运行模型可以:

  • 数据完全本地化,隐私有保障
  • 加载完成后可离线使用
  • 无需服务器,部署简单(一个静态页面就行)

当然,浏览器端也有局限:模型不能太大,加载需要时间,性能取决于用户设备。好在 WebGPU 的出现让浏览器端推理性能大幅提升,配合量化模型(如 q4f16),1.5B 参数级别的模型已经可以流畅运行。

2. 技术选型与整体架构

这个项目主要依赖:

  • @huggingface/transformers:Transformers.js 库,提供 tokenizer 和模型加载/推理 API,支持 WebGPU 后端。
  • WebGPU:新一代浏览器图形/计算 API,比 WebGL 更适合神经网络推理。
  • Web Worker:将模型加载和推理放到子线程,避免阻塞 UI。
  • React(或任意前端框架):负责 UI 和状态管理。
  • marked + DOMPurify + MathJax:将模型返回的 Markdown 渲染成安全的 HTML,并支持 LaTeX 公式。

整体架构如下:

scss 复制代码
主线程 (React App)
   │
   ├─ 创建 Worker (worker.js)
   │     ├─ 加载模型 (Tokenizer + LLM)
   │     ├─ 接收消息 (check / load / generate / interrupt / reset)
   │     └─ 发送进度 / 状态 / 生成结果
   │
   ├─ 渲染聊天界面
   └─ 处理用户输入

主线程和 Worker 之间通过 postMessage 通信,实现了解耦。

3. 环境准备与 TypeScript 类型处理

3.1 检测 WebGPU 支持

在浏览器中判断是否支持 WebGPU 非常简单:

ini 复制代码
const IS_WEBGPU_AVAILABLE = !!navigator.gpu;

但如果你直接在 TypeScript 里写 navigator.gpu,会报错:

bash 复制代码
Property 'gpu' does not exist on type 'Navigator'.

这是因为 navigator.gpu 属于比较新的实验性 API,TypeScript 标准库还没收录。解决方法有两种:

方法一:使用类型断言

ini 复制代码
const IS_WEBGPU_AVAILABLE = !!(navigator as any).gpu;

as any 可以绕过类型检查,但会失去类型提示,也可能因为 any 泛滥导致代码质量下降。

方法二:安装类型声明文件(推荐)

css 复制代码
pnpm i -D @webgpu/types

然后在 tsconfig.app.json 里加上:

perl 复制代码
{
  "compilerOptions": {
    "types": ["vite/client", "@webgpu/types"]
  }
}

这样 navigator.gpu 就有了完整的类型支持,写起来更安全。TypeScript 的本质就是通过类型声明文件来扩展标准库的类型,理解了这一点,以后遇到类似问题就知道怎么解决了。

3.2 在 Worker 中使用 WebGPU

Worker 里同样需要检测 WebGPU,这里用 navigator.gpu.requestAdapter() 来检查适配器是否可用:

javascript 复制代码
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 模型输出再转换为文本输出

kotlin 复制代码
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.tokenizernullundefined,才执行赋值。因为 AutoTokenizer.from_pretrained 返回的是 Promise,所以第一次调用时会将 Promise 对象赋给 this.tokenizer,后续调用直接复用同一个 Promise,从而避免重复下载和加载。

promise.all: 等待分词器和模型全部加载完成,才返回

4.2 加载进度回调

模型下载通常需要较长时间(文件几百 MB),我们需要给用户展示进度。from_pretrained 支持传入 progress_callback,它会回调每个文件的下载进度。

javascript 复制代码
const [tokenizer, model] = await TextGenerationPipeline.getInstance((x) => {
  self.postMessage(x); // x 包含 status, file, progress, total 等字段
});
arduino 复制代码
 x = {
        status: "initiate",
        file: "model_q4f16.onnx",
        total: 10485760   // 文件总字节大小
      }

主线程接收到这些进度消息后,更新进度条 UI。良好的进度反馈是用户体验的关键,尤其是面对大文件下载时,用户最怕的就是"卡住了"的未知等待。

4.3 预热 shader

WebGPU 在第一次执行模型推理时需要编译 shader,这会花费几秒钟。如果直接在用户发送消息时编译,会明显卡顿。所以加载完成后,我们会用一个小输入提前触发编译:

ini 复制代码
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 主 → Worker messages 数组 生成回复
interrupt 主 → Worker 中断生成
reset 主 → Worker 重置状态
loading Worker → 主 提示文本 加载中
initiate Worker → 主 文件信息 开始下载某个文件
progress Worker → 主 进度信息 下载进度
done Worker → 主 文件信息 单个文件下载完成
ready Worker → 主 模型就绪
start Worker → 主 开始生成
update Worker → 主 输出片段、tps 等 流式更新
complete Worker → 主 完整输出 生成完成
error Worker → 主 错误信息 出错

主线程根据 status 字段更新 React 状态:

kotlin 复制代码
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 存储的引用

  • worker:Worker 实例。Worker 实例在整个组件生命周期中只需创建一次,且其变化不应触发重新渲染,所以用 useRef 保存。
  • chatContainerRef:聊天容器 DOM 引用,用于实现智能滚动。
  • textareaRef:输入框 DOM 引用,用于自适应高度调整。

useState 管理的状态

  • status:模型加载状态(null / "loading" / "ready"),控制页面显示不同区域。
  • error:错误信息,加载失败时显示。
  • loadingMessage:加载提示文本,例如"Loading model..."。
  • progressItems:下载进度数组,每个元素包含 fileprogresstotal 等字段,用于展示多文件下载进度。
  • isRunning:是否正在生成,用于禁用输入框和切换发送/停止按钮。
  • messages:对话消息数组,元素形如 { role: "user" | "assistant", content: string, answerIndex?: number }
  • input:输入框内容。
  • tps / numTokens:性能指标,展示生成速度。

这些状态在 Worker 消息回调中更新,驱动 UI 变化。messages 的更新采用不可变方式(拷贝数组再替换最后一项),确保 React 能正确检测到变化并重新渲染。

textarea 自适应高度 :通过 useEffect 监听 input 变化,调用 resizeInput 调整高度,最小 24px,最大 200px。

ini 复制代码
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 id:

php 复制代码
const [START_THINKING_TOKEN_ID, END_THINKING_TOKEN_ID] = tokenizer.encode(
  "<think></think>",
  { add_special_tokens: false },
);

然后在 token 回调中检测:

ini 复制代码
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 属性,它表示模型开始输出最终回答的字符位置。分割逻辑如下:

ini 复制代码
const thinking = answerIndex ? content.slice(0, answerIndex) : content;
const answer = answerIndex ? content.slice(answerIndex) : "";
  • answerIndex 存在,则 thinking 是思考部分(从 0 到 answerIndex-1),answer 是回答部分(从 answerIndex 到结尾)。
  • answerIndexundefined(模型还在思考阶段,或未开始回答),则整个 content 都视为思考部分,answer 为空字符串。
  • UI 上,思考部分默认折叠,点击"View reasoning"展开;回答部分直接渲染。当 answer.length > 0 时,表示思考已结束,按钮文字变为"View reasoning.",否则显示"Thinking..."。

这个设计巧妙地将流式输出中的状态切换转化为字符偏移量,实现了思考与回答的分离展示。

6.2 流式输出

Transformers.js 提供了 TextStreamer 实现逐 token 输出:

arduino 复制代码
const streamer = new TextStreamer(tokenizer, {
  skip_prompt: true,          // 不输出提示词
  skip_special_tokens: true,  // 不输出特殊标记
  callback_function,          // 每生成一段文本回调
  token_callback_function,    // 每生成一个 token 回调
});

callback_function 将输出片段通过 postMessage 发给主线程:

lua 复制代码
const callback_function = (output) => {
  self.postMessage({
    status: "update",
    output,
    tps,
    numTokens,
    state,
  });
};

主线程则通过不断追加字符串实现打字机效果:

ini 复制代码
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 提供了 InterruptableStoppingCriteria

arduino 复制代码
const stopping_criteria = new InterruptableStoppingCriteria();

// 中断
case "interrupt":
  stopping_criteria.interrupt();
  break;

// 重置
case "reset":
  stopping_criteria.reset();
  break;

interrupt() 会设置一个标志位,模型在每次预测下一个 token 前检查该标志,如果为真就停止。主线程收到 complete 消息后更新 UI。

6.4 性能指标 tps

tps 表示每秒生成的 token 数,是衡量推理速度的重要指标。在 token 回调中计算:

ini 复制代码
const token_callback_function = (tokens) => {
  startTime ??= performance.now();
  if (numTokens++ > 0) {
    tps = (numTokens / (performance.now() - startTime)) * 1000;
  }
};

主线程展示 tps,让用户了解当前的生成速度。

6.5 聊天容器的智能滚动

流式输出时,用户通常希望自动滚动到底部查看最新内容,但如果用户正在回看历史消息,强制滚动会打断阅读。因此实现了一个"智能滚动"逻辑:

ini 复制代码
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 风险:

javascript 复制代码
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 里维护了一个状态:

ini 复制代码
let state = "thinking"; // 或 "answering"

每次生成 token 时,如果检测到 END_THINKING_TOKEN_ID(即 </think> 对应的 token),就把状态切成 "answering"

ini 复制代码
const token_callback_function = (tokens) => {
  if (tokens[0] == END_THINKING_TOKEN_ID) {
    state = "answering";
  }
};

然后 update 消息会把 state 一起发给主线程。

主线程在 App.jsx 里收到 update 后,当发现 state === "answering" 且当前这条 assistant 消息还没有 answerIndex 时,就把当前内容的长度记录下来:

ini 复制代码
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 的消息后,做两段拆分:

ini 复制代码
const thinking = answerIndex ? content.slice(0, answerIndex) : content;
const answer = answerIndex ? content.slice(answerIndex) : "";
  • 如果还没有 answerIndex(模型还在思考阶段),整段内容都显示在"思考框"里。
  • 一旦 answerIndex 出现,前面的就是思考过程,后面的才是正式回答。

思考框(可折叠)

ini 复制代码
{thinking.length > 0 && (
  <div className="bg-white dark:bg-gray-800 rounded-lg flex flex-col">
    <button
      className="flex 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 className="ml-auto text-gray-700">
        {showThinking ? "▲" : "▼"}
      </span>
    </button>

    {showThinking && (
      <MathJax className="border-t border-gray-200 dark:border-gray-700 px-4 py-2" dynamic>
        <span
          className="markdown"
          dangerouslySetInnerHTML={{ __html: render(thinking) }}
        />
      </MathJax>
    )}
  </div>
)}

默认是折叠的,点击按钮展开,里面用 render(thinking) 把 Markdown 转成 HTML 显示。

回答框(正式回答)

ini 复制代码
{doneThinking && (
  <MathJax className="mt-2" dynamic>
    <span
      className="markdown"
      dangerouslySetInnerHTML={{ __html: render(answer) }}
    />
  </MathJax>
)}

doneThinking 就是 answer.length > 0,只有模型开始输出正式回答后,回答框才会出现。


3. 没开始回答时的占位动画

如果 thinking.length === 0 且还没有回答内容,说明模型刚开始生成,界面上会显示三个跳动的圆点:

ini 复制代码
<span className="h-6 flex items-center gap-1">
  <span className="w-2.5 h-2.5 bg-gray-600 dark:bg-gray-300 rounded-full animate-pulse"></span>
  <span className="w-2.5 h-2.5 bg-gray-600 dark:bg-gray-300 rounded-full animate-pulse animation-delay-200"></span>
  <span className="w-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_values,ONNX 会认为 input_ids 只是新增 token,导致注意力掩码形状无法广播。

解决方案:不要复用 KV Cache,每次都传完整历史。虽然会损失一些性能,但在这个场景下足够用了。

8.2 量化模型的选择

模型使用了 dtype: "q4f16" 量化,大幅减小模型体积并加速推理。量化会带来一定精度损失,但对于 1.5B 模型来说影响不大。你也可以尝试其他量化级别,如 q8f16fp16,根据设备性能调整。

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 查看完整源码,亲手试一试。

相关推荐
XLYcmy1 小时前
小红书 算法一面 二
llm·sft·memory·多模态·位置编码·grpo·视觉数据
名字还没想好☜1 小时前
React 用 useEffect 做轮询实战:setInterval 拿到旧 state 的闭包陷阱与正确清理
前端·javascript·react.js·react·useeffect
CodeBlog-star5 小时前
LLM能力与边界:多模态、幻觉、上下文窗口及开源模型对比
人工智能·python·开源·llm
冬奇Lab5 小时前
Code Agent 解剖(02):agent 是怎么一轮一轮思考和行动的?
人工智能·llm·agent
张元清7 小时前
React useLatest Hook:在异步回调里读到最新状态 (2026)
javascript·react.js
武子康8 小时前
Pi 怎样决定模型看见什么:AGENTS.md、SYSTEM.md 与 Skills 的加载边界
人工智能·llm·agent
Revolution618 小时前
DeepSeek Harness 最近上线:Everything is a Plugin 有什么特别之处
llm·github·deepseek
光影少年8 小时前
react navite图片加载优化、大图卡顿、缓存策略
前端·react native·react.js
武子康8 小时前
从 DeepSeek Harness 看:Tool 注册成功,为什么还不等于安全可用
人工智能·llm·agent