欢迎光临
我们一直在努力

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

文章目录

    • 一、为什么要把大模型跑在浏览器里
    • 二、两个关键概念:推理模型与思维链
      • 2.1 DeepSeek-R1 是什么
      • 2.2 思维链(Chain of Thought)
    • 三、技术栈拆解:三块核心拼图
      • 3.1 Transformers.js:浏览器里的推理引擎
      • 3.2 WebGPU:用显卡加速
      • 3.3 Web Worker:不卡界面
    • 四、项目架构与消息协议
      • 4.1 主线程和 Worker 的分工
      • 4.2 消息协议
      • 4.3 完整流程
    • 五、实战代码解析
      • 5.1 模型加载与量化:TextGenerationPipeline
      • 5.2 构造输入:chat template
      • 5.3 流式输出:TextStreamer
      • 5.4 主线程如何接收流式消息
    • 六、重点难点展开
      • 6.1 流式拼接与 React 不可变更新
      • 6.2 answerIndex 分界点的时机
      • 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 只加载一次:

    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, // 过滤 <think>、<|im_end|> 这些特殊标记
    callback_function, // 文本级回调:每解出一段新文本就触发
    token_callback_function, // token 级回调:每生成一个 token 就触发
    });

    它有两个回调,分工不同:

    token 级回调——拿到的是原始 token id,用来算速度和检测状态切换:

    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 会得到无穷大。

    文本级回调——拿到的是解码后的文本,直接发给主线程:

    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 那边已经检测到 </think> 了。

    两个条件首次同时成立,说明"思考刚结束、回答刚开始",就把当前内容长度记下来。之后渲染层用它切分:

    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 为什么被注释掉了

    细心的同学看代码会发现一段"半成品":

    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 的能力是有上限的。


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

    赞(0)
    未经允许不得转载:171主机测评 » 在浏览器里跑 DeepSeek-R1:用 WebGPU 把大模型搬进前端
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址