深度解析:Claude 是如何给 AI 生成的文本打水印的

前几天在 Substack 上发了一条 note,介绍 Claude 新上线的文本水印机制及其实现方式。这个话题引发了很热烈的讨论,所以值得展开,把它的工作原理讲得更细一些。

这次没有写常规的文字文章,而是录了一段讲解视频(换个形式)。下面是视频和配套的文字实录。

原本只计划做 10 页幻灯片、录 10 分钟,结果不断补充关键细节,最后变成 50 多页幻灯片、48 分钟的视频。

说明:下面的文字实录在不改变原意的前提下做了轻微的编辑和整理,整体顺序和节奏与视频一致。

为什么要讲这个话题

0:00

第 2 页(共 52 页),时间戳 0:00

几天前,Anthropic 宣布将给 Claude 模型的文本输出加水印。我在社交媒体上发了一条帖子,简要解释了它的原理,反响出乎意料地好——受欢迎的倒不是水印本身,而是背后的机制和解释。那条帖子只有一张图,引出的问题和讨论却很多。

所以值得多做一些图,把它讲透:水印技术到底怎么工作、为什么会失效、又如何被去除。现在很多人都在用 LLM,也在网上消费大量可能由 LLM 生成的文本。水印来了,大家难免担心:它会不会让文本变差?它到底有什么好处?把水印是什么搞清楚,再来自己判断利弊,会容易得多。

目标很明确:解释底层机制,以及他们打算如何实现这种文本水印。

第 2 页,时间戳 1:41

这也是一个很好的例子,说明「从零开始理解一件事」为什么有用。水印机制恰好能顺带解释 LLM 在底层是如何工作的。熟悉我的读者知道,我喜欢 from scratch——《Build a Large Language Model From Scratch》《Build a Reasoning Model From Scratch》都是这个思路。

from scratch 通常意味着写代码。这篇不涉及编码,但亲手写代码确实是理解实现细节最有效的方式——没有代码,很多东西是含糊的。当然,现在不是每个人都在手写代码了。以前只有人写代码,现在 LLM 也能写。但这并不削弱读代码的价值,因为代码里承载了大量信息。以这次的水印为例,亲手从零写一个 LLM,会让你真正看清采样发生在哪个环节;再回头看,就会明白水印施加在哪个位置、会带来什么后果。

所以,即使不再事事手写代码,出于教育和研究的目的从零搭建一个东西依然很有价值——可以用透明的方式操纵它,而不是隔着层层抽象。巧的是,这套幻灯片里的很多图,正来自我从零写代码的教学材料。

第 3 页,时间戳 3:58

8 月 14 日,Anthropic 发布了《How Claude's Text Watermark Works》一文。文章后来更新过几次,最初读到的版本短得多。即便到现在,它仍然偏概念:只有文字概览,一张图都没有。他们花了很多篇幅解释「为什么做」,却没解释「怎么做」,只在某处链接了一篇非常技术化的论文。所以有必要退后一步,从头理解他们打算实现的这套水印技术。

顺带说一下动机:水印是为了让他们能够识别——如果有人贴出一段文本,他们可以判断「这段文本是我们的 Claude Opus 4.8 模型生成的」,因为文本里带着水印。水印对用户不可见,只有他们能解码、能判断一段文本是否带水印。为什么只有他们能做到?后面会讲到,一步一步来。

前奏:LLM 是怎么生成文本的

5:38

第 4 页,时间戳 5:35

先简单讲一下 LLM 的文本生成过程。有了这个基础,就更容易理解水印的工作原理——它并不是什么昂贵的附加物,只是在常规生成流程里做了一点小改动。

第 5 页,时间戳 6:01

以 ChatGPT 为例:问「德国的首都是」,它会回答「Berlin」。这里实际生成了两个 token(「Berlin」和句号),为简单起见,假设只生成一个 token:「Berlin」。当我们在输入框敲下「德国的首都是」、收到「Berlin」这个回答时,背后到底发生了什么?

第 6 页,时间戳 6:41

接下来几页,简要讲讲生成下一个 token 时,模型内部发生了什么。

第 7 页,时间戳 6:50

假设 prompt 还是「德国的首都是」。第一步是分词:把文本转换成 token ID。这一步发生在 LLM 之外,只是把它变成 embedding 层能处理的格式。

第 8 页,时间戳 7:22

然后 token ID 送进 LLM,LLM 输出一个针对下一个 token 的分数分布。

