在您的管線中重複使用驗證器會反轉失敗的意義
同一個故障關閉檢查,在產生資料時是一個安全的略過,而在稽核它時則是一個破壞性的刪除。以下說明如何安全地移動它。
一個已在線上環境運行數月的驗證器,是最吸引人重用的程式碼。它經過測試、調校,而且已經了解您資料的結構。因此,當您需要稽核在驗證器存在前就已寫入的紀錄時,將它指向舊的資料列,看起來就像一行程式碼就能解決的工作:同樣的函式,新的輸入集。這種直覺在邏輯上是對的,但在後果上卻是錯的。一個回傳「無效」的檢查,在生成路徑上意指「不要寫入此資料」,而在稽核路徑上則意指「刪除已存在的資料」,但程式碼無法分辨兩者的差異。本文旨在探討此舉所產生的特定故障,以及修復此問題的三向判斷法。
隱藏在共用檢查器中的不對稱性
試想一個管線,它會產生翻譯術語,並在儲存前逐一驗證。該驗證器會執行三個階段:完全相符的捷徑、低成本的嵌入距離測試,以及針對模糊的中間區間,使用一個會回答「是」或「否」的語言模型。在任何階段失敗的項目,都會直接不寫入。呼叫者會繼續執行,並在隔天重試。
現在來看看該驗證器內部的錯誤處理:
for _, r := range gray {
ok, err := e.verifyGray(ctx, r.ko, r.backKo)
if err != nil {
r.verdict = DropBacktranslate // treat an API error as a failed check
continue
}
if ok {
r.verdict = VerdictPass
} else {
r.verdict = DropBacktranslate
}
}
在生成路徑上,這是正確甚至可說是優雅的。逾時、速率限制、格式錯誤的回應:全都歸結為「我們未確認此項,因此我們不會寫入。」。這是預設關閉 (Fail-closed) 的作法。偽陰性的代價是跳過一筆資料,並在明天重試一次。
將同一個函式指向現有的已確認記錄,代價就會反轉。現在,單單一次 API 逾時就會將一筆正確、經人工核准的資料標記為刪除。驗證器並未改變,但影響範圍(blast radius)卻變了,因為呼叫者將「不寫入」的決定變成了「銷毀」的決定。
這個陷阱在於,這種不對稱性在呼叫點 (call site) 是看不見的。稽核程式碼讀起來和生成程式碼一樣無害:
verdicts, err := engine.VerifyPairs(ctx, pairs)
for _, v := range verdicts {
if !v.Pass {
rejectRecord(ctx, v.ID) // looks symmetric, is not
}
}
傳回值無法告訴你的事
更深層的問題是,一個布林值的通過旗標將兩種不同的情況壓縮成了一種。「模型檢查了這對資料並表示它們不匹配」和「我們從未得到答案」兩者都會得到 Pass: false 的結果。在生成路徑上,這種壓縮是無害的,因為兩種結果都會導向相同的安全操作。但在稽核路徑上,你迫切需要區分這兩種情況,而型別簽章卻不允許你這麼做。
解決方法不是讓驗證器變得更聰明,而是停止向驗證器索取裁決,開始向它索取證據。大多數驗證器已經會傳回比布林值更多的資訊:一個距離、一個信賴度、一個中介產物。改為透過這些資訊來引導你的破壞性決策。
在我們的案例中,傳回型別除了旗標之外,還帶有一個距離:
type PairVerdict struct {
Pass bool
BackKo string // the round-trip translation, empty if we never got one
Dist float64 // embedding distance, 0 when the shortcut matched
}
那個距離是測量出來的,而非判斷出來的。API 錯誤無法捏造出很大的距離,因為失敗的呼叫根本不會產生任何距離值。因此,距離的閾值是對資料的聲明,而布林值則是對過程的聲明。
三向判斷
稽核需要三種結果,而不是兩種,而第三種才是重點所在:
- 刪除:不匹配的確鑿證據。在我們的管線中,這表示距離超過了上限範圍,遠遠超出了合法同義詞所在的區域。
- 保留:驗證器明確地通過了該記錄,包括語言模型檢視了模糊配對並予以批准的情況。
- 擱置:其他所有情況。檢查無法完成、往返結果為空,或模型在模糊範圍內表示否定。記錄此發現,不要更動資料,並將其放入人工佇列。
擱置的分類是讓重複使用變得安全的關鍵。它將「我不確定」從一個破壞性動作轉變為一個工作項目。它也將您的稽核從清理工作轉變為分診工作,而這本來就應該是稽核的樣貌。
這個設計中潛藏著一個細微的順序錯誤,值得詳細說明,因為我們就曾遇到過。我們的 distance 欄位在兩種完全不同的情況下會是零:當完全匹配的捷徑觸發時(完美通過),以及當往返結果為空時(完全無法評估)。僅根據距離編寫的雙分支規則會將空的往返結果歸檔為通過,寫入一筆通過記錄,從而透過冪等性過濾器將該列從未來的所有稽核中排除。該列將在從未被驗證過一次的情況下,被永久標記為已驗證。在處理距離之前檢查往返產物的空值狀態,只需一行程式碼就能堵住這個漏洞。
模擬運行的數據
我們使用三向規則建立了稽核機制,並在動手處理任何東西之前,對 300 筆舊有紀錄以模擬運行模式執行。距離直方圖是其中有趣的部分。每筆紀錄都落在上限閾值以下。沒有任何一筆符合刪除資格。
更有說服力的是中間區段。一個意為「皮下填充物注射」的詞彙,其四種語言的翻譯全都來回翻譯成「玻尿酸注射」,距離為 0.36。這些都是正確的翻譯。來回翻譯描述的是物質而非程序,這正是語言模型會標示為「不相同」的那種語義漂移。在雙向規則下,這四列資料會因為翻譯正確,反而在四種語言中被刪除。
單單這個觀察就證明了整個設計的價值。它也帶來了我們意料之外的第二個發現:我們零刪除的結果,並不能證明資料是乾淨的。這部分是群體篩選器所造成的人為結果。稽核只檢視了其上層條目處於已確認狀態的紀錄,而我們最著名的錯位範例,一個其翻譯已漂移成醫生傳記段落的詞彙,其上層條目卻是未確認的。稽核不可能找到它。驗證器的優劣,取決於你提供給它的資料列。
將驗證器移至破壞性路徑的規則
- 詢問每條路徑上失敗的代價為何。 如果一條路徑的代價是重試,而另一條是刪除資料,那麼你並不是在重複使用一個驗證器,而是在賦予它一個新任務。
- 透過測量值而非判定結果來引導破壞性操作。 距離、計數和差異比較不會因基礎設施故障而被偽造。布林值則可能。
- 在加入刪除操作前,先新增一個保留區。 如果你的稽核沒有第三種結果,它會將不確定性以損害的形式表現出來。
- 檢查產出物,而不僅僅是分數。 一個空的中間結果,其數值常常與一個完美的結果相同。請明確地測試這種情況。
- 小心冪等性陷阱。 如果你為了避免重複處理而記錄「已檢查」,一個錯誤歸檔的通過結果將是永久性的。該資料列會被標記為已驗證,且再也不會被檢查。
- 先對真實資料切片進行空跑 (dry-run),並觀察其分佈。 零刪除的結果本身就是一種資訊:要嘛是資料很乾淨,要嘛是你的閾值設定錯誤,或是你的母體篩選器排除了你正要尋找的那些資料列。
- 在覆寫前先保存原始資料。 我們的做法是將其放入日誌表格中一個現有的未使用欄位。回滾的保險通常比看起來的要便宜。
通用的形式
這其實不是關於驗證器(validator)的問題。而是關於一個事實:函式的安全屬性並非函式本身所固有的。它們存在於函式及其呼叫者(caller)的配對之中。故障封閉(Fail-closed)是「當此檢查無法完成時會發生什麼」的一種屬性,而會發生什麼,則完全由 return 陳述式另一端的程式碼決定。
下次當您將成熟的驗證邏輯應用於新的資料集時,要問的問題不是該邏輯是否仍然適用。而是「不」的意義是否仍然適用。如果說「不」過去意味著「等待」,而現在意味著「銷毀」,那麼您就需要第三個詞。