当前位置: 首页 > 技术干货 > Prompt is Search:GCG 与大模型对抗后缀攻击

Prompt is Search:GCG 与大模型对抗后缀攻击

发表于:2026-05-13 14:30 作者: 秋名山上的小柠 阅读数(14人)

0.前言

在上一次的技术分享文章中,着重讨论了 RAG 时代的数据投毒问题,也就是当外部文档被检索、拼接并送入大模型上下文时,数据就不再只是被动的信息来源,它可能变成一段能够影响模型行为的代码,详细可以搜索《Data is Code:RAG 时代的数据投毒与大模型上下文劫持》

这种风险在 RAG 系统中尤为明显,攻击者不一定需要入侵服务器,也不一定需要修改模型权重,只要一段被污染的文本进入知识库,并在合适的问题下被召回,它就有机会改变模型的回答逻辑,突破指令边界,甚至诱导模型泄露同一上下文中的敏感信息。

上次我在第三种RAG投毒方式,零交互数据窃取中,提到这种攻击还可以进一步升级,即用GCG计算出一串人类看不懂的乱码,这串乱码在向量空间里的坐标跟很多都重合,完成一次更加隐蔽的攻击

RAG 投毒更多讨论的是攻击内容如何进入上下文,而 GCG 是探讨,如果我们已经知道模型会受上下文影响,那么能不能用算法自动搜索出最容易影响模型的那一小段文本?

这就是 GCG 值得被单独拿出来讲的原因,因为它把大模型越狱从人写 prompt推进到了算法优化 prompt的阶段

说到底,如果说 RAG 投毒讨论的是外部数据如何劫持上下文,那么 GCG 讨论的就是另一个更底层的问题:模型的安全边界,是否可以被算法自动搜索出来?

1.GCG介绍

在讨论 GCG 之前,先要把它放回到大模型越狱的语境里

1.1从 Jailbreak 到 Adversarial Suffix

传统的 Jailbreak(越狱),本质上是通过构造特殊提示词,让模型偏离原本的安全对齐策略。比如通过角色扮演、规则重写、上下文欺骗、任务拆分等方式,让模型误以为自己可以回答原本应该拒绝的问题

这类方法有一个共同点:它们基本上是由人写出来的

也就是说,攻击效果依赖于攻击者对模型行为的观察、对提示词的理解,以及大量试错。攻击者要不断调整表达方式,测试模型是否会拒绝,观察模型在哪些语境下更容易被攻击,比如说会说一些不该说的话,或者是泄露不该泄露的东西

但 GCG 的出现,把这个问题变成了个半自动,即GCG 不再把 Jailbreak 看成一个单纯的提示词写作问题,而是把它建模成一个优化问题:

在用户原始问题后面,能不能自动搜索出一小段 token 后缀,让模型更倾向于生成目标响应,而不是执行安全拒答?

这段被搜索出来的文本,通常叫做adversarial suffix,也就是对抗后缀

它可以被抽象成下面这个形式:

用户问题 + 对抗后缀  →  模型输出

这里真正被优化的,不是用户问题本身,也不是模型权重,而是后面那一小段额外文本

这也是 GCG 和传统 Jailbreak 最大的差别

说得通俗易懂点,就是传统 Jailbreak 更像是在说服模型,而GCG算法更像是在搜索模型的脆弱方向

之前的分享里讲 RAG 投毒时,重点是外部数据如何进入上下文,并在推理期影响模型行为

而这次讲 GCG,就是在进一步探索,如果说模型确实会被上下文影响,那么什么样的上下文片段最容易影响它?

1.2 GCG算法

GCG的原文链接 https://arxiv.org/abs/2307.15043

GCG 是Greedy Coordinate Gradient的缩写,可以拆成三个关键词来看:

Greedy      贪心
Coordinate 坐标
Gradient   梯度

这三个词基本上就概括了它的核心思想

Gradient 指的是,算法会利用模型的梯度信息,判断当前后缀中的某个 token 如果被替换,模型输出会朝哪个方向变化

