大模型水印:给生成文本打上隐形指纹

当大模型被大规模部署在写作、客服、问答等场景后,一个现实问题随之而来:我们很难区分屏幕上的一段文字是人写的,还是模型生成的。较之被动式的「AI 检测器」(训练一个分类器去判别文本来源),水印选择了一条更主动的路子——在模型生成时就悄悄嵌入一个统计信号,让文本带上人类读不出、但持钥方能用算法验证的「隐形指纹」。本文面向已经理解自回归生成机制的工程师,讲清水印为什么必要、生成侧与检测侧各自怎么做、以及它在真实场景下的权衡与边界。

为什么需要大模型水印

水印的价值不在「炫技」,而在解决两类具体诉求。

内容溯源(Provenance)。 对于平台方与监管方,能够判断某段内容是否由自家或指定模型产出,意味着可以标注 AI 参与、统计 AI 内容占比、在争议时提供证据。相比事后靠风格或困惑度去猜测,水印是从源头携带的可验证标记。

滥用检测(Abuse Detection)。 大模型可被用来批量制造垃圾内容、社交机器人话术、或虚假信息。若主流模型默认带水印,平台就能在大规模内容流中筛出「疑似机器生成」的部分,按策略降权、标注或复核,从而抬高滥用成本。

需要区分的是,水印与「AI 文本分类器」是两套思路:分类器是被动识别,模型方无法控制对方是否规避;水印是主动嵌入,信号由生成方掌握,且检测通常不需要访问模型权重或 API。代价是水印只能覆盖「带水印的生成方自己产出的内容」,对第三方无约束。

生成时水印:绿名单与红名单词表偏置

最具代表性的方案来自 Kirchenbauer 等人(ICML 2023,arXiv:2301.10226,已核验)。其核心思想非常简洁:在生成每一个新 token 之前,先根据上下文确定一个「绿名单(green list)」词表,然后在采样时对绿名单里的 token 施加轻微正向偏置,使生成文本在统计上更「偏爱」绿名单词。人类读不出区别,但持钥方可以还原绿名单并检验这种偏爱是否显著存在。

具体流程如下:

  1. 划分词表。 在生成第 i 个 token 前,用「上一个 token(或更长上下文)」加上一个只有生成方持有的「密钥(secret key)」作为种子,驱动一个伪随机数发生器,把整个词表确定性地随机切成两份:占比为 gamma 的绿名单,以及剩下的红名单(red list)。因为种子里含密钥,没拿到密钥的人无法复现同一份划分。
  2. 偏置 logits。 在 softmax 之前,给绿名单中所有 token 的 logits 加上一个固定增量 delta(红名单不动)。这样经过 softmax 后,绿名单 token 被采样到的概率整体被抬高。
  3. 正常采样。 其余一切照旧,模型按偏置后的分布采样出下一个 token。由于 delta 通常很小(论文实验中约 1 到 4 量级),文本流畅度几乎不受影响。

直觉上,若没有水印,绿名单词的命中比例应当接近其占比 gamma;一旦文本带水印,这个比例会被系统性地抬高。检测阶段正是围绕这个偏离做统计检验。

检测:对数似然比 z 检验与 p 值

持有密钥的检测方,对一段待检文本逐 token 地「回放」生成时的划分过程:用上一个 token 与密钥还原该位置的绿名单,判断当前 token 是否落在绿名单中,然后累加命中数。

设文本共贡献 N 个可检验的 token(即从第 2 个 token 开始计数),其中命中绿名单的有 n_g 个。在「文本无 waterm」的原假设 H0 下,每个 token 落入绿名单的概率为 gamma,因此:

  • 期望命中数:E = gamma · N
  • 方差:Var = gamma · (1 − gamma) · N

大样本下,命中数近似服从正态分布,于是构造标准化分数(z 分数):

z = (n_g − gamma · N) / sqrt(gamma · (1 − gamma) · N)

