浏览器里的 DeepSeek 到底怎么跑起来?关键在 Worker、缓存和流式消息

浏览器里的 DeepSeek 到底怎么跑起来?关键在 Worker、缓存和流式消息

很多人第一次看 WebGPU 大模型项目,会把注意力集中在 device: "webgpu"。但真正决定代码是否清晰、页面是否能交互的,往往是另外三件事:模型不能重复加载,推理不能堵住主线程,生成内容要能持续回传。

本文以一个 React + Transformers.js 项目为例,沿着真实调用链解释这三个问题。源码是静态阅读,运行未验证,所以浏览器兼容性、模型下载和性能只讨论代码意图,不当作实测结论。

先建立一个判断

这个项目采用了清晰的职责拆分:

text 复制代码
React:输入、按钮、消息和进度
Worker:WebGPU 检查、模型加载、推理
Transformers.js:tokenizer、模型和 streamer

主线程和 Worker 之间不是直接调用函数,而是约定消息格式:

js 复制代码
worker.current.postMessage({ type: "generate", data: messages });

Worker 再按消息类型调用不同函数。这样做的好处是,页面不需要知道模型加载的细节,只需要处理状态和结果。

check 不是"能运行"的最终保证

页面先用:

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

判断 API 是否存在。Worker 中又执行:

js 复制代码
const adapter = await navigator.gpu.requestAdapter();
if (!adapter) {
  throw new Error("WebGPU is not supported (no adapter found)");
}

两者层次不同:前者是属性存在性判断,后者是获取适配器。即使属性存在,也不应直接推断模型一定可以完成推理;还要看浏览器、驱动、模型格式和资源情况。

模型为什么放到 Worker

模型下载和推理都可能产生长任务。项目在 React 中创建 module Worker:

js 复制代码
worker.current = new Worker(new URL("./worker.js", import.meta.url), {
  type: "module",
});

Worker 不能直接操作 windowdocument,但可以通过 self.postMessage 回传:

js 复制代码
self.postMessage({
  status: "update",
  output,
  tps,
  numTokens,
  state,
});

主线程监听这些消息,再更新 React state。这里的重点不是"用了一个新 API",而是把重任务和 UI 生命周期隔离开。

getInstance 实际上管理了什么

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

调用方:

js 复制代码
const [tokenizer, model] =
  await TextGenerationPipeline.getInstance();

这里有两层逻辑。

第一层是异步:from_pretrained 需要下载和初始化资源,所以返回 Promise;Promise.all 等 tokenizer 和 model 都完成后,再用数组解构拿到两个结果。

第二层是缓存:??= 让第一次调用负责加载,后续调用复用已有结果。模型初始化很重,如果每次提问都重新加载,架构就失去了意义。

它是"单例模式"的一种轻量实现:使用类的静态属性管理全局共享资源。需要注意,源码体现的是单例式缓存,不是严格禁止 new TextGenerationPipeline() 的完整单例实现。

从一句话到 token,再回到文字

生成函数首先将聊天数据交给 tokenizer:

js 复制代码
const inputs = tokenizer.apply_chat_template(messages, {
  add_generation_prompt: true,
  return_dict: true,
});

模型接收的是 token 序列,不是页面上的原始字符串。生成后,TextStreamer 负责把新 token 逐步转换成文字。

js 复制代码
const streamer = new TextStreamer(tokenizer, {
  skip_prompt: true,
  skip_special_tokens: true,
  callback_function,
  token_callback_function,
});

然后把 streamer 交给模型:

js 复制代码
await model.generate({
  ...inputs,
  max_new_tokens: 2048,
  streamer,
  stopping_criteria,
  return_dict_in_generate: true,
});

因此,完整链路是:

text 复制代码
聊天消息
  → chat template
  → token
  → model.generate
  → streamer 回调
  → Worker update 消息
  → React 追加 assistant 文本

为什么页面可以边生成边显示

