文章目录
-
- 一、为什么要把大模型跑在浏览器里
- 二、两个关键概念:推理模型与思维链
-
- [2.1 DeepSeek-R1 是什么](#2.1 DeepSeek-R1 是什么)
- [2.2 思维链(Chain of Thought)](#2.2 思维链(Chain of Thought))
- 三、技术栈拆解:三块核心拼图
-
- [3.1 Transformers.js:浏览器里的推理引擎](#3.1 Transformers.js:浏览器里的推理引擎)
- [3.2 WebGPU:用显卡加速](#3.2 WebGPU:用显卡加速)
- [3.3 Web Worker:不卡界面](#3.3 Web Worker:不卡界面)
- 四、项目架构与消息协议
-
- [4.1 主线程和 Worker 的分工](#4.1 主线程和 Worker 的分工)
- [4.2 消息协议](#4.2 消息协议)
- [4.3 完整流程](#4.3 完整流程)
- 五、实战代码解析
-
- [5.1 模型加载与量化:TextGenerationPipeline](#5.1 模型加载与量化:TextGenerationPipeline)
- [5.2 构造输入:chat template](#5.2 构造输入:chat template)
- [5.3 流式输出:TextStreamer](#5.3 流式输出:TextStreamer)
- [5.4 主线程如何接收流式消息](#5.4 主线程如何接收流式消息)
- 六、重点难点展开
-
- [6.1 流式拼接与 React 不可变更新](#6.1 流式拼接与 React 不可变更新)
- [6.2 answerIndex 分界点的时机](#6.2 answerIndex 分界点的时机)
- [6.3 KV cache 为什么被注释掉了](#6.3 KV cache 为什么被注释掉了)
- 七、全文总结
- 八、核心知识点复盘
- [九、常见问题 / 避坑指南](#九、常见问题 / 避坑指南)
一个完全离线、零后端、靠 GPU 加速、还能展示"思考过程"的浏览器大模型应用,是怎么一步步搭起来的?
最近很多同学好奇:大模型能不能直接跑在浏览器里? 答案是能。今天我们就来讲解一个项目,它把一个 1.5B 参数的推理模型塞进浏览器,全程本地运行,不联网、不调接口、甚至还能看到模型的"内心推理过程"。
这篇文章我会从背景讲起,逐步讲清原理,再带你看关键代码,最后总结踩坑点和适用场景。即使你基础比较薄弱,只要跟着读下来,也能把整条链路串明白。
一、为什么要把大模型跑在浏览器里
先想一个问题:平时我们用大模型,流程是什么样的?
你输入问题 → 前端把文字发到服务器 → 服务器调用大模型 API → 返回结果 → 前端渲染
这个流程有个绕不开的痛点:
- 隐私:你的问题(可能包含敏感信息)被发到了第三方服务器;
- 延迟:每次都要等网络往返,模型在云端排队;
- 成本:服务商要持续为 GPU 算力买单,所以大模型 API 基本都收费;
- 依赖:断网了,就彻底没法用。
而浏览器本地推理的思路是反过来的:
你输入问题 → 浏览器本地加载模型 → 用你电脑的 GPU 算 → 直接显示结果
数据不出浏览器,模型文件加载一次后就能离线使用,还不用付 API 费用。
这件事以前很难,因为浏览器里没有合适的算力。但 WebGPU 出现后,情况变了------它允许网页直接调用你电脑的显卡(GPU)做通用计算。于是,"在浏览器里跑大模型"从玩具变成了真正可用的方案。
二、两个关键概念:推理模型与思维链
2.1 DeepSeek-R1 是什么
这个项目用的模型叫 DeepSeek-R1-Distill-Qwen-1.5B-ONNX。名字很长,拆开看:
- DeepSeek-R1:DeepSeek 推出的"推理模型",特点是擅长数学、逻辑、代码这类需要多步思考的任务;
- Distill:蒸馏,意思是它其实是把大模型的能力"蒸馏"到一个小模型上;
- Qwen-1.5B:底层基座是阿里通义千问(Qwen)的 15 亿参数版本,1.5B 属于"小模型",正好适合在浏览器里跑;
- ONNX:一种通用的模型文件格式,方便跨平台部署(浏览器就是其中一个平台)。
2.2 思维链(Chain of Thought)
DeepSeek-R1 这类推理模型有个特殊能力:它会在"正式回答"之前,先自己默默推理一段。
举个例子,你问它一道数学题,它的输出其实是两段:
(思考过程,内部推理)
让我们设未知数 x...
第一步移项,第二步因式分解...
(正式回答,展示给用户)
x 的取值是 1 和 2。
第一段"思考过程"叫思维链(Chain of Thought,简称 CoT)。模型用一段特殊的标记把它包起来:
<think>
这里是模型内心的推理过程...
</think>
这里是给用户看的正式回答...
<think> 和 </think> 是两个特殊的 token(标记),它们就像 HTML 标签一样,把"思考内容"和"回答内容"区分开。这个项目最有趣的地方,就是利用了这两个标记,把模型的思考过程单独拿出来展示------你能亲眼看到模型"是怎么想的"。
记住这个知识点:
<think>标记思考开始,</think>标记思考结束。后面切分思考/回答,全靠它俩。
三、技术栈拆解:三块核心拼图
这个项目整体是 React + Vite 的前端应用,但它真正硬核的是下面三块拼图:
| 技术 | 作用 | 打个比方 |
|---|---|---|
| Transformers.js | 在浏览器里加载模型、执行推理 | 引擎:负责"算" |
| WebGPU | 调用显卡做并行计算,加速推理 | 涡轮:让算得飞快 |
| Web Worker | 把推理放到后台线程,不卡界面 | 副驾:替你干活,不打扰你 |
3.1 Transformers.js:浏览器里的推理引擎
Transformers.js 是 Hugging Face 出的库,本质是把 Python 生态里大名鼎鼎的 transformers 搬到了 JavaScript。它底层用 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 里用了一个单例类来管理模型,保证 tokenizer 和 model 只加载一次:
js
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,不会重复下载。
加载完成后,还要"预热"一下:
js
// 用假输入跑一次,让 GPU 提前编译好 shader(着色器)
const inputs = tokenizer("a");
await model.generate({ ...inputs, max_new_tokens: 1 });
GPU 第一次运行某个计算时,需要现场编译着色器,会卡顿。提前跑一次 dummy 输入,就把这个卡顿提前到加载阶段了,用户真正对话时才流畅。
5.2 构造输入:chat template
用户发来的消息长这样:
js
[{ role: "user", content: "求解 x^2 - 3x + 2 = 0" }]
但模型不认识这种结构,它只认识一段带特殊标记的文本。于是需要 apply_chat_template 把消息"翻译"成模型训练时见过的格式:
js
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 实时解码成文本、并回调出来的"中间人":
js
const streamer = new TextStreamer(tokenizer, {
skip_prompt: true, // 不重复输出输入的那部分
skip_special_tokens: true, // 过滤 <think>、<|im_end|> 这些特殊标记
callback_function, // 文本级回调:每解出一段新文本就触发
token_callback_function, // token 级回调:每生成一个 token 就触发
});
它有两个回调,分工不同:
token 级回调------拿到的是原始 token id,用来算速度和检测状态切换:
js
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) { // 检测到 </think>
state = "answering"; // 从"思考"切到"回答"
}
};
numTokens++ > 0这里很巧妙:numTokens++先返回旧值再自增,所以等价于"是不是已经生成超过 1 个 token"。跳过第一个 token 是因为那时耗时趋近 0,算 tps 会得到无穷大。
文本级回调------拿到的是解码后的文本,直接发给主线程:
js
const callback_function = (output) => {
self.postMessage({
status: "update",
output, // 一小段增量文本
tps, // 当前速度
numTokens, // 已生成 token 数
state, // thinking 还是 answering
});
};
最后把这些组装起来调 generate:
js
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,主线程要把它追加到已有内容后面:
js
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,表示"思考在哪结束、回答从哪开始"。
它只在第一次满足这个条件时赋值:
js
if (data.answerIndex === undefined && state === "answering") {
data.answerIndex = last.content.length;
}
answerIndex === undefined:还没记录过分界点;state === "answering":Worker 那边已经检测到</think>了。
两个条件首次同时成立,说明"思考刚结束、回答刚开始",就把当前内容长度记下来。之后渲染层用它切分:
js
const thinking = answerIndex ? content.slice(0, answerIndex) : content; // 思考段
const answer = answerIndex ? content.slice(answerIndex) : ""; // 回答段
UI 上,思考段默认折叠,点一下才展开,显示成"View reasoning."------这就是你能看到模型推理过程的原因。
有个细节:这里用的是
last.content.length(拼接前 的旧长度),而不是拼接后的新长度,目的是把"这一波 output 之前的内容"都算作思考,分界点刚好卡在</think>结束处。
6.3 KV cache 为什么被注释掉了
细心的同学看代码会发现一段"半成品":
js
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 复用:多轮对话时,把上一次已经算好的历史 token 的注意力缓存(KV cache)存下来,下次直接复用,就不用从头重新计算所有历史 token 了,能显著加速多轮对话。
但因为当时有 bug 没修好(TODO: Add back when fixed),我临时禁用了这个功能。于是这个变量变成了"存了但没人读"的死代码------保存和清空都没实际作用。这是一个很好的反面教材:代码里如果发现某段逻辑"看起来在做、其实没生效",一定要去找它被注释掉或跳过的根源。
七、全文总结
这篇文章我们完整拆解了一个"浏览器本地大模型"应用。核心结论是:
- 浏览器跑大模型是可行的,靠的是 WebGPU 提供 GPU 算力 + 量化压缩模型体积;
- 架构上采用主线程 + Web Worker 分离:Worker 负责吃力的推理,主线程只做渲染,两者靠消息协议通信;
- 流式输出 是体验的关键,靠
TextStreamer逐个 token 解码回调,主线程增量拼接实现"打字机"效果; - 思考/回答的区分 利用了
<think>/</think>特殊 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 |
| 思维链标记 | <think> / </think> 区分思考与回答 |
token 151648 / 151649 |
| 函数式 setState | 基于最新状态更新,避免丢更新 | setMessages((prev) => ...) |
| 不可变更新 | 拷贝新对象/数组触发重渲染 | [...prev]、{ ...last } |
| KV cache | 缓存历史注意力,加速多轮对话 | 被 TODO 禁用 |
九、常见问题 / 避坑指南
1. 浏览器不支持 WebGPU 怎么办?
项目开头就做了检测 const IS_WEBGPU_AVAILABLE = !!navigator.gpu,不支持时直接显示提示页。WebGPU 目前 Chrome、Edge 已支持,Firefox 部分支持,Safari 较新版本才逐步跟进。所以做这类应用,一定要先做特性检测并给出降级提示。
2. 模型下载很慢 / 加载很久怎么办?
1.5B 模型量化后也有几百 MB,首次下载慢是正常的。项目用进度条(initiate/progress/done)让用户知道进度。优化方向:用 CDN 加速、预缓存模型文件(配合 Service Worker 做离线缓存)。
3. 为什么生成一会儿就停了?
看 max_new_tokens: 2048------超过 2048 个 token 就会截断。如果回答被截断,可以调大这个值,但要注意越大会越慢、越占显存。
4. 为什么"思考过程"有时候是空的?
思考/回答的切分完全依赖 <think>/</think> 标记。如果模型某次输出没用这个标记(比如某些蒸馏模型偶发不稳定),answerIndex 就不会被设置,所有内容都会被当成"思考"折叠起来。这是当前实现的一个局限。
5. 流式输出会卡顿或丢字吗?
正常情况下不会,因为函数式 setState 保证了高频 update 不丢更新。但要注意:主线程做太重的渲染(比如每次 update 都重新解析整个 Markdown + MathJax)会变卡。这个项目里思考段是折叠的、只在需要时才渲染,就是为此做的优化。
6. 这个方案适合什么场景?
适合:对隐私敏感的场景、离线场景、想省 API 成本的轻量问答/数学/代码辅助、以及技术演示和教学。不适合:需要超大模型能力(如复杂多轮长文本)、需要低延迟高并发、或用户设备很弱(没有独立 GPU)的场景------毕竟 1.5B 的能力是有上限的。
整篇文章到这里就结束了。项目代码在这,如果你把这个项目跑起来,亲眼看到模型的"思考过程"逐字浮现,会对"推理模型 + 思维链"这件事有更直观的感受。希望这篇文章能帮你把这条技术链路彻底打通。