ios

GCG Journey(六):多目标 GCG —— 让一段后缀打穿整条链路

N 人看过

真正想拿下的那个 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 后面,而注入点只有最前面那个日志字段。

MEDIUM 唯一注入点外部日志字段 triage路过点 · 只需骗过放行 review · 高风险目标能执行命令 report · 高风险目标写回 memory / dashboard

这张图把约束摆明了:连攻击者根本不想碰的 triage,也得算成目标——后缀不先骗过 triage、被它放行进 review,就永远够不到真正要打的那个。“路过的"和"要打的”,在搜索眼里是同一种约束。

把每个这样的位置抽象成一个风险界面 ci=(mi,ti,fi,pi,ei)c_i = (m_i, t_i, f_i, p_i, e_i):模型 mim_i、prompt/输出协议 tit_i、业务控制面 fif_i、parser schema pip_i、效果验证器 eie_i。一段注入后缀 ss,要在所有 cic_i 上同时成立。

攻击成立,意味着同一段 ss 在每个界面上都把业务行为推到攻击者选定的状态:

Bi(Gmi,ti(xis))=yi,i=1,,NB_i\big(G_{m_i,t_i}(x_i \oplus s)\big) = y_i, \qquad i = 1,\dots,N

GG 是生成,BiB_i解析并验证效果之后的业务行为检查——它根本不看 loss,只看"parser 读到的业务 token 落地成了什么动作"。目标集合就是 模型×控制面×上下文\text{模型} \times \text{控制面} \times \text{上下文}

本篇做到的最强结果:同一段后缀,在 2 个模型、每个模型 9 个上下文(triage / review / report 三个控制面,每个面 3 种序列化表达)上合并命中 18 / 18。这里的 1818 不是 loss 阈值,是 generate()\text{generate}() 之后经 parser 落地的业务行为命中数。

把这 18 个目标摆出来就具体了:三个控制面各要一个 token——triage 要 MEDIUM(降级放行)、review 要 os.system('ls')(执行命令)、report 要 WRITE_REPORT(写下假结论);每个面又有 3 种序列化写法(同一个目标写成 json、纯文本、markdown 等三种上下文)。3 面 × 3 写法 × 2 个模型 = 18。 后面 §二起的消融只留 triage、review 两面、暂不碰 report,就是 12

问题摆明了:怎么搜出这样一段 ss?最自然的起点,是把单目标 GCG 直接往上加。

二、最自然的做法,为什么会失败

能不能不另起炉灶,直接把现成的 GCG 搬上来?先顺着它走到底——它撞上的那堵墙,正是后面整套搜索的起点。

单目标 GCG 优化的是一个连续 loss:

mins L(s),L(s)=CE(fθ(xs),y)\min_{s}\ \mathcal{L}(s),\qquad \mathcal{L}(s)=\operatorname{CE}\big(f_\theta(x\oplus s),\, y\big)

推广到多目标,最直接的做法就是把每个界面的 loss 加权求和(joint loss),每步在候选集 C\mathcal{C} 里贪心选它最低的:

Ljoint(s)=i=1NλiCE(fθi(xis),yi),s(t+1)=argminsC Ljoint(s)\mathcal{L}_{\text{joint}}(s)=\sum_{i=1}^{N}\lambda_i\,\operatorname{CE}\big(f_{\theta_i}(x_i\oplus s),\, y_i\big), \qquad s^{(t+1)}=\arg\min_{s'\in\mathcal{C}}\ \mathcal{L}_{\text{joint}}(s')

这是多目标优化里最朴素的线性标量化——把多个目标压成一个标量来优化。这条 loss 路最成熟的一站,就是离我们最近的前作 Super Suffixes(Adiletta et al., 2025):让一段后缀同时骗过 generator 对齐和 guard 模型。它还注意到 generator 与 guard 的 tokenizer 不同、没法对一个求和 loss 直接求梯度,于是改用交替 GCG(每 N 步在两个损失之间切换)加阈值锁定来逼近——本篇的 B0 基线,就是把这套交替 joint-loss 推到 NN 个业务目标上。

