浏览器里的 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

相关推荐
jktantan1 小时前
Vue keep-alive 切标签后滚动位置回顶?因为 onDeactivated 里读到的 scrollTop 是 0
前端·javascript·vue.js
总要学点什么2 小时前
第89篇:三维特效-螺旋圆效果——让螺旋臂卷起一圈圈外扩的涟漪
开发语言·javascript·ecmascript·webgl·cesium·空间分析·1024程序员节
前端精髓3 小时前
调用openrouter的免费API模型
java·前端·javascript
螺丝钉tina9 小时前
[Playwright] [02]常用命令与调试:Debug UI、Mode和 Trace Viewer
开发语言·javascript·测试工具·ui·自动化
开开心心就好10 小时前
竖式除法出题工具,整除余数混合模式
运维·服务器·前端·javascript·ocr·perl·1024程序员节
এ慕ོ冬℘゜10 小时前
JS 事件循环、宏任务微任务:单线程底层原理 + 执行顺序
开发语言·javascript·ecmascript
web打印社区10 小时前
Lodop排障总纲-常见报错一张表
开发语言·前端·javascript·chrome·websocket
程序员大阳11 小时前
副队长JavaScript教程(56)-DOM之表单操作
javascript·dom·表单操作
Dxy123931021611 小时前
JS AES加密详解
开发语言·javascript·ecmascript
FRing0312 小时前
JavaScript入门到精通三十三(异常处理、call方法、apply方法、bind方法、防抖、节流)
开发语言·前端·javascript