偵測引擎逾時了。使用者的文件正等著處理。系統應該:
A. 擋下來,告訴使用者「系統忙碌,請稍後再試」。 B. 放行,把原文送給模型,先讓使用者有東西用。
選 A 是 fail-closed,選 B 是 fail-open。
這個決定在架構文件裡通常佔不到一行,但它決定了系統在最壞情況下的行為。而最壞情況一定會發生。
沒有人會在設計會議上說「我們選 fail-open 吧」。它是預設發生的,因為它就是「什麼都不做」的結果。
def process(text):
try:
findings = detect(text)
text = deidentify(text, findings)
except Exception as e:
log.warning("偵測失敗,繼續處理: %s", e) # ← 這裡
return call_llm(text)
這段程式碼看起來很負責任——有 try/except、有 log。但它的行為是:偵測失敗時,把原文送出去。
這種寫法通常來自「不要讓使用者的操作失敗」這個很合理的直覺。在大多數系統裡這是對的。在資料防護管線裡這是災難。
def process(text, ctx):
try:
findings = detect(text)
except DetectionTimeout:
audit("detection_failed", ctx, {"reason": "timeout"})
raise ProcessingBlocked("偵測逾時,為保護資料已中止處理")
except Exception as e:
audit("detection_failed", ctx, {"reason": type(e).__name__})
raise ProcessingBlocked("偵測異常,為保護資料已中止處理")
if not detection_result_is_sane(text, findings):
audit("detection_suspect", ctx, {"finding_count": len(findings)})
raise ProcessingBlocked("偵測結果異常,需人工確認")
return call_llm(deidentify(text, findings))
三個要點:
一、例外一律擋下。 不區分是哪種例外——因為你無法列舉所有可能的失敗方式。
二、記錄失敗。 失敗事件本身是重要的稽核與維運訊號(D20 的健康檢查靠它)。
三、加一個合理性檢查。 這是防止「沒有拋例外但結果錯誤」的情況。
最危險的失效不是拋例外,是成功回傳了錯誤結果。例如規則檔載入失敗但程式沒報錯,偵測器回傳空清單。
def detection_result_is_sane(text, findings):
# 一定長度的法務文件,不可能一個個資都沒有
if len(text) > 500 and len(findings) == 0:
return False
# 偵測器數量檢查:確認所有偵測器都有回應
if len(active_detectors()) < EXPECTED_DETECTOR_COUNT:
return False
# 金絲雀:在文件尾端插入已知個資,檢查它有沒有被抓到
return True
第三項的金絲雀技巧特別有用:
CANARY = "(系統測試:王大明 A199999999 0900-000-000)"
def detect_with_canary(text):
probe = text + CANARY
findings = detect(probe)
canary_hits = [
f for f in findings if f["start"] >= len(text)
]
if len(canary_hits) < 3: # 應該抓到姓名、ID、電話
raise DetectorMalfunction(
f"金絲雀檢查失敗:預期 3 項,實際 {len(canary_hits)} 項"
)
return [f for f in findings if f["start"] < len(text)]
每一次呼叫都驗證偵測器是活的。 成本是多處理幾十個字元,收益是幾乎不可能發生靜默失效。這是我認為整個系列裡 CP 值最高的一個技巧。
擋下來的代價是使用者用不了。如果偵測引擎不穩定,使用者會天天被擋,最後的結果是——有人會去把這個檢查關掉。
所以 fail-closed 必須搭配:
一、高可用性。 偵測服務要有備援、要能水平擴展。它現在是關鍵路徑上的元件,SLA 要對齊主服務。
二、降級路徑,而不是關閉路徑。 提供一個「更嚴格但仍可用」的模式:
DEGRADED_MODE = {
"use_semantic_layer": False, # 語意層不可用
"rule_layer_aggressive": True, # 規則層放寬,寧可過度遮罩
"require_human_review": True, # 結果需人工確認才能送出
"block_sensitive_doc_types": True # 高敏感文件類型直接擋
}
降級模式的哲學是:能力下降時,保護程度上升。 語意層掛了,就用更激進的規則多遮一些,並且要求人工複核。使用者仍然能工作,只是比較麻煩。
這比 fail-open 好,也比完全擋死好。
三、明確的例外流程。 一定會有「這份文件現在就要送出去,法院明天開庭」的情況。與其讓人繞過系統,不如提供一個需主管核准、全程留痕、事後複查的緊急通道。
def emergency_bypass(doc, ctx, approver, reason):
require_role(approver, "emergency_approver")
audit("emergency_bypass", ctx, {
"approver": approver,
"reason": reason,
"doc_hash": sha256(doc),
"review_required_by": now() + timedelta(days=1),
})
notify_security_team(ctx)
return process_with_manual_review(doc)
有記錄的例外,好過沒記錄的繞過。 如果系統沒有例外通道,使用者會自己找路——用個人帳號、用手機、用別的工具。那時候你連知道都不會知道。
不是所有環節都該 fail-closed:
| 環節 | 失敗時 | 理由 |
|---|---|---|
| 偵測引擎 | 擋 | 漏抓不可逆 |
| 去識別化 | 擋 | 同上 |
| 送出至 LLM | 重試後告知失敗 | 不涉及資料洩漏 |
| 還原服務 | 擋 | 無法確認授權就不能給明文 |
| 稽核日誌寫入 | 擋 | 無法留痕的操作不應執行 |
| 統計分析 | 略過 | 非關鍵路徑 |
倒數第二列值得展開:如果稽核日誌寫不進去,該不該繼續執行操作?
我的答案是不該。因為一個無法被記錄的還原操作,等於一個不存在的還原操作——出事時你查不到。這叫做 write-ahead audit:先確保能記錄,再執行。
def reidentify_with_audit(req):
audit_id = audit_log.reserve(req) # 先預留一筆
try:
result = do_reidentify(req)
audit_log.commit(audit_id, "success")
return result
except Exception as e:
audit_log.commit(audit_id, "failed", error=type(e).__name__)
raise
如果 reserve() 失敗,整個操作就不執行。
最後一個建議:把 fail 策略明確寫進架構文件,並且讓它成為一個需要簽核的決定。
因為當事故發生、稽核來問「為什麼原文會送出去」,你需要能指著文件說「這是當初的設計決定,由誰在什麼時候核准的」。而不是「因為那段程式碼是這樣寫的」。
明天開始紅隊。第一個目標不是模型,是去識別化引擎本身。
我是 Fngi,專注在 AI 資安、LLM 紅隊與 AI 治理框架落地。
IG:@aid3fend