在浏览器里跑 DeepSeek-R1:用 WebGPU 把大模型搬进前端

文章目录

    • 一、为什么要把大模型跑在浏览器里
    • 二、两个关键概念:推理模型与思维链
      • [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 → 返回结果 → 前端渲染

这个流程有个绕不开的痛点:

  1. 隐私:你的问题(可能包含敏感信息)被发到了第三方服务器;
  2. 延迟:每次都要等网络往返,模型在云端排队;
  3. 成本:服务商要持续为 GPU 算力买单,所以大模型 API 基本都收费;
  4. 依赖:断网了,就彻底没法用。

而浏览器本地推理的思路是反过来的:

复制代码
你输入问题 → 浏览器本地加载模型 → 用你电脑的 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),我临时禁用了这个功能。于是这个变量变成了"存了但没人读"的死代码------保存和清空都没实际作用。这是一个很好的反面教材:代码里如果发现某段逻辑"看起来在做、其实没生效",一定要去找它被注释掉或跳过的根源。


七、全文总结

这篇文章我们完整拆解了一个"浏览器本地大模型"应用。核心结论是:

  1. 浏览器跑大模型是可行的,靠的是 WebGPU 提供 GPU 算力 + 量化压缩模型体积;
  2. 架构上采用主线程 + Web Worker 分离:Worker 负责吃力的推理,主线程只做渲染,两者靠消息协议通信;
  3. 流式输出 是体验的关键,靠 TextStreamer 逐个 token 解码回调,主线程增量拼接实现"打字机"效果;
  4. 思考/回答的区分 利用了 <think>/</think> 特殊 token,配合 answerIndex 分界点实现;
  5. 代码里处处是 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 的能力是有上限的。


整篇文章到这里就结束了。项目代码在这,如果你把这个项目跑起来,亲眼看到模型的"思考过程"逐字浮现,会对"推理模型 + 思维链"这件事有更直观的感受。希望这篇文章能帮你把这条技术链路彻底打通。

相关推荐
用户461169657284 分钟前
网站 HTTPS 不稳定?证书排查指南
前端
n876515 分钟前
Playwright Java WEB UI 自动化实现框架与逻辑
java·前端·ui
keyipatience17 分钟前
TypeScript学习(1)
前端·javascript·学习·typescript·html·css3
xifangge20251 小时前
基于 JavaScript 实现 CNC 数控切削参数与螺纹公差纯前端核算引擎
开发语言·前端·javascript
wangruofeng1 小时前
2026 Web 技术选型指南,AI 时代如何选择适合项目的技术栈
前端·aigc·ai编程
honkun61 小时前
vue 表格组件 vxe-table 修改滚动条样式,修改滚动条样式,统一宽度、颜色与自动隐藏
前端·javascript·vue.js
爱勇宝2 小时前
研究数字孪生的这半年,做了个 Demo,也有了一些新想法
前端·程序员
不可能片场2 小时前
工程实践 一个缓存把选片从十分钟压到一分钟
前端·electron
前端小万2 小时前
探索副业五个月,最终以放弃告终
前端·后端·面试
FRing032 小时前
JavaScript入门到精通二十四(正则表达式、元字符、修饰符)
开发语言·前端·javascript·正则表达式