最近在折腾一个有意思的项目:让 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.tokenizer 为 null 或 undefined,才执行赋值。因为 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:下载进度数组,每个元素包含file、progress、total等字段,用于展示多文件下载进度。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 到结尾)。 - 若
answerIndex为undefined(模型还在思考阶段,或未开始回答),则整个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 模型来说影响不大。你也可以尝试其他量化级别,如 q8f16 或 fp16,根据设备性能调整。
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 查看完整源码,亲手试一试。