第 9 页,时间戳 7:31

这里不展开 LLM 的内部机制(其他 From Scratch 视频和书里讲过很多次)。重要的是:生成下一个 token(比如「Berlin」)时,我们手上有一个分数分布。图中是 logit 值,也就是从负无穷到正无穷的原始分数,这个例子里大约在 -9 到 20 之间。可以把它转换成概率分布,但严格来说取决于采样方式,并非必须。可以把 logit 理解成原始分数,覆盖整个词表。

第 10 页,时间戳 8:39

也就是 LLM 可能生成的每一个词。图中词表索引 19,846 这个位置拿到了最高分。为了视觉效果,我把分布拉宽了;真实跑一遍的话会更极端——其他位置几乎全贴近 0,「Berlin」会高得多。其他候选可能是 Hamburg、Munich 之类的错误猜测,但现在的 LLM 对「Berlin」应该相当确定。

词表索引覆盖的是整个词表。现在的 LLM 大概有 25 万个可能的输出 token,这里只截取了 19,800 到 19,900 这一段,因为幻灯片空间有限。真实分布画出来会密得什么都看不清。重点就一句:常规文本生成里,我们会得到一个分数分布,然后看最高分。

第 11 页,时间戳 10:33

具体怎么选,后面会细说。不一定严格选最高分,但先假设选最高分,这里是 19,846。

第 12 页,时间戳 10:52

这个分数再经过反分词(detokenize),还原成文本「Berlin」。整个流程:输入 prompt → 分词转成 token ID → 送进 LLM → 得到分数分布 → 选出下一个 token → 还原成文本。

第 13 页,时间戳 11:12

然后这段文本被拼回输入。如果回答需要多个 token,就循环这个过程,直到 LLM 生成 end-of-text token 为止。为简单起见,图中只展示一轮迭代。

那么,下一个 token 到底是怎么「采样」出来的?

11:39

采样:从贪心解码到随机种子

第 14 页,时间戳 11:44

前面说可以直接选最高分——这叫贪心解码(greedy decoding)。但大多数 LLM 并不这么做。因为总选最高分,模型容易复述训练数据、每次都给出同样的回答。我们其实希望输出有些变化,但又不能变到胡说八道的程度。做法是:先把分数分布转换成概率分数。

第 15 页,时间戳 12:28

图中用 NumPy 演示(用 PyTorch 等工具,概念相同):对分数做 softmax。实际工程里会用数值稳定的 softmax 实现,能同时处理很大的正数、很小的正数和很负的数;这里写成最标准的形式只是为了可读性,细节不重要。

第 16 页,时间戳 13:20

重要的是:转换之后,这些分数加起来等于 1——softmax 做的就是归一化。有了概率,就可以用随机采样算法。比如 NumPy 的 choice:传入一个词表索引数组,再传入概率作为权重——权重决定每个 token 被选中的可能性。如果「Berlin」归一化后有 99% 的概率,其余加起来 1%,采样 100 次,大约 99 次会得到「Berlin」。

训练良好的真实 LLM 里,「Berlin」的概率可能是 99.999999%,几乎必然被选中。此外还有 top-k、top-p 等变体:以 top-k 为例,先取分数最高的 100 个 token,再只在这 100 个里做随机选择,避免采样到荒谬的 token。这个例子里可以假设看到的已经是 top-k 筛选后的结果。

第 17 页,时间戳 15:37

举例:采样 10,000 次,「Berlin」概率 99.9%,结果是 9,997 次「Berlin」、2 次「Hal」、1 次「Moh」——后两个基本是无意义 token。之所以会出现,是因为我为了演示把分布拉宽了;真实 LLM 大概率 10,000 次全是「Berlin」。

16:19

水印的原理

第 18 页,时间戳 16:21

前面交代了 LLM 的底层工作方式,接下来进入正题:水印怎么运作。

第 19 页,时间戳 16:41

前面提到,采样时通常选最高分,或者按概率采样、大多数时候落在高分 token 上。再看一个没有水印的例子。prompt 换成:今天的天气是 cold,一个可能的下文是 gray 或 overcast。和「Berlin」的例子不同,gray 和 overcast 几乎可以互换——作为下一个 token 都合理,没有客观的优劣。

由于两者分数都高且接近,随机采样时大约一半概率得到 overcast、一半得到 gray。这就是为什么同一个 prompt 问多次,LLM 常给出略有不同的回答:某些位置上两个 token 概率几乎相等,选了谁,后面的 token 就都跟着变了。

