0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

自動レッドチーミングの主要手法 GCG・PAIR・TAP:仕組みの違いと評価での使い分けを模擬実装で確かめる

0
Posted at

LLM アプリのレッドチームを人手だけで回すと、試せるプロンプトの数がすぐに頭打ちになります。そこで、攻撃プロンプトの生成そのものを自動化する手法が研究されてきました。よく名前が挙がるのが GCG、PAIR、TAP の 3 つで、OSS のレッドチームツールにも実装されています。

この記事では、3 つの手法が「何を手がかりに、何を探しているのか」を整理し、自社のモデルやアプリを評価するときにどれを使うかの基準をまとめます。仕組みは、実際の LLM を使わない小さな模擬実装で確かめます。攻撃に使える具体的なプロンプトは載せません。

3 つの手法は「どこまでモデルが見えるか」で分かれる

手法 必要なアクセス 探すもの 手がかり 出てくるプロンプト
GCG 重みと勾配(ホワイトボックス) 依頼文の後ろに付けるトークン列(サフィックス) 損失の勾配 記号や単語の断片が並ぶ、人が読めない文字列
PAIR 応答だけ(ブラックボックス) 依頼文そのものの言い換え 評価役 LLM の点数 自然な文章
TAP 応答だけ(ブラックボックス) 依頼文そのものの言い換え 評価役 LLM の点数と枝刈り 自然な文章

GCG は 2023 年に Zou らが発表した手法です。PAIR は同じ年に Chao らが、TAP は PAIR を発展させる形で Mehrotra らが発表し、TAP は NeurIPS 2024 に採録されています。

GCG:勾配でトークンの候補を絞り、実際に評価して 1 つずつ置き換える

GCG(Greedy Coordinate Gradient)は、依頼文の後ろに付けるサフィックスを最適化します。目的は、モデルの応答が「はい、以下が〜です」のような肯定的な書き出しで始まる確率を最大にすることです。書き出しさえ肯定させれば、その後も続けて答えてしまう傾向を利用しています。

1 ステップの手順は次のとおりです。

  1. サフィックスの各位置のトークンを one-hot ベクトルとみなし、損失の勾配を計算する
  2. 各位置で、勾配が最も負になる上位 k 個のトークンを置き換え候補にする
  3. 候補の中から「どの位置を、どのトークンに置き換えるか」をランダムに B 個選ぶ
  4. B 個すべてについて損失を実際に計算し、最も低いものを採用する

勾配は「1 トークンだけ変えたら損失がどう動くか」の一次近似にすぎないため、候補を絞るためだけに使い、採用は実際の損失で決めます。論文の実験では、サフィックス長 20 トークン、k = 256、B = 512、500 ステップでした。複数の依頼文・複数のモデルで同時に損失を下げる「ユニバーサル」なサフィックスも作れ、オープンなモデルで作ったサフィックスが商用 API のモデルにも一部転移したことが報告されています。

この探索の流れを、LLM の代わりに乱数パラメータの小さな数式モデルで再現しました。サフィックスのトークン列からスコアを計算し、「スコアが目標を超える確率」の負の対数を損失とします。同じ評価回数のランダム探索と比べます。

"""GCG の探索手順を、LLM の代わりの小さな数式モデルで再現する。

「モデル」は語彙 V 個・埋め込み次元 D の乱数パラメータで、
サフィックス(トークン列)に対して「目標の応答を出す確率」の代わりになるスコアを返す。
GCG と同じく、one-hot ベクトルに対する勾配で候補トークンを絞り、
B 個の 1 トークン置換を実際に評価して、損失が最小のものを採用する。
同じ評価回数のランダム探索と比べる。
"""
import numpy as np

V, D, L = 200, 16, 12          # 語彙数、埋め込み次元、サフィックス長
OFFSET = 60.0                  # 目標を高めに置き、損失が飽和しないようにする
STEPS, TOP_K, BATCH = 60, 16, 64

def make_model(seed):
    r = np.random.default_rng(seed)
    E = r.normal(size=(V, D)) / np.sqrt(D)
    M = r.normal(size=(D, D)) / np.sqrt(D)
    w = r.normal(size=D)
    return E, M, w

def loss(tokens, E, M, w):
    e = E[tokens]                                   # (L, D)
    s = (e @ w).sum() + np.einsum("id,de,ie->", e[:-1], M, e[1:])
    return np.logaddexp(0.0, -(s - OFFSET))         # -log sigmoid(s - OFFSET)