Coordinate 指的是,它不是一次性改完整段文本,而是把后缀看成多个位置,每次选择其中一个 token 位置进行修改

这里的位置可以简单理解成后缀中的第几个 token

比如:

[x0] [x1] [x2] [x3] [x4]

GCG 每次会尝试修改其中某一个位置,比如先看 x3 能不能换成更合适的 token,再看 x1x4 等位置

Greedy指的是,每一轮修改时,它都会倾向于保留当前看起来效果最好的替换。也就是说,它不保证一次找到全局最优,但会不断做局部最优选择,让后缀逐步

朝目标方向靠近

所以,用一句话解释 GCG

GCG 是一种利用梯度信息,在离散 token 空间中贪心搜索对抗后缀的方法

如果说得更人话一点:

它就像是在模型输入后面放了一串可调参数,然后不断问模型:我把这里换成哪个 token,最容易让你的输出朝目标方向偏移

这里可能会有人有个疑问,特别是有做图像干扰的师傅们

就是图像可以做梯度优化很好理解,因为图片是像素矩阵,像素值是连续的

比如一个像素原来是:0.31 我们可以把它微调成:0.33 但文本不是连续的

一个 token 要么是猫,要么是狗,要么是某个标点符号,不能把猫加上 0.01 变成另一个 token

所以疑问就是

token 是离散的,GCG 为什么还能用梯度?

其实关键在于语言模型真正处理的并不是 token 字符串本身,而是 token 对应的 embedding 向量

Embedding 向量就像是给每一个词语或事物分配的多维特征坐标位置,它把人类才能懂的抽象概念变成了一串数字,让意思越相近的东西,在这个数学坐标系里

住得越紧凑,从而让计算机能直接通过量距离来算出它们的关系

举个最直白的例子解释一下

如果把词语当成找对象,我们可以给它们打分(坐标):

  • “苹果”:甜度(0.8),水分(0.9),机械感(0.0) -> [0.8, 0.9, 0.0]

  • “香蕉”:甜度(0.9),水分(0.5),机械感(0.0) -> [0.9, 0.5, 0.0]

  • “汽车”:甜度(0.0),水分(0.0),机械感(1.0) -> [0.0, 0.0, 1.0]

在计算机眼里,它算一下距离就会发现,苹果和香蕉的向量数字非常接近,所以它们是同一类,都是属于水果范畴

而汽车跟它们差了十万八千里,这就是 Embedding 的核心作用

回到GCG,一个输入 token 进入模型时,会先被映射成一个高维向量,虽然 token ID 是离散的,但 embedding 向量是连续的,连续向量就可以参与梯度计算

可以这样理解:

token            →    embedding 向量
离散文本         →   连续空间中的一个点
不可直接求导       →   可以通过向量方向估计变化趋势

GCG 并不是直接对 token 做加减法,而是通过梯度判断:

如果想让模型更接近某个目标输出,那么当前这个 token 对应的 embedding 应该往哪个方向变化?

然后算法会回到词表中,寻找那些更接近这个方向的候选 token,再尝试用它们替换当前 token

所以,GCG 的关键并不是文本本身可导,而是:

文本进入模型后会变成 embedding,而 embedding 空间中的方向变化可以用梯度来估计

这也是为什么它经常会生成一些人类看起来像乱码的后缀,因为GCG算法并不是在追求人类读起来通顺,而是在追求模型内部表示空间中的有效扰动

从安全对齐的角度看,一个经过对齐的模型在面对危险问题时,理想行为应该是拒绝回答

也就是说,当输入是危险问题的时候,模型应该更倾向于输出:

抱歉,我不能帮助完成这个请求

而不是输出具体的危险内容

GCG 要做的事情,就是在不修改模型权重的情况下,只通过修改输入后缀,让模型的输出概率发生偏移

可以抽象成:

原始状态:
用户问题 → 模型倾向于拒答

加入后缀后:
用户问题 + 后缀 → 模型更容易生成目标响应

这里需要注意一点:

GCG 并不是让模型理解这段后缀的语义,也不一定是通过自然语言逻辑说服模型。