这个 z 检验在统计上等价于一个对数似然比(likelihood-ratio)检验在大数据下的近似:它衡量「观测到的绿名单偏好」相比「纯随机基线」偏离了多少个标准差。偏离越大,越有理由认为文本带水印。

对应的单边 p 值为 p = 1 − Φ(z),其中 Φ 是标准正态分布的累积分布函数。p 越小,说明「仅凭随机性就出现如此强绿名单偏好」的概率越低。实践中常设定一个显著性阈值(例如 p < 0.01 或更低),低于该值则判定为「带水印」。需要注意,p 值同时受文本长度影响:文本越长,检验功效越高,越短的片段越容易因统计量不足而漏检。

权衡与局限

水印并非免费的银弹,需要在多个维度上做取舍,且存在明确的场景边界。以下只做描述,不提供任何可绕过水印的实现细节。

质量与检测力的权衡。 偏置强度 delta 越大,绿名单偏好越明显、越容易检测;但 delta 过大会改变语言分布,使文本变得生硬、重复或偏离原意。gamma 的选取同理:绿名单占比越大,命中空间越足、检测越稳,但对分布的人为干扰也越强。二者都需要在「可检测性」与「生成质量」之间找平衡。

鲁棒性(对改写的抵抗力)。 水印面对的最直接威胁是改写与复述。Kirchenbauer 等人的后续研究(ICML 后续 / ICLR 2024,arXiv:2306.04634,已核验)表明,即便经过人类或另一模型改写,水印仍可能被检出——因为改写往往会残留原文片段,从而泄漏带水印的 n-gram;但在强改写下信号会被稀释,例如强人类改写后平均需观察约 800 个 token,在万分之一假阳性率下才能稳定检出。

透明度与密钥管理。 检测算法本身可以开源,但还原绿名单必须依赖密钥。密钥若泄露,攻击者便能自行复现绿名单、伪造水印或规避检测;密钥若过于集中,又带来单点失控风险。这是水印方案的信任与治理核心。

多语与代码场景的局限。 这两类场景的水印效果普遍更弱。其一,低熵输出(如事实性短答、代码、确定性格式文本)可自由选择的 token 空间很小,绿名单「发挥余地」有限。Kuditipudi 等人的无失真稳健水印工作(TMLR,arXiv:2307.15593,已核验)在 Alpaca-7B 的案例中发现,典型用户指令的回复中仅约 25% 能以 p ≤ 0.01 被检出,且对自动改写更脆弱。其二,多语言词表与分词(tokenizer)差异,会使「按 token 划分绿名单」的稳定性下降;代码场景里 token 分布高度集中、重复结构多,水印信号也更容易被稀释。

最小代码示例

下面用一个仅依赖标准库的极简示例,演示「伪随机绿名单偏置采样分布」与「基于 z 检验的检测」两个核心环节。为避免引入真实模型依赖,这里用均匀先验模拟 logits,仅用于说明机制,不构成生产实现。注意:真实部署中 delta、gamma 与密钥管理需结合具体模型谨慎设计。

import hashlib
import math
import random

  # 水印超参数:绿名单占比与对绿名单词表的正向偏置
SECRET_KEY = "xlaiskill-demo-key"
VOCAB_SIZE = 1000
GREEN_FRACTION = 0.5
BIAS_DELTA = 2.0


def prng_seed(prev_token_id, key):
  # 由上一 token 与密钥生成确定性种子,使检测方能复现同一份绿名单
  blob = (key + ":" + str(prev_token_id)).encode("utf-8")
  digest = hashlib.sha256(blob).digest()
  return int.from_bytes(digest[:8], "big")


def split_vocab(prev_token_id):
  # 依据种子把词表确定性地随机切成绿名单与红名单
  rng = random.Random(prng_seed(prev_token_id, SECRET_KEY))
  order = list(range(VOCAB_SIZE))
  rng.shuffle(order)
  green_size = int(VOCAB_SIZE * GREEN_FRACTION)
  return set(order[:green_size])