def grad_onehot(tokens, E, M, w):
    """損失を各位置の one-hot ベクトルで微分した値 (L, V)。"""
    e = E[tokens]
    s = (e @ w).sum() + np.einsum("id,de,ie->", e[:-1], M, e[1:])
    dl_ds = -1.0 / (1.0 + np.exp(s - OFFSET))
    g = np.zeros((L, V))
    for i in range(L):
        de = w.copy()
        if i + 1 < L:
            de += M @ e[i + 1]
        if i > 0:
            de += M.T @ e[i - 1]
        g[i] = dl_ds * (E @ de)
    return g

def gcg(E, M, w, rng):
    tok = rng.integers(0, V, size=L)
    best = loss(tok, E, M, w)
    evals = 1
    for _ in range(STEPS):
        g = grad_onehot(tok, E, M, w)
        cand = np.argsort(g, axis=1)[:, :TOP_K]       # 勾配が最も負のトークン
        trials = []
        for _ in range(BATCH):
            t = tok.copy()
            pos = rng.integers(0, L)
            t[pos] = cand[pos, rng.integers(0, TOP_K)]
            trials.append(t)
        losses = [loss(t, E, M, w) for t in trials]
        evals += BATCH
        j = int(np.argmin(losses))
        if losses[j] < best:
            best, tok = losses[j], trials[j]
    return best, evals

def random_search(E, M, w, rng, budget):
    tok = rng.integers(0, V, size=L)
    best = loss(tok, E, M, w)
    for _ in range(budget - 1):
        t = tok.copy()
        t[rng.integers(0, L)] = rng.integers(0, V)
        l = loss(t, E, M, w)
        if l < best:
            best, tok = l, t
    return best

if __name__ == "__main__":
    rows = []
    for seed in range(20):
        E, M, w = make_model(seed)
        lg, n = gcg(E, M, w, np.random.default_rng(1000 + seed))
        lr = random_search(E, M, w, np.random.default_rng(2000 + seed), n)
        rows.append((lg, lr))
    a = np.array(rows)
    print(f"評価回数: {n} 回/モデル, モデル数: {len(a)}")
    print(f"GCG        の最終損失 中央値: {np.median(a[:,0]):.3f}")
    print(f"ランダム探索 の最終損失 中央値: {np.median(a[:,1]):.3f}")
    print(f"GCG の方が低かったモデル: {(a[:,0] < a[:,1]).sum()} / {len(a)}")

実行環境は Python 3.13.16、NumPy 2.5.3 です(実際の LLM は使っていません)。

評価回数: 3841 回/モデル, モデル数: 20
GCG        の最終損失 中央値: 26.743
ランダム探索 の最終損失 中央値: 27.333
GCG の方が低かったモデル: 18 / 20

同じ評価回数でも、勾配で候補を絞った方が 20 モデル中 18 で低い損失に届きました。差が小さいのは模擬モデルが単純なためで、語彙が数万あり、損失の形も複雑な実際の LLM では、勾配で候補を絞ることの効果はより大きくなると考えられます。

防御側から見た GCG の性質は 3 つです。

  • 重みが必要:API でしか使えないモデルには直接かけられません。オープンなモデルで作ったものを転移させる使い方になります
  • 出てくる文字列が不自然:人が読める文章ではないため、後述のパープレキシティによる検査で見つけやすくなります
  • 計算が重い:1 ステップごとに B 回の順伝播が要り、GPU 上で数百ステップ回します

PAIR:攻撃役の LLM に、標的の応答を見せながら改良させる

PAIR(Prompt Automatic Iterative Refinement)は、3 つの LLM を使います。

  • 攻撃役:目的と、これまでの試行(プロンプト・標的の応答・点数)を受け取り、「どう改良するか」の説明と次のプロンプトを JSON で返す
  • 標的:評価したいモデル。会話履歴なしで 1 回ずつ応答する
  • 評価役(ジャッジ):プロンプトと応答の組を 1〜10 点で採点し、10 点なら成功とみなす

攻撃役のシステムプロンプトには、ロールプレイや権威への訴えといった言い換えの戦略が書かれていて、社会工学のように「断られた理由を見て言い方を変える」ことを自動で繰り返します。改良の連鎖(ストリーム)を複数並列に走らせ、各ストリームは数回の反復で打ち切ります。公式実装の README の既定値は、ストリーム 5 本・反復 5 回です。

GCG と違って勾配も重みも要らず、出てくるのは自然な文章です。論文のタイトルどおり、少ない問い合わせ回数で成功することが多いのが特徴です。

TAP:PAIR を木に広げ、標的に送る前に枝を刈る