很多时候,这段后缀在人类看来没有明确含义,但它在模型内部可能会影响某些 token 的生成概率

类比到图像对抗样本一样,人眼看到的图片几乎没变化,但模型的分类结果可能发生变化,GCG 对语言模型做的是类似的事情,只不过扰动对象从像素变成了 token

因此,GCG 的真正意义不是发现了一种奇怪的越狱提示词,而是说明:

大模型的安全边界可能不是一个稳定的语义规则边界,而是一个可以被搜索和逼近的概率边界。

1.3 GCG具体流程

通俗易懂来说,GCG可以具体分为六步

第一步:初始化一段后缀

算法首先会在用户问题后面放一段初始后缀

这段后缀一开始可以是随机 token,也可以是某种占位文本

抽象表示如下:

用户问题 + [x1, x2, x3, x4, ..., xn]

其中 [x1, x2, x3, ..., xn] 就是后面要不断优化的部分

第二步:设定优化目标

GCG 需要一个目标方向,比如,它可能希望模型更倾向于生成某类目标响应,而不是安全拒答

可以把它抽象成:

目标:让模型输出从拒答路径偏向目标响应路径

第三步:计算当前后缀的影响

模型会根据当前输入计算输出概率,此时算法会评估:

当前后缀距离目标还有多远?

如果当前后缀效果不好,说明它还需要继续被修改

这个距离通常会通过损失函数来衡量

损失函数就是 AI 的错题扣分器,预测答案偏离标准答案越离谱,扣的分,也就是Loss 值就越高,AI 学习的过程就是想方设法把这个分数降到最低

举个例子,最开始的 Loss 是 6.13,说明那一组前缀离成功劫持大模型还差得很远;经过 200 轮的不断纠错调整,Loss 降到了 0.0004,说明算法已经找到了近乎

完美的payload,错题本上的扣分基本清零了

损失越高,说明模型越不倾向于生成目标响应,损失越低,说明当前后缀越能把模型推向目标方向

说白了,就是GCG 会把模型有没有被诱导到目标方向转化成一个可计算的损失值

第四步:用梯度寻找候选 token

接下来是 GCG 最关键的一步。

算法会查看后缀中每一个位置,估计如果替换这个位置上的 token,损失可能如何变化

比如当前后缀是:

[x0] [x1] [x2] [x3] [x4]

算法可能发现,修改 x3 对降低损失最有帮助,于是它会围绕 x3 这个位置,从词表中挑出一批候选 token

这里的梯度就像一个方向指示器,它告诉算法,当前这个位置,往哪些 token 方向替换更可能有效?

第五步:尝试替换并评估效果

找到候选 token 后,算法会尝试把当前位置替换成不同候选项,然后重新计算损失。

比如:

原始后缀:
[x0] [x1] [x2] [x3] [x4]

候选替换:
[x0] [x1] [a] [x3] [x4]
[x0] [x1] [b] [x3] [x4]
[x0] [x1] [c] [x3] [x4]

算法会比较这些替换方案,选择让损失下降最多的那个

第六步:重复迭代

完成一次替换后,算法会继续下一轮,它会再次计算梯度,再次选择位置,再次生成候选 token,再次替换。

整个过程可以画成下面这个循环:

初始化后缀
  ↓
计算损失
  ↓
计算梯度
  ↓
选择候选 token
  ↓
尝试替换
  ↓
保留效果最好的替换
  ↓
重复迭代

经过多轮迭代后,原本随机或普通的后缀,可能会变成一段对模型输出有明显影响的 adversarial suffix

整个过程的伪代码如下:

输入:
模型 M
原始输入 x
可优化后缀 s = [s1, s2, ..., sn]
目标响应 y
迭代轮数 T

初始化:
随机或固定初始化一段 suffix s

循环 T 轮:
 1. 将 x 与当前 suffix s 拼接,送入模型 M
 2. 计算模型生成目标响应 y 的损失 L
 3. 对 suffix 中每个 token 位置计算梯度
 4. 根据梯度为每个位置选出若干候选 token
 5. 尝试替换某个位置上的 token
 6. 重新计算替换后的损失
 7. 保留让损失下降最多的那次替换

