从“加载模型”界面到端侧推理:拆解一个 React + WebGPU 大模型 Demo

这个项目还没有真正把大模型跑起来,但它已经把端侧 AI 应用最重要的 UI 骨架搭好了:能力检测、加载状态、错误出口、下载进度和输入交互。本文从现有代码出发,梳理它已经完成的部分,以及接入真实推理链路还差什么。

每次调用云端大模型 API,输入内容都要离开浏览器,还会受到网络、额度和服务可用性的影响。另一条路线是把模型和推理放在用户设备上:模型首次下载到浏览器缓存,后续由本机 GPU 或 CPU 执行,用户的提示词不必为了推理而发送到业务服务器。

deepseek-r1-webgpu 正是在探索这条路线。项目的页面描述指向 DeepSeek-R1-Distill-Qwen-1.5B-ONNX、Transformers.js、ONNX Runtime Web 和 WebGPU;代码则先实现了一个 React + TypeScript + Vite + Tailwind CSS 的交互骨架。

先说明本文的边界:当前仓库尚未 安装或调用 @huggingface/transformersonnxruntime-web,也没有模型下载与文本生成代码。点击 Load Model 只会把 status'0' 改为 'loading'。因此它是一个很适合学习前端状态设计的端侧 AI 页面原型,而不是已经可推理的完整应用。

项目先解决了什么问题

入口 src/main.tsx 使用 React 19 的 createRoot 挂载 App,并由 Vite 插件启用 React 与 Tailwind CSS。页面主逻辑在 src/App.tsx,可先把它看成一台由状态驱动的界面机器:

tsx 复制代码
const [inputValue, setInputValue] = useState('');
const [status, setStatus] = useState('0');
const [error, setError] = useState(null);
const [loadingMessage, setLoadingMessage] = useState('开始加载');
const [progressItems, setProgressItems] = useState([]);

const isWebGpuAvailable = !!navigator.gpu;

这些变量对应的并不是某几个散落的 DOM 节点,而是页面的业务状态:

状态 当前用途 应驱动的界面
isWebGpuAvailable 检测浏览器是否暴露 WebGPU API 主界面或"不支持 WebGPU"提示
status 初始、加载、就绪等模型生命周期 按钮可用性、输入框可用性、进度区
error 失败原因 错误文案与重试入口
progressItems 模型文件下载事件 多条下载进度条
inputValue 用户正在输入的问题 受控 textarea 与 Enter 发送

这种写法的价值在于,界面不再由"找到某个元素再改样式"的命令式 DOM 操作拼出来。状态变化后,React 重新执行组件函数,再根据条件渲染对应 UI:

tsx 复制代码
disabled={status !== '0' || error !== null}

{status === 'loading' && <LoadingPanel />}

<textarea disabled={status !== 'ready'} />

Load Model 只有在初始状态且没有错误时可点击;模型未就绪时文本框保持禁用。即便真实模型逻辑还没接入,这两个约束已经避免了"模型还没加载完,用户就发送请求"的无效交互。

第一层降级:先判断 WebGPU 能力

项目使用下面的判断:

ts 复制代码
const isWebGpuAvailable = !!navigator.gpu;

navigator.gpu 是浏览器提供 WebGPU 接口时暴露的入口。双重否定把可能为 undefined 的值转换成清晰的布尔值,随后组件在支持与不支持之间选择不同视图:

tsx 复制代码
return isWebGpuAvailable ? <MainPage /> : <div>您的浏览器还不支持 WebGPU</div>;

这是合理的第一步,不过它只回答"浏览器有没有 API",并不保证设备一定能完成模型推理。真实接入时,还应调用 navigator.gpu.requestAdapter() 并处理拿不到 adapter 或设备创建失败的情况。错误状态应该把这些失败呈现给用户,而不是只停留在控制台。

这里也有一个产品取舍:如果应用目标必须依赖 GPU,明确告知不支持是最诚实的方案;如果希望覆盖更多设备,可以把 WebGPU 作为优先后端,再提供 WASM/CPU 作为性能较低的回退。是否回退取决于模型体积、内存占用和可接受的响应时间,不是一条单纯的 API 判断。

进度条为何要拆成组件

src/components/Progress.tsx 把一条下载项的展示抽成组件:

tsx 复制代码
<Progress
  key={index}
  text={text}
  percentage={percentage}
  total={total}
/>

组件内部把百分比映射为宽度,并将字节数格式化成用户可读的单位:

tsx 复制代码
percentage ??= 0;

<div style={{ width: `${percentage}%` }} className="bg-blue-400 h-2" />

这对应模型加载的真实形态:一个"模型"通常不是一份孤立文件,配置、分词器、权重分片和 ONNX 图都可能分别下载。父组件持有下载项数组,子组件只按 props 渲染,最适合表达这种一对多关系。

后续对接运行库时,进度回调应只更新父组件的数组。下面是与当前页面结构匹配的示意代码,并非仓库已有实现:

ts 复制代码
type ProgressItem = {
  text: string;
  percentage?: number;
  total?: number;
};

function updateProgress(item: ProgressItem) {
  setProgressItems((previous) => {
    const index = previous.findIndex((entry) => entry.text === item.text);
    if (index === -1) return [...previous, item];

    return previous.map((entry, currentIndex) =>
      currentIndex === index ? { ...entry, ...item } : entry,
    );
  });
}