TAP(Tree of Attacks with Pruning)は、PAIR の 1 本の連鎖を木構造の探索に広げたものです。1 回の反復は次の 4 段階です。

  1. 分岐:攻撃役が、各葉の候補から b 個ずつ子の候補を作る
  2. 枝刈り 1:評価役が、目的から外れた候補を標的に送る前に捨てる
  3. 攻撃と採点:残った候補を標的に送り、評価役が応答を採点する
  4. 枝刈り 2:点数の高い w 個だけを残し、次の反復の葉にする

論文のアブレーションでは、枝刈りをせずに分岐だけすると、成功率は近いものの標的への問い合わせが 2 倍近く必要になりました。TAP の利点は、分岐で探索を広げることと、枝刈りで無駄な問い合わせを減らすことの組み合わせにあります。論文では、Llama Guard で守られたモデルに対しても高い成功率を保ったと報告されています。

この「枝刈りがどこで効くか」を、模擬の攻撃役・標的・評価役で確かめました。候補プロンプトは「品質」と「目的から外れていないか」の 2 つの属性だけを持つ抽象的なオブジェクトで、評価役の点数は品質から決まります。LLM の出力は、固定のシードの乱数で決まる値で置き換えています。

"""PAIR(1 本の改良の連鎖を並列に回す)と TAP(分岐+2 段階の枝刈り)の
探索構造を、固定の乱数で動く模擬の攻撃役・標的・評価役で比べる。

実際の LLM は呼ばない。候補プロンプトは「品質」と「話題から外れていないか」
という 2 つの属性だけを持つ抽象的なオブジェクトで、評価役の 1〜10 点は
品質から決まる(10 点で成功)。数値は探索構造の違いを見るためのもので、
論文の成功率とは比べられない。
"""
from dataclasses import dataclass
import random

OFF_TOPIC_RATE = 0.3   # 攻撃役の改良が目的から外れる割合
BUDGET = 60            # 標的への問い合わせ上限(1 目的あたり)

@dataclass
class Cand:
    quality: float
    on_topic: bool = True

def attacker_refine(parent, rng):
    """攻撃役 LLM の代わり。親の候補と評価結果から子を 1 つ作る。"""
    if rng.random() < OFF_TOPIC_RATE:
        return Cand(parent.quality, on_topic=False)
    return Cand(parent.quality + rng.gauss(0.6, 1.0))

def judge(c, rng):
    """標的の応答を評価役が 1〜10 点で採点する代わり。話題外は 1 点。"""
    if not c.on_topic:
        return 1
    return max(1, min(10, round(c.quality + rng.gauss(0, 0.5))))

def pair(rng, n_streams=3):
    queries = 0
    streams = [Cand(1.0) for _ in range(n_streams)]
    while queries < BUDGET:
        for i, c in enumerate(streams):
            child = attacker_refine(c, rng)
            queries += 1                          # PAIR は話題外の候補も標的に送る
            if judge(child, rng) == 10:
                return True, queries
            if child.on_topic:
                streams[i] = child
            if queries >= BUDGET:
                break
    return False, queries

def tap(rng, branching=4, width=4):
    queries = 0
    leaves = [Cand(1.0)]
    while queries < BUDGET:
        children = [attacker_refine(c, rng) for c in leaves for _ in range(branching)]
        children = [c for c in children if c.on_topic]       # 枝刈り 1: 標的に送る前に話題外を捨てる
        scored = []
        for c in children:
            if queries >= BUDGET:
                break
            queries += 1
            s = judge(c, rng)
            if s == 10:
                return True, queries
            scored.append((s, c))
        scored.sort(key=lambda x: -x[0])
        leaves = [c for _, c in scored[:width]] or [Cand(1.0)]  # 枝刈り 2: 点の高い width 個だけ残す
    return False, queries

def run(fn, trials=2000):
    rng = random.Random(0)
    ok, used = 0, []
    for _ in range(trials):
        s, q = fn(rng)
        ok += s
        if s:
            used.append(q)
    return ok / trials, sum(used) / len(used)

if __name__ == "__main__":
    for rate_off in (0.0, 0.3, 0.6):
        OFF_TOPIC_RATE = rate_off
        for name, fn in [("PAIR", pair), ("TAP", tap)]:
            rate, q = run(fn)
            print(f"話題外 {rate_off:.0%} {name:4s}: 成功割合 {rate:6.1%}, 成功までの平均問い合わせ {q:5.1f} 回")