输出:
优化后的 suffix s

上面这段伪代码看起来像普通优化算法,但它真正特殊的地方在于,优化对象不是模型参数,而是输入文本

优化空间不是连续向量,而是离散 token,优化目标不是提升任务准确率,而是改变模型在安全边界附近的输出倾向

2.GCG实践

说了这么多,我们来做点GCG实践

2.1 Hackergame

https://github.com/USTC-Hackergame/hackergame2023-writeups

这里拿中国科学技术大学2023的一道Hackergame题目来讲解

本地部署环境一定要 Gradio 3.50.2Transformers 4.35.0

否则本地跑起来会有各种各样的错误

这里我们着重来看第三个flag

flag3已经不像之前的flag1和flag2可以通过手写提示词的方式就可以直接拿到flag的

这里就必须用到GCG算法了,我们回顾一下刚刚所讲的GCG步骤

先初始化,设定目标,计算后缀,梯度寻找token,替换评估,重复迭代,这里就不再过多赘述了,直接看解题代码

from transformers import AutoModelForCausalLM, AutoTokenizer, GenerationConfig
from livelossplot import PlotLosses  # pip install livelossplot
import gc
import numpy as np
import torch
import torch.nn as nn
from transformers import AutoModelForCausalLM, AutoTokenizer

np.random.seed(20)
torch.manual_seed(20)
torch.cuda.manual_seed_all(20)

target = "hackergame"  # 这个就是我所说的初始化目标
num_steps = 500 #最多优化 500 轮
adv_string_init = "!"*100 #初始前缀,先用 100 个感叹号占位
adv_prefix = adv_string_init #当前正在被优化的前缀
# larger batch_size means more memory (but more likely to succeed)
batch_size = 512 #每轮尝试 512 个候选前缀
device = 'cuda:0'
topk = 256 #每个位置从梯度推荐的前 256 个 token 里采样


def get_embedding_matrix(model):
   return model.transformer.wte.weight


def get_embeddings(model, input_ids):
   return model.transformer.wte(input_ids)


def token_gradients(model, input_ids, input_slice, target_slice, loss_slice):
   """
  Computes gradients of the loss with respect to the coordinates.

  Parameters
  ----------
  model : Transformer Model
      The transformer model to be used.
  input_ids : torch.Tensor
      The input sequence in the form of token ids.
  input_slice : slice
      The slice of the input sequence for which gradients need to be computed.
  target_slice : slice
      The slice of the input sequence to be used as targets.
  loss_slice : slice
      The slice of the logits to be used for computing the loss.

  Returns
  -------
  torch.Tensor
      The gradients of each token in the input_slice with respect to the loss.
  """

   embed_weights = get_embedding_matrix(model)
   one_hot = torch.zeros(
       input_ids[input_slice].shape[0],
       embed_weights.shape[0],
       device=model.device,
       dtype=embed_weights.dtype
  )
   one_hot.scatter_(
       1,
       input_ids[input_slice].unsqueeze(1),
       torch.ones(one_hot.shape[0], 1,
                  device=model.device, dtype=embed_weights.dtype)
  )
   one_hot.requires_grad_()
   input_embeds = (one_hot @ embed_weights).unsqueeze(0)

   # now stitch it together with the rest of the embeddings
   embeds = get_embeddings(model, input_ids.unsqueeze(0)).detach()
   full_embeds = torch.cat(
      [
           input_embeds,
           embeds[:, input_slice.stop:, :]
      ],
       dim=1
  )

   logits = model(inputs_embeds=full_embeds).logits
   targets = input_ids[target_slice]
   loss = nn.CrossEntropyLoss()(logits[0, loss_slice, :], targets)

   loss.backward()

   grad = one_hot.grad.clone()
   grad = grad / grad.norm(dim=-1, keepdim=True)

   return grad