18:23

第 20 页,时间戳 18:25

简单讲讲随机数生成。用随机数生成器产生一串数,每次运行结果都不同。但如果设定随机种子(比如 123),多次运行得到的序列完全一样——数仍然是随机的、彼此不同,但可复现。记住这一点:用不用随机种子,得到的都是随机数;区别在于,有种子就可复现。 马上会用到这个概念。

第 21 页,时间戳 19:26

前面说 gray 和 overcast 随机二选一。如果固定随机种子 42,可能永远选中 overcast——仍然是随机选择,只是变成了确定性的:给定这个 prompt,模型每次都选 overcast。

第 22 页,时间戳 19:49

换一个种子,可能永远选中 gray。依然是随机采样,只是结果由种子决定。

20:03

第 23 页,时间戳 20:04

Claude 的水印,本质上就是「设置一个随机种子」。只不过这个种子不是一个写死的数字,而是由一个类似 API key 的密钥(secret key)结合前四个词推导出来的。

回到上一页的思路:效果等同于有一个固定的随机种子,每次都选中同一个 token。区别只在于种子的来源——密钥加上前文信息。细节后面再说。

第 24 页,时间戳 21:06

也就是说,水印让文本生成在特定位置上变得更确定。比如这段文本:

The weather today is cold and

下一个词可以选 overcast 或 gray;再往下:

a light 或 gentle breeze is moving 或 blowing through the trees, and the streets seem quiet 或 still.

这些位置上的 token 几乎等概率。没有水印时,多次运行会得到不同的组合。按这些可互换位置的数量,可能产出 128 种不同的文本;文本越长、可互换位置越多,组合就越多。

比如一种输出是:

The weather today is cold and overcast. A light breeze is moving through the trees, and the streets seem quiet. I think I'll stay home and read a book with a cup of tea.

另一种是:

The weather today is cold and gray. A gentle breeze is blowing through the trees, and the streets seem still. I think I'll stay inside and read a novel with a mug of tea.

两种都很合理,没有谁更好,只是略有差异。没有水印时,得到哪一种完全是随机的。

第 25 页,时间戳 23:31

如果固定随机种子(比如 99),可能永远得到第一种文本。随机种子可以把输出固定下来——采样仍然随机,但可复现,每次都一样。这还是没有水印、只加了随机种子的情况。

第 26 页,时间戳 23:59

水印做的事本质上一样,只是种子换成了所谓的随机密钥(random key),由它参与文本的选择。

有一点可以先说:Anthropic 在博客里声称水印不会让文本变差。看了这个机制就明白为什么——对用户来说,水印无非就是固定随机种子、让采样变得确定。 这里没有替水印辩护的意思,只是解释机制。

24:45

第 27 页,时间戳 24:47

小结一下:没有水印时,采样通常不带随机种子——大多数人根本不用,Claude 和 OpenAI 的 API 也未必支持;Ollama 支持,但我在写书的配套材料里用过,固定种子也不总能保证确定性,不同软件版本表现不一。

有水印时,采样照常进行,但多了一个水印密钥:它被传给随机种子生成器,设定一个特定的种子,让采样确定下来。

现在我们知道水印施加在哪里了:施加在采样环节,而不是 LLM 内部。这是个很关键的认知——他们不需要为此训练新模型,用现有模型、只在采样阶段动手就行,不需要重新训练任何东西。

26:13

检测与去除水印

第 28 页,时间戳 26:21

还没讲完:如何判断一段文本是否带水印?检测只对有密钥的人才可行。 比如在网上发现一段文本(假设是图中的文本 4),想知道它有没有水印——没有密钥就无从判断。检测需要一个打分函数:给文本打分,超过某个阈值就是带水印的,否则不是。普通用户没有这个密钥,只有 Anthropic 有。

博客里提到他们会开发一个 API 提供检测能力。细节我不清楚(我和 Anthropic 没有关联,信息都来自那篇博客)。这个 API 也许只开放给特定公司——比如 X 或 Substack Notes 想给 AI 生成的帖子打标签;也许会对公众开放。只能等等看。结论:检测水印必须有水印密钥,或者等他们的 API。

28:00

第 29 页,时间戳 28:03

去除水印就有意思了。知道了原理,也就知道了短板:水印高度依赖特定位置上的特定 token。如果图中彩色标记的位置就是水印位置,那么把这些位置的词全部换掉,就能 100% 干掉水印。问题是:我们不知道这些位置在哪。

