前面幾天的架構都建立在雲端服務上。但 D7 提過一個根本問題:要做偵測,就要把原文送出去。
對某些機構這不能接受。特別是當文件包含特種個資、或者當合規要求「個資不得離開機構控制的實體環境」時。
所以我做了一套地端的工具 VeilCapybara,核心設計是同型改寫式遮罩。今天講它的設計思路,以及跟雲端方案的實際差別。
D9 提過,把所有東西替換成 [PERSON] 這種佔位符會造成三個問題。同型改寫的做法是:替換成同類型、同樣態、但不同的值。
王小明 → 林建成 (中文姓名 → 中文姓名)
A123456789 → B847263915 (身分證格式 → 身分證格式,檢查碼有效)
台北市中山區民生東路三段 XX 號 → 新北市板橋區文化路二段 XX 號
0912-345-678 → 0987-654-321
慢性腎病 → 【健康資訊已遮蔽】(特種個資不改寫,直接遮罩)
跟 FPE 的差別在於:FPE 只能處理規整格式,同型改寫可以處理自由文字——代價是需要 Vault 存對應。
同一個「王小明」,在不同文件裡應該對到同一個假名嗎?
確定性(同一個名字永遠對到同一個假名)
隨機性(每次都產生新假名)
我的選擇是範圍內確定性(scoped determinism):在同一個 scope(案件/session)內確定性,跨 scope 隨機。
def scoped_pseudonym(real_value, scope_id, key, pool):
material = f"{scope_id}\x00{real_value}".encode("utf-8")
h = hmac.new(key, material, hashlib.sha256).digest()
return pool[int.from_bytes(h[:8], "big") % len(pool)]
把 scope_id 混進 HMAC 的輸入,就自然得到這個性質:同案件內一致,跨案件不可關聯。這是 D18 Token 隔離的實作基礎。
分隔符用 \x00 而不是直接串接,是為了避免歧義——scope="A", value="BC" 和 scope="AB", value="C" 不應該產生相同的 HMAC 輸入。
假名不能隨便產生。三個要求:
一、要像真的。 中文姓名有結構:姓氏來自有限集合,名字有常見用字。隨機組合漢字會產生「垚甯彧」這種一看就假的結果。
二、不能撞到真人。 這跟 D10 的 FPE 問題一樣。緩解方式是用「合理但罕見」的組合。
三、要有足夠的空間。 假名庫太小會造成碰撞。以中文姓名為例,100 個姓氏 × 500 個名字組合 = 5 萬個,對單一案件足夠,對全行資料量可能不夠。
我的作法是分層產生:
SURNAMES = ["陳", "林", "黃", "張", "李", ...] # 常見姓氏
GIVEN_1 = ["建", "淑", "俊", "雅", "志", ...] # 名字首字
GIVEN_2 = ["成", "芬", "宏", "婷", "豪", ...] # 名字次字
def name_pool_index(idx: int) -> str:
"""把一個整數映射到假名,空間 = len(S) * len(G1) * len(G2)"""
s = idx % len(SURNAMES)
idx //= len(SURNAMES)
g1 = idx % len(GIVEN_1)
idx //= len(GIVEN_1)
g2 = idx % len(GIVEN_2)
return SURNAMES[s] + GIVEN_1[g1] + GIVEN_2[g2]
這樣不需要預先產生整個假名庫,只要一個索引函式。空間是三個列表長度的乘積。
要保留姓名的性別線索嗎? 這是一個真實的取捨。保留(男名換男名)會讓文件更自然、模型表現更好,但性別本身是準識別碼,保留它就是保留了一部分識別資訊。我的預設是不保留,需要時才開啟。
地址最麻煩,因為它同時包含需要保留的資訊(管轄地區)和必須移除的資訊(門牌)。
ADDR_PATTERN = re.compile(
r"(?P<city>[^縣市]{1,4}[縣市])"
r"(?P<district>[^鄉鎮市區]{1,4}[鄉鎮市區])"
r"(?P<rest>.*)"
)
def deidentify_address(addr: str, level: str = "district") -> str:
m = ADDR_PATTERN.match(addr)
if not m:
return "【地址已遮蔽】"
if level == "city":
return m.group("city")
if level == "district":
return m.group("city") + m.group("district")
# level == "replace":整段換成另一個真實存在的地址
return replace_with_pseudo_address(addr)
三個層級對應不同需求:法務分析通常需要到 district(判斷管轄法院),客訴分析可能只需要 city。
replace 模式要小心——換成另一個真實地址,等於指向了一個真實存在的地方,可能誤指到無辜的住戶。用於需要格式完整性的測試資料時才開啟,且應該用保留地址區段。
| 面向 | 雲端 SDP | 地端自建 |
|---|---|---|
| 規整格式偵測 | 優(內建 + hotword) | 需自己寫規則 |
| 中文姓名偵測 | 中(PERSON_NAME 對中文較弱) | 優(可針對訓練) |
| 特種個資偵測 | 弱(無對應 infoType) | 優(可訓練,見 D6) |
| 同型改寫 | 部分(FPE 限規整格式) | 優(設計目標) |
| 偵測前資料出行 | 是 | 否 |
| 延遲 | 網路往返(百毫秒級) | 本機(十毫秒級) |
| 維運負擔 | 低 | 高 |
| 可稽核性 | 中(黑箱部分) | 高(規則全在自己手上) |
| 擴展性 | 優 | 受硬體限制 |
最後兩列是真正的取捨點。地端方案的可稽核性優勢在金融場景很有價值——當稽核問「為什麼這筆沒被抓到」,你能打開規則檔案指給他看。但維運負擔是真實的:規則要維護、模型要重訓、硬體要管。
不是二選一,而是按敏感度分流:
文件進入
↓
分類器判斷敏感等級
↓
├─ 含特種個資/高敏感 → 地端管線 → 地端 LLM(或完全不上雲)
├─ 一般法務文件 → 地端偵測 + 雲端去識別化
└─ 低敏感 → 全雲端管線
這樣做的成本是要維護兩套管線,收益是你可以誠實地回答「最敏感的那批資料從來沒有離開過機房」。
在金融業的合規審查裡,這句話的價值很高。
明天進非結構化文件與 OCR,那裡有一個大部分架構圖都畫錯的地方。
我是 Fngi,專注在 AI 資安、LLM 紅隊與 AI 治理框架落地。
IG:@aid3fend
有想討論的架構細節或不同意見,留言或私訊都歡迎。