def sample_control(control_toks, grad, batch_size):

   control_toks = control_toks.to(grad.device)

   original_control_toks = control_toks.repeat(batch_size, 1)
   new_token_pos = torch.arange(
       0,
       len(control_toks),
       len(control_toks) / batch_size,
       device=grad.device
  ).type(torch.int64)

   top_indices = (-grad).topk(topk, dim=1).indices
   new_token_val = torch.gather(
       top_indices[new_token_pos], 1,
       torch.randint(0, topk, (batch_size, 1),
                     device=grad.device)
  )
   new_control_toks = original_control_toks.scatter_(
       1, new_token_pos.unsqueeze(-1), new_token_val)
   return new_control_toks


def get_filtered_cands(tokenizer, control_cand, filter_cand=True, curr_control=None):
   cands, count = [], 0
   for i in range(control_cand.shape[0]):
       decoded_str = tokenizer.decode(
           control_cand[i], skip_special_tokens=True)
       if filter_cand:
           if decoded_str != curr_control \
                   and len(tokenizer(decoded_str, add_special_tokens=False).input_ids) == len(control_cand[i]):
               cands.append(decoded_str)
           else:
               count += 1
       else:
           cands.append(decoded_str)

   if filter_cand:
       cands = cands + [cands[-1]] * (len(control_cand) - len(cands))
   return cands


def get_logits(*, model, tokenizer, input_ids, control_slice, test_controls, return_ids=False, batch_size=512):

   if isinstance(test_controls[0], str):
       max_len = control_slice.stop - control_slice.start
       test_ids = [
           torch.tensor(tokenizer(
               control, add_special_tokens=False).input_ids[:max_len], device=model.device)
           for control in test_controls
      ]
       pad_tok = 0
       while pad_tok in input_ids or any([pad_tok in ids for ids in test_ids]):
           pad_tok += 1
       nested_ids = torch.nested.nested_tensor(test_ids)
       test_ids = torch.nested.to_padded_tensor(
           nested_ids, pad_tok, (len(test_ids), max_len))
   else:
       raise ValueError(
           f"test_controls must be a list of strings, got {type(test_controls)}")

   if not (test_ids[0].shape[0] == control_slice.stop - control_slice.start):
       raise ValueError((
           f"test_controls must have shape "
           f"(n, {control_slice.stop - control_slice.start}), "
           f"got {test_ids.shape}"
      ))

   locs = torch.arange(control_slice.start, control_slice.stop).repeat(
       test_ids.shape[0], 1).to(model.device)
   ids = torch.scatter(
       input_ids.unsqueeze(0).repeat(test_ids.shape[0], 1).to(model.device),
       1,
       locs,
       test_ids
  )
   if pad_tok >= 0:
       attn_mask = (ids != pad_tok).type(ids.dtype)
   else:
       attn_mask = None

   if return_ids:
       del locs, test_ids
       gc.collect()
       return forward(model=model, input_ids=ids, attention_mask=attn_mask, batch_size=batch_size), ids
   else:
       del locs, test_ids
       logits = forward(model=model, input_ids=ids,
                        attention_mask=attn_mask, batch_size=batch_size)
       del ids
       gc.collect()
       return logits


def forward(*, model, input_ids, attention_mask, batch_size=512):

   logits = []
   for i in range(0, input_ids.shape[0], batch_size):

       batch_input_ids = input_ids[i:i+batch_size]
       if attention_mask is not None:
           batch_attention_mask = attention_mask[i:i+batch_size]
       else:
           batch_attention_mask = None

       logits.append(model(input_ids=batch_input_ids,
                     attention_mask=batch_attention_mask).logits)

       gc.collect()
       del batch_input_ids, batch_attention_mask

   return torch.cat(logits, dim=0)


def target_loss(logits, ids, target_slice):
   crit = nn.CrossEntropyLoss(reduction='none')
   loss_slice = slice(target_slice.start-1, target_slice.stop-1)
   loss = crit(logits[:, loss_slice, :].transpose(1, 2), ids[:, target_slice])
   return loss.mean(dim=-1)