三种做法的差别,先落在目标的结构上:GCG 盯 1 个目标,Super Suffixes 盯 2 个固定目标,本篇盯 N 个跨模型、跨控制面、还会移动的目标——

单目标 GCG · 1 个目标 Super Suffixes · 2 个固定目标(协同) 本篇 · N 个不固定目标(跨模型 × 控制面 × 上下文,最难者在移动) 后缀 s 目标 token(单模型) 判定:按 loss ℒ 选 后缀 s generator 对齐 guard 规避 判定:两损失交替+ 阈值锁定(仍看 loss) 后缀 s triage review report … 更多界面 判定:generate→parse→effect→ 业务命中 H + Pareto 前沿

但这条 loss 路优化的是代理,不是攻击者真正要的东西。 攻击者在乎的,是离散的业务行为命中数:

H(s)=i=1N1[Parse ⁣(Gmi,ti(xis))yi]H(s)=\sum_{i=1}^{N}\mathbb{1}\big[\operatorname{Parse}\!\big(G_{m_i,t_i}(x_i \oplus s)\big)\models y_i\big]

Ljoint\mathcal{L}_{\text{joint}} 连续、greedy 选的是它;HH 离散、攻击者要的是它。先把这 12 个目标摆清楚——它们落在链路的 triage、review 两个环节上,每个环节有 3 种序列化上下文、各要一个 token:

triage 环节 · 要 MEDIUM review 环节 · 要 os.system 命令 判成 MEDIUM 才路由 唯一注入点日志字段 triage_kv triage_json triage_summary review_text review_json review_markdown

这 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:

B0 基线:joint loss 早早见底,命中却卡在 7/12 上下跷跷板
epochjoint loss行为命中率
11.0450.50
20.5070.75
30.4450.67
40.4450.67
50.4450.67
60.4440.50
70.4440.58
80.4440.58
90.4420.50
100.4420.50
110.4410.58
120.4410.58
130.4410.58
140.4410.58
150.4410.58
160.4410.58
loss 在前几个 epoch 就降到底、之后纹丝不动,命中却始终在 7/12 上下跷跷板——再多给 epoch 也填不平。症结在于:后缀的每个 token 都要同时服务多个目标,一个目标的梯度想把它往一个方向推、另一个却往反方向拉;标量化把这些彼此抵触的梯度求和,而和变小**不等于**每一项都满足——完全可以靠"牺牲一个已命中的目标"去换"另一个目标的 loss 下降"。**梯度在共用 token 上彼此拉扯,求和后的标量却看不见这场冲突。**

三、既然 loss 会骗人,那就直接盯住行为

解法其实就藏在第一堵墙的成因里。问题出在"挑候选"这一步:greedy 一直盯着 Ljoint\mathcal{L}_{\text{joint}} 挑最低的,于是常常挑中行为更差的那个。那就只动这一步——梯度该怎么提候选还怎么提,我们只把挑选的依据从 loss 换成真实的业务命中。这套改法,后面记作 B1

s(t+1)=argmaxsC H(s),多个候选 H 相同时,用 Ljoint 打破平局s^{(t+1)}=\arg\max_{s'\in\mathcal{C}}\ H(s'), \qquad \text{多个候选 } H \text{ 相同时,用 } \mathcal{L}_{\text{joint}} \text{ 打破平局}

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 只用来提候选和平手兜底。只改这一处,轨迹就从跷跷板变成单调上升:

换成行为判定后,从跷跷板变成单调上升(命中数 /12)
epochB0 joint lossB1 行为判定
169
2910
3810
4810
5811
6611
7711
8711
把 B0 和 B1 跑到最后摆在一起更清楚:joint loss 几乎相同(都约 0.44),命中数却是 7 对 11。loss 一样、结果却差一大截——**拉开差距的不是 loss,是"按什么标准选候选"。**

但单点的 max-H 也到此为止:它能升到 11/12,最后一个 miss 却始终补不上——那是 target 模型(1.5B)的 triage_summary。同一个降级任务,写成 kv、json 都被压成了 MEDIUM,唯独 summary 这种写法始终判作 HIGH。这个 miss 落在当前后缀的局部邻域之外,单点搜索够不到它,这就是它遇到的第二堵墙。