Worker 并不等模型全部结束才通信。每次 streamer 得到输出,就发送一次 update。App 收到后,会把输出拼接到最后一条 assistant 消息:

js 复制代码
setMessages((prev) => {
  const cloned = [...prev];
  const last = cloned.at(-1);
  cloned[cloned.length - 1] = {
    ...last,
    content: last.content + output,
  };
  return cloned;
});

这就是流式 UI 的基本模型:后端或计算线程不断产生片段,前端不断追加片段。

项目还通过 token 数量和耗时计算 TPS,并把思考状态从 thinking 切换到 answering,让界面可以折叠推理过程。

加载时为什么要先"预热"

模型加载后,Worker 使用简单输入执行一次最多生成一个 token 的任务:

js 复制代码
const inputs = tokenizer("a");
await model.generate({ ...inputs, max_new_tokens: 1 });
self.postMessage({ status: "ready" });

这一步不是为了得到业务答案,而是让模型和 WebGPU 提前完成一部分初始化。正式聊天前先做一次预热,是为了把首次调用的准备工作放到加载阶段。源码没有提供实际耗时对比,因此不能据此宣称具体性能提升。

输出到页面前为什么还要清理 HTML

模型输出通常是 Markdown。Chat.jsx 使用 marked 转换,再用 DOMPurify 清理:

js 复制代码
const result = DOMPurify.sanitize(
  marked.parse(text, {
    async: false,
    breaks: true,
  }),
);

最后才通过 dangerouslySetInnerHTML 渲染。这个顺序很重要:Markdown 转 HTML 只是格式转换,不等于内容安全;清理步骤承担了 XSS 防护边界。MathJax 则负责数学公式显示。

一张排错表

问题 根因方向
WebGPU 类型警告 TypeScript 缺少 WebGPU 类型定义
Worker 找不到 引用文件名和实际文件名不一致
Failed to resolve import 依赖未安装或 npm、pnpm 混用导致依赖状态混乱
模型加载后仍不能输入 没有收到 ready 或状态没有更新
回答不流式 streamerupdate 处理链断开
渲染 Markdown 有风险 没有先使用 DOMPurify 清理
停止按钮不生效 stopping criteria 没有传入生成过程

最后回到工程设计

如果只记住一个结论,可以记住这条:

浏览器端大模型项目的核心不是某一行 WebGPU 配置,而是"UI 与推理解耦、模型只初始化一次、结果通过消息流式返回"。

继续学习时,可以按这个顺序拆解:先手写 Worker 的 postMessage 往返,再理解 tokenizer 的输入输出,最后研究 model.generate 和 streamer。这样每一步都有可观察的结果,排错也不会陷入"页面为什么不动"的黑盒状态。

标签: WebGPU、Web Worker、Transformers.js、React、浏览器 AI

相关推荐
BreezeJiang1 小时前
单例模式真正解决的不是“只能 new 一次”,而是统一资源入口
javascript
小林ixn1 小时前
WebGPU + Transformers.js:把 DeepSeek-R1 塞进浏览器,真香!
javascript·llm·gpu
xiaominlaopodaren1 小时前
three.js地图数学基础(四):瓦片金字塔
javascript·gis·three.js
mONESY1 小时前
🔥 React Hooks 进阶实战:受控组件 + 性能优化一站式掌握
javascript
Synmbrf2 小时前
flv播放设置hasAudio为true黑屏
javascript
看到我请叫我铁锤2 小时前
vue编写web端在线预览文档
前端·javascript·vue.js
ssshooter2 小时前
为什么明明只有一个 12px 的小元素,父容器却有 63px 高?
前端·javascript·面试
breeze jiang3 小时前
React + WebGPU 在浏览器运行 DeepSeek:从 Worker 通信到流式生成
前端·javascript·react.js
sunly_3 小时前
React 三个重要概念:Ref、Props、State 详解
前端·javascript·react.js