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