iT邦幫忙

2026 iThome 鐵人賽

DAY 16
0
Security

《30 天為金融法務部門打造 LLM 個資防護閘:去識別化、可控還原與紅隊驗證》系列 第 16 篇

Day 16|Token 一致性:同一個人要對到同一個 Token

  • 分享至 

  • xImage
  •  

為什麼一致性是必要的

假設一份契約有 30 頁,「王小明」出現了 47 次。如果每次都產生不同的 Token:

第 1 頁:立約人林建成……
第 3 頁:立約人陳志豪就前開事項……
第 8 頁:張雅婷應於期限內清償……

模型會認為這是三個不同的人。摘要出來的內容會是錯的,而且錯得很難發現——因為每一句話單獨看都合理。

所以一致性不是「加分項」,是正確性的前提。

三個層級的一致性

層級一:文件內一致。 同一份文件裡的同一個實體對到同一個 Token。這是最低要求。

層級二:案件內一致。 同一個案件的所有文件(起訴狀、答辯狀、附件)之間一致。這是做跨文件分析(D17)的前提。

層級三:全域一致。 所有文件、所有案件之間都一致。

層級三聽起來最方便,但它是安全上最糟的選擇。 因為它讓 Token 變成一個穩定的全域識別碼——攻擊者只要在任何一個地方確認了「林建成 = 某人」,就能關聯到所有文件。這等於把去識別化降級成了「換一個名字繼續當識別碼」。

所以正確的目標是層級二:案件內一致,跨案件不可關聯。 這就是 D13 提到的 scoped determinism。

實作

import hmac, hashlib

def get_token(real_value: str, entity_type: str, scope_id: str,
              key: bytes) -> str:
    material = b"\x00".join([
        scope_id.encode(),
        entity_type.encode(),
        real_value.encode(),
    ])
    digest = hmac.new(key, material, hashlib.sha256).digest()
    idx = int.from_bytes(digest[:8], "big")
    return generate_from_index(entity_type, idx)

把 entity_type 也放進去,是為了避免跨類型碰撞——一個剛好等於某個帳號的字串,不應該跟人名共用同一個 Token 空間。

這個函式是純函數:同樣輸入永遠得到同樣輸出,不需要查資料庫。所以文件內、案件內一致性是自動達成的,不需要維護狀態。

難的部分:實體正規化

真正的困難不在雜湊,在於判斷兩個字串是不是同一個實體。

王小明
王 小明          ← 中間有空白
王小明先生        ← 帶稱謂
小明             ← 簡稱
甲方(即王小明)   ← 定義式引用
上開王君          ← 法律文書慣用語

這六個都指向同一個人。如果只做字串比對,會產生六個不同的 Token。

需要一個正規化層:

TITLES = ["先生", "女士", "小姐", "君", "氏", "律師", "法官"]
PARTY_REFS = ["甲方", "乙方", "丙方", "原告", "被告", "上訴人", "相對人"]

def normalize_entity(name: str) -> str:
    s = unicodedata.normalize("NFKC", name)
    s = re.sub(r"\s+", "", s)
    for t in TITLES:
        if s.endswith(t):
            s = s[: -len(t)]
    return s

稱謂和空白好處理。難的是後三種:

簡稱。 「小明」要對應到「王小明」,需要在文件範圍內維護一個實體表,用後綴匹配。

定義式引用。 「甲方(即王小明)」建立了一個別名,之後所有「甲方」都指向王小明。這需要抓出定義句型並記錄別名。

ALIAS_PATTERN = re.compile(
    r"(?P<alias>甲方|乙方|丙方|原告|被告|上訴人|相對人)"
    r"[((](?:即|下稱|以下簡稱)?\s*(?P<real>[^))]{2,10})[))]"
)

def extract_aliases(text):
    aliases = {}
    for m in ALIAS_PATTERN.finditer(text):
        aliases[m.group("alias")] = normalize_entity(m.group("real"))
    return aliases

法律文書慣用語。 「上開王君」「本件被告」這類指代,需要語意層才能解。這是 D6 那個 NER 模型可以擴充的方向,但坦白說解不乾淨。

一致性反過來也是問題:同名不同人

案件裡有兩個「陳美玲」:借款人的配偶,以及承辦的律師。

如果只用姓名做 key,這兩個人會共用同一個 Token。結果是模型會以為律師和配偶是同一人。

需要用更多欄位做區辨。實務上的作法是優先用強識別碼建立實體身分:

def entity_key(attrs: dict, scope_id: str) -> str:
    """優先用強識別碼,退化才用姓名"""
    for field in ("tw_id", "passport", "account_no"):
        if attrs.get(field):
            return f"{field}:{attrs[field]}"
    # 沒有強識別碼,用姓名 + 角色
    role = attrs.get("role", "unknown")
    return f"name:{attrs['name']}:{role}"

如果連角色都判斷不出來,那就是一個該標記為需人工複核的情境,不要猜。猜錯的代價是實體合併,而實體合併會讓整份摘要失真。

一致性檢查

因為一致性是正確性的前提,它應該被測試:

def check_consistency(original, deidentified, entity_map):
    """檢查每個實體的出現次數在去識別化前後是否一致"""
    errors = []
    for real, token in entity_map.items():
        n_real = original.count(real)
        n_token = deidentified.count(token)
        if n_real != n_token:
            errors.append({
                "entity": real,
                "original_count": n_real,
                "token_count": n_token,
            })
    return errors

次數不一致代表有漏替換(有些出現位置沒被偵測到)或過度替換。這是一個能自動抓到偵測漏抓的檢查,而且不需要人工標註——比 D8 的評測便宜得多,可以對每一份實際文件都跑。

我很推薦把這個檢查放進正式管線,不只是測試環境。它是一個廉價的第二道防線。


明天談跨文件查核:多份附件之間怎麼在去識別後仍能比對。


關於作者

我是 Fngi,專注在 AI 資安、LLM 紅隊與 AI 治理框架落地。
IG:@aid3fend

有想討論的架構細節或不同意見,留言或私訊都歡迎。


上一篇
Day 15|OCR:偵測前就出行的那個破口
下一篇
Day 17|跨文件查核:去識別化之後還能比對嗎?
系列文
《30 天為金融法務部門打造 LLM 個資防護閘:去識別化、可控還原與紅隊驗證》 共 21 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言