AI蒸馏攻防-大模型文本水印

大模型文本水印(Text Watermarking)的底层核心,是由马里兰大学的 Tom Goldstein 和 John Kirchenbauer 等人在 2023 年提出的伪随机词表切分机制。它巧妙地利用了密码学哈希函数和统计学假设检验,在不改变大模型原本语义的前提下,秘密地给文本盖上“数字印章”。

一、 文本水印的数学原理

文本水印的核心思想是:在模型生成每一个词时,暗中用一把“私钥”将词表动态划分为红绿榜,并人为拉偏它们的选字概率。

整个数学流程可以分为生成(添加水印)检测(提取水印)两个阶段:

1. 水印生成阶段:动态 Logits 偏置

假设大模型的词表大小为 V。在生成第 t 个词 x_t 时,模型已经输出了前一个词 x_{t-1}(实际工程中通常会取前 n个词构成的窗口)。

  1. 伪随机切分:系统使用一个秘密的哈希密钥 K,计算前一个词的哈希值作为随机种子:

    S = \text{Hash}(x_{t-1}, K)

  2. 划分红绿榜:用这个种子 S初始化一个伪随机数生成器(PRNG),将整个词表 V随机划分为两部分:

    • 绿名单(Greenlist, G):允许高频使用的词,占总词表的比例为 \gamma(通常取 \gamma = 0.5)。

    • 红名单(Redlist, R):被限制使用的词,占总词表的比例为 1 - \gamma。

  3. 注入偏置(Bias):模型原本输出的原始未归一化概率(Logits)为 l_t。水印系统对属于绿名单的词强行加上一个扰动正值 \delta:

    l'_t[i] = \begin{cases} l_t[i] + \delta & \text{if } i \in G \\ l_t[i] & \text{if } i \in R \end{cases}

  4. 采样输出:最终通过 \text{Softmax}(l'_t)得到新的概率分布并进行常规采样。因为 \delta > 0$,模型会以极高的概率倾向于选择绿名单中的词。

2. 水印检测阶段:单侧 Z 检验

当拿到一段长度为 N的未知文本时,检测方因为拥有相同的密钥 K,可以完全复现大模型在生成每一步时的红绿榜划分。

检测方在整段文本中,统计实际上命中了多少个“绿名单词”,记为 N_G。

  • 零假设(Null Hypothesis, H_0):该文本是人类写的,或者是由未加水印的模型生成的。此时,每个词命中绿名单的概率是完全随机的,即独立同分布的伯努利试验,概率为 \gamma。

  • 统计学指标:在零假设下,N_G 的期望值和方差分别为:

    \mu = \gamma N, \quad \sigma^2 = \gamma(1 - \gamma)N

  • 计算 Z-score:

    Z = \frac{N_G - \gamma N}{\sqrt{\gamma(1 - \gamma)N}}

如果计算出的 Z 值远大于常规阈值(例如 Z > 4.0),则意味着“一段文本连续随机命中绿名单”的概率极低(p \text{-value} < 0.00003)。此时,我们可以以几乎 100% 的绝对把握拒绝零假设,断定该文本包含 AI 水印

二、 Python 代码实现:简易红绿单水印生成与检测器

下面我们用一个极简的 Python 脚本,完整模拟词表划分 \to 水印文本生成 \to 水印检测的端到端全流程。为了保证代码完全可运行,我们用简单的随机数模型模拟大模型的 Logits。

Python

import hashlib import numpy as np class GreenlistWatermarker: def __init__(self, vocab_size=1000, gamma=0.5, delta=2.0, secret_key="my_secret_salt"): """ :param vocab_size: 虚拟词表大小 :param gamma: 绿名单占比 (0-1) :param delta: 绿名单 Logits 增加的偏置大小 :param secret_key: 密码学密钥,用于哈希混淆 """ self.vocab_size = vocab_size self.gamma = gamma self.delta = delta self.secret_key = secret_key self.vocab = list(range(vocab_size)) def _get_greenlist(self, prev_token): """根据前一个 token 和密钥,伪随机生成当前步的绿名单""" # 将前一个 token 和密钥结合做 SHA256 哈希 hash_input = f"{self.secret_key}_{prev_token}".encode('utf-8') hash_seed = int(hashlib.sha256(hash_input).hexdigest(), 16) % (2**32) # 种子锚定伪随机数发生器,确保切分可复现 rng = np.random.default_rng(hash_seed) shuffled_vocab = rng.permutation(self.vocab) green_size = int(self.vocab_size * self.gamma) greenlist = set(shuffled_vocab[:green_size]) return greenlist def generate_token(self, prev_token, original_logits): """给 Logits 注入水印偏置,并采样生成下一个 token""" greenlist = self._get_greenlist(prev_token) # 复制一份原始 logits 并加上偏置 watermarked_logits = original_logits.copy() for idx in range(self.vocab_size): if idx in greenlist: watermarked_logits[idx] += self.delta # Softmax 并采样 exp_logits = np.exp(watermarked_logits - np.max(watermarked_logits)) probs = exp_logits / np.sum(exp_logits) next_token = np.random.choice(self.vocab, p=probs) return next_token def detect(self, token_sequence): """检测输入序列,返回绿色词总数和统计学 Z-score""" if len(token_sequence) < 2: return 0, 0.0 green_count = 0 N = len(token_sequence) - 1 # 第一个词没有前序,不计入统计 # 逐个位置复现绿名单并进行校验 for i in range(1, len(token_sequence)): prev_token = token_sequence[i-1] curr_token = token_sequence[i] greenlist = self._get_greenlist(prev_token) if curr_token in greenlist: green_count += 1 # 计算数学期望、方差与 Z-score expected_green = self.gamma * N variance = self.gamma * (1 - self.gamma) * N std_dev = np.sqrt(variance) z_score = (green_count - expected_green) / std_dev if std_dev > 0 else 0.0 return green_count, z_score # ==================== 验证与测试 ==================== if __name__ == "__main__": # 初始化水印中心 watermarker = GreenlistWatermarker(vocab_size=1000, gamma=0.5, delta=4.0) print("--- 实验 1:模拟【有水印】大模型文本生成与检测 ---") watermarked_text = [42] # 初始种子 token for _ in range(100): # 模拟大模型输出的原始概率分布(纯随机基础 Logits) mock_original_logits = np.random.normal(0, 1, size=1000) next_t = watermarker.generate_token(watermarked_text[-1], mock_original_logits) watermarked_text.append(next_t) g_count, z = watermarker.detect(watermarked_text) print(f"总检验 Token 数 (N): {len(watermarked_text)-1}") print(f"命中绿色词数 (N_G): {g_count} (理论期望值约为: {(len(watermarked_text)-1)*0.5})") print(f"计算所得 Z-score: {z:.4f} (当 Z > 4.0 时,即可 99.99% 确定被恶意蒸馏/白嫖)") print("\n--- 实验 2:模拟【人类创作/无水印】普通文本检测 ---") # 模拟一段完全不带偏置随机产生的文本 normal_text = list(np.random.randint(0, 1000, size=101)) g_count_norm, z_norm = watermarker.detect(normal_text) print(f"总检验 Token 数 (N): {len(normal_text)-1}") print(f"命中绿色词数 (N_G): {g_count_norm}") print(f"计算所得 Z-score: {z_norm:.4f} (数值接近 0,属于正常随机噪声)")

为什么这个机制无法被轻易破解?

  1. 无感:对大模型来说,\delta 的加入只是稍微改变了同义词之间的微小概率(比如在生成“非常”和“十分”之间,倾向于选择落入绿名单的那一个),完全不破坏语义。

  2. 安全:红绿榜是由Hash(prev_token, Secret_key)决定的。只要不泄露Secret_key,哪怕黑客把生成的文本拿去反复洗稿、替换同义词,只要无法改变前后的词串组合,他就无法抹去由于高频碰撞绿名单而导致的 Z值异常。这就是防御黑盒蒸馏的杀手锏。