跳到內容
WeiHung 書寫
返回

附錄:文字浮水印經過少量編輯的 SynthID-Text 實驗

Published: at 07:00 PM
編輯本頁

本文是〈我是不是「被 AI」了——文字工作者如何面對 AI 浮水印〉的技術附錄。這個附錄想展示:一段帶有統計浮水印的文字,經過少量正常編輯後,訊號會怎麼改變?

這不是 Claude 實測。實驗使用 Qwen/Qwen2.5-1.5B-Instruct,嵌入器是 Hugging Face Transformers 4.57.6 公開的 SynthID-Text logits processor。Anthropic 表示 Claude 會採用某版本 SynthID-Text,但沒有公開實際模型、金鑰、參數、雜湊方式與正式 detector。因此,以下結果只能示範同類 generation-time watermark 的行為,不能還原或估計 Claude 的偵測表現。

實驗文本與方法

模型以〈薛西弗斯的人生有意義嗎?——談卡繆與荒謬〉中的一段話為生成基礎:

然而,何不想像薛西弗斯是快樂的呢?想像他在山腳下凝視這顆巨石,將自己一連串意義不明的行動、透過自己的記憶連結起來,將這當成自己的命運。在這個時刻,他就成了自己命運的主人。他不需要眾神,更無論懲罰。

方法論是:讓模型先產一份完整水印改寫版。其後各版本都從這份文字分叉:增加一字、刪除一字、替換一字、在段落中分散修改五處,以及不改動任何字詞與標點,只在每個逗號、問號和句號後插入空行。這樣可以把觀察集中在「編輯量」本身,而不是把人類原稿與模型改寫的差異混在一起。

以下「水印證據分數」以 0 作為無水印時的期望方向;正值越大,整段選字越常符合這組公開金鑰偏好的方向。負值只表示低於隨機期望,不是「負浮水印」。它不是機率,也不是 Claude detector 的判定結果。

「有效文字視窗」則是排除重複 context 後,真正納入計算的五-token n-gram 數量。視窗越多,證據基礎通常越充足。

以〈薛西弗斯的人生有意義嗎?〉正文全文來說,同一組公開金鑰下共有 1,807 個有效文字視窗,水印證據分數就跑到了 -0.008,接近未嵌入水印時的期望值 0。而實驗的段落的水印證據分數是 -1.543,有效文字視窗為 71

完整水印改寫版

證據分數:3.429;有效文字視窗:80

然而是否想像西快樂在他下的沉重自己一系列意義不明行為透過自身的連結在一起把它是他的一部分此時此刻他就成為了自己的也不

綠色越深,表示該 token 對這組公開金鑰下的統計訊號貢獻越高;它不是「這個字有浮水印」的逐字判決。

修改結果

版本證據分數相對完整水印有效視窗
完整水印3.429基準80
增加一字3.519+0.09081
刪除一字3.280-0.14980
替換一字3.280-0.14980
分散修改五處1.132-2.29778
徹底分段1.342-2.08780

增加一字:3.519(+0.090)

在「也不必懼怕」中增加「仍」。

然而,是否不願想像薛西弗斯是快樂的呢?在他腳下的山崖前,他凝視著這座沉重的巨石,將自己一系列意義不明的行為,透過自身的記憶連結在一起,把它看作是他命運的一部分。此時此刻,他就已成為了自己的命運之主。無需仰賴諸神,也不必懼怕懲罰。

查看這個版本的 token 訊號

然而是否想像西快樂在他下的沉重自己一系列意義不明行為透過自身的連結在一起把它是他的一部分此時此刻他就成為了自己的不必

刪除一字:3.280(-0.149)

刪除「他就已成為」中的「就」。

然而,是否不願想像薛西弗斯是快樂的呢?在他腳下的山崖前,他凝視著這座沉重的巨石,將自己一系列意義不明的行為,透過自身的記憶連結在一起,把它看作是他命運的一部分。此時此刻,他已成為了自己的命運之主。無需仰賴諸神,也不必懼怕懲罰。

查看這個版本的 token 訊號

然而是否想像西快樂在他下的沉重自己一系列意義不明行為透過自身的連結在一起把它是他的一部分此時此刻成為了自己的也不

替換一字:3.280(-0.149)

把「這座沉重的巨石」改成「這顆沉重的巨石」。

然而,是否不願想像薛西弗斯是快樂的呢?在他腳下的山崖前,他凝視著這沉重的巨石,將自己一系列意義不明的行為,透過自身的記憶連結在一起,把它看作是他命運的一部分。此時此刻,他就已成為了自己的命運之主。無需仰賴諸神,也不必懼怕懲罰。

查看這個版本的 token 訊號

