浏览器里的 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 不能直接操作 window 和 document,但可以通过 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 或状态没有更新
回答不流式 streamer 或 update 处理链断开
渲染 Markdown 有风险 没有先使用 DOMPurify 清理
停止按钮不生效 stopping criteria 没有传入生成过程

最后回到工程设计

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

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

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

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

相关推荐
夏幻灵7 小时前
JavaScript this 面试:五种绑定规则、优先级与常见面试陷阱
开发语言·javascript·面试
三天不学习8 小时前
Egg.js 4 突然爆火,原因是否归结于AI 原生落地需求爆发?
前端·javascript·全栈·egg.js
xcs194059 小时前
前端 vue 的前端页面debugger 进不去
前端·javascript·vue.js
小宋102114 小时前
A2UI从零实战:Agent安全生成交互界面与事件回传
javascript·人工智能·安全·交互
大包子14 小时前
structuredClone vs lodash.cloneDeep:核心差异与选型指南
javascript·vue.js
lerhxx15 小时前
AI 应用中的上下文管理 —— 从"上下文窗口"到"分层压缩"的工程实践
前端·javascript
Mahut15 小时前
我在 Mac 上做了个本地剪辑器
javascript·架构·产品
汉堡大王952715 小时前
GPT-6 上线 48 小时,我扒开了 Intelligent UI 的运行机制:DIL、沙箱 Worker 和一个 React 式协调器
前端·javascript·后端
hai_android15 小时前
Chat 聊天模块功能总结
前端·javascript·vue.js
创世虚拟世界16 小时前
我做了一个 3D 虚拟世界基底,基于这个再做 3D 虚拟世界或者元宇宙,会事半功倍
javascript·人工智能·3d·gitee·虚拟现实