资讯详情

Fashion-MNIST对抗攻击与对抗训练:FGSM/PGD复现避坑

📅 2026/10/29 10:41:30 | 华诺云谱 👁 阅读
Fashion-MNIST对抗攻击与对抗训练:FGSM/PGD复现避坑
简介一份基于Fashion-MNIST数据集的图像分类对抗攻防实践项目面向深度学习安全方向的学习者与研究者围绕对抗样本生成、鲁棒性评估与对抗训练防御三条主线展开。压缩包共121个文件、约15.12MB其中9个py脚本负责模型搭建、FGSM与PGD攻击实现及对抗训练流程2个h5文件保存训练好的模型权重8个pkl文件存放数据与中间结果100张jpg直接展示对抗样本与原图的对比效果另外还有md说明方便快速上手。目前已有117人学习。读者可以复现从常规训练、生成对抗样本到对抗训练的完整实验观察细微扰动如何改变分类结果并对比对抗训练前后准确率与泛化能力的变化掌握鲁棒性评估的基本方法也适合作为课程设计或安全研究的基础代码。1. 一个 92% 的模型被一步扰动打到 31%一个在 Fashion-MNIST 上能跑到 92% 的小 CNN用 FGSM 取 eps0.03 走一步准确率直接掉到 31% 左右这个扰动换算回像素只有 8 个灰度级上下肉眼几乎看不出差别。我最早做对抗攻击时不信这个数把 eps 一路加到 0.1 才看到明显掉点后来查出来是归一化尺度算错扰动的量级被自己调小了将近三倍——这是对抗攻击里最典型的一次翻车。这份 Attack_Fashion_MNIST 资源做的正是同一件事在 28×28 单通道灰度图上生成对抗样本再把样本喂回训练循环做对抗训练。下面按攻击面拆解、复现跑通、对抗训练落地、避坑排查四段走新手能照着跑出结果熟手能看到每个参数为什么这么定。2. 攻击面拆解FGSM、PGD、CW 在 28×28 灰度图上的真实差别Fashion-MNIST 的攻击性价比和 ImageNet 完全不是一回事。同样是 eps0.03 的 L∞ 预算在 28×28 的图上这个预算相对整幅图像的比例比 224×224 的图要高出一个数量级换句话说小图上看不见的扰动能撬动的信息更多模型也更脆。这就是为什么很多人拿 CIFAR-10 上那套 eps8/255 的经验直接搬到 Fashion-MNIST 上会发现模型掉得太快、对抗训练又救不太回来。先把三种攻击的定位讲清楚再谈参数。2.1 威胁模型先定死L∞ 预算、eps 换算与白盒假设动手写代码前有三个量必须先写死在配置里否则后面所有数字都不可比。第一个是范数。Fashion-MNIST 上默认用 L∞因为它的物理含义最直观——每个像素最多改多少个灰度级L2 更适合看最小改动量但评估鲁棒性时会掩盖单点的大幅修改。第二个是预算 eps 的坐标空间。这是个必须显式约定的东西扰动到底加在 [0,1] 的像素空间还是加在减均值除标准差之后的归一化空间。这两种写法的数值差一个 std 的倍数Fashion-MNIST 常用 mean0.2860、std0.3530差了三倍多。我一般把扰动定义在像素空间归一化只发生在喂进网络前的那一步理由是扰动可解释、可视化时不用反变换、clip 到 [0,1] 也自然。第三个是攻击者能力假设。评估鲁棒性上界用白盒——攻击者知道结构和全部参数测这种攻击思路换到别的模型上还有没有效用迁移攻击——拿 A 模型生成样本打 B 模型。Fashion-MNIST 上的实验绝大多数是白盒因为目的是给对抗训练定一个可复现的靶子不是模拟真实对手。配置项建议取值说明范数L∞小图上 L2 会掩盖单点大改扰动空间像素 [0,1]归一化放在模型前向内部主评估预算eps0.03约合 8/255图上基本无感预算扫描0.01 / 0.02 / 0.03 / 0.05画成功率曲线的常用档位评估集测试集 10000 张不要用训练集会虚高提示eps 一旦定在像素空间所有脚本、所有对比实验都必须沿用同一约定中途换一次就等于换了一套实验。2.2 FGSM一步到位的下界也是最快的体检FGSM 的思路就一句话沿着能让损失变大的方向一个步长走到预算边界。它用的是损失对输入的梯度取符号只保留方向不保留大小所以幅度天然由 eps 控制。import torch import torch.nn.functional as F MEAN, STD 0.2860, 0.3530 # Fashion-MNIST 训练集统计值 def fgsm_attack(model, x, y, eps): 单步 FGSM。x 是 [0,1] 像素空间的输入扰动也加在像素空间。 model.eval() # 关掉 BN 统计更新和 dropout x_adv x.clone().detach().requires_grad_(True) # 梯度要对输入求不是对权重 logits model((x_adv - MEAN) / STD) # 归一化只在这里发生 loss F.cross_entropy(logits, y) grad torch.autograd.grad(loss, x_adv)[0] # 拿到 dLoss/dInput x_adv x_adv.detach() eps * grad.sign() # 梯度上升最大化 loss return torch.clamp(x_adv, 0.0, 1.0).detach() # 投影回合法像素范围几个容易写错的点requires_grad_(True)必须加在输入上而不是模型参数上grad.sign()取完符号后扰动是均匀的这正是 L∞ 约束下的最优一步最后那次clamp不是可选项越界的像素在可视化时会变成纯黑纯白看上去像模型崩了实际是自己没剪裁。FGSM 的价值在于快——一次前向加一次反向就能评估整个测试集几十秒出结果。它的局限也很明确对 loss 做了线性近似eps 一大就失真遇到做了梯度掩蔽比如输入做量化、加随机变换的模型FGSM 会给出虚高的鲁棒准确率。所以我一般拿它当第一道体检不当最终结论。2.3 PGD迭代步数、步长与随机起点的取舍PGD 是 FGSM 的迭代版走很多小步每步都投影回 eps 球内。它更强也更贵参数从两个变成四个。def pgd_attack(model, x, y, eps, alpha, steps, restarts1): L∞ PGD带随机起点。alpha 是单步步长steps 是迭代次数。 model.eval() best_adv, best_loss x.clone().detach(), torch.full((x.size(0),), -1.0, devicex.device) for _ in range(restarts): # 随机起点在 eps 球内均匀采样比从原点出发更容易跳出局部极大 x_adv torch.clamp(x torch.empty_like(x).uniform_(-eps, eps), 0.0, 1.0).detach() for _ in range(steps): x_adv.requires_grad_(True) loss F.cross_entropy(model((x_adv - MEAN) / STD), y) grad torch.autograd.grad(loss, x_adv)[0] x_adv x_adv.detach() alpha * grad.sign() delta torch.clamp(x_adv - x, -eps, eps) # 第一步投影回到 eps 球 x_adv torch.clamp(x delta, 0.0, 1.0).detach() # 第二步投影回到合法像素 # 多次重启时按 loss 保留每组样本攻击最狠的那一版 cur_loss F.cross_entropy(model((x_adv - MEAN) / STD), y, reductionnone).detach() mask cur_loss best_loss best_adv[mask], best_loss[mask] x_adv[mask], cur_loss[mask] return best_adv参数字典里最值得琢磨的是 alpha 和 steps 的配比。常见写法是 alpha eps/4、steps 10这样总行程是 eps 的 2.5 倍靠每步的投影把越界部分拉回来——这是有意为之多走一点比走得不够更容易逼近真正的最大值。步长再小比如 eps/10收敛更稳但更慢在 28×28 这种小图上收益不明显步长再大eps/2会出现震荡攻击成功率反而掉。随机起点这一项经常被省掉。省掉之后 PGD 变成确定性攻击遇到对抗训练过的模型成功率会低估好几个点因为固定的起点容易卡在同一个局部极大上。评估对抗训练效果时我一般用 restarts1 保底、restarts5 做最终验收。关于 steps在 Fashion-MNIST 上PGD-10 和 PGD-20 的成功率通常只差一两个百分点但耗时差一倍。做对抗训练的内层攻击用 PGD-3 到 PGD-7 就够做最终评估用 PGD-20 起步。2.4 什么时候才轮到 CW 和迁移攻击CW 走的是另一条路不追求在预算内最大化 loss而是优化一个带置信度 margin 的目标去找能让模型翻类的最小扰动。它慢一次优化要几百步但在 Fashion-MNIST 上有个独特用途——看清楚改多少个像素就能让模型改判。我做过一次可视化某些类之间比如 Shirt 和 T-shirt只改动十几个像素、集中在领口区域模型就翻了这种观察对理解模型到底学到了什么比对着一张成功率表看数字有用得多。迁移攻击的逻辑不一样拿 A 模型生成的对抗样本去打 B 模型衡量的是扰动在不同模型间的可迁移性。它的成功率天然比白盒低但能说明一件事——模型的鲁棒性是不是只对特定梯度方向有效。如果白盒成功率 95%、迁移成功率 60%说明对抗训练学到的东西有一定共性如果迁移成功率只有 10%多半是模型在做梯度掩蔽而不是真的鲁棒。攻击需要的梯度单张耗时主要用途未防御模型掉点FGSM一次反向极低快速体检、训练中内层攻击92% → 30% 量级PGD-10十次反向中鲁棒性评估主力92% → 5% 量级PGD-20 × 5 重启一百次反向高最终验收92% → 2% 量级CW-L2数百步优化很高最小扰动分析、可视化取决于目标 margin迁移源模型梯度中检测梯度掩蔽因模型而异3. 复现 Attack_Fashion_MNIST.zip从目录到第一组对抗样本这类工程解压后基本是四块数据加载、模型定义与训练、攻击脚本、对抗训练脚本。第一次跑不要一上来就执行对抗训练那个最慢也最容易因为前置没对齐而白跑。按基线 → 攻击 → 对抗训练的顺序来每一步都能独立验证。3.1 目录与运行顺序先跑基线再跑攻击最后跑对抗训练先把工程按职责对一遍数据部分负责下载 Fashion-MNIST 和做归一化模型部分定义一个小型卷积网络两三层卷积加池化参数量几十万单卡几分钟一个 epoch攻击部分是一组独立的函数对抗训练部分是训练循环里调用攻击函数。运行顺序上我一般这样做先训练或加载一个基线模型并把权重固定下来存成固定文件后面所有实验都用这一份再拿它跑一遍干净测试集确认准确率在正常区间然后跑 FGSM 和 PGD 各一次看掉点幅度最后才开对抗训练。这么排的原因是攻击脚本的正确性可以用基线模型的掉点幅度快速判断——如果基线模型在 eps0.03 的 FGSM 下只掉了两三个点八成不是模型太鲁棒是攻击写错了。基线模型的期望值给个参考一个正常训练的小 CNN 在 Fashion-MNIST 上干净准确率 91%~93%FGSM(0.03) 掉到 25%~35%PGD-20(0.03) 掉到 3%~8%。偏离这个区间太多先怀疑数据管线和归一化别急着调模型。3.2 归一化对齐eps 到底该乘还是该除这是最容易出问题的地方单独拎出来说。扰动定义在像素空间 [0,1]而模型吃的是(x - 0.2860) / 0.3530。如果把 eps 直接加在归一化后的张量上等效的像素扰动就是 0.03 × 0.3530 ≈ 0.0106只有预期值的三分之一——攻击看起来太弱其实是自己把预算缩小了。MEAN, STD 0.2860, 0.3530 def eps_norm_to_pixel(eps_norm, stdSTD): 如果别人的脚本里 eps 是加在归一化空间的用它换回像素空间再比较。 return eps_norm * std def eps_pixel_to_norm(eps_pixel, stdSTD): 反过来像素空间的 eps 折算到归一化空间是多少。 return eps_pixel / std # 我推荐的写法扰动只在像素空间操作归一化写在模型前向内部 class NormalizedModel(torch.nn.Module): def __init__(self, backbone, meanMEAN, stdSTD): super().__init__() self.backbone, self.mean, self.std backbone, mean, std def forward(self, x): return self.backbone((x - self.mean) / self.std)把归一化包进模型里好处是攻击函数和训练循环都只跟 [0,1] 的像素打交道eps 的语义永远一致读写别人的脚本时对照一次eps_norm_to_pixel就能判断对方的预算到底是多大。代价是state_dict的键名会多一层前缀加载权重时要对应处理。注意Fashion-MNIST 的 mean/std 是数据集统计值不是 ImageNet 那套 0.485/0.456/0.406。抄错这一组数攻击会莫名其妙地失效。3.3 基线模型的训练与权重固定基线模型不需要多复杂两层卷积加一个全连接足够参数量大了反而拖慢对抗训练的内层迭代。训练配置上Adam 或 SGD 都能到 92%关键是固定下来固定随机种子、固定 epoch 数、把权重存成单独文件。def set_seed(seed42): import random, numpy as np random.seed(seed); np.random.seed(seed) torch.manual_seed(seed); torch.cuda.manual_seed_all(seed) torch.backends.cudnn.deterministic True # 复现优先牺牲一点速度 torch.backends.cudnn.benchmark False def evaluate(model, loader, device): model.eval() correct total 0 with torch.no_grad(): # 评估必须关梯度否则显存会涨 for x, y in loader: x, y x.to(device), y.to(device) correct (model(x).argmax(1) y).sum().item() total y.numel() return correct / totalcudnn.deterministic打开后训练会慢一点但换来的是每次跑出来的数字一致做消融实验时这点很重要——不然你分不清掉点是模型变了还是随机性造成的。3.4 攻击脚本的四个必查参数拿到一份攻击脚本我一般先查四件事顺序固定。第一查 eps 的坐标空间看扰动是加在像素上还是归一化后看有没有这次 clamp 到 [0,1]。第二查梯度是对谁求的requires_grad_加在输入上还是模型参数上加错地方梯度就是零攻击不报错但完全不生效。第三查模式攻击时有没有model.eval()忘了切 eval 会让 BatchNorm 用当前 batch 的统计量同一个样本单独攻击和批量攻击结果不一样。第四查随机性DataLoader有没有设shuffleFalse、worker 数量固定没有评估集的顺序变了攻击成功率会有零点几个百分点的抖动做对比实验时容易误判。这四项查完才轮到看攻击强度对不对。4. 对抗训练落地把 PGD 塞进训练循环的代价与配比对抗训练的核心思想很朴素训练时别只喂干净样本把攻击产生的对抗样本也喂进去让模型在学习过程中见到这些输入。麻烦的地方在于代价——每步训练要多跑几次前向反向以及配比——纯对抗训练会把干净准确率拖下去多少。4.1 三种对抗训练配方与它们各自的账我试过的配方归成三类各有各的账。纯 PGD 对抗训练每个 batch 先用 PGD-k 生成对抗样本然后只用对抗样本更新参数。鲁棒准确率最高干净准确率掉得也最多通常是 2~4 个点。计算开销大概是普通训练的 k1 倍。单步快速对抗训练用 FGSM 生成对抗样本开销接近普通训练训练速度快得多但鲁棒准确率明显偏低而且 eps 一大就容易出现灾难性过拟合。适合做快速迭代和超参搜索不适合交最终结果。混合对抗训练一个 batch 里一半干净样本、一半对抗样本。干净准确率损失小鲁棒准确率介于前两者之间是我在 Fashion-MNIST 上最常用的折中。配方干净准确率FGSM(0.03)PGD-20(0.03)训练开销基线 CNN92.4%31.7%4.6%1×FGSM 对抗训练90.8%76.2%42.5%约 1.4×混合PGD-3 干净90.2%84.9%74.8%约 2.6×纯 PGD-7 对抗训练89.1%82.6%78.3%约 3.8×表里最值得注意的是 FGSM 对抗训练那一行FGSM 攻击下 76.2% 看着还行但换成 PGD-20 就只有 42.5% 了。这说明它对单步攻击过拟合了鲁棒性是假的。这种模型如果只拿 FGSM 评估会得出完全错误的结论——所以评估必须用比训练更强的攻击。4.2 训练循环内层攻击、混合比例与 BN 处理下面是混合对抗训练的主循环几个关键决策都写在注释里。def adversarial_train(model, train_loader, device, epochs30, eps0.03, alpha0.0075, steps3, adv_ratio0.5): opt torch.optim.SGD(model.parameters(), lr0.05, momentum0.9, weight_decay5e-4) sched torch.optim.lr_scheduler.CosineAnnealingLR(opt, T_maxepochs) for ep in range(epochs): model.train() for x, y in train_loader: x, y x.to(device), y.to(device) n_adv int(x.size(0) * adv_ratio) # 一个 batch 里取一半做对抗样本 xa, ya x[:n_adv], y[:n_adv] model.eval() # 生成对抗样本时临时切 eval避免 BN 统计被污染 xa_adv pgd_attack(model, xa, ya, eps, alpha, steps) model.train() # 立刻切回来参数更新要用当前 batch 的统计量 x_mix torch.cat([x, xa_adv], dim0) # 干净 对抗拼成一个 batch y_mix torch.cat([y, ya], dim0) loss F.cross_entropy(model(x_mix), y_mix) opt.zero_grad(); loss.backward(); opt.step() sched.step()生成的对抗样本如果是用model.train()模式跑的BatchNorm 会用对抗 batch 的均值方差去更新 running stats而这些统计量的分布和干净数据不一样跑几十个 epoch 之后模型的 BN 参数会整体偏移——表现是训练集准确率还行测试集莫名其妙掉几个点。切成eval()生成、再切回train()更新是个很便宜的修法。另一个参数是adv_ratio。0.5 是个稳妥的起点数据分布偏简单时可以往下调到 0.3干净准确率更保得住想冲鲁棒准确率就调到 1.0代价是干净准确率和训练时间。学习率也要单独说一句对抗训练对 lr 比普通训练敏感用基线模型那套 lr 常常训到一半就开始震荡。我一般把初始 lr 降一档加 5 个 epoch 的 warmup再走余弦退火。4.3 结果对比干净准确率换鲁棒准确率汇率是多少上表那组数字可以换个角度看从混合训练换到纯 PGD-7鲁棒准确率涨了 3.5 个点干净准确率掉了 1.1 个点 —— 汇率大概是 3:1。这个比例在 Fashion-MNIST 上比较稳定可以作为判断还要不要继续加码对抗训练的依据如果某次改动让干净准确率掉了 5 个点、鲁棒准确率只涨 1 个点那基本是训练配置出了问题不是对抗训练本身的极限。还有一项经常被忽略鲁棒准确率要和攻击强度一起看。同一份权重PGD-20 下 78%、PGD-50 下 71%说明它还没到真鲁棒只是把决策边界推远了。评估时把攻击步数标在数字旁边比单独一个百分数有意义得多。4.4 灾难性过拟合与早停什么时候该怀疑训练崩了单步对抗训练在 eps 稍大时会出现一种很怪的现象训练过程中鲁棒准确率突然从 70% 掉到 20% 出头而且训练 loss 依然很低看起来学得很好。这就是灾难性过拟合——模型找到了一个对当前 FGSM 攻击有效的退化解对任何迭代攻击都完全失效。排查办法很简单训练过程中每隔几个 epoch 存一次权重同时用 PGD-20 评估把曲线画出来。如果看到干净准确率平平稳稳、PGD 准确率断崖下跌就是踩上了。修法有几种换成迭代攻击训练、降低 eps、加梯度惩罚、或者干脆用 EMA 权重做评估。最省事的是按鲁棒验证集的准确率早停别按训练 loss 早停。5. 避坑与排查五个让我返工的细节这一节全是踩过的坑每条按现象 → 原因 → 解决写能对号入座就不用重新走一遍。5.1 扰动看不见但攻击几乎无效现象eps0.03 的 FGSM 跑完模型准确率只从 92% 掉到 88%可视化看扰动确实加上了。原因扰动加在了归一化空间。像素空间里实际只有 0.03×0.3530≈0.0106 的改动预算被自己砍到三分之一。解决把归一化包进模型前向或显式做一次eps_pixel_to_norm换算。改完重跑掉点应该落在 25%~35% 这个区间对不上就继续查梯度是不是对输入求的。5.2 同一个样本单张攻击和批量攻击结果不一样现象把一张图单独拿出来跑 PGD成功翻类放进 batch 里跑同一张图没翻。原因攻击时没有model.eval()BatchNorm 用的是当前 batch 的均值方差batch 组成一变前向结果就变了。dropout 没关也是一个来源。解决攻击函数入口强制model.eval()并在函数内部不要恢复训练模式让调用方自己决定什么时候切回来。评估和攻击分开写别共用同一段状态管理。5.3 评估数字每次抖动一两个点现象代码没改重跑一次 PGD-20 的鲁棒准确率差 1.2 个点消融实验完全没法比。原因两处随机性。一是DataLoader的 shuffle 和多 worker评估集顺序每轮不一样二是 PGD 的随机起点没固定种子。解决评估时shuffleFalse、num_workers固定且worker_init_fn里设种子PGD 随机起点用局部torch.Generator固定。做对比实验时把这两条都写进配置别靠记忆。5.4 对抗训练后干净准确率掉太多现象纯 PGD 训练训了 30 个 epoch鲁棒准确率 78%干净准确率从 92% 掉到 85%比预期多掉了一倍。原因多数情况是学习率没降、没有 warmup前半程模型在干净数据上根本还没收敛就被对抗样本推着走最后停在一个偏的位置。另一种是 BN 统计被污染。解决降初始 lr、加 5 个 epoch warmup、生成对抗样本时切 eval。三处一起改干净准确率一般能回到 89% 上下。5.5 攻击成功率虚高换个攻击就露馅现象FGSM 对抗训练后 FGSM 攻击下 76%自己看着挺满意换成 PGD-20 只剩 42%。原因拿训练时用的同一种攻击做评估等于自己批自己的卷子。单步对抗训练特别容易对这种攻击过拟合。解决评估一律用比训练更强的攻击训练用 PGD-3评估就用 PGD-20 带多次重启再加一列迁移攻击的结果看鲁棒性有没有共性。三列数字放在一起看虚高的部分会立刻暴露。6. 验收一张扰动放大图和一条成功率曲线对抗攻击这个方向最容易犯的错是只看百分数不看图。我最后一道验收固定做两件事把对抗样本和放大十倍的扰动并排画出来确认扰动确实集中在有语义的区域再扫一遍 eps 画成功率曲线看曲线是不是单调、拐点在哪。import matplotlib.pyplot as plt def viz(model, x, y, eps0.03, n5): x_adv pgd_attack(model, x[:n], y[:n], epseps, alphaeps/4, steps20) pred model(x_adv).argmax(1) fig, axes plt.subplots(3, n, figsize(2.2*n, 7)) for i in range(n): axes[0, i].imshow(x[i, 0].cpu(), cmapgray); axes[0, i].set_title(fy{y[i].item()}) axes[1, i].imshow(x_adv[i, 0].cpu(), cmapgray); axes[1, i].set_title(fpred{pred[i].item()}) # 扰动放大 10 倍再偏移 0.5否则一片灰什么都看不出来 axes[2, i].imshow((0.5 10 * (x_adv[i, 0] - x[i, 0])).cpu(), cmapgray, vmin0, vmax1) for r in range(3): axes[r, i].axis(off) plt.tight_layout(); plt.savefig(adv_grid.png, dpi150)三段分别对应干净图、对抗图、放大后的扰动。扰动图里如果是一整片均匀噪点说明攻击在乱改如果集中在衣领、鞋底、包带这些轮廓位置说明梯度指对了地方。这个观察比准确率数字更能说明模型学到了什么。eps 扫描就简单了0.01 到 0.1 取五档每档跑 FGSM 和 PGD-20 各一次画两条线。表现正常的模型和鲁棒模型在图上很好区分正常模型的曲线在 eps0.01 就已经掉到 70% 以下鲁棒模型的曲线更平到 0.05 才开始明显下滑。如果某条曲线在某个 eps 上突然反弹多半是评估集或者随机种子出了问题。扫描档位基线 PGD-20混合对抗训练 PGD-20eps0.0142.1%88.6%eps0.0213.4%82.3%eps0.034.6%74.8%eps0.050.8%58.2%eps0.100.1%21.7%这张表最有价值的信息在最后两行鲁棒模型在 eps0.03 以内的表现很稳超出训练预算后一样会崩。所以对抗训练给的是预算内的鲁棒不是抗一切扰动写结论时这一点必须说清楚。回过头看我从一开始把 eps 尺度算错、到后来每次实验都先跑一张扰动放大图中间返工了三四次。从那以后我每次开新的对抗训练实验都强制先走一遍流程先确认 eps 在哪个空间、跑一次 FGSM 看掉点是否落在预期区间、画一张扰动图确认梯度没指错、再用 PGD-20 出最终数字这四步少一步都不敢往下写结论。希望帮到你。本文还有配套的精品资源点击获取
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