第 30 页,时间戳 28:41

因为水印不是我们生成的,不知道哪些位置被动过手脚。实际可行的办法是随机编辑:随机换掉一些词,期望碰巧改掉足够多的水印位置。而且由于我们没有 LLM 的访问权限、无法重跑 prompt 找出哪些词是高分 token,只能靠猜。

比如把 overcast 换成 cloudy,因为我们不知道 gray 才是高分候选。有些位置直觉上能猜到,有些不能。要点是:一般的文本编辑、在若干位置做替换,只要改得够多,也能破坏水印。

30:17

加分内容:锦标赛采样与打分函数

第 31 页,时间戳 29:59

前面提到检测需要一个打分函数。视频已经很长了,但作为加分内容,还是简单讲讲打分函数的原理——这部分有点复杂,不理解它也不影响理解水印本身。

为什么要把检测设计成这样?为了降低成本。否则,哪怕是 Anthropic 自己,想检测一段文本也得重跑 prompt、拿到分数分布、套用水印种子重新生成,再逐字比对——每检测一段文本都要重跑一次 LLM,还得知道用的是哪个模型,而且很多时候根本不知道原始 prompt,完全不可行。所以他们用了一个技巧改造采样过程,让后续检测不再依赖 LLM。

博客里提到这个方法源自一篇 Nature 论文,叫 SynthID-Text,是 Google 一两年前发表的。Claude 的水印用了类似的技术(是否完全一致不得而知,博客里提到的是它)。

31:18

第 32 页,时间戳 31:39

回顾之前的整体流程:文本进 LLM → 得到 logit 分布 → 采样得到输出 token,采样环节使用水印密钥和随机种子生成器。这个描述仍然成立,但采样的具体方式更有讲究:他们用的不是 NumPy 的 random choice 这种简单方法,而是更精细的做法。

第 33 页,时间戳 32:16

假设上下文是 "the weather today is cold",要生成下一个 token。候选:gray 50%、overcast 30%、gloomy 15%、cloudy 5%,其余近似为 0。为了简单,假设词表只有这四个词。前面说过,可以用 NumPy 的 random choice 按概率采样。

第 34 页,时间戳 33:13

再叠加水印密钥和随机种子,让采样确定下来,得到想要的水印。但前面说过,这样做的代价不在采样(采样很便宜),而在检测——想检查网上的随机文本,就得重跑 LLM。

第 35 页,时间戳 33:35

所以他们改用锦标赛采样(tournament sampling)。这个机制在生成阶段就埋好了伏笔,让检测阶段可以复用。看着有点复杂,其实是唬人的,多看一会儿图就明白了。

候选 token 和概率不变,新增一组「随机水印函数」。

34:33

第 36 页,时间戳 34:35

图中有三个水印函数 G1、G2、G3(实际可能有 30、50 个甚至更多,这里用三个只是为了画得下)。以 gray 为例,它可能得到签名 101。具体过程:用水印密钥生成随机种子,把 gray 连同前面三四个词(比如 cold)一起输入 G1,输出 1——这个函数本质上是随机的,给定密钥和输入,要么输出 0 要么输出 1。同一个密钥换 G2,输出 0;G3 输出 1。函数多了(比如 30 个),签名就是一长串 0 和 1,相当于一个比特串。

第 37 页,时间戳 36:00

对其他候选做同样的操作:overcast 是 010,gloomy 是 001,cloudy 是 100。每个 token 都有自己的签名。

接下来进入锦标赛环节:两两配对。

36:36

第 38 页,时间戳 36:39

像足球淘汰赛或美式橄榄球季后赛,两支队伍对决。token 两两配对,「比分」来自水印函数。第一轮用 G1:

  • cloudy vs gray:查表,都是 1
  • overcast vs gray:0 vs 1
  • gloomy vs overcast:0 vs 0
  • gray vs gray:候选不够了,随机补一个重复项,1 vs 1

判定规则:平局时用随机种子和水印密钥随机选;分出胜负时,函数值为 1 的胜出。于是 cloudy 晋级,gray(胜 overcast)晋级,overcast 和 gray 也分别晋级。

第二轮用 G2:cloudy 是 0,gray 是 0,overcast 是 1,另一个 gray 是 0。

第 39 页,时间戳 38:24

