基于WebGPU的端侧AI推理:从模型部署到浏览器内运行DeepSeek R1
2026/8/13 9:26:12 网站建设 项目流程

1. 项目概述:当现代前端工程遇上端侧AI推理

最近在折腾一个挺有意思的项目:在浏览器里,用WebGPU直接跑DeepSeek R1模型进行推理。这听起来可能有点“硬核”,但背后的逻辑其实很清晰——我们正处在一个AI应用需要“下沉”到边缘、需要更低延迟和更高隐私保护的时代。传统的云端API调用模式,虽然方便,但在响应速度、数据安全和离线可用性上始终存在瓶颈。而WebGPU,作为下一代Web图形与计算API,终于让浏览器具备了执行高性能通用计算的能力,这为在端侧(特别是浏览器环境)直接运行中小型AI模型打开了大门。

这个项目的核心目标,就是打通从获取模型、转换格式、构建前端推理引擎到最终在浏览器中流畅运行DeepSeek R1的完整链路。它不仅仅是一个“Hello World”式的Demo,更是一次对现代前端工程化能力边界的探索。你需要处理的不再是简单的DOM操作或状态管理,而是模型加载、内存管理、计算着色器编写、性能优化等一系列接近底层系统开发的挑战。适合谁来参考呢?如果你是对AI应用落地感兴趣的前端工程师,想深入了解模型部署和优化的机器学习实践者,或者任何希望探索Web平台计算潜力的开发者,这个项目都能提供一套完整的、可复现的实践路径。接下来,我会把整个构建过程中的关键决策、踩过的坑以及最终沉淀下来的经验,毫无保留地分享出来。

2. 核心架构设计与技术选型解析

2.1 为什么是WebGPU?而不是WebAssembly或WebGL?

在决定端侧推理方案时,我们有几个候选:纯CPU计算的WebAssembly(WASM)、基于图形API的WebGL,以及较新的WebGPU。最终选择WebGPU,是基于以下几个核心考量:

首先,计算范式与性能。WebAssembly虽然通用,但其并行计算能力依赖于SIMD(单指令多数据)指令,且对GPU没有直接访问权,在处理矩阵乘法等神经网络核心运算时,性能天花板明显。WebGL虽然能利用GPU,但其设计初衷是图形渲染,用于通用计算(GPGPU)属于“曲线救国”,需要将数据包装成纹理,通过渲染管线来模拟计算,编程模型非常别扭且效率有损耗。而WebGPU是为通用计算和图形渲染共同设计的现代API,它提供了直接的计算管线(Compute Pipeline),可以像CUDA或Metal那样直接编写计算着色器(Compute Shader)来操作缓冲区(Buffer)中的数据,这正契合了神经网络中大量的张量运算需求。

其次,内存与数据交换。在WebGL的GPGPU方案中,数据需要在JavaScript的ArrayBuffer和GPU的纹理之间来回转换、拷贝,这个过程(称为“像素打包/解包”)不仅麻烦,更是性能瓶颈。WebGPU引入了映射缓冲区(Mapped Buffer)的概念,允许CPU和GPU更高效地共享内存,数据准备和读取的延迟大大降低。对于需要频繁在JS中预处理输入数据、后处理输出结果的推理场景,这一点至关重要。

最后,生态与未来性。WebGPU是W3C的标准,得到了主流浏览器(Chrome、Edge、Safari、Firefox)的积极支持。更重要的是,整个机器学习社区正在向WebGPU靠拢。例如,微软的ONNX Runtime提供了Web后端,一些新兴的Web原生ML框架(如web-llmtransformers.js)也开始优先支持WebGPU作为加速后端。选择WebGPU,就是选择了未来的主流技术栈。