model = AutoModelForCausalLM.from_pretrained(
   'roneneldan/TinyStories-33M',
   torch_dtype=torch.float16,
   trust_remote_code=True,
).to(device).eval()
tokenizer = AutoTokenizer.from_pretrained(
   "EleutherAI/gpt-neo-125M", use_fast=False)


def is_success(prompt):
   input_ids = tokenizer.encode(prompt, return_tensors="pt")
   output = model.generate(input_ids.to(
       device), max_new_tokens=50, num_beams=1, temperature=0)
   output = output[:, len(input_ids[0]):]
   output_text = tokenizer.decode(output[0], skip_special_tokens=True)
   print(output_text)
   if output_text.lower().find(target.lower()) != -1:
       return True
   return False


plotlosses = PlotLosses()
adv_slice = slice(0, len(tokenizer.encode(
   adv_string_init, add_special_tokens=False)))
target_slice = slice(adv_slice.stop, adv_slice.stop +
                    len(tokenizer.encode(target, add_special_tokens=False)))
loss_slice = slice(target_slice.start-1, target_slice.stop-1)

best_new_adv_prefix = ''

for i in range(num_steps):

   input_ids = tokenizer.encode(
       adv_prefix+target, add_special_tokens=False, return_tensors='pt').squeeze()

   input_ids = input_ids.to(device)

   coordinate_grad = token_gradients(model,
                                     input_ids,
                                     adv_slice,
                                     target_slice,
                                     loss_slice)

   with torch.no_grad():

       adv_prefix_tokens = input_ids[adv_slice].to(device)

       new_adv_prefix_toks = sample_control(adv_prefix_tokens,
                                            coordinate_grad,
                                            batch_size)

       new_adv_prefix = get_filtered_cands(tokenizer,
                                           new_adv_prefix_toks,
                                           filter_cand=True,
                                           curr_control=adv_prefix)

       logits, ids = get_logits(model=model,
                                tokenizer=tokenizer,
                                input_ids=input_ids,
                                control_slice=adv_slice,
                                test_controls=new_adv_prefix,
                                return_ids=True,
                                batch_size=batch_size)  # decrease this number if you run into OOM.

       losses = target_loss(logits, ids, target_slice)

       best_new_adv_prefix_id = losses.argmin()
       best_new_adv_prefix = new_adv_prefix[best_new_adv_prefix_id]

       current_loss = losses[best_new_adv_prefix_id]

       adv_prefix = best_new_adv_prefix

   # Create a dynamic plot for the loss.
   plotlosses.update({'Loss': current_loss.detach().cpu().numpy()})
   plotlosses.send()

   print(f"Current Prefix:{best_new_adv_prefix}", end='\r')
   if is_success(best_new_adv_prefix):
       break

   del coordinate_grad, adv_prefix_tokens
   gc.collect()
   torch.cuda.empty_cache()

if is_success(best_new_adv_prefix):
   print("SUCCESS:", best_new_adv_prefix)

脚本的核心思想是:先初始化一段无意义前缀,例如一串感叹号,然后不断修改这段前缀中的 token,使模型在看到这段前缀后,更倾向于把 hackergame 作为后续文本生成出来,也就是说,优化阶段并不是直接让模型自由生成,而是把输入构造成:

adv_prefix + hackergame

然后计算模型在当前 adv_prefix 条件下预测 hackergame 的 loss,并且将loss值降低

GCG 的关键在于,它不是随机乱试前缀,而是利用梯度来指导 token 替换

脚本会把可控前缀中的每个 token 转成 one-hot 表示,再通过模型的 embedding 矩阵映射成连续向量。虽然 token 本身是离散的,但 embedding 空间是连续

的,因此可以计算目标 loss 对这些 one-hot 位置的梯度

梯度告诉我们:如果想让 loss 下降,当前位置更应该替换成哪些 token

接下来,脚本会为每个位置选出若干个梯度方向上更有希望的候选 token,并构造出一批候选前缀