然而是否想像西快樂在他下的沉重自己一系列意義不明行為透過自身的連結在一起把它是他的一部分此時此刻他就成為了自己的也不

分散修改五處:1.132(-2.297)

在段落不同位置各做一次等義的小幅修改。

然而,是否不想像薛西弗斯是快樂的呢?在他腳下的山前,他凝視著這沉重的巨石,將自己一系列意義不明的行為,透過自身的記憶連結在一起,把它作是他命運的一部分。此時此刻,他就已成為了自己的命運。無需仰賴諸神,也不必懼怕懲罰。

查看這個版本的 token 訊號

然而是否不妨想像西快樂在他下的沉重自己一系列意義不明行為透過自身的連結在一起把它是他的一部分此時此刻他就成為了自己的主人也不

徹底分段:1.342(-2.087)

字詞與標點不變,只在每個逗號、問號與句號後插入空行。

然而, 是否不願想像薛西弗斯是快樂的呢? 在他腳下的山崖前, 他凝視著這座沉重的巨石, 將自己一系列意義不明的行為, 透過自身的記憶連結在一起, 把它看作是他命運的一部分。 此時此刻, 他就已成為了自己的命運之主。 無需仰賴諸神, 也不必懼怕懲罰。

查看這個版本的 token 訊號

然而是否想像西快樂在他下的沉重自己一系列意義不明行為透過自身的連結在一起把它是他的一部分此時此刻他就成為了自己的也不

控制組:相同模型、prompt、seed 與取樣設定在未啟用 SynthID-Text 時,證據分數為 -1.805。

增加一字後,分數從 3.429 微升到 3.519;刪除或替換一字則只小幅降到 3.280。這是因為統計浮水印不是檔案 checksum。單一編輯只擾動附近的一些 token 視窗,有限樣本下的分數可能往任一方向移動。

當修改分散到五處,分數才降到 1.132。徹底分段版沒有改動任何字詞與標點,只加入空行,分數也降到 1.342。這不是純視覺排版:換行字元實際進入模型讀到的純文字,因而改變附近 token 的 context。

這些結果說明正常編輯確實可能逐步削弱浮水印訊號,但「改一個字」不會機械式地讓水印消失;反過來,我們也不能從某一個字的高亮深淺判定它是否出自 AI。

把浮水印硬塞進公文會怎樣?

我另外選了一份中央銀行公開的真實公文:民國 102 年發布「證券業辦理外匯業務管理辦法」時的中央銀行函。以下截取它從發文日期到正、副本的內容;只省略地址與承辦人聯絡資料,並統一網頁上的空白和全、半形括號。

中央銀行 函
發文日期:中華民國102年12月26日
發文字號:台央外柒字第1020052536號
速別:普通件
密等及解密條件或保密期限:
附件:如主旨
主旨:「證券業辦理外匯業務管理辦法」業經本行於102年12月26日以台央外柒字第1020050216號令訂定發布;茲檢送條文如附件,請惠予轉知。
說明:有關證券業辦理外匯業務相關規範,應請配合遵循。
正本:中華民國證券商業同業公會、中華民國銀行商業同業公會全國聯合會
副本:財團法人中華民國證券櫃檯買賣中心、財團法人台灣地理資訊中心、本行法務室(均含附件)

我要求模型「逐字逐行抄錄,不得改字、增刪、換句」,再用相同的 prompt、seed 與取樣設定,分別停用和啟用水印。啟用版使用九層公開示範金鑰:這是模型在單次生成中,對每個有效 n-gram 同時計算九個 key layer,不是把完成的文本反覆加水印九次。這裡的「字元序列相似度」以原始公文為基準,1 代表完全相同。

版本水印證據分數有效文字視窗字元序列相似度
原始公文1.1241861.000
未加水印繕打1.6421850.981
啟用九層水印繕打1.8181840.911

未加水印的版本只移除了標題中的空格,並把各欄位改成段落,所有實質內容仍然相同。啟用九層水印後,分數僅增加 0.176,卻開始改動正式機構名稱(變成簡體字):

- 正本:中華民國證券商業同業公會、中華民國銀行商業同業公會全國聯合會
+ 正本:中華民國證券商業同業公會、中華民國銀行商業同業公會全国联合会

- 副本:財團法人中華民國證券櫃檯買賣中心、財團法人台灣地理資訊中心、本行法務室(均含附件)
+ 副本:財團法人中華民國证券柜台买卖中心、财团法人台湾地理资讯中心、本行法务室(均含附件)

這正是低熵(low entropy)文本的困難:日期、文號、法規名稱、固定公文用語與機構全銜不能任意換字。水印器若尊重限制,便沒有多少空間累積訊號;若仍試圖影響選字,付出的就是格式或內容忠實性。這單一案例不能估計公文的誤判率,但它顯示的風險方向是「浮水印難以嵌入,或嵌入時容易破壞文本」,而非固定格式會讓無水印公文變成陽性。