注意:WebGPU的浏览器支持仍在完善中,通常需要在浏览器设置中显式启用标志(如chrome://flags/#enable-unsafe-webgpu)。在生产环境部署时,务必做好能力检测和优雅降级方案。

2.2 模型格式转换:从PyTorch到WebGPU可用的ONNX

DeepSeek R1原始模型通常是PyTorch格式(.pth或保存在Hugging Face Hub)。浏览器中的WebGPU无法直接加载这种格式。我们需要一个中间格式。常见的选择有ONNX、TensorFlow.js(TFJS)格式、或者新兴的GGUF/MLC格式。

这里我选择ONNX(Open Neural Network Exchange)。原因如下:ONNX是一个开放的模型格式标准,几乎所有的训练框架(PyTorch, TensorFlow)都能方便地导出为ONNX。它拥有一个相对精简的运行时(ONNX Runtime),并且其计算图表示非常清晰,便于我们理解和进行后续的图优化(如算子融合、常量折叠)。虽然最终在浏览器中我们需要自己实现ONNX算子的WebGPU内核,但拥有一个标准化的中间表示,让整个流程更可控、更易于调试。

转换步骤大致如下:

  1. 环境准备:在Python环境中安装torch,transformers, 以及onnxonnxruntime包。
  2. 加载原始模型:使用Hugging Face的transformers库加载DeepSeek R1模型和分词器。
  3. 构建示例输入:ONNX导出需要知道输入张量的形状和类型。对于文本生成模型,我们需要准备一个示例的input_idsattention_mask张量。
    # 示例代码片段 import torch from transformers import AutoTokenizer, AutoModelForCausalLM import onnx model_name = "deepseek-ai/deepseek-llm-7b-base" # 此处替换为实际R1模型名 tokenizer = AutoTokenizer.from_pretrained(model_name) model = AutoModelForCausalLM.from_pretrained(model_name, torch_dtype=torch.float16) # 准备示例输入 dummy_input = tokenizer("Hello, how are you?", return_tensors="pt") input_ids = dummy_input["input_ids"] attention_mask = dummy_input["attention_mask"] # 可能还需要position_ids等,具体看模型结构
  4. 执行导出:使用torch.onnx.export函数。这里的关键是设置dynamic_axes参数,因为推理时输入序列长度是变化的。
    torch.onnx.export( model, (input_ids, attention_mask), # 模型输入参数元组 "deepseek-r1.onnx", input_names=["input_ids", "attention_mask"], output_names=["logits"], dynamic_axes={ "input_ids": {0: "batch_size", 1: "sequence_length"}, "attention_mask": {0: "batch_size", 1: "sequence_length"}, "logits": {0: "batch_size", 1: "sequence_length"} }, opset_version=14, # 使用较新的opset以支持更多算子 do_constant_folding=True )
  5. 验证与简化:导出后,使用onnxruntime进行推理验证,确保输出与原始PyTorch模型一致。还可以使用onnx-simplifier工具对计算图进行优化,合并冗余算子,使图结构更简洁,有利于后续的WebGPU内核映射。

2.3 前端工程化架构:模块化与性能的平衡

一个健壮的浏览器推理应用,不能把所有代码堆在一个文件里。我们需要一个清晰的架构:

  • 模型管理层:负责加载ONNX模型文件、解析计算图、管理权重数据。权重数据(通常是Float16)需要从ONNX文件中提取,并转换成WebGPU所需的缓冲区格式。考虑到模型文件可能很大(数GB),需要设计分片加载和增量加载策略。
  • 运行时引擎层:这是最核心的部分。它需要实现一个算子注册表,将ONNX标准算子(如MatMul,Add,LayerNormalization,Gelu,Softmax等)映射到我们编写的WebGPU计算着色器上。同时,它要管理推理会话,维护中间激活值的生命周期。
  • 资源管理层:WebGPU资源(设备device、命令队列queue、缓冲区buffer、纹理texture、管线pipeline)的创建、绑定和销毁非常繁琐且容易出错。这一层需要抽象出统一的资源申请、释放和缓存机制,避免内存泄漏和资源冲突。
  • 应用UI层:基于现代前端框架(如React、Vue或Svelte)构建用户界面,提供文本输入、生成过程流式显示、性能监控(Tokens/s, 延迟)等功能。UI需要与推理引擎异步通信,通常使用Web Worker将计算密集型任务与主线程隔离,防止界面卡顿。

3. WebGPU推理引擎的核心实现

3.1 WebGPU上下文初始化与适配器选择

一切始于获取WebGPU上下文。这个过程需要注意兼容性和性能。

async function initWebGPU() { // 1. 检测浏览器支持 if (!navigator.gpu) { throw new Error('WebGPU is not supported in this browser.'); } // 2. 请求适配器(Adapter) const adapter = await navigator.gpu.requestAdapter({ powerPreference: 'high-performance', // 优先高性能GPU(如独立显卡) // forceFallbackAdapter: false // 通常不强制使用软件回退适配器 }); if (!adapter) { throw new Error('Failed to get WebGPU adapter.'); } // 3. 请求逻辑设备(Device) const device = await adapter.requestDevice({ requiredLimits: { maxBufferSize: adapter.limits.maxBufferSize, // 尽可能申请大缓冲区 maxStorageBufferBindingSize: adapter.limits.maxStorageBufferBindingSize, } // 注意:不要随意请求超出limits的特性,会导致设备创建失败 }); // 4. 获取Canvas上下文 const canvas = document.getElementById('webgpu-canvas'); // 可能用于可视化,非必须 const context = canvas.getContext('webgpu'); const canvasFormat = navigator.gpu.getPreferredCanvasFormat(); context.configure({ device: device, format: canvasFormat, alphaMode: 'opaque' }); return { device, context, canvasFormat, adapter }; }

实操心得powerPreference设置为'high-performance'并不总是最佳选择。在某些轻薄本或电池模式下,系统可能限制独立显卡的功耗。有时'low-power'(集成显卡)反而能提供更稳定的性能输出。一个更健壮的做法是尝试两种模式,并进行一个简单的基准测试(如运行几次矩阵乘法)来选择当前环境下更快的适配器。

3.2 模型权重加载与缓冲区管理

ONNX模型文件包含两部分:计算图结构(protobuf格式)和权重数据(外部数据或嵌入在文件中)。我们需要解析出权重。

  1. 解析ONNX模型:可以使用onnxruntime-web的API在JS中解析,或者使用专门的解析库(如@webonnx/onnx-parser)。解析后得到一个个Tensor对象,包含名称、数据类型(如FLOAT16)和原始数据(ArrayBuffer)。
  2. 创建GPU缓冲区:将每个权重张量的ArrayBuffer数据上传到GPU。
    // 假设 weightData 是一个 Float32Array 或 Float16Array const weightBuffer = device.createBuffer({ size: weightData.byteLength, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST, // 存储缓冲区,并可被拷贝 mappedAtCreation: false // 对于大权重,不建议映射创建,而是先创建后拷贝 }); // 通过队列写入数据 device.queue.writeBuffer(weightBuffer, 0, weightData);
  3. 缓冲区内存对齐:WebGPU对缓冲区的偏移量(offset)有对齐要求(通常是256字节)。在组织权重缓冲区,特别是将多个小权重打包到一个大缓冲区时,必须计算正确的偏移量。
    const alignment = 256; // 常见的对齐要求 function alignedSize(size) { return Math.ceil(size / alignment) * alignment; } let offset = 0; for (const tensor of weightTensors) { const tensorSize = tensor.data.byteLength; const alignedOffset = alignedSize(offset); // 计算对齐后的起始位置 // ... 将数据写入大缓冲区的 alignedOffset 位置 offset = alignedOffset + tensorSize; }
  4. 缓冲区复用与缓存:一次推理过程中,许多中间激活张量(如每一层的输出)的大小是固定的。我们可以预先分配一个“内存池”,从中分配和回收这些固定大小的缓冲区,避免频繁的createBufferdestroy调用,这对性能提升显著。

3.3 计算着色器(Compute Shader)编写:以矩阵乘法为例

矩阵乘法(MatMul)是Transformer中最核心、最耗时的操作。在WebGPU中实现一个高效的MatMul内核是引擎性能的关键。

一个基础的、面向推理优化的MatMul着色器(使用WGSL语言)可能如下所示。这里展示一个使用工作组(Workgroup)和共享内存(Workgroup Storage)进行优化的平铺(Tiling)算法示例,这能显著减少对全局内存的访问次数。

// matmul.wgsl @group(0) @binding(0) var<storage, read> a : array<f32>; // 矩阵A, 形状 [M, K] @group(0) @binding(1) var<storage, read> b : array<f32>; // 矩阵B, 形状 [K, N] @group(0) @binding(2) var<storage, read_write> c : array<f32>; // 输出矩阵C, 形状 [M, N] // 假设我们以二维方式调度,每个工作组处理一个瓦片(Tile) const TILE_SIZE = 16u; // 瓦片大小,通常为16或32,取决于硬件 @compute @workgroup_size(TILE_SIZE, TILE_SIZE, 1) fn main(@builtin(global_invocation_id) global_id : vec3<u32>, @builtin(local_invocation_id) local_id : vec3<u32>, @builtin(workgroup_id) workgroup_id : vec3<u32>) { let row = workgroup_id.y * TILE_SIZE + local_id.y; let col = workgroup_id.x * TILE_SIZE + local_id.x; // 边界检查 if (row >= M || col >= N) { return; } var sum = 0.0; // 将K维度分成多个瓦片进行循环 for (var tile_idx = 0u; tile_idx < K / TILE_SIZE; tile_idx += 1u) { // 1. 将A和B的一个瓦片加载到共享内存(更快) // 这里需要声明共享内存变量,并让工作项协作加载。 // 为简化,此处省略了共享内存加载的详细同步代码(使用workgroupBarrier)。 // 2. 计算当前瓦片对结果的贡献 for (var k_in_tile = 0u; k_in_tile < TILE_SIZE; k_in_tile += 1u) { let a_index = row * K + tile_idx * TILE_SIZE + k_in_tile; let b_index = (tile_idx * TILE_SIZE + k_in_tile) * N + col; sum += a[a_index] * b[b_index]; } // 3. 工作组内同步,等待所有工作项完成当前瓦片计算 // workgroupBarrier(); } let c_index = row * N + col; c[c_index] = sum; }

注意事项

  • 共享内存同步:上述代码省略了关键的共享内存加载和同步逻辑。实际实现中,需要声明var<workgroup> tileA : array<f32, TILE_SIZE*TILE_SIZE>;,然后让工作组内的线程协作将全局内存数据加载到tileAtileB,之后调用workgroupBarrier()确保所有数据加载完毕,再进行计算。这是性能优化的精髓,但代码复杂度会急剧上升。
  • 数据类型:为了节省内存和带宽,模型权重通常使用f16(半精度浮点数)。WGSL支持f16,但需要在着色器顶部启用enable f16;。在JS端,需要使用Float16Array来准备数据。
  • 绑定组(Bind Group):着色器中声明的@binding变量,需要在JS端通过创建GPUBindGroup来关联具体的缓冲区。

3.4 算子集成与推理图执行

有了核心算子的WebGPU实现后,我们需要一个调度系统来执行整个计算图。

  1. 算子注册表:建立一个Map,将ONNX算子类型(如"Gemm","Add","LayerNormalization")映射到一个配置对象,该对象包含对应的WGSL着色器代码、计算管线创建函数、以及资源绑定布局描述符。
    const operatorRegistry = new Map(); operatorRegistry.set('MatMul', { shaderCode: matmulShaderWGSL, getPipeline: (device) => { /* 创建计算管线 */ }, bindGroupLayout: [/* 绑定组布局条目 */] }); operatorRegistry.set('Add', { /* ... */ });
  2. 会话与内存规划:加载ONNX图后,遍历所有节点,为每个节点的输出张量分配一个唯一的标识符和一块内存(GPU缓冲区)。这需要做内存复用分析:如果一个张量只在后续的一个节点中被使用,之后不再需要,那么它的缓冲区可以被后续的另一个张量复用。这能极大降低峰值内存消耗。
  3. 命令录制与提交:对于一次层的前向传播,我们需要为每一个算子节点录制命令。
    const commandEncoder = device.createCommandEncoder(); const passEncoder = commandEncoder.beginComputePass(); for (const node of computationGraph) { const opConfig = operatorRegistry.get(node.opType); const pipeline = opConfig.getPipeline(device); const bindGroup = createBindGroupForNode(device, pipeline, node); passEncoder.setPipeline(pipeline); passEncoder.setBindGroup(0, bindGroup); // 根据输出张量大小,计算需要调度多少个工作组 const workgroupCountX = Math.ceil(node.outputSize[0] / TILE_SIZE); const workgroupCountY = Math.ceil(node.outputSize[1] / TILE_SIZE); passEncoder.dispatchWorkgroups(workgroupCountX, workgroupCountY, 1); } passEncoder.end(); const commandBuffer = commandEncoder.finish(); device.queue.submit([commandBuffer]);
  4. 异步等待与结果读取:命令提交后是异步执行的。我们需要使用device.queue.onSubmittedWorkDone()来等待计算完成,然后才能将输出缓冲区映射回CPU进行读取(例如,将最后一个解码层的logits取回,进行token采样)。

4. 性能优化与调试实战

4.1 性能瓶颈分析与工具使用

浏览器开发者工具是性能分析的首选。在Chrome/Edge中:

  • Performance面板:录制整个推理过程,查看主线程、Web Worker线程的活动,找到长任务和卡顿点。
  • Memory面板:监控JS堆内存和Detached DOM trees。更重要的是,WebGPU有自己独立的内存(GPU内存)。虽然不能直接查看,但通过监控GPUBuffer的创建和销毁,可以间接判断是否有内存泄漏。确保GPUBuffer在不再需要时调用destroy()
  • WebGPU Inspector:这是一个浏览器扩展,是WebGPU开发的“神器”。它可以:
    • 实时查看所有创建的缓冲区、纹理、管线、绑定组。
    • 捕获一帧内的所有WebGPU命令,并可视化地展示渲染或计算通道的执行过程。
    • 检查资源绑定错误、管线编译错误。
    • 对计算着色器进行基础的性能分析(如指令数估算)。

常见的性能瓶颈:

  1. CPU端数据准备/后处理:在JS中频繁使用new Float32Array()或进行复杂的数组操作,会阻塞主线程。解决方案:使用Web Worker将数据处理移出主线程;对于固定大小的数据,复用TypedArray对象。
  2. GPU内核启动开销:过于频繁地调度非常小的计算任务(dispatchWorkgroups数量很少)。解决方案:尽可能将多个小算子融合(Fusion)成一个大的计算内核。例如,将LayerNormReduceMeanSubPowReduceMeanAgainAddSqrtDiv等操作融合进一个着色器。
  3. 内存带宽限制:这是最常见的瓶颈。优化方法包括:
    • 使用f16:将权重和激活值从f32转为f16,带宽需求减半,性能提升几乎翻倍。
    • 优化数据布局:使用NHWC(Channel-last)布局可能比NCHW(Channel-first)在某些硬件上更友好。使用vec4<f16>等向量化数据类型,让内存访问更连续。
    • 平铺(Tiling)与共享内存:如前文MatMul示例,这是减少全局内存访问次数的核心手段。

4.2 内存优化策略:从OOM到流畅运行

大型语言模型动辄数十亿参数,很容易在内存有限的设备上导致OOM(内存溢出)。除了使用f16,还有以下策略:

  1. 权重量化(Quantization):将f16进一步量化为int8甚至int4。这需要修改模型和图结构,在算子实现中引入反量化步骤。社区已有一些工具(如onnxruntime的量化工具)可以辅助。量化后模型大小可减少为原来的1/2到1/4,内存压力骤减,但会引入精度损失,需要评估。
  2. 权重压缩与流式加载:将模型权重文件进行分片(例如每100MB一个分片)。在应用初始化时只加载第一层所需的权重,在用户输入时或后台线程中预加载后续层的权重。
  3. 激活值重计算(Activation Recomputation/Checkpointing):在Transformer的解码阶段,为了生成下一个token,我们需要保存之前所有token的键值对(KV Cache),这会随着生成长度线性增长内存。一种策略是只保存部分层的激活值,在需要时从更早的检查点重新计算中间层的激活。这用计算时间换取了内存空间。
  4. 内存池与缓冲区复用:如前所述,这是必须实现的。设计一个基于大小的缓冲区分配器,推理过程中所有中间张量都从这个池中申请,用完立即归还。

4.3 调试技巧:当推理结果全是NaN或零时

这是开发过程中最令人头疼的问题之一。可能的原因和排查步骤:

  1. 数据上传错误:首先检查CPU端的权重数据是否正常。可以写一个简单的测试,将权重数据下载回CPU,并与原始ONNX模型中的数据进行逐元素对比(允许微小的浮点误差)。
    // 将GPU缓冲区读回 const gpuBuffer = /* ... */; const readbackBuffer = device.createBuffer({ size: gpuBuffer.size, usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ }); // ... 拷贝命令 await readbackBuffer.mapAsync(GPUMapMode.READ); const cpuData = new Float32Array(readbackBuffer.getMappedRange()); console.log('First 10 values from GPU:', cpuData.slice(0, 10));
  2. 着色器代码错误
    • 越界访问:检查所有数组索引计算,确保没有超出缓冲区范围。在WGSL中,越界访问行为是未定义的,可能导致NaN。
    • 除零操作:在LayerNorm、Softmax等算子的分母中,如果没有加上epsilon(一个极小值,如1e-5),当方差为零时会导致除零,产生Inf或NaN。
    • 数据类型不匹配:确保着色器中的数据类型(f32,f16,i32)与缓冲区中存储的数据类型完全一致。
  3. 绑定组配置错误:确保@binding索引与JS端创建GPUBindGroup时设置的binding值一一对应。确保每个缓冲区在绑定组布局中声明的typeread-only-storage,storage)和实际使用方式匹配。
  4. 使用“调试着色器”:编写一个极简的调试用着色器,例如只将输入缓冲区的值乘以2输出。用这个着色器替换复杂的算子,逐步定位是哪个算子的实现出了问题。

5. 应用集成与用户体验打磨

5.1 流式文本生成与UI反馈

大语言模型的生成是逐token进行的。为了获得类似ChatGPT的打字机效果,我们需要实现流式输出。

  1. 推理循环:在Web Worker中运行一个循环。每次循环执行一次模型的前向传播,得到下一个token的logits分布,采样得到token id,通过分词器解码成文本片段,然后通过postMessage发送给主线程。
    // 在Web Worker中 while (!stopGenerating) { const nextTokenId = await modelInstance.generateNextToken(); const textFragment = tokenizer.decode([nextTokenId], { skipSpecialTokens: true }); self.postMessage({ type: 'stream', data: textFragment }); // 将新生成的token作为输入的一部分,准备下一次推理 modelInstance.updateInput(nextTokenId); }
  2. 主线程更新:主线程监听Worker的消息,将收到的文本片段不断追加到UI的显示区域。为了流畅,可以使用requestAnimationFrame进行节流更新,避免每收到一个token就更新DOM导致的性能问题。
  3. 中断生成:提供一个“停止”按钮。点击时,主线程向Worker发送一个终止消息,Worker中的循环标志stopGenerating置为true,当前推理完成后退出循环。

5.2 性能监控与设置面板

一个专业的应用应该让用户感知到性能。可以添加以下监控信息:

  • 生成速度:Tokens per second (TPS)。在Worker中计算从开始生成到结束的总token数和耗时。
  • 首Token延迟(Time to First Token, TTFT):从用户点击“生成”到收到第一个token的时间。这是衡量响应速度的关键指标。
  • GPU内存使用估算:在JS端统计所有已创建的GPUBuffersize总和,展示给高级用户。

提供一个简单的设置面板,允许用户切换:

  • 精度fp32(高精度,慢) vsfp16(平衡) vsint8(快速,可能质量下降)。
  • 生成长度限制max_new_tokens)。
  • 采样参数:如温度(Temperature)、Top-p(核采样)。

5.3 离线与部署考量

  1. Service Worker与缓存:利用Service Worker缓存模型文件(ONNX权重分片)、WebAssembly运行时和前端静态资源。这样用户第二次访问时,几乎可以瞬间加载。
  2. 模型存储:考虑使用IndexedDB来存储已下载的模型分片,避免每次重新下载。
  3. 包体积优化:使用构建工具(如Vite, Webpack)进行代码分割(Code Splitting),将推理引擎、UI组件、第三方库拆分成不同的chunk,按需加载。
  4. 兼容性兜底:在应用入口处进行能力检测。如果浏览器不支持WebGPU,则降级到纯WebAssembly CPU推理模式(虽然慢很多,但功能可用),或者显示友好的提示信息。

整个项目走下来,最深的一点体会是,端侧AI应用开发是一个典型的“系统级”前端工程,它要求开发者具备跨领域的知识。你不仅需要理解Transformer模型的结构和推理过程,还要深入掌握WebGPU的底层API、并行计算优化技巧,同时兼顾前端应用的工程化、性能与用户体验。这个过程充满挑战,但当你看到自己编写的代码在浏览器中流畅地运行着一个数十亿参数的模型,并生成有意义的文本时,那种成就感是无与伦比的。它预示着一种新的应用范式:更私密、更实时、更可控的AI,正在通过Web技术,触达每一个普通的浏览器窗口。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询