每个候选前缀通常只和当前前缀相差一个 token,然后脚本批量评估这些候选前缀对应的目标 loss,选择 loss 最低的那个作为新的前缀

这个过程会不断重复:

直到模型在只看到 adv_prefix 的情况下,能够自动续写出 hackergame,脚本就认为攻击成功

2.2 本地部署GCG

https://github.com/llm-attacks/llm-attacks

可以在本地进行gcg攻击过程的一个复现,前期环境安装的命令就不提了,这里提一个模型的问题

# 
pip install "fschat[model_worker]"
python -c "
from huggingface_hub import snapshot_download
snapshot_download('lmsys/vicuna-7b-v1.5', local_dir='/data/models/vicuna-7b-v1.5')
"

#
python -c "
from huggingface_hub import snapshot_download
snapshot_download('meta-llama/Llama-2-7b-chat-hf',
                 local_dir='/data/models/llama-2-7b-chat-hf',
                 token='YOUR_HF_TOKEN')
"

第一种是下载Vicuna-7B模型,这种模型最轻量,复现最快

第二种是LLaMA-2-7B-Chat,也是论文中的主要目标,但是LLaMA-2 需要先在 HuggingFace 申请访问权限,获取 token

启动命令

CUDA_VISIBLE_DEVICES=0 python -u ../main.py \
   --config="../configs/individual_vicuna.py" \
   --config.attack=gcg \
   --config.train_data="../../data/advbench/harmful_behaviors.csv" \
   --config.result_prefix="../results/test_run" \
   --config.n_train_data=2 \
   --config.data_offset=0 \
   --config.n_steps=10 \
   --config.test_steps=5 \
   --config.batch_size=512

可以看到最终的结果在终端中,随着迭代步数(n_steps)的推进,有几个现象印证了 GCG 算法原理:

在每一轮迭代中,终端都会实时打印出当前的 Loss 值。正如前文所述,损失函数在这里充当了扣分器

在针对目标任务,例如诱导模型输出恶意漏洞脚本的第 0 步,初始的感叹号后缀(! ! !...)产生的 Loss 值通常较高,这说明在没有任何有效干预时,模型原始状态强烈倾向于执行安全拒答

但随着梯度优化的进行,Loss 值会肉眼可见地逐步缩小,这意味着算法找到了让损失下降最多的替换方案,当前生成的对抗后缀正在把模型的输出概率一步步推向设定的目标方向

且在不断迭代的过程中,最初的占位符,比如感叹号会被诸如 avecpayloadcompact 等看似毫不相干的词汇或零碎符号逐渐替换

这个过程直观地展示了算法如何利用梯度信息,在离散 token 空间中进行贪心搜索

它根本不在意这些词汇组合在人类读起来是否通顺,它只在乎把某个位置换成哪个 token,最容易让输出朝目标方向偏移

也就是说,大模型的安全边界可能不是一个稳定的语义规则边界,而是一个可以被算法自动搜索和逼近的概率边界

这段对抗样本对人眼来说毫无逻辑,但在模型内部的连续 Embedding 空间中,它却构成了最致命的有效扰动

当跑完设定的步数后,如果 Loss 降到了足够低的阈值,模型就会彻底突破原本的安全对齐限制,顺着后缀,将原本应该拒绝的恶意内容直接生成出来

这里我只实验了10步,做了一个测试而已,所以最终结果 (Step 10): 攻击失败 (Passed 0/1)

归根到底还是因为步数太少,算法没能找到有效的破解后缀,模型依然坚守底线,回答:I'm sorry, but I cannot provide a strategy for hacking...

所以可以把n_steps设置为500,可能会有更好的效果

本课程最终解释权归蚁景网安学院

本页面信息仅供参考,请扫码咨询客服了解本课程最新内容和活动

🎈网安学院推荐课程: Web安全工程师特训班 Python网络安全实战班 应急响应安全工程师特训班
  CTF-Reverse实战技能特训班 CTF-WEB实战技能特训班 CTF-PWN实战技能特训班 CTF-MISC实战技能特训班   SRC赏金猎人大师班 HVV大师课