注意这里使用函数式更新。下载事件可能连续到达,下一次数组取决于前一次数组时,用 setProgressItems(previous => ...) 能避免闭包拿到旧值。渲染列表的 key 也建议使用稳定的文件名或路径,而不是数组下标;下载项顺序变化时,稳定 key 才能让 React 正确复用节点。

受控输入与发送时机

底部输入框采用受控组件:

tsx 复制代码
<textarea
  value={inputValue}
  disabled={status !== 'ready'}
  onInput={(event) => {
    const target = event.target as HTMLTextAreaElement;
    setInputValue(target.value);
  }}
  onKeyDown={(event) => {
    if (inputValue.length > 0 && event.key === 'Enter' && !event.shiftKey) {
      event.preventDefault();
      onEnter();
    }
  }}
/>

它的行为很符合聊天产品:普通 Enter 发送,Shift + Enter 保留换行。代码还在事件中通过类型断言把 event.target 视为 HTMLTextAreaElement,这样 TypeScript 才能安全读取 value

当前 onEnter 只将状态设置为 loading,没有真正把输入交给模型。接上推理后,建议把"模型正在下载"和"模型正在生成"拆开,而不是共用一个模糊的 loading。例如:

ts 复制代码
type ModelStatus = 'idle' | 'downloading' | 'ready' | 'generating' | 'error';

用联合类型代替字符串 '0',可以让编辑器检查拼写,也能让状态分支更易读。生成期间禁用重复发送按钮,结束或失败后回到 ready,聊天体验会更完整。

真正的端侧推理链路长什么样

要让"Load Model"不再只是切换页面状态,需要把页面状态与模型运行时连接起来。结合项目已有的模型链接,最小闭环可以拆为六步:

  1. 检查 WebGPU 并请求 adapter/device,失败时进入可见的错误或回退状态。
  2. 首次点击后加载模型配置、分词器和 ONNX 权重;利用运行库的进度回调更新 progressItems
  3. 创建可复用的模型或 pipeline 实例,完成后将状态设为 ready
  4. 用户提交文本后,将 prompt 经过 tokenizer 编码为 token。
  5. 推理后端在浏览器的本机设备上逐步生成 token;生成过程可持续刷新输出区。
  6. 解码为文本并追加到会话;缓存命中时,后续访问可跳过或减少下载。

"端侧"不等于模型文件永远不接触网络。首次加载仍需从模型托管服务获取资源,除非它们已经在浏览器缓存中;真正的边界是,完成加载后,推理提示词和生成计算可留在本机执行。生产应用仍需审视模型来源、资源完整性、缓存策略以及浏览器存储配额。

当前工程还需要补的两处基础工作

执行 npm run build 时,当前项目会在 TypeScript 检查阶段停止。原因包括:App.tsxsetErrorsetLoadingMessagesetProgressItems 还没有被使用;Progress.tsx 的 props 没有类型;并且该文件保留了未使用的 zod 导入。项目的 tsconfig.app.json 启用了 strictnoUnusedLocalsnoUnusedParameters,所以这些教学阶段的占位代码会被严格检查出来。

这是一个很好的提醒:TypeScript 不是等所有功能做完才开启的装饰。把状态模型和组件 props 定义清楚,能让"UI 正在等待什么、谁可以修改这个数据"在编译期就被约束。

例如进度条可以先定义一个明确的契约:

tsx 复制代码
type ProgressProps = {
  text: string;
  percentage?: number;
  total?: number;
};

function Progress({ text, percentage = 0, total }: ProgressProps) {
  // render...
}

同时,未接入的 setter 应暂时移除,或在真正的模型加载、报错和回调逻辑接入后再使用。这样 npm run build 才能成为可信的交付检查。

小结

这个项目最值得学习的,不是"已经在浏览器跑通 DeepSeek-R1",而是它为端侧 AI 建立了正确的前端分层:

  • 用 WebGPU 能力检测决定入口体验。
  • 用 React 状态驱动加载、就绪、错误与交互禁用。
  • 用父组件数组和 Progress 子组件表达多个文件的下载反馈。
  • 用受控输入把键盘行为收束为可预测的状态更新。
  • 用 TypeScript 严格检查为真实运行时接入留出边界。

模型运行时接入以后,UI 层不需要推倒重来,只需让运行库的下载、加载和生成事件进入现有的状态机。对学习端侧大模型应用而言,这正是最可靠的起点:先把用户能看见、能恢复、能理解的状态做对,再把模型接进去。

参考

相关推荐
Larcher2 小时前
从状态快照到惰性初始化:读懂 React useState 的三个关键场景
javascript·人工智能·后端
lazy H2 小时前
Git clone 怎么用?克隆项目及常见问题完整教程
大数据·git·后端·学习·搜索引擎·github
wang09072 小时前
自己动手写一个spring之aop_1
java·后端·spring
神奇小汤圆2 小时前
IDEA 运行报 Command line is too long?别慌,两招搞定(附原理)
后端
SelectDB3 小时前
Apache Doris 4.1 全面增强 Iceberg:支持 UPDATE、MERGE INTO 与 Iceberg V3
后端
Conan在掘金3 小时前
ArkTS 进阶之道(13):ForEach 循环渲染边界——为啥 build 里不能写 for 循环
后端
Hilaku3 小时前
工作 5 年后,决定你薪资上限的究竟是什么?
前端·javascript·程序员
妙码生花3 小时前
从 PHP 到 AI + Golang,程序员自救转型手记(四十一):增加管理员账号管理接口
后端·go·gin
用户0678260743273 小时前
APP版本管理全链路(后端设计)
后端