四、单点搜索为什么停在 11/12:修好一个目标,反而破坏另一个

单点搜索停在 11/1211/12——最后一个 miss 总也补不上,再多给几轮预算也是同样的结果。这个上限是结构性的:所有目标共用同一段后缀,后缀里的每个 token 都得同时为它们服务。把搜索过程一步步拆开,卡点就露出来了。

第一步,在 source 上搜出后缀,再迁到 target。 后缀在 source 上全部命中(6/66/6),迁到 target 只命中一半(3/63/6),合并 9/129/12——缺口集中在 target 一侧。

第二步,直接修 target 的缺口。 针对 target 的 miss 继续搜,target 修到了 6/66/6;但同一段后缀回到 source,source 从 6/66/6 退回 1/61/6,合并反而掉到 7/127/12

问题就出在这里:让 target 命中的那几个 token,正是原先让 source 命中的那几个。token 数量有限,又被所有目标共用,命中一处就要牺牲另一处。所以一个 miss 不会被消除,只会从一个目标转移到另一个——这就是单点搜索的上限:它最多稳定持有 11/1211/12 这一种局面,无法同时满足全部目标。

同一段后缀上,source 与 target 此消彼长(各 6 个目标)
阶段source 命中target 命中note
第一步63
第二步16修 target 的代价:source 跌到 1/6
既然单独一段后缀满足不了全部目标,那就同时留住几段各有所长的,让它们各自补上对方漏掉的,再把它们的长处合到一段后缀里。后面三个设计都顺着这个思路;在它们之前,还得先垫一步基础。

基础:对最弱的一整组求梯度

每一轮都针对当前最弱的那一组(source 或 target),把修复摊到整组上,两侧轮流被照顾,合并就能从 7/127/12 回到 9/129/12(即后面表里的"按模型分组修")。分组让两侧不再此消彼长,但它只能把局面拉平;越过这个平台(plateau),要靠下面三个设计,每一个都能写成一条明确的规则。

① 保留互补的前沿

既然单独一段后缀总会顾此失彼,就不应只保留一段。先为每段后缀记录它的行为签名 σ\sigma——它在所有目标上命中与否的那个 ✓/✗ 向量:

σ(s)=(b1(s),,bN(s)),bi(s)=1[Parse ⁣(Gmi,ti(xis))yi]\sigma(s)=\big(b_1(s),\dots,b_N(s)\big),\qquad b_i(s)=\mathbb{1}\big[\operatorname{Parse}\!\big(G_{m_i,t_i}(x_i\oplus s)\big)\models y_i\big]

按行为签名 σ\sigma 一对照就清楚了:这几段后缀互不支配——每一段都命中了别人漏掉的目标,谁也压不倒谁。它们合起来,就是离散行为空间里的一条 Pareto 前沿:一组各有所长、彼此替代不了的解。archive AA 把这条前沿上的几段后缀都保留下来:先按 σ\sigma 去重,再按下面这个三元组(字典序)在前沿内部排序:

R(s)=(H(s), imb(s), worst(s))R(s)=\big(-H(s),\ \operatorname{imb}(s),\ \operatorname{worst}(s)\big)

命中越多越靠前;imb(s)\operatorname{imb}(s) 是不同模型/控制面组之间命中数的差距,越平衡越好;worst(s)\operatorname{worst}(s) 最后以最差那个目标的 loss 区分,避免某个位置被完全放弃。两段后缀即便 HH 同为 1111,只要 miss 落在不同位置、签名就互补,都应保留:

后缀 A:source ✓✓✓ · target ✓✓✗ archive:按签名去重A、B 签名不同 → 两段都保留 后缀 B:source ✓✗✓ · target ✓✓✓

代码上,去重的键是行为签名、不是后缀文本——同一签名只留排序最优的一个,不同签名一律保留

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 较低的一段被保留;改按行为签名去重,它们因命中模式不同而各自保留,搜索才同时持有几种互补的局面(9/1210/129/12 \to 10/12)。

