GCG Journey(六):多目标 GCG —— 让一段后缀打穿整条链路
真正想拿下的那个 agent,往往不在链路最前面。攻击者只能在最前面注入一次,可这段后缀要穿过整条链路、精准命中深处的目标,并在那里执行。
第五篇的双重后缀已经露出苗头:同一段文本,先在 triage agent 那里把告警从 HIGH 压成 MEDIUM、路由进 review_agent,再在 review agent 里吐出 os.system('ls')。一段外部文本,连开了两个控制面。
但那只是两个控制面、一个模型。真实的 agent 链路要深得多——而且攻击者真正想拿下的东西,几乎从不在最前面。
设想一条 SOC 处置链:日志先进 triage agent 分级;判成 MEDIUM 才被路由给 review agent,而 review agent 能执行命令;处置完还会有 report agent 把结论写进 dashboard 和 memory。
triage agent 只是一道门。攻击者真正的收益在后面:让 review agent 执行命令,或者让 report agent 把一条假事实写进 dashboard 和 memory,污染后续所有 agent 眼里的"事实"。可攻击者够不着这些深处的 agent——他只能控制最前面那个日志字段。这一段注入,得先被 triage 读到、被它路由进去,才轮得到 review;得进了 report 的上下文,才谈得上写假事实。链路越深,前面挡着的模型和控制面越多;不同环节还可能跑着不同的模型。
所以攻击的单位变了,从"让一个模型输出一个 token"升级成:
让同一段后缀穿过整条链路——
既骗过"路过的",又打中"要打的",还要在目标位置真的执行。
这就是多目标 GCG(multi-objective GCG):让同一段后缀精准命中链路深处那个 agent、并在那里发起执行——为此它要同时在"路过 + 要打"的多个模型、多个控制面、多个上下文上成立。
一、该打哪个 agent,又得先骗过哪些?
在一个智能体工作流(agentic workflow)的 AI 应用里,攻击者要先在链路里定位高风险目标——能执行命令、能把结论写回 memory/dashboard、或负责发送与提权动作的那几环 agent。攻击者用提示词泄漏做功能探测,套出各 agent 的系统提示、工具权限和路由规则,还原出整条链路的结构——谁的输出喂给谁、满足什么条件才路由进下一环;再用模型指纹识别(本系列第四篇)逐环确认基座模型和版本。两步探测做完,黑盒应用就收敛成一张链路图:真正想拿下的 review agent 埋在 triage 后面,而注入点只有最前面那个日志字段。
这张图把约束摆明了:连攻击者根本不想碰的 triage,也得算成目标——后缀不先骗过 triage、被它放行进 review,就永远够不到真正要打的那个。“路过的"和"要打的”,在搜索眼里是同一种约束。
把每个这样的位置抽象成一个风险界面 :模型 、prompt/输出协议 、业务控制面 、parser schema 、效果验证器 。一段注入后缀 ,要在所有 上同时成立。
攻击成立,意味着同一段 在每个界面上都把业务行为推到攻击者选定的状态:
是生成, 是解析并验证效果之后的业务行为检查——它根本不看 loss,只看"parser 读到的业务 token 落地成了什么动作"。目标集合就是 。
本篇做到的最强结果:同一段后缀,在 2 个模型、每个模型 9 个上下文(triage / review / report 三个控制面,每个面 3 种序列化表达)上合并命中 18 / 18。这里的 不是 loss 阈值,是 之后经 parser 落地的业务行为命中数。
把这 18 个目标摆出来就具体了:三个控制面各要一个 token——triage 要 MEDIUM(降级放行)、review 要 os.system('ls')(执行命令)、report 要 WRITE_REPORT(写下假结论);每个面又有 3 种序列化写法(同一个目标写成 json、纯文本、markdown 等三种上下文)。3 面 × 3 写法 × 2 个模型 = 18。 后面 §二起的消融只留 triage、review 两面、暂不碰 report,就是 12。
问题摆明了:怎么搜出这样一段 ?最自然的起点,是把单目标 GCG 直接往上加。
二、最自然的做法,为什么会失败
能不能不另起炉灶,直接把现成的 GCG 搬上来?先顺着它走到底——它撞上的那堵墙,正是后面整套搜索的起点。
单目标 GCG 优化的是一个连续 loss:
推广到多目标,最直接的做法就是把每个界面的 loss 加权求和(joint loss),每步在候选集 里贪心选它最低的:
这是多目标优化里最朴素的线性标量化——把多个目标压成一个标量来优化。这条 loss 路最成熟的一站,就是离我们最近的前作 Super Suffixes(Adiletta et al., 2025):让一段后缀同时骗过 generator 对齐和 guard 模型。它还注意到 generator 与 guard 的 tokenizer 不同、没法对一个求和 loss 直接求梯度,于是改用交替 GCG(每 N 步在两个损失之间切换)加阈值锁定来逼近——本篇的 B0 基线,就是把这套交替 joint-loss 推到 个业务目标上。
三种做法的差别,先落在目标的结构上:GCG 盯 1 个目标,Super Suffixes 盯 2 个固定目标,本篇盯 N 个跨模型、跨控制面、还会移动的目标——
但这条 loss 路优化的是代理,不是攻击者真正要的东西。 攻击者在乎的,是离散的业务行为命中数:
连续、greedy 选的是它; 离散、攻击者要的是它。先把这 12 个目标摆清楚——它们落在链路的 triage、review 两个环节上,每个环节有 3 种序列化上下文、各要一个 token:
这 6 个上下文在 0.5B、1.5B 两个模型上各算一份,就是 2 面 × 3 写法 × 2 模型 = 12 个目标(§一那完整的 18 还多一个 report 面,消融时暂不碰)。
把这条 loss 路(B0)放到这 12 个目标上:从一段已经命中其中 10 个的后缀起步往下搜,loss 和命中立刻脱钩——loss 一路降,命中却原地跷跷板:
| checkpoint | searched suffix | prefix payload | 判定 |
|---|---|---|---|
| epoch 1 1.0447 | source 梯度 |
6/12:起步就退化 |
loss 选了行为更差的候选 |
| epoch 2 0.5068 | target 梯度 |
9/12:短暂峰值 |
偶然改善 |
| epoch 3 0.4453 | target 梯度 |
8/12 |
退化 |
| epoch 6 0.4436 | target 梯度 |
6/12:严重退化 |
跷跷板 |
| epoch 8 0.4435 | source 梯度 |
7/12:仍未恢复(之后到 16 epoch 也不动) |
卡住 |
把 loss 和命中率画到同一张图上,跑满 16 个 epoch:
| epoch | joint loss | 行为命中率 |
|---|---|---|
| 1 | 1.045 | 0.50 |
| 2 | 0.507 | 0.75 |
| 3 | 0.445 | 0.67 |
| 4 | 0.445 | 0.67 |
| 5 | 0.445 | 0.67 |
| 6 | 0.444 | 0.50 |
| 7 | 0.444 | 0.58 |
| 8 | 0.444 | 0.58 |
| 9 | 0.442 | 0.50 |
| 10 | 0.442 | 0.50 |
| 11 | 0.441 | 0.58 |
| 12 | 0.441 | 0.58 |
| 13 | 0.441 | 0.58 |
| 14 | 0.441 | 0.58 |
| 15 | 0.441 | 0.58 |
| 16 | 0.441 | 0.58 |
三、既然 loss 会骗人,那就直接盯住行为
解法其实就藏在第一堵墙的成因里。问题出在"挑候选"这一步:greedy 一直盯着 挑最低的,于是常常挑中行为更差的那个。那就只动这一步——梯度该怎么提候选还怎么提,我们只把挑选的依据从 loss 换成真实的业务命中。这套改法,后面记作 B1:
B1 和按 joint loss 选候选的 B0(§二那条基线)只差一处:排序候选时它看的是业务命中、不是 loss。落到代码上几乎一行之差:
# B0(Super-Suffixes 式):交替梯度提候选,仍按 joint loss 选
best = min(candidates, key=joint_loss)
# B1:先用 loss 提候选,再真的跑一遍、按业务命中选
for c in top_by_loss(candidates):
c.behavior = generate_parse_effect(c.suffix) # generate → parse → effect
best = max(candidates, key=lambda c: hit_count(c.behavior)) # 命中最多者胜,平手再看 loss
generate_parse_effect 才是关键:它真的把候选后缀拼进每个目标的 prompt、跑一遍生成、过一遍 parser、验一次 effect,拿回离散的命中。loss 只用来提候选和平手兜底。只改这一处,轨迹就从跷跷板变成单调上升:
| epoch | B0 joint loss | B1 行为判定 |
|---|---|---|
| 1 | 6 | 9 |
| 2 | 9 | 10 |
| 3 | 8 | 10 |
| 4 | 8 | 10 |
| 5 | 8 | 11 |
| 6 | 6 | 11 |
| 7 | 7 | 11 |
| 8 | 7 | 11 |
但单点的 max-H 也到此为止:它能升到 11/12,最后一个 miss 却始终补不上——那是 target 模型(1.5B)的 triage_summary。同一个降级任务,写成 kv、json 都被压成了 MEDIUM,唯独 summary 这种写法始终判作 HIGH。这个 miss 落在当前后缀的局部邻域之外,单点搜索够不到它,这就是它遇到的第二堵墙。
四、单点搜索为什么停在 11/12:修好一个目标,反而破坏另一个
单点搜索停在 ——最后一个 miss 总也补不上,再多给几轮预算也是同样的结果。这个上限是结构性的:所有目标共用同一段后缀,后缀里的每个 token 都得同时为它们服务。把搜索过程一步步拆开,卡点就露出来了。
第一步,在 source 上搜出后缀,再迁到 target。 后缀在 source 上全部命中(),迁到 target 只命中一半(),合并 ——缺口集中在 target 一侧。
第二步,直接修 target 的缺口。 针对 target 的 miss 继续搜,target 修到了 ;但同一段后缀回到 source,source 从 退回 ,合并反而掉到 。
问题就出在这里:让 target 命中的那几个 token,正是原先让 source 命中的那几个。token 数量有限,又被所有目标共用,命中一处就要牺牲另一处。所以一个 miss 不会被消除,只会从一个目标转移到另一个——这就是单点搜索的上限:它最多稳定持有 这一种局面,无法同时满足全部目标。
| 阶段 | source 命中 | target 命中 | note |
|---|---|---|---|
| 第一步 | 6 | 3 | |
| 第二步 | 1 | 6 | 修 target 的代价:source 跌到 1/6 |
基础:对最弱的一整组求梯度
每一轮都针对当前最弱的那一组(source 或 target),把修复摊到整组上,两侧轮流被照顾,合并就能从 回到 (即后面表里的"按模型分组修")。分组让两侧不再此消彼长,但它只能把局面拉平;越过这个平台(plateau),要靠下面三个设计,每一个都能写成一条明确的规则。
① 保留互补的前沿
既然单独一段后缀总会顾此失彼,就不应只保留一段。先为每段后缀记录它的行为签名 ——它在所有目标上命中与否的那个 ✓/✗ 向量:
按行为签名 一对照就清楚了:这几段后缀互不支配——每一段都命中了别人漏掉的目标,谁也压不倒谁。它们合起来,就是离散行为空间里的一条 Pareto 前沿:一组各有所长、彼此替代不了的解。archive 把这条前沿上的几段后缀都保留下来:先按 去重,再按下面这个三元组(字典序)在前沿内部排序:
命中越多越靠前; 是不同模型/控制面组之间命中数的差距,越平衡越好; 最后以最差那个目标的 loss 区分,避免某个位置被完全放弃。两段后缀即便 同为 ,只要 miss 落在不同位置、签名就互补,都应保留:
代码上,去重的键是行为签名、不是后缀文本——同一签名只留排序最优的一个,不同签名一律保留:
def behavior_signature(row): # σ(s):每个目标 hit 与否,拼成 ✓/✗ 向量
return tuple((name, bool(v["hit"])) for name, v in sorted(row["behavior"].items()))
def frontier_key(row):
# 命中越多越靠前 → source/target 越平衡越好 → 最后用最差目标的 loss 兜底
return (-hit_count(row), abs(source_hits(row) - target_hits(row)), worst_loss(row))
def dedupe_and_select(rows, beam_size):
best_by_sig = {}
for row in rows:
sig = behavior_signature(row)
if sig not in best_by_sig or frontier_key(row) < frontier_key(best_by_sig[sig]):
best_by_sig[sig] = row # 同一签名只留最好的一个
return sorted(best_by_sig.values(), key=frontier_key)[:beam_size] # 不同签名全留下
关键在那个去重键 best_by_sig[sig]:若按后缀文本去重,A、B 这种"命中总数相同、miss 位置不同"的后缀会被当成两个普通候选,通常只有 loss 较低的一段被保留;改按行为签名去重,它们因命中模式不同而各自保留,搜索才同时持有几种互补的局面()。
② token 级重组
当 archive 里已经有两段互补的父代 ,下一步顺理成章:既然一段覆盖了另一段的 miss,就把两段的 token 各取一截拼接起来。对交叉点 :
代码上,对每个切点都生成两个方向的孩子(左前+右后、右前+左后),再按业务命中挑:
left, right = ids(parent_A), ids(parent_B) # 两个互补父代的 token 序列
for cut in range(1, length):
children.append(decode(left[:cut] + right[cut:])) # 左前段 + 右后段
children.append(decode(right[:cut] + left[cut:])) # 右前段 + 左后段
best = max(children, key=lambda s: hit_count(behavior(s))) # 仍然按业务命中选
A 命中的目标,靠的是它后缀里的某些 token;B 命中的,靠另一些。把两段在某处剪开、交叉拼接,两边管用的 token 就有机会同时落进一个孩子里。这一步正是越过平台的关键()。
③ 保护性修复
到 时只剩最后一个 miss,且已经离成功很近——此时才引入"保护"约束:记 为未命中集、 为已命中集,只在不破坏 的前提下去命中 :
这一步必须留到最后:过早施加,约束会把搜索锁得过死、反而困住探索()。
三步合一:完整算法
把 triage / review 两个控制面做到 ,再加 report 控制面,扩到 :
| 阶段 | source | target | 合并 | 这一步在解决什么 |
|---|---|---|---|---|
| source 深后缀迁移 | 6/6 | 3/6 | 9/12 | 起点:source 强,target 欠 |
| 直接修 target | 1/6 | 6/6 | 7/12 | 修好 target,反而打坏 source |
| 按模型分组修 | 4/6 | 5/6 | 9/12 | 别被单个最弱目标牵着走 |
| 保留互补前沿 ① | 5/6 | 5/6 | 10/12 | 同时握住几种 miss 互补的后缀 |
| 前沿 token 重组 ② | 6/6 | 5/6 | 11/12 | 拼出两边都覆盖的后缀,逃 plateau |
| 保护性最终修复 ③ | 6/6 | 6/6 | 12/12 | 锁住已命中,够最后一个 miss |
| 加 report 控制面 | 9/9 | 9/9 | 18/18 | 第三个控制面:每侧上下文 6 → 9 |
把"合并"一列单独画成柱状,整条路径的形状便一目了然:先因直接修复跌到 7,经分组恢复到 9,再由三个设计依次推到 18:
| 阶段 | combined |
|---|---|
| source 迁移 | 9 |
| 直接修 target | 7 |
| 分组修 | 9 |
| 互补前沿 | 10 |
| 重组 | 11 |
| 最终修复 | 12 |
| 加 report | 18 |
写成伪代码(算法 1):
输入: 目标界面 {c_i}, 种子后缀
输出: 行为命中数最高的后缀
A ← 前沿 archive,按签名 σ 去重、按 R 排序,用种子初始化
for epoch = 1..K:
for s in TopK(A):
g ← 当前最弱的模型/控制面组 # 分组:别被单个目标牵着走
C ← GCGProposal(s, g) # 沿 ∇L_g 用 TopK 提候选(GCG 原样)
for s' in C:
σ(s') ← (generate → parse → effect) # 关键:以业务行为判定
A.update(C, key=σ, rank=R) # ① 按签名去重,保留互补前沿
if plateau(A):
p1, p2 ← 两段 miss 互补的前沿
A.add(Recombine(p1, p2)) # ② token 级交叉
if nearSuccess(A):
A.add(ProtectedRepair(A.best)) # ③ 修 M 不破坏 ¬M
return argmax_s H(s) over A
五、提升靠的是算法,还是只是多花了算力?
命中从 7/12 涨到 12/12,会不会只是多跑了几个 epoch 的结果——给 joint loss 同样的预算,它是不是也追得上?要回答这个,只能控制变量:同一个 起点、同一套梯度超参,逐个换算法组件。
qwen/Qwen2.5-0.5B-Instruct
bfloat16
Qwen/Qwen2.5-1.5B-Instruct
bfloat16
2 模型 × 6 上下文(triage+review 两面)
统一候选预算
统一 token 提议
互补前沿中间状态(含 10/12、9/12 两段)
三种核心策略从同一段 10/12 种子出发,逐 epoch 的命中轨迹放在一起:
| epoch | B0 joint loss | B1 行为判定 | B2 前沿 beam |
|---|---|---|---|
| 1 | 6 | 9 | 9 |
| 2 | 9 | 10 | 9 |
| 3 | 8 | 10 | 11 |
| 4 | 8 | 10 | 12 |
| 5 | 8 | 11 | — |
| 6 | 6 | 11 | — |
| 7 | 7 | 11 | — |
| 8 | 7 | 11 | — |
| 变体 | 合并命中 |
|---|---|
| B2(前沿 beam) | 12 |
| B3(+动态分组) | 10 |
| B4(+保护性修复) | 10 |
从 B2 到 B4,每叠一个组件也就动一行:
# B2:固定交替,决定这一轮提谁的梯度
group = "source" if (epoch // N) % 2 == 0 else "target"
# B3:换成挑当前命中最少的那组
group = weakest_group(frontier)
# B4:再给"破坏已命中"的候选扣分
best = max(cands, key=lambda c: (hit_count(c), -breaks_already_hit(c)))
两处变差,根源都在引入的时机,而非组件本身。分组在 §四只用于把跌至 7/12 的局面拉回 9/12 的平台,B3 却全程启用,搜索被持续拉向当前最弱的一组,前沿的多样性随之收窄;保护在 §四仅在只剩一个 miss 时施加,B4 从第一轮便锁定已命中,使搜索过早困于原地。同一个组件,引入时机不同,作用便从"收尾"转为"掣肘"。把五个变体的最终命中与算力开销放在一起:
| variant | 合并 | forward passes | 结论 |
|---|---|---|---|
| B0 交替 joint loss(Super-Suffixes 式) | 7/12 | 2112 | loss 降但行为退化 |
| B1 行为判定 | 11/12 | 2112 | 拒绝退化,稳定改善 |
| B2 前沿 beam | 12/12 | 924 | 多签名前沿最快突破 |
| B3 动态分组路由 | 10/12 | 1980 | 过度聚焦最弱组,卡住 |
| B4 过早保护 | 10/12 | 1980 | 过早锁定,限制探索 |
这里 B2 单独就跑满 12/12,靠的是起点好——一段现成的 10/12 种子,前沿 beam 的多样性足够补上最后两格。§四是从 source 迁移那个更差的起点一路爬,最后两格够不着,才要 ② 重组、③ 保护接力。同一个前沿,难度不同,单独够不够用也就不同。
六、当模型换掉输出协议,这套方法在哪里失效
跨模型的结果不能一概称作"通用"。按迁移落到的目标模型分,成败属于性质截然不同的两类。
第一类,同族迁移,成立。三控制面的后缀在 Qwen2.5 家族内迁移,跨 3 个随机起点累计 54 个界面(两侧各 9 上下文 × 3 起点),两个方向都稳: 命中 ,反向 也是 ,唯一的缺口恒定落在 source 的 triage_kv 上。
第二类,协议偏移,断链。迁到 Qwen3 方向,target 侧 27 个界面(3 起点 × 9 上下文)整片归零,。但这个 不是"搜索没搜动"——Qwen3 的原始输出经常以 <think> 开头:对模型,它在执行推理协议;对 parser,业务 token 根本没落到可消费的位置,于是解析为空。失败发生在 parser 之前,在搜索够不着的地方。 一个 no-think 对照足以坐实这一点:固定同一段后缀只换界面,解析命中只从 提到 ,收益全在 review / report,第一道 triage 风险入口仍然 ;而无论加不加 no-think 提示,<think> 标记都照常出现()。这不是改一句提示词能解决的。
把两族模型放到同一条 generate → parse → effect 流水线上,断点的位置一眼可辨:
同族那条流水线一路走通;Qwen3 那条在 parse 处就断了,而真正的成因(<think>)发生在更早的 generate 内部。
51/54
协议和输出模板接近,后缀在三控制面上保持较强可达性。
target 0/27
失败在 parser 之前:输出进 <think>,业务 token 没落到可读位置。
七、回头看:从"压低一个 loss"到"搜索一组业务行为"
第五篇问的是应用边界:模型输出什么时候变成业务控制信号。这一篇往深里走一步——当真正要打的目标埋在链路深处、前面隔着多个模型和控制面时,攻击会变成什么。
答案是一条被几堵墙逐步逼出来的路:
成功是 generate → parse → effect 的离散业务行为命中 ,不是 loss 阈值。joint loss 会朝错误方向下降(跷跷板),所以选择函数必须用 。
跨模型多目标无法单阶段贪心解决:直接修一个会打坏另一个,miss 只会移动;于是按签名保留互补前沿、token 重组逃 plateau、保护性修复收尾——顺序比组件更重要。
同族迁移成立(51/54);换到协议不同的模型,失败在 parser 之前(<think>,target 0/27)——这是协议偏移,要单独建模,不是搜索失败。
相对最接近的前作,差别也就清楚了:Super Suffixes 解决"一段后缀同时骗过 generator 和 guard"——两个协同目标、靠交替优化与 loss 阈值;本篇解决"一段后缀穿过整条链路、在多个模型上把多个业务控制面推到目标状态"—— 个协同目标、看离散行为。问题的规模和性质一变,搜索就从单阶段贪心,变成了对业务行为状态空间的分阶段搜索。逐维度对照:
| 维度 | Super Suffixes | 本篇 |
|---|---|---|
| 目标数量 | 2(generator + guard) | 18+(模型 × 控制面 × 上下文) |
| 目标性质 | 连续 loss 阈值 | 离散业务行为命中 |
| 目标关系 | 协同:guard 规避不能破坏 gen 越狱 | 协同:一段后缀要在所有位置同时成立 |
| 核心机制 | 交替窗口 + 阈值锁定 | 多阶段分解 + 行为前沿 + 保护性修复 |
一处边界要划清:§二的 B0 基线是 Super Suffixes 最接近的可抽取形态——交替 joint-loss,并非论文算法的完整复现;本篇论证的是这套交替优化在 个业务目标上会失效,而非否定那篇工作。
余下的留给后续:把协议兼容性显式写进适应度(让后缀穿过 <think> 这类协议前缀),把搜索从 Qwen2.5 扩到更多模型族,把控制面从三个推到更多。但这些都是顺着同一条路接着往下走;这一篇真正改写的,是 GCG 优化的对象本身。
References
- Andy Zou, Zifan Wang, Nicholas Carlini, et al., Universal and Transferable Adversarial Attacks on Aligned Language Models, 2023. https://arxiv.org/abs/2307.15043(GCG 原始的离散后缀优化与梯度提议,本篇主干)
- Andrew Adiletta, Kathryn Adiletta, Kemal Derya, Berk Sunar, Super Suffixes: Bypassing Text Generation Alignment and Guard Models Simultaneously, 2025. https://arxiv.org/abs/2512.11783(最接近的前作:一段后缀同时绕过文本生成对齐与 guard 模型;两目标协同,因 tokenizer 不同而用交替 GCG + 阈值锁定,本篇对照基线 B0 的思想来源)
- Zhao et al., AmpleGCG, 2024. https://arxiv.org/abs/2404.07921(中间成功后缀有用,只挑最低 loss 会漏候选——支持按行为保留前沿)
- Faster-GCG, 2024. https://arxiv.org/abs/2410.15362(GCG 的效率与去重候选评估)
- 本系列:《GCG Journey(五):当模型输出进入业务控制面》——应用边界;本篇承接它,讨论链路纵深。