def watermark_logits(logits, prev_token_id):
  # 对绿名单位置施加正向偏置,使采样更倾向绿名单 token
  green = split_vocab(prev_token_id)
  biased = list(logits)
  for tid in green:
    biased[tid] += BIAS_DELTA
  return biased


def sample_token(logits):
  # 教学用 softmax 采样,不追求数值稳定
  exps = [math.exp(x) for x in logits]
  total = sum(exps)
  probs = [e / total for e in exps]
  r = random.random()
  acc = 0.0
  for tid, p in enumerate(probs):
    acc += p
    if r <= acc:
      return tid
  return len(probs) - 1


def generate(seq_len, use_watermark):
  # 从均匀先验出发逐 token 生成;绿名单偏置仅在开启水印时施加
  tokens = [random.randrange(VOCAB_SIZE)]
  for i in range(1, seq_len):
    base = [0.0 for _ in range(VOCAB_SIZE)]
    prev = tokens[i - 1]
    logits = watermark_logits(base, prev) if use_watermark else base
    tokens.append(sample_token(logits))
  return tokens


def detect_watermark(token_ids):
  # 逐 token 还原绿名单并统计命中数,再做单边正态 z 检验
  n = 0
  green_hits = 0
  for i in range(1, len(token_ids)):
    prev = token_ids[i - 1]
    green = split_vocab(prev)
    if token_ids[i] in green:
      green_hits += 1
    n += 1
  expected = GREEN_FRACTION * n
  std = math.sqrt(GREEN_FRACTION * (1 - GREEN_FRACTION) * n)
  z = (green_hits - expected) / std if std > 0 else 0.0
  # 单边检验:绿名单命中越多,z 越大,对应 p 值越小
  p_value = 0.5 * math.erfc(z / math.sqrt(2))
  return green_hits, n, z, p_value


wm = generate(200, use_watermark=True)
plain = generate(200, use_watermark=False)

print("watermarked:", detect_watermark(wm))
print("plaintext:  ", detect_watermark(plain))

运行后,带水印文本通常得到很小的 p 值(绿名单命中显著高于随机基线),而无水印文本 p 值接近 0.5。代码里刻意只做机制演示:真实系统需要把「上一 token」替换为更稳健的上下文哈希、处理 batch 与流式生成、并对密钥做安全存储。

小结

  • 水印是生成方主动嵌入的隐形统计信号,用于内容溯源与滥用检测,与事后分类器思路不同。
  • 生成侧做法:用「上下文 + 密钥」伪随机划分绿/红名单,并对绿名单 logits 施加轻微偏置。
  • 检测侧做法:持钥方逐 token 还原绿名单、统计命中数,再用 z 检验(对数似然比检验的大样本近似)与 p 值判定。
  • 主要权衡在质量、检测力、鲁棒性、密钥透明度之间;多语言与代码等低熵场景水印效果明显更弱。
  • 水印只能覆盖「带水印方自己生成」的内容,对未部署水印的模型无约束力,应作为治理工具箱的一环而非唯一手段。

参考与延伸阅读

  • Kirchenbauer, Geiping, Wen 等. A Watermark for Large Language Models. ICML 2023. arXiv:2301.10226.(核心绿名单/红名单偏置方案,已核验)
  • Kirchenbauer, Geiping, Wen 等. On the Reliability of Watermarks for Large Language Models. ICLR 2024. arXiv:2306.04634.(改写与复述下的鲁棒性,已核验)
  • Kuditipudi, Thickstun, Hashimoto, Liang. Robust Distortion-free Watermarks for Language Models. TMLR. arXiv:2307.15593.(无失真稳健水印、逆变换采样与指数最小采样,已核验)
  • 延伸方向(待核实):水印综述类论文、基于 Gumbel 噪声的 EXP 水印、以及针对 paraphrasing 攻击的下游防御工作,建议按需另行检索与核对原始出处。
本文累计阅读