話題外 0% PAIR: 成功割合  99.6%, 成功までの平均問い合わせ  29.6 回
話題外 0% TAP : 成功割合  68.0%, 成功までの平均問い合わせ  50.7 回
話題外 30% PAIR: 成功割合  90.5%, 成功までの平均問い合わせ  39.4 回
話題外 30% TAP : 成功割合  92.7%, 成功までの平均問い合わせ  42.9 回
話題外 60% PAIR: 成功割合  36.6%, 成功までの平均問い合わせ  46.9 回
話題外 60% TAP : 成功割合  97.5%, 成功までの平均問い合わせ  34.3 回

数値そのものは模擬のパラメータで決まるもので、論文の成功率とは比べられません。読み取れるのは構造の違いです。攻撃役がほとんど目的から外れない条件では、TAP は 1 段で b × w 個の候補を標的に送るため、同じ問い合わせ上限の中では PAIR より不利になりました。攻撃役が目的から外れやすくなるほど、PAIR は外れた候補にも問い合わせを使ってしまい、標的に送る前に捨てる TAP が有利になります。TAP が標的に送る前の枝刈りを設けているのは、攻撃役の LLM が目的から外れた候補を作ることがあるためです。

評価で使い分ける基準

3 つの手法は、優劣ではなく評価したい対象と条件で選びます。

自社でホストするオープンなモデルには GCG も回す

重みを持っているなら、GCG で「最適化で見つかる最悪の入力」を試せます。ファインチューニングで安全性が崩れていないか、ベースモデルと比べる用途にも向いています。ただし、GCG が通らないことは安全の証明にはなりません。GCG の文字列はパープレキシティの検査で見つけやすいため、本番の入力検査を挟んだ状態で GCG だけを試すと、防御を過大評価します。

API 越しのモデルやアプリ全体には PAIR・TAP を使う

システムプロンプト、入出力のガードレール、RAG まで含めたアプリ全体を評価するなら、ブラックボックスで動く PAIR・TAP が向いています。自然な文章を作るので、入力の分類器やパープレキシティの検査をすり抜けるかどうかも同時に確かめられます。問い合わせの回数がそのまま費用になるため、目的(評価項目)ごとの問い合わせ上限を先に決めておきます。

評価役の採点を信用しすぎない

PAIR と TAP は、評価役 LLM の採点を手がかりに探索し、成功の判定にも使います。評価役が甘ければ成功率は高く出て、厳しければ低く出ます。PAIR の論文でも、どのモデルを評価役にするかを人手の判定との一致率や誤検知の少なさで比べて選んでいます。成功とされた例は、少なくとも抜き取りで人が確認します。LLM による採点の誤差は、別の記事で詳しく扱う予定です。

単発の評価であることを忘れない

PAIR の標的は会話履歴なしで応答し、GCG も 1 回の入力を最適化します。何往復もかけて少しずつ話を誘導するマルチターンの攻撃は、これらの手法の評価範囲の外です。

パープレキシティの検査が GCG には効き、PAIR・TAP には効かない理由

GCG の出力が不自然であることを使う防御として、入力のパープレキシティ(言語モデルから見た文の不自然さ)を測って高すぎるものを止める方法があります。Jain らは、GCG のサフィックスがパープレキシティの検査、特に窓をずらして局所的な値を見る方式でほぼすべて検出されることを示しました。同時に、通常の依頼文の誤検知が多く、単独の防御としては扱いにくいとも評価しています。

この性質を、文字バイグラムの小さな言語モデルで確かめました。GCG 風のサフィックスは乱数で並べた無意味な文字列で、実際の攻撃文字列ではありません。

"""パープレキシティ(文としての不自然さ)で入力を検査するフィルターの考え方を、
文字バイグラムの小さな言語モデルで確かめる。

- 自然な文は低い値、記号や単語の断片が並ぶ GCG 風のサフィックスは高い値になる
- 全体の平均では薄まるので、窓をずらして最大値を見る(windowed perplexity)
- PAIR・TAP が作るような自然な文は、このフィルターでは区別できない

GCG 風のサフィックスは乱数で作った無意味な文字列で、実際の攻撃文字列ではない。
"""
import math
import random
from collections import Counter

CORPUS = """
Please summarize the following report in three short bullet points.
Can you explain how the login process works for new employees?
Write a short email to the team about the meeting schedule next week.
What are the main differences between the two database designs?
Translate this paragraph into plain English for a general audience.
I am writing a novel about a detective who solves crimes in a small town.
You are a helpful assistant that answers questions about our product.
Give me a list of ideas for a team building event in the spring.
Explain the steps to reset a password when the user forgets it.
The customer asked whether the order can be shipped before Friday.
""" * 3

def train(text):
    text = text.lower()
    uni = Counter(text)
    bi = Counter(zip(text, text[1:]))
    return uni, bi, len(set(text)) + 1