更重要的是,這些數字只能回答表面文本和一組金鑰間的統計關係。它們不能告訴我們誰提出論點、誰負責查核,或 AI 的介入是否足以改變作者應該揭露的創作貢獻。這些問題留在討論原文處理。

實驗資料

兩組實驗都使用 5-gram 與九層公開示範金鑰;〈薛西弗斯〉生成的隨機 seed 是 19,公文繕打則是 31。可參考 SynthID-Text 公開實作Transformers 文件Anthropic 技術說明

實驗核心程式

以下只保留水印生成、計分與編輯比較的核心。實驗使用 Python 3.12、PyTorch 2.13.0 與 Transformers 4.57.6;輸入原文篇幅較長,因此以代替文字省略,實際使用的 prompt 則完整列出。

import math
import re

import torch
from transformers import (
    AutoModelForCausalLM,
    AutoTokenizer,
    SynthIDTextWatermarkLogitsProcessor,
    SynthIDTextWatermarkingConfig,
)

MODEL = "Qwen/Qwen2.5-1.5B-Instruct"
KEYS = [654, 400, 836, 123, 340, 443, 597, 160, 57]
NGRAM_LEN = 5

SOURCE = "<上文的薛西弗斯原稿>"
SYSTEM_PROMPT = (
    "你是臺灣作家,只寫臺灣正體中文。用詞範例:想像、快樂、記憶、連結、透過、"
    "成為、無須、懲罰、眾神、裡面、這裡。只輸出一個段落。"
)
USER_PROMPT = (
    "請沿用原文的正體中文用字,保留論證但重新組織句子,寫成約 160 字的一段:"
    + SOURCE
)

tokenizer = AutoTokenizer.from_pretrained(MODEL)
model = AutoModelForCausalLM.from_pretrained(MODEL)
watermark_config = SynthIDTextWatermarkingConfig(
    keys=KEYS,
    ngram_len=NGRAM_LEN,
)


def generate(with_watermark: bool) -> str:
    messages = [
        {"role": "system", "content": SYSTEM_PROMPT},
        {"role": "user", "content": USER_PROMPT},
    ]
    inputs = tokenizer.apply_chat_template(
        messages,
        add_generation_prompt=True,
        tokenize=True,
        return_dict=True,
        return_tensors="pt",
    )
    options = {"watermarking_config": watermark_config} if with_watermark else {}
    torch.manual_seed(19)
    output = model.generate(
        **inputs,
        do_sample=True,
        temperature=0.85,
        top_p=0.92,
        max_new_tokens=280,
        **options,
    )
    generated = output[0, inputs["input_ids"].shape[1] :]
    return tokenizer.decode(generated, skip_special_tokens=True).strip()


processor = SynthIDTextWatermarkLogitsProcessor(
    ngram_len=NGRAM_LEN,
    keys=KEYS,
    sampling_table_size=2**16,
    sampling_table_seed=0,
    context_history_size=1024,
    device=torch.device("cpu"),
)


def score(text: str) -> tuple[float, int]:
    input_ids = tokenizer(text, add_special_tokens=False, return_tensors="pt")[
        "input_ids"
    ]
    g_values = processor.compute_g_values(input_ids)[0].to(torch.float32)
    mask = processor.compute_context_repetition_mask(input_ids)[0]
    valid = g_values[mask]
    count = valid.numel()
    z_score = (valid.sum().item() - count * 0.5) / math.sqrt(count * 0.25)
    return z_score, mask.sum().item()


plain = generate(with_watermark=False)
watermarked = generate(with_watermark=True)

# 其餘增字、刪字與替換版本也由完整水印版分叉,再交給同一個 score;
# 實際修改片段已在上方比較中顯示,這裡省略。
variants = {
    "未加水印控制": plain,
    "完整水印": watermarked,
    "徹底分段": re.sub(r"([,。!?])", r"\1\n\n", watermarked).strip(),
}

for name, text in variants.items():
    z_score, effective_ngrams = score(text)
    print(f"{name}: z={z_score:.3f}, effective_ngrams={effective_ngrams}")

# 公文實驗沿用相同的 generate / score 流程:
OFFICIAL_SOURCE = "<上文的中央銀行函>"
OFFICIAL_SYSTEM_PROMPT = (
    "你是公文繕打員。逐字逐行抄錄使用者提供的公文,不得改字、增刪、"
    "換句或解釋。只輸出公文本身。"
)
OFFICIAL_PROMPT = "請逐字繕打以下公文:\n\n" + OFFICIAL_SOURCE

編輯本頁

Next Post
我是不是「被 AI」了——文字工作者如何面對 AI 浮水印