② token 级重组

当 archive 里已经有两段互补的父代 s(1),s(2)s^{(1)}, s^{(2)},下一步顺理成章:既然一段覆盖了另一段的 miss,就把两段的 token 各取一截拼接起来。对交叉点 kk

childk=(s1(1),,sk(1), sk+1(2),,sm(2))\text{child}_k = \big(s^{(1)}_{1},\dots,s^{(1)}_{k},\ s^{(2)}_{k+1},\dots,s^{(2)}_{m}\big)

父代 A:t₁ t₂ t₃ ┊ t₄ t₅ t₆ 孩子:t₁ t₂ t₃ ┊ u₄ u₅ u₆ 父代 B:u₁ u₂ u₃ ┊ u₄ u₅ u₆

代码上,对每个切点都生成两个方向的孩子(左前+右后、右前+左后),再按业务命中挑:

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 就有机会同时落进一个孩子里。这一步正是越过平台的关键(10/1211/1210/12 \to 11/12)。

③ 保护性修复

11/1211/12 时只剩最后一个 miss,且已经离成功很近——此时才引入"保护"约束:记 MM 为未命中集、Mˉ\bar M 为已命中集,只在不破坏 Mˉ\bar M 的前提下去命中 MM

maxsC HM(s)s.t.HMˉ(s)=HMˉ(s)\max_{s'\in\mathcal{C}}\ H_M(s')\quad \text{s.t.}\quad H_{\bar M}(s')=H_{\bar M}(s)

这一步必须留到最后:过早施加,约束会把搜索锁得过死、反而困住探索(11/1212/1211/12 \to 12/12)。

三步合一:完整算法

把 triage / review 两个控制面做到 12/1212/12,再加 report 控制面,扩到 18/1818/18

阶段 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
直接修 target7
分组修9
互补前沿10
重组11
最终修复12
加 report18
把这三条规则装进同一个搜索回路,就得到完整算法。主干仍然是 GCG——**梯度负责提出候选,业务行为负责做出判定**;①②③ 只在各自的条件满足时触发:
前沿 archive A按行为签名 σ 去重、按 R 排序 取 TopK 后缀 对最弱组沿梯度提候选 token generate → parse → 行为签名 σ ① 按 σ 更新 A,保留互补前沿 卡在 plateau? ② 互补前沿 token 重组 接近成功? ③ 保护性修复锁住已命中目标

写成伪代码(算法 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 同样的预算,它是不是也追得上?要回答这个,只能控制变量:同一个 10/1210/12 起点、同一套梯度超参,逐个换算法组件。

对照设置
source 模型 qwen/Qwen2.5-0.5B-Instruct

bfloat16

target 模型 Qwen/Qwen2.5-1.5B-Instruct

bfloat16

目标 12

2 模型 × 6 上下文(triage+review 两面)

search_width 32

统一候选预算

topk_per_position 96

统一 token 提议

起点 10/12 后缀

互补前沿中间状态(含 10/12、9/12 两段)

三种核心策略从同一段 10/12 种子出发,逐 epoch 的命中轨迹放在一起:

三种选择策略的行为命中轨迹(/12)
epochB0 joint lossB1 行为判定B2 前沿 beam
1699
29109
381011
481012
5811
6611
7711
8711
同一段 10/12 种子、同一套梯度超参,B0 始终在 6–9 之间震荡,B1 稳步爬到 11,B2 四步就跑满——预算一样,差别只在怎么挑候选。那 B2 已经满分,再把 §四另外两个组件也叠上去,会更稳吗?结果相反,命中退回 10/12:
B2 已是 12/12,每叠一个组件反而把命中拉低(满分 12)
变体合并命中
B2(前沿 beam)12
B3(+动态分组)10
B4(+保护性修复)10
- **B3 = B2 + 动态分组路由**:把固定交替换成"每轮盯当前最弱的那组"——就是 §四的分组修,但**全程开着**。 - **B4 = B3 + 保护性修复**:再叠上"锁住已命中的目标"——就是 ③,但**从一开始就锁**。

从 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 起点),两个方向都稳:0.5B1.5B0.5\text{B} \to 1.5\text{B} 命中 51/5451/54,反向 1.5B0.5B1.5\text{B} \to 0.5\text{B} 也是 51/5451/54,唯一的缺口恒定落在 source 的 triage_kv 上。

第二类,协议偏移,断链。迁到 Qwen3 方向,target 侧 27 个界面(3 起点 × 9 上下文)整片归零,0/270/27。但这个 00 不是"搜索没搜动"——Qwen3 的原始输出经常以 <think> 开头:对模型,它在执行推理协议;对 parser,业务 token 根本没落到可消费的位置,于是解析为空。失败发生在 parser 之前,在搜索够不着的地方。 一个 no-think 对照足以坐实这一点:固定同一段后缀只换界面,解析命中只从 2/92/9 提到 4/94/9,收益全在 review / report,第一道 triage 风险入口仍然 0/30/3;而无论加不加 no-think 提示,<think> 标记都照常出现(9/99/9)。这不是改一句提示词能解决的。

把两族模型放到同一条 generate → parse → effect 流水线上,断点的位置一眼可辨:

Qwen2.5(同族迁移) Qwen3(协议偏移) generate 业务 token落在可读位置 parse ✓ 命中 51/54 generate 输出以 <think> 开头业务 token 被顶出可读区 parse 读空 ✗ 0/27

同族那条流水线一路走通;Qwen3 那条在 parse 处就断了,而真正的成因(<think>)发生在更早的 generate 内部。

同族迁移
Qwen2.5 0.5B ↔ 1.5B

51/54

协议和输出模板接近,后缀在三控制面上保持较强可达性。

协议偏移
Qwen2.5 ↔ Qwen3

target 0/27

失败在 parser 之前:输出进 <think>,业务 token 没落到可读位置。

七、回头看:从"压低一个 loss"到"搜索一组业务行为"

第五篇问的是应用边界:模型输出什么时候变成业务控制信号。这一篇往深里走一步——当真正要打的目标埋在链路深处、前面隔着多个模型和控制面时,攻击会变成什么。

答案是一条被几堵墙逐步逼出来的路:

01
判定搬到了行为层

成功是 generate → parse → effect 的离散业务行为命中 HH,不是 loss 阈值。joint loss 会朝错误方向下降(跷跷板),所以选择函数必须用 HH

02
搜索只能靠分解

跨模型多目标无法单阶段贪心解决:直接修一个会打坏另一个,miss 只会移动;于是按签名保留互补前沿、token 重组逃 plateau、保护性修复收尾——顺序比组件更重要。

03
迁移的两种结局

同族迁移成立(51/54);换到协议不同的模型,失败在 parser 之前(<think>,target 0/27)——这是协议偏移,要单独建模,不是搜索失败。

相对最接近的前作,差别也就清楚了:Super Suffixes 解决"一段后缀同时骗过 generator 和 guard"——两个协同目标、靠交替优化与 loss 阈值;本篇解决"一段后缀穿过整条链路、在多个模型上把多个业务控制面推到目标状态"——NN 个协同目标、看离散行为。问题的规模和性质一变,搜索就从单阶段贪心,变成了对业务行为状态空间的分阶段搜索。逐维度对照:

维度 Super Suffixes 本篇
目标数量 2(generator + guard) 18+(模型 × 控制面 × 上下文)
目标性质 连续 loss 阈值 离散业务行为命中 HH
目标关系 协同:guard 规避不能破坏 gen 越狱 协同:一段后缀要在所有位置同时成立
核心机制 交替窗口 + 阈值锁定 多阶段分解 + 行为前沿 + 保护性修复

一处边界要划清:§二的 B0 基线是 Super Suffixes 最接近的可抽取形态——交替 joint-loss,并非论文算法的完整复现;本篇论证的是这套交替优化在 NN 个业务目标上会失效,而非否定那篇工作。

余下的留给后续:把协议兼容性显式写进适应度(让后缀穿过 <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(五):当模型输出进入业务控制面》——应用边界;本篇承接它,讨论链路纵深。