def char_nll(a, b, model):
    uni, bi, v = model
    return -math.log((bi[(a, b)] + 1) / (uni[a] + v))   # ラプラス平滑化

def perplexity(s, model):
    s = s.lower()
    nll = [char_nll(a, b, model) for a, b in zip(s, s[1:])]
    return math.exp(sum(nll) / len(nll))

def windowed_max(s, model, win=20):
    if len(s) <= win:
        return perplexity(s, model)
    return max(perplexity(s[i:i + win], model) for i in range(len(s) - win + 1))

def gcg_like_suffix(rng, n=20):
    pieces = ["describing", "\\", "+", "similarlyNow", "]", "(", "Me", "oppos",
              "!--", "revert", "{", "**", "ONE", "please", "?", "Two", "=", "%"]
    return " ".join(rng.choice(pieces) for _ in range(n))

if __name__ == "__main__":
    model = train(CORPUS)
    rng = random.Random(0)
    base = "Explain how the login process works for new employees."
    natural = ("You are a writer working on a novel. The detective in the story "
               "needs to explain to a new employee how the login process works.")
    samples = {
        "通常の依頼": base,
        "通常の依頼 + GCG 風サフィックス": base + " " + gcg_like_suffix(rng),
        "PAIR/TAP 風の言い換え(自然文)": natural,
    }
    # しきい値は学習に使っていない通常の依頼文で決める
    held_out = ["Could you check whether the invoice was sent to the customer?",
                "Summarize the meeting notes and list the open questions.",
                "How do I change the shipping address on an existing order?"]
    th = 1.5 * max(windowed_max(s, model) for s in held_out)
    print(f"しきい値(学習外の通常文での最大値の 1.5 倍): {th:.1f}")
    for k, s in samples.items():
        p, w = perplexity(s, model), windowed_max(s, model)
        print(f"{k:32s} 全体 {p:6.1f}  窓の最大 {w:6.1f}  判定 {'遮断' if w > th else '通過'}")
しきい値(学習外の通常文での最大値の 1.5 倍): 26.2
通常の依頼                            全体   10.8  窓の最大   13.7  判定 通過
通常の依頼 + GCG 風サフィックス              全体   17.7  窓の最大   44.4  判定 遮断
PAIR/TAP 風の言い換え(自然文)             全体   10.7  窓の最大   15.0  判定 通過

GCG 風のサフィックスを付けた入力は、全体の平均では 17.7 としきい値を下回り、自然な依頼文に薄められて通ってしまいます。窓ごとの最大値を見ると 44.4 になり、止められます。一方、PAIR・TAP が作るような自然な言い換えは通常の依頼文と区別がつきません。

パープレキシティの検査は「GCG のような不自然な入力」を安く止める層として使い、自然な文章による攻撃には、出力側の検査やツール権限の制限など別の層で備えます。

公開された手法で防御を評価するときの限界

GCG・PAIR・TAP は、どれも防御の中身を知らない攻撃です。Nasr らは、勾配法・強化学習・ランダム探索・人手による探索を、防御ごとに合わせて組み立てる「適応的な攻撃」を使い、攻撃成功率がほぼ 0 と報告されていた 12 の防御を回避しました。大半の防御で成功率は 90% を超えています(USENIX Security 2026)。

また、Hughes らの Best-of-N は、大文字・小文字の入れ替えなどの単純な変形をランダムに加えたプロンプトを大量に試すだけの手法ですが、試行回数を増やすほど成功率がべき乗則に近い形で上がることを示しました。問い合わせ回数に上限を設けない評価では、どんなモデルも最終的には破られる前提で、「何回の試行で破られたか」を記録しておく方が比較に使えます。

自動化した手法で見つからなかったことは、「見つからなかった」以上の意味を持ちません。自動レッドチーミングは、変更のたびに同じ条件で回して後退を見つける回帰テストとして使い、防御の強さそのものは、防御を知ったうえでの人手の評価と組み合わせて判断します。

OSS のツールでの実装

自分で実装しなくても、OSS のレッドチームツールに実装があります。

  • garak(NVIDIA):tap モジュールに TAP と PAIR のプローブがあり、PAIR は TAP の設定を変えた特殊ケースとして実装されています。攻撃役・評価役のモデルは設定で差し替えられます
  • PyRIT(Microsoft):攻撃の実装として TreeOfAttacksWithPruningAttack(TAP)と PAIRAttack があり、攻撃役となるチャットのターゲットを指定して使います

どちらも攻撃役と評価役に別の LLM を使うため、評価対象とは別に API の費用やローカルの計算資源が要ります。評価役に何を使ったかは、結果と一緒に記録しておきます。

参考

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?