平局,随机选出 gray;另一边 overcast 胜出。决赛:gray vs overcast,查 G3——gray 是 1,overcast 是 0,gray 夺冠。token「gray」就这样被采样出来了。

水印密钥在这里的作用:它决定了那些水印函数输出的具体值。密钥不同,各轮签名就不一样。

39:18

第 40 页,时间戳 39:11

至此下一个 token 采样完成。本来用 NumPy 的 random choice 也行,但那样检测网上的随机文本时必须重跑 LLM;锦标赛采样不用,马上会看到。这套机制看起来别扭又繁琐,优势只有一个:检测更便宜,不用重跑模型。

第 41 页,时间戳 39:43

第 42 页,时间戳 39:48

比如检测阶段遇到一段「新」文本:the weather today is cold and gray。Anthropic 手上有密钥和 G1、G2、G3。把文本送进这些函数,gray 这个位置得到签名 101——和生成阶段一致。把这一行加起来,有两个 1,可以说这个位置带了「两比特信息」,记 2 分。

换不同的词,得分不同:overcast 得 1 分,gloomy 1 分,cloudy 1 分。对每个位置都这样做:假设第一个位置 2 分、today 3 分、is 2 分、cold 2 分、gray 3 分。

第 43 页,时间戳 41:33

把每个位置在三个函数下的得分加起来。水印函数计算成本很低,可以快速扫完整段文本。然后取平均:比如平均 2.23。

第 44 页,时间戳 41:55

换一段略有改动的文本(today 换成 now,gray 换成 overcast),这两个位置只得 1 分,平均变成 1.71。整个过程不需要 LLM,只需要水印密钥、随机种子生成器和那组函数。

第 45 页,时间戳 42:27

然后设一个阈值。实际阈值只有他们知道,这里假设阈值是 2:平均分大于 2 判定为带水印,否则不带。2.23 > 2,是;1.71 < 2,否。这就是检测网上随机文本是否带水印的方法——跑一遍水印函数,算平均分,对阈值。

第 46 页,时间戳 43:13

再强调一次:锦标赛采样主要是为了让检测更容易、更便宜。 用带种子的 NumPy random choice 也能让采样确定,但那样就很难给网上的任意文本打分了。

43:26

第 47 页,时间戳 43:30

总结不变:有无水印的区别在于,采样这一步被水印密钥控制,内部用的是锦标赛采样。检测水印需要密钥和水印函数 G1 到 Gn。

第 48 页,时间戳 43:54

再说去除水印。理想做法是把所有水印位置都改掉,但我们不知道哪些位置有水印。而且他们选位置时,专挑有等概率候选词的地方;像 trees 这种没有高分替代词的位置,可能根本不会加水印。水印只落在特定位置上。 既然不知道位置,就只能……

第 49 页,时间戳 44:29

……在多处做编辑。

这对未来意味着什么

44:34

第 50 页,时间戳 44:36

这件事可能会让网上的 AI 生成文本变得更差。设想一个爱用 AI 生成文本的新闻网站:水印挡不住他们,因为生成文本本来就是他们工作流的一部分。我猜他们会再加一个模型。

第 51 页,时间戳 45:08

流水线会变成:用 Claude 生成文本 → 过一个本地模型做编辑 → 得到「编辑过的 AI 文本」,水印大概率已经没了。

为什么用本地模型?因为主要的闭源厂商——不只 Claude,Google 大概率也在给 Gemini 加水印(论文就是他们写的),OpenAI 可能已经在做或即将做。这背后有欧盟监管的要求,按博客的说法,这正是 Claude 做水印的原因。而本地模型至少目前不会实现水印。

为什么文本会变差?回看前面那张随机改词的图:为了改而改,就有把文本改糟的风险。最后得到的仍是生成文本,但改得别扭。当然也可以直接用本地小模型从头生成,但编辑比生成简单——合理的分工是:用最贵的高端模型生成初稿,用便宜的本地模型做「外科手术式」的修改。

47:13

第 52 页,时间戳 47:20

这次的目标是解释水印的原理,至于它会给世界带来什么连锁反应,就不展开了。希望这个「掀开引擎盖」的视角有用。水印没有看上去那么复杂——虽然 52 页幻灯片也说明它不算 trivial。


PS:如果喜欢这种风格的讲解——我不常在 YouTube 更新,但这些年积累了 300 多个视频,都在我的 YouTube 频道上。

如果这篇文章对你有帮助,欢迎点个赞 :)