大模型文本水印(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值异常。这就是防御黑盒蒸馏的杀手锏。

更多推荐