在浏览器里跑 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 的能力是有上限的。


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

相关推荐
kyriewen2 小时前
面试官说"打开你的AI工具"——我才发现,他考的根本不是写代码
前端·人工智能·面试
IT_陈寒3 小时前
Vue的双向绑定把我坑惨了,原来这个场景不能用
前端·人工智能·后端
hunterandroid3 小时前
[Android 从零到一] Compose LazyColumn 性能优化:key、稳定性与重组治理
android·前端
lichenyang4534 小时前
从一次团队邀请开始:用 React、NestJS 与 Socket.IO 做可靠的实时通知
前端
vtian4 小时前
一篇文章吃透 Monorepo:pnpm + Turborepo + Changesets 全流程实战(含 8 个踩坑)
前端
默_笙4 小时前
❗ 点击计数按钮,为什么"峨眉队"也跟着重新渲染?React.memo 说:我记住了
前端·javascript
31535669134 小时前
DeepSeek Harness 发布后,我没急着跑 Demo,先把 `.agents/` 翻了一遍
前端·后端·github
名字还没想好☜4 小时前
Next.js 用 Server Components 直连数据库:去掉 API 层的边界,和三条别踩的安全红线
前端·javascript·数据库·安全·react·next.js
学习zhao极致it4 小时前
2020全新React教程全家桶实战redux+antd+React Hooks前端js视频
前端
用户921080262865 小时前
Bubble 消息操作区改造:复制、重新生成和反馈
前端