在浏览器里跑 DeepSeek-R1:用 WebGPU 把大模型搬进前端
一个完全离线、零后端、靠 GPU 加速、还能展示"思考过程"的浏览器大模型应用,是怎么一步步搭起来的?
最近很多同学好奇:
大模型能不能直接跑在浏览器里?
这篇文章我会从背景讲起,逐步讲清原理,再带你看关键代码,最后总结踩坑点和适用场景。即使你基础比较薄弱,只要跟着读下来,也能把整条链路串明白。
一、为什么要把大模型跑在浏览器里
先想一个问题:平时我们用大模型,流程是什么样的?
你输入问题 → 前端把文字发到服务器 → 服务器调用大模型 API → 返回结果 → 前端渲染
这个流程有个绕不开的痛点:
- :你的问题(可能包含敏感信息)被发到了第三方服务器;
隐私
- :每次都要等网络往返,模型在云端排队;
延迟
- :服务商要持续为 GPU 算力买单,所以大模型 API 基本都收费;
成本
- :断网了,就彻底没法用。
依赖
而
浏览器本地推理
你输入问题 → 浏览器本地加载模型 → 用你电脑的 GPU 算 → 直接显示结果
数据不出浏览器,模型文件加载一次后就能离线使用,还不用付 API 费用。
这件事以前很难,因为浏览器里没有合适的算力。但
WebGPU
二、两个关键概念:推理模型与思维链
2.1 DeepSeek-R1 是什么
这个项目用的模型叫
DeepSeek-R1-Distill-Qwen-1.5B-ONNX
- :DeepSeek 推出的"推理模型",特点是擅长数学、逻辑、代码这类需要多步思考的任务;
DeepSeek-R1
- :蒸馏,意思是它其实是把大模型的能力"蒸馏"到一个小模型上;
Distill
- :底层基座是阿里通义千问(Qwen)的 15 亿参数版本,1.5B 属于"小模型",正好适合在浏览器里跑;
Qwen-1.5B
- :一种通用的模型文件格式,方便跨平台部署(浏览器就是其中一个平台)。
ONNX
2.2 思维链(Chain of Thought)
DeepSeek-R1 这类推理模型有个特殊能力:
它会在"正式回答"之前,先自己默默推理一段
举个例子,你问它一道数学题,它的输出其实是两段:
(思考过程,内部推理) 让我们设未知数 x... 第一步移项,第二步因式分解... (正式回答,展示给用户) x 的取值是 1 和 2。
第一段"思考过程"叫
思维链(Chain of Thought,简称 CoT)
这里是模型内心的推理过程... 这里是给用户看的正式回答...
和 是两个特殊的 token
记住这个知识点:
标记思考开始,标记思考结束。后面切分思考/回答,全靠它俩。
三、技术栈拆解:三块核心拼图
这个项目整体是
React + Vite
| 技术 | 作用 | 打个比方 |
|---|---|---|
Transformers.js | 在浏览器里加载模型、执行推理 | 引擎:负责"算" |
WebGPU | 调用显卡做并行计算,加速推理 | 涡轮:让算得飞快 |
Web Worker | 把推理放到后台线程,不卡界面 | 副驾:替你干活,不打扰你 |
3.1 Transformers.js:浏览器里的推理引擎
Transformers.js 是 Hugging Face 出的库,本质是把 Python 生态里大名鼎鼎的 transformers 搬到了 Ja vaScript。它底层用 ONNX Runtime Web 跑模型,所以能在浏览器里执行神经网络计算。
在这个项目里,它提供了两个关键 API:
AutoTokenizer:分词器,把文字转成模型认识的数字(token);AutoModelForCausalLM:因果语言模型,负责根据输入"续写"出下一个词。
3.2 WebGPU:用显卡加速
神经网络推理的核心是大量矩阵运算,这类运算天生适合 GPU 并行处理。WebGPU 是浏览器的新一代图形/计算接口,让网页能直接调度显卡。项目里用 device: "webgpu" 指定用 GPU 跑模型,速度能比纯 CPU 快很多。
3.3 Web Worker:不卡界面
模型推理很吃算力,如果放在主线程跑,页面会卡死(点不动、滚不了)。所以项目把推理整个扔进一个
Web Worker
四、项目架构与消息协议
4.1 主线程和 Worker 的分工
项目代码主要分两部分:
src/App.jsx(主线程):React 组件,负责界面渲染、收集用户输入、显示生成结果;src/worker.js(Worker 线程):负责加载模型、执行推理、把结果流式发回主线程。
它们之间不能直接调用对方的函数,只能通过 postMessage 发消息。于是就有了下面这套
双向消息协议
4.2 消息协议
主线程 → Worker
| 消息 type | 含义 |
|---|---|
check | 检测当前浏览器支不支持 WebGPU |
load | 加载模型 |
generate | 开始生成(携带对话消息) |
interrupt | 中断当前生成 |
reset | 清空缓存、重置状态 |
Worker → 主线程
| 消息 status | 含义 |
|---|---|
loading | 正在加载模型 |
initiate / progress / done | 某个模型文件开始下载 / 下载中 / 下载完 |
ready | 模型就绪,可以对话了 |
start | 生成开始(主线程据此插入一条空消息占位) |
update | 生成中,携带一小段新文本(流式) |
complete | 生成结束 |
error | 出错 |
4.3 完整流程
把整条链路串起来看,一次对话是这样走的:
1. 页面加载 → 创建 Worker → 发 check 检测 WebGPU 2. 用户点 "Load model" → 发 load → Worker 下载模型、编译 shader → 回报 ready 3. 用户输入问题按回车 → 主线程追加 user 消息 → 发 generate 4. Worker 收到 → 套模板 → 开始生成 → 回报 start 5. Worker 每生成一小段 → 回报 update → 主线程逐字拼接到界面上 6. 生成完毕(或被打断)→ 回报 complete → 主线程解锁界面
后面讲代码时,每一步都能对上这个流程。
五、实战代码解析
5.1 模型加载与量化:TextGenerationPipeline
Worker 里用了一个
单例类
class TextGenerationPipeline {
static model_id = "onnx-community/DeepSeek-R1-Distill-Qwen-1.5B-ONNX";
static async getInstance(progress_callback = null) {
// ??= 是"空值合并赋值":只有第一次(还是 null)时才执行加载
this.tokenizer ??= AutoTokenizer.from_pretrained(this.model_id, {
progress_callback,
});
this.model ??= AutoModelForCausalLM.from_pretrained(this.model_id, {
dtype: "q4f16", // 关键:4-bit 量化
device: "webgpu", // 关键:用 GPU 跑
progress_callback,
});
return Promise.all([this.tokenizer, this.model]);
}
}
这里有两个关键点,值得单独说说:
① 量化 q4f16
一个 1.5B 参数的模型,如果按原始 16 位浮点数存,体积会很大。q4f16 的意思是"用 4-bit 存储权重、计算时再转回 16 位浮点"。简单理解就是
把模型"压缩"了 4 倍
② 单例模式 ??=
??= 运算符的意思是"如果左边是 null 或 undefined,就执行右边赋值"。所以 getInstance 被调用很多次,但真正加载只发生第一次,之后都复用已经加载好的 model 和 tokenizer,不会重复下载。
加载完成后,还要"预热"一下:
// 用假输入跑一次,让 GPU 提前编译好 shader(着色器)
const inputs = tokenizer("a");
await model.generate({ ...inputs, max_new_tokens: 1 });
GPU 第一次运行某个计算时,需要现场编译着色器,会卡顿。提前跑一次 dummy 输入,就把这个卡顿提前到加载阶段了,用户真正对话时才流畅。
5.2 构造输入:chat template
用户发来的消息长这样:
[{ role: "user", content: "求解 x^2 - 3x + 2 = 0" }]
但模型不认识这种结构,它只认识一段带特殊标记的文本。于是需要 apply_chat_template 把消息"翻译"成模型训练时见过的格式:
const inputs = tokenizer.apply_chat_template(messages, {
add_generation_prompt: true, // 末尾追加 assistant 起始标记,告诉模型"该你说话了"
return_dict: true, // 返回 token 化后的 input_ids + attention_mask
});
翻译完大致是:
<|im_start|>user 求解 x^2 - 3x + 2 = 0<|im_end|> <|im_start|>assistant
add_generation_prompt: true至关重要:它在结尾处添加了一个<|im_start|>assistant,就好像在告诉模型“接下来该你回答了”,模型便从此处开始续写。而return_dict: true则使其顺便完成了token化,返回的input_ids能够直接提供给模型。
5.3 流式输出:TextStreamer
模型不是一次性生成整段回答,而是
一个字一个字(一个 token 一个 token)地吐
TextStreamer 就是那个把逐个 token 实时解码成文本、并回调出来的"中间人":
const streamer = new TextStreamer(tokenizer, {
skip_prompt: true, // 不重复输出输入的那部分
skip_special_tokens: true, // 过滤 、<|im_end|> 这些特殊标记
callback_function, // 文本级回调:每解出一段新文本就触发
token_callback_function, // token 级回调:每生成一个 token 就触发
});
它有两个回调,分工不同:
token 级回调
const token_callback_function = (tokens) => {
startTime ??= performance.now(); // 记录开始时间
if (numTokens++ > 0) { // 跳过第一个 token
tps = (numTokens / (performance.now() - startTime)) * 1000; // 算 tokens/秒
}
if (tokens[0] == END_THINKING_TOKEN_ID) { // 检测到
state = "answering"; // 从"思考"切到"回答"
}
};
numTokens++ > 0这里很巧妙:numTokens++先返回旧值再自增,所以等价于"是不是已经生成超过 1 个 token"。跳过第一个 token 是因为那时耗时趋近 0,算 tps 会得到无穷大。
文本级回调
const callback_function = (output) => {
self.postMessage({
status: "update",
output, // 一小段增量文本
tps, // 当前速度
numTokens, // 已生成 token 数
state, // thinking 还是 answering
});
};
最后把这些组装起来调 generate:
const { past_key_values, sequences } = await model.generate({
...inputs,
do_sample: false, // 贪心解码,每次选概率最高的 token(结果稳定)
max_new_tokens: 2048, // 最多生成 2048 个 token
streamer, // 挂上流式输出
stopping_criteria, // 支持外部中断
return_dict_in_generate: true,
});
5.4 主线程如何接收流式消息
Worker 每条 update 只带来一小段 output,主线程要把它
追加
case "update": {
const { output, tps, numTokens, state } = e.data;
setTps(tps);
setNumTokens(numTokens);
setMessages((prev) => {
const cloned = [...prev]; // ① 拷贝数组(不可变更新)
const last = cloned.at(-1); // ② 取最后一条(就是那条 assistant 消息)
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.1 流式拼接与 React 不可变更新
上面代码里有两个容易被新手忽略、却很重要的 React 细节:
为什么要用 setMessages((prev) => ...) 这种函数形式?
由于update消息出现得相当频繁,一秒钟可能就有几十甚至上百条。要是写成setMessages([...messages, xxx]),就会依赖闭包中捕获的messages,但那个值可能早就过时了。而在函数式更新里,prev始终是“最新状态”,每次都是在最新的基础上进行累加,这样就不会遗漏更新了。
为什么要拷贝数组 [...prev] 和对象 { ...last }?
React 要求状态不可变:如果你直接改原对象 last.content += output,React 检测不到变化,就不会重新渲染。必须拷贝出新对象、新数组,React 通过"引用变了"来判断"该重渲染了"。
6.2 answerIndex 分界点的时机
思考/回答的切分,是整个项目最巧妙的地方。主线程维护一个 answerIndex,表示"思考在哪结束、回答从哪开始"。
它只在
第一次
if (data.answerIndex === undefined && state === "answering") {
data.answerIndex = last.content.length;
}
answerIndex === undefined:还没记录过分界点;state === "answering":Worker 那边已经检测到了。
两个条件首次同时成立,说明"思考刚结束、回答刚开始",就把当前内容长度记下来。之后渲染层用它切分:
const thinking = answerIndex ? content.slice(0, answerIndex) : content; // 思考段 const answer = answerIndex ? content.slice(answerIndex) : ""; // 回答段
UI 上,思考段默认折叠,点一下才展开,显示成"View reasoning."——这就是你能看到模型推理过程的原因。
有个细节:这里用的是
last.content.length(拼接的旧长度),而不是拼接后的新长度,目的是把"这一波 output 之前的内容"都算作思考,分界点刚好卡在前
结束处。
6.3 KV cache 为什么被注释掉了
细心的同学看代码会发现一段"半成品":
let past_key_values_cache = null; // 声明了缓存变量 // 生成时注释掉了: // past_key_values: past_key_values_cache, // TODO: Add back when fixed // 生成完保存: past_key_values_cache = past_key_values; // reset 时清空: past_key_values_cache = null;
这段代码的
本意
KV cache 复用
但因为当时有 bug 没修好(TODO: Add back when fixed),我临时禁用了这个功能。于是这个变量变成了"存了但没人读"的死代码——保存和清空都没实际作用。
这是一个很好的反面教材
七、全文总结
这篇文章我们完整拆解了一个"浏览器本地大模型"应用。核心结论是:
- ,靠的是 WebGPU 提供 GPU 算力 + 量化压缩模型体积;
浏览器跑大模型是可行的
- 分离:Worker 负责吃力的推理,主线程只做渲染,两者靠消息协议通信;
架构上采用主线程 + Web Worker
- 是体验的关键,靠
流式输出
TextStreamer逐个 token 解码回调,主线程增量拼接实现"打字机"效果; - 利用了
思考/回答的区分
/特殊 token,配合answerIndex分界点实现; - 代码里处处是 React 的、
不可变更新
、函数式 setState
等工程技巧。单例懒加载
八、核心知识点复盘
| 知识点 | 一句话解释 | 在代码里的位置 |
|---|---|---|
| WebGPU | 浏览器调用 GPU 做通用计算 | device: "webgpu" |
| 量化 q4f16 | 4-bit 存权重、16-bit 计算,压缩 4 倍 | dtype: "q4f16" |
| Web Worker | 后台线程跑推理,不卡 UI | new Worker(...) |
| 单例懒加载 | 模型只加载一次 | static getInstance + ??= |
| chat template | 把消息列表转成模型认识的文本 | apply_chat_template |
| 流式输出 | 逐 token 解码回调 | TextStreamer |
| 思维链标记 | / 区分思考与回答 | token 151648 / 151649 |
| 函数式 setState | 基于最新状态更新,避免丢更新 | setMessages((prev) => ...) |
| 不可变更新 | 拷贝新对象/数组触发重渲染 | [...prev]、{ ...last } |
| KV cache | 缓存历史注意力,加速多轮对话 | 被 TODO 禁用 |
九、常见问题 / 避坑指南
1. 浏览器不支持 WebGPU 怎么办?
项目开头就做了检测 const IS_WEBGPU_A VAILABLE = !!na vigator.gpu,不支持时直接显示提示页。WebGPU 目前 Chrome、Edge 已支持,Firefox 部分支持,Safari 较新版本才逐步跟进。所以做这类应用,
一定要先做特性检测
2. 模型下载很慢 / 加载很久怎么办?
1.5B 模型量化后也有几百 MB,首次下载慢是正常的。项目用进度条(initiate/progress/done)让用户知道进度。优化方向:用 CDN 加速、预缓存模型文件(配合 Service Worker 做离线缓存)。
3. 为什么生成一会儿就停了?
看 max_new_tokens: 2048——超过 2048 个 token 就会截断。如果回答被截断,可以调大这个值,但要注意越大会越慢、越占显存。
4. 为什么"思考过程"有时候是空的?
思考/回答的切分完全依赖 / 标记。如果模型某次输出没用这个标记(比如某些蒸馏模型偶发不稳定),answerIndex 就不会被设置,所有内容都会被当成"思考"折叠起来。这是当前实现的一个局限。
5. 流式输出会卡顿或丢字吗?
正常情况下不会,因为函数式 setState 保证了高频 update 不丢更新。但要注意:主线程做太重的渲染(比如每次 update 都重新解析整个 Markdown + MathJax)会变卡。这个项目里思考段是折叠的、只在需要时才渲染,就是为此做的优化。
6. 这个方案适合什么场景?
适合:对隐私敏感的场景、离线场景、想省 API 成本的轻量问答/数学/代码辅助、以及技术演示和教学。不适合:需要超大模型能力(如复杂多轮长文本)、需要低延迟高并发、或用户设备很弱(没有独立 GPU)的场景——毕竟 1.5B 的能力是有上限的。
整篇文章到这里就结束了。项目代码在这,如果你把这个项目跑起来,亲眼看到模型的"思考过程"逐字浮现,会对"推理模型 + 思维链"这件事有更直观的感受。希望这篇文章能帮你把这条技术链路彻底打通。
