去年有一次线上事故让我印象特别深。某公司一个图像审核系统上线了大半年,各项指标都稳得让人放松警惕,直到测试团队拿着几张肉眼看起来完全正常的照片在系统面前晃了一圈——审核结果全部出错,有人脸被误判成另一个人,有张压根就没识别出来。后来排查发现,这几张照片上都叠加了一层极其轻微的、人眼注意不到的点状噪声,就是这层噪声让一个成熟模型瞬间“失明”。这类噪声在AI安全领域里有一个专门的名字:对抗样本。
这篇文章我想从一次攻防复盘的角度,把对抗攻击的核心原理、主流攻击手法、防御策略以及落地时踩过的坑一条线讲清楚。如果你正在做模型上线前的安全评估,或者正在为业务场景挑防御方案,这篇文章应该能帮你少走不少弯路。先说明白一点:讲攻击不是为了制造恐慌,而是只有搞懂了刀往哪儿砍,才知道盾该怎么铸。
1. 对抗攻击到底是什么:从一次事故聊起
1.1 一次“看不见的噪声”引发的上线风波
那次事故排查到后面,整个技术团队都有点懵。原始图片是完整的人脸照片,分类器在干净数据上测试准确率一直维持在99%以上,可一旦图片经过某个细微扰动,系统就像换了个人一样乱猜。问题不在于模型本身漂移,而在于输入数据被刻意“污染”了。
这类操作在学术界的定义很明确:给定一个原本能被模型正确分类的样本 x,攻击者可以构造一个微小的扰动 δ,使得 x+δ 在人类看来和原图几乎没有区别,但模型给出的预测结果彻底改变。这个经过扰动的样本就叫对抗样本,δ 则是对抗扰动。
放到实际业务里,这意味着什么?内容审核模型可以被绕过、人脸识别门禁可以被伪装、自动驾驶的路牌识别可以被欺骗。最头疼的是,这类攻击不依赖任何漏洞,不需要对目标系统做任何物理侵入,攻击者只需要“喂”给模型一张精心构造的图片或者一段文字,模型自己就会犯错。所以安全团队面对的不是传统意义上的漏洞修复,而是一个更底层的问题:深度模型本身有着结构性的弱点。
1.2 对抗样本的底层原理:模型学的是特征,不是概念
为什么微小扰动会造成如此大的误判?我自己的理解是,深度学习模型在高维空间里学到的是统计特征,而不是人类意义上的“语义概念”。人在看一张猫的照片时,关注的是耳朵、眼睛、毛发的整体形态,但模型实际上是在数千维的特征空间里做划分,它的决策边界是跟着训练数据分布走出来的。
高维空间有一个反直觉的特性:维度越高,同样大小的扰动向量在空间中的“影响范围”就越大。你可以把模型的输入空间想象成一个铺满决策边界的迷宫,干净样本在迷宫里处于正确区域,只要沿着某个特定方向轻轻推一下,它就可能越过边界滑进错误区域,而这个方向人不敏感,模型却极其敏感。
一张图的像素值通常有上万个维度,每个像素只需改动很小的数值,累积起来就足以把样本推出正确区域。FGSM这个经典攻击方法,本质上就是沿着损失函数对输入梯度上升的方向,一次性加一个固定大小的扰动。公式写出来很简洁:
x_adv = x + ε * sign(∇x L(θ, x, y))x是原始输入,θ是模型参数,y是真实标签,L是损失,ε是扰动强度。sign表示取梯度的符号方向,相当于只保留“往哪个方向推最有效”这个信息,然后以ε为步长推一下。这就是对抗攻击最底层的思想:把模型的梯度当成一份“导航地图”,梯度方向就是模型最容易犯错的方向。
2. 主流攻击手法:知道刀怎么砍,才知道盾怎么造
2.1 白盒攻击家族:从FGSM到PGD,再到CW
FGSM是最容易理解也最容易实现的攻击类型,但它有个明显短板:只做一步更新,扰动方向不一定足够精确。很多防御方法稍微做点输入平滑,FGSM就失效了。真正让安全团队头疼的是它的迭代版本——PGD。
PGD的思想也简单:一步推不动,那就多推几步。每一步沿着梯度符号方向走一个小步长 α,走完一步就把结果裁剪回原始样本的 ε 邻域内,防止扰动超出预算。公式长这样:
x^{t+1} = clip(x^t + α * sign(∇x L(θ, x^t, y)), x-ε, x+ε)这个循环迭代的“投影梯度下降”方法,在数学上可以理解为一个寻找最坏情况扰动的过程。我实测下来的感受是,PGD做攻击时收敛速度很快,通常在10到20步迭代之后就能稳定地把一个原本预测正确的样本打到错误分类。正因为PGD足够强,它后来也成了对抗训练里最常用的攻击基准。
再往后是CW攻击,一个思路完全不同但同样硬核的方法:把“制造对抗样本”建模成一个优化问题,目标函数分成两项,一项希望扰动尽可能小,另一项希望模型输出目标错误类别,然后用优化器去平衡这两项。CW的白盒版本攻击成功率很高,而且它生成的扰动通常比FGSM和PGD隐蔽得多。过去很多防御方案号称能抵御攻击,结果在CW面前一测就原形毕露。
下面这段PyTorch代码是我在项目里常用的一套FGSM攻击实现,方便你快速上手理解整个流程:
import torch import torch.nn as nn def fgsm_attack(model, x, y, eps): model.eval() x.requires_grad = True loss_fn = nn.CrossEntropyLoss() loss = loss_fn(model(x), y) model.zero_grad() loss.backward() # 取梯度符号方向,叠加扰动 grad_sign = x.grad.data.sign() x_adv = x + eps * grad_sign # 裁剪到合法像素范围 x_adv = torch.clamp(x_adv, 0, 1) return x_adv这段代码里最关键的一行是x.requires_grad = True。没有它,PyTorch不会对输入做梯度计算,整个攻击就没法生效。做防御实验时,我建议把eps从0.01开始逐步加大,看看模型准确率和扰动强度的关系曲线,这样能对模型的鲁棒性有个直观认知。
2.2 黑盒攻击与物理世界攻击:现实威胁升级
白盒攻击的前提是攻击者能拿到目标模型的结构和参数。现实业务里,这不现实。于是就有了黑盒攻击,它分两大类:基于迁移性的攻击和基于查询的攻击。
基于迁移性的思路是:攻击者先自己训练一个替代模型,在这个替代模型上生成对抗样本,再拿去攻击目标系统。因为同类任务上不同模型的决策边界往往有相似性,在替代模型上有效的扰动,经常也能骗过目标模型。这个特性叫攻击的迁移性。我在测试中就遇到过这样的情况:用一个公开数据上训出来的小型替代模型生成的对抗样本,直接就让线上另一个完全不同架构的模型出现明显错误。所以,哪怕你的模型参数没泄露,也不能觉得自己就安全了。
基于查询的攻击更“笨”一点但也更直接:攻击者反复向目标模型发送输入,观察输出的置信度或者预测标签,通过大量查询反向推断出模型的敏感方向。比如边界攻击这类方法,连梯度都不用算,只需要知道模型在某个输入下给出的预测类别,就能逐步逼近对抗样本。这类攻击对部署在API里的模型威胁很大,因为API天然给了攻击者无限次的查询机会。
更麻烦的是物理世界攻击。研究者已经做过很多实验:在眼镜框上贴对抗花纹让人脸识别系统认错人,在停车标志上贴几块黑白贴纸让自动驾驶模型把它识别成限速牌。这类攻击不再局限于数字世界里改像素,而是把扰动打印到实物上,通过摄像头进入系统。物理世界攻击意味着,AI安全不只是线上安全团队要考虑的事,凡是摄像头能拍到的地方都可能是攻击面。
3. 防御策略全景:先搞清楚你防的是谁
3.1 按攻击者能力分级:白盒、灰盒、黑盒
聊防御方案之前,一定要先明确一个东西:威胁模型。所谓威胁模型,就是假设攻击者对你的系统了解多少、能施加什么样的操作。不同威胁模型对应的防御策略完全不一样。
如果攻击者对模型结构和参数一清二楚(白盒),那么绝大多数启发式防御都靠不住,因为攻击者可以利用梯度信息针对性地绕过你的防御,这也就是后面要讲的“梯度掩盖”问题的来源。如果攻击者只有模型输入输出接口(黑盒),那么输入预处理、随机化这些偏工程的手段就有实际意义,虽然不能彻底阻断攻击,但能显著抬高攻击成本。还有一种灰盒场景,攻击者可能知道你的模型结构但不知道参数,这种情况下可以综合用迁移性评估和输入变化检测来防御。
业务侧最常见的问题是一上来就追求“万能防御”。我跟不少团队聊过,大家的期待是找一个方案,装上去之后所有攻击都进不来。但现在的技术现状是,不存在这样的方案。理性的做法是:先根据业务场景定义攻击者的能力上限,再选择性价比最高的防御组合。
3.2 训练侧防御:对抗训练与它的代价
在目前所有防御手段里,对抗训练是理论依据最扎实、实践效果最稳定的一个。它的核心思想特别直接:把对抗样本当成训练数据的一部分,让模型在训练阶段就见过这些“坏样本”,从而学会抵抗它们。数学上是一个典型的Min-Max问题:
min_θ [ max_δ L(θ, x+δ, y) ]内层max是在寻找最能让模型出错的数据分布,外层min是让模型在这个最坏情况下仍然把损失降到最低。换句话说,就是在一场模拟攻防中训练模型:内层是攻击者拼命构造扰动,外层是模型拼命抵抗。
工程实现上,最经典的做法是用PGD生成对抗样本,然后放进训练循环。我在PyTorch里写过一段简化版对抗训练逻辑,核心代码大致是这样:
def pgd_attack(model, x, y, eps, alpha, iters): x_adv = x.clone().detach() for _ in range(iters): x_adv.requires_grad = True loss = nn.CrossEntropyLoss()(model(x_adv), y) grad = torch.autograd.grad(loss, x_adv)[0] x_adv = x_adv + alpha * grad.sign() x_adv = torch.clamp(x_adv, x - eps, x + eps) x_adv = torch.clamp(x_adv, 0, 1) return x_adv.detach() # 训练循环片段 for x, y in trainloader: x_adv = pgd_attack(model, x, y, eps=0.03, alpha=0.01, iters=10) loss = nn.CrossEntropyLoss()(model(x_adv), y) optimizer.zero_grad() loss.backward() optimizer.step()这段代码跑起来不难,但真正用到生产环境时有两个问题要注意。一是计算开销巨大:每个训练batch都要做一次完整的PGD迭代,等于训练成本翻了10倍甚至20倍。二是对抗训练往往会让模型在干净数据上的准确率下降,也就是“鲁棒性与准确率不可兼得”这个困境。
针对这个问题,学术界做了很多改进,最著名的是TRADES算法,它在损失函数里显式加入了一项衡量“干净样本和对抗样本预测差异”的正则项,让模型在提升鲁棒性的同时尽量少牺牲精度。我实践下来的体验是,TRADES在中小规模数据集上的平衡效果好于朴素对抗训练,但超参数调整更费工夫。具体到业务里,你是选PGD对抗训练还是TRADES,取决于你容忍多少精度损失,通常要在验证集上做一轮网格搜索才能定下来。
3.3 输入与输出侧防御:预处理、检测与拒答
训练侧防御成本高、周期长,很多人就想着在输入输出侧加一些“便宜”的防御:对输入做预处理,或者对输出做检测,发现可疑样本就直接拒绝。这类方案能不能用?能用,但要有清醒的预期。
输入预处理的思路是给输入“洗个澡”,比如JPEG压缩、图像缩放、中值滤波、随机裁剪、加随机噪声等。原理是这些操作能破坏对抗扰动精心构造的结构,让扰动失效。我在早期项目里试过对这些方法做过一轮对比,表面上看起来有一定效果,但后来用白盒攻击一测,很多预处理方案在自适应攻击面前基本不堪一击。所谓自适应攻击,就是攻击者知道你做了预处理之后,把预处理函数也纳入梯度的计算范围,专门绕过它。这条经验算是我踩过最深的坑之一:任何防御方案,如果评估时没有考虑自适应攻击,得出的安全结论都可能是幻觉。
输出侧检测是另一种思路:不改变模型本身,而是加一个哨兵模块判断当前输入是不是对抗样本。常见方法有用局部内在维度(LID)来度量样本周围空间结构的异常程度,也有用随机平滑做多次预测一致性检查的。原理是对抗样本在特征空间里的分布和正常样本有明显差异,检测器可以把这个差异识别出来。这类方法的优点是部署灵活,不需要重训主模型,缺点是检测器本身也可能被绕过,而且检测器的误报率在生产环境里可能造成不少正常请求被拒掉。
这里必须提一下防御蒸馏。它曾经在业界很火,思路是让模型输出“软化”的概率分布,从而平滑损失面、让小梯度攻击失效。但后来有研究者证明,防御蒸馏可以被CW这类基于优化的攻击轻松绕过,关键原因是蒸馏并没有真正消除模型对扰动的敏感性,只是隐藏了梯度信息。这个案例提醒我:判断防御方案是否有效,不能只看它在某些攻击下稳不稳,还要看它是不是只是在“掩盖”问题。
4. 一次防御实战:从评估基到部署总结
4.1 第一步:建立攻击基准,量化模型“受伤”程度
我每次接一个新模型的AI安全评估,第一件事不是急着上防御,而是先测出模型在攻击下的“受伤基线”。没有基线,后面做的所有防御都说不清楚有没有效果。
实际操作上,我一般固定三四种代表性攻击方法,覆盖单步攻击、迭代攻击和优化攻击三个梯度,然后在不同攻击强度下跑一遍模型,记录一堆指标。下表是一个比较典型的评估基线样例,用的是我在某个图像分类Demo里的测试结果,四舍五入一下大概是这样:
| 攻击方法 | 扰动强度 | 干净准确率 | 攻击后准确率 | 攻击成功率 |
|---|---|---|---|---|
| FGSM | ε=0.03 | 98.2% | 43.6% | 55.6% |
| PGD-10 | ε=0.03 | 98.2% | 18.4% | 81.2% |
| PGD-20 | ε=0.03 | 98.2% | 11.7% | 88.1% |
| CW-L2 | 自动优化 | 98.2% | 4.8% | 95.1% |
这张表一眼就能看出几个关键信息:FGSM虽然好实现但攻击效果在迭代攻击面前不算强;PGD迭代次数增加带来的准确率下降非常明显;CW攻击几乎能把模型打穿。如果你的模型连FGSM都扛不住,那线上环境肯定存在很大风险;如果评估时只用FGSM,又容易高估模型的安全性。我在评估时还习惯把同一套方案在不同随机种子上跑三遍取均值,把方差也记录下来,防止某一次评估结果有偶然性。
4.2 第二步:对抗训练的工程化落地
拿到基线之后,我开始分层推进防御方案。对抗训练永远放在第一位,因为它能真正改变模型的决策边界,而不是在外部做遮掩。工程落地时有几个细节值得专门讲一下。
第一个细节是数据比例。不是生成多少对抗样本就无脑塞进训练集,我建议一开始从20%-30%的比例试起。对抗样本是“硬样本”,数量一多模型很容易训练不稳,干净准确率掉得厉害。先用小比例热热身,再逐步往上加,稳定性和鲁棒性之间往往能找到一个可接受的平衡点。
第二个细节是扰动强度和迭代步数。训练用的攻击强度(内置的eps)直接决定模型能扛住的扰动上限,一般选测试时攻击强度上限的80%作为训练强度就够了。迭代步数方面,训练阶段其实不建议用特别大的步数,通常5到7步就行。步数太大训练慢,而且过强的训练攻击反而会让模型收敛到很差的局部最优。我试过用10步迭代做训练,效果反而不如7步稳定。
第三个细节是优化器与学习率。对抗训练本身是一个偏对抗的动态过程,损失面往往更陡峭,学习率要相对保守一些。我用SGD配动量的经验是,学习率设为普通训练的0.5到0.7倍比较稳。另外对抗训练的过程需要更长的热身阶段,让模型先对“干净数据”有一个稳定的拟合基础,再逐步引入对抗样本,不然一开始模型可能根本收不拢。经历几次失败后,我把热身策略定为前5个epoch纯用干净数据训练,之后再混合对抗样本,收敛稳定性明显改善。
4.3 第三步:评估不能光看准确率,还要揭穿“假防御”
防御训练做完,最后一个环节是重新评估。最忌讳的是拿和防御时同一种攻击、同一个参数去评估,那样做的结果往往“好看”但不可靠。我复盘过不少防御方案,凡是只报“防御后准确率从20%提升到90%”的结论,十有八九是评估幻觉。
正确的做法是引入更强、更全面的攻击来交叉验证。具体来说,我会同时跑这几类测试:一是用多种白盒攻击(FGSM、PGD、CW、AutoAttack等)交叉验证;二是测试对抗样本在不同初始化条件下的攻击成功率,看模型的防御效果是否稳定;三是做迁移性测试,看黑盒场景下的防御表现。在这些测试里,AutoAttack是一个很值得用的标准评估库,因为它集成了多种强攻击策略,能自动组合出当前模型最容易中招的攻击方式。我用AutoAttack跑过好几个号称“已经很稳”的模型,结果不少都明显高估了自己的防御能力。
评估结果的表现形式,我通常画一条鲁棒性-准确率曲线,横轴是攻击强度,纵轴是模型准确率。这条曲线能直观看出模型的鲁棒性边界在哪里。还有一个关键检查项:验证防御是否引入了“梯度掩盖”。最简单的方法是检查模型在输入上的梯度是否出现大量饱和或几乎为0的情况,因为如果梯度消失了,攻击虽然暂时失败,但同时也说明模型把敏感信息藏起来了,攻击者换一种不求梯度的攻击方式就可能重新攻破。这种状态下的“防御成功”靠不住,一定要在评估报告里明确标注。
5. 常见问题与避坑清单
5.1 对抗训练不收敛?先查这一项
对抗训练最让人崩溃的问题就是loss死活不降。我遇到过几次,排查到最后发现同一个元凶:对抗样本生成过程中,扰动没有做范围裁剪或者裁剪顺序错了。扰动超出合法范围,模型在训练时接收到了一堆完全畸形的输入,拟合难度剧增,自然不收敛。
排错方法其实很直接:单独写一段测试脚本,把生成的对抗样本可视化出来,看一眼图像是不是还保持基本语义。如果图像看起来已经像纯粹噪声,说明裁剪逻辑有问题。另外一个常见的坑是把对抗样本的梯度传回了原输入图,导致训练时输入本身的像素被改动,模型永远也学不到稳定特征。正确做法是确保输入图requires_grad=False,只让对抗样本生成过程内部传播梯度,训练阶段用detach后的对抗样本算损失。
5.2 防御后干净样本掉点严重?从正则项和数据比例找平衡
对抗训练掉点几乎是必然的,但掉点幅度有讲究。我见过有人做完对抗训练,干净准确率从97%掉到70%,那基本等于模型被练废了。出现这种问题的原因通常有两个:一是对抗样本比例太高,模型被“硬样本”带偏了;二是训练攻击强度太高,相当于你要模型在所有方向上都有很强的鲁棒性,最终顾此失彼。
解决的路径我记得很清楚:第一步,降低对抗样本混入比例,从50%降到25%,看看掉点是否缓解;第二步,尝试TRADES这类显式带正则项的损失函数,它的调节参数能直接控制精度与鲁棒性的取舍;第三步,降低对抗扰动强度,把训练时用的eps从一个较大的值减下来,具体情况要根据业务对鲁棒性和精度的容忍度来权衡,没有统一答案。
5.3 越防越危险:梯度掩盖与评估幻觉
这一条是很多团队在防御评估中容易忽略的:通过某种防御手段把梯度弄得特别粗糙或者特别平缓,攻击者用梯度类方法确实打不穿,于是团队宣判防御生效。但换个黑盒攻击一测,防线瞬间瓦解。这就是梯度掩盖问题。
怎么判断自己的防御是否在掩盖梯度?我通常做一个简单实验:在更大的扰动预算下,用基于边界搜寻的攻击(不需要梯度)测试模型,比如边界攻击或者基于决策的黑盒攻击。如果原先用FGSM能防住、换黑盒攻击后防御效果大幅缩水,基本可以断定存在梯度掩盖。这类防御不仅没有提升安全性,还会给业务方制造虚假的安全感,潜在风险更大。
我整理了一份很常用的排查速查表,放在下面供你参考:
| 现象 | 可能原因 | 检查动作 |
|---|---|---|
| 对抗训练loss不降 | 扰动裁剪错误 / 输入梯度泄漏 | 可视化对抗样本,检查detach逻辑 |
| 干净准确率大幅下降 | 对抗样本比例过高 / 训练扰动过大 | 降低比例,尝试TRADES正则 |
| FGSM防住了但黑盒攻击失败 | 梯度掩盖 | 用不需要梯度的攻击重新测试 |
| 防御后攻击成功率反而更高 | 防御诱导模型产生新的脆弱方向 | 用AutoAttack做多攻击交叉验证 |
| 不同随机种子评估差异大 | 评估样本量不足 / 攻击初始化影响 | 扩大测试集,多种子求均值 |
这个表看起来简单,但我每次做项目评审时都会逐条过一遍,能拦住大部分“看似安全实则裸奔”的情况。
最后再分享一个我个人的体会。做AI安全防御这几年,我最深的感受是:安全不是一个结果,而是一个持续的过程。对抗攻击技术在演进,防御方案也在迭代,今天有效的防线,明天可能就被新的攻击手法绕过。所以我现在做任何项目,都会在交付文档里附上一份“持续评估计划”:约定每隔一段时间重新跑一次攻击基线,监测模型鲁棒性是否有漂移。这套做法虽然不起眼,但在生产环境里带来的安全感,远高于任何“装上就不用管”的防御方案。如果你正准备给自己的模型做安全加固,我建议你也从建立基线评估开始,先搞清楚模型到底有多脆弱,再决定要花多大力气去防守。