在 LLM 共識門控中,無回答並非異議
當一個模型無法回覆時,將其計為分歧會侵蝕您的重試預算,並將服務中斷變為定論。如何區分這兩種狀態。
雙模型共識閘是個簡單的概念:向兩個獨立的 LLM 提出相同的問題,僅在它們意見一致時才採取行動,並將其他所有情況都轉交給人工處理。這個方法很有效,我之前也寫過文章,說明為什麼對於必須正確的決策而言,共識勝過單一模型。這篇文章要談的是這個模式本身內部的一種失敗模式。在一個對抓取到的術語候選詞進行分類的管線中,我們的分歧率在幾週內從 18% 攀升到 68%,而且越來越多的項目因為「模型無法達成共識」而被永久地從自動化流程中剔除。這些項目中,有很大一部分根本從未出現過意見分歧。其中一個模型只是沒有回答,而程式碼將沉默視為異議。
共識閘門有四種結果,而非兩種
對於共識閘門的淺白心智模型有兩種結果:模型們同意,或不同意。真實的狀態空間更大,而多出來的狀態正是可靠性流失之處:
- 兩個模型都回答了,結論相同。可以安全採取行動。
- 兩個模型都回答了,結論不同。一次真正的意見不合,值得重試或人工檢視。
- 一個模型回答了,另一個沒有。你只有一個意見,不是兩個。沒有進行任何比較。
- 兩個模型都沒有回答。此批次完全中斷。
當你在撰寫程式碼時,狀態 2 和 3 感覺很相似,因為在這兩種情況下你都無法繼續進行。但它們的意義卻相反。意見不合是關於資料的訊號:該項目確實模稜兩可,重複提問也不太可能收斂。未回答則是關於基礎設施的訊號:逾時、速率限制、內容過濾器、部分解析。項目本身可能非常簡單。
大多數的實作會處理狀態 4,因為全面中斷是明顯且引人注目的。狀態 3 則是那個被默默歸入狀態 2 的情況,而這個歸入的動作就是錯誤所在。
非答案如何在程式碼中成為分歧
這種崩潰通常發生在 map 查詢時。每個模型會回傳一批判斷結果,你按項目將其編制索引,然後進行比較:
gv, gok := geminiVerdicts[item]
ov, ook := openaiVerdicts[item]
if !gok || !ook {
return Judgment{Agreed: false} // missing answer, "not agreed"
}
if gv != ov {
return Judgment{Agreed: false} // real disagreement
}
這兩條路徑都會產生相同的 struct。在下游,Agreed == false 會被記錄為 drop-disagree,一個重試計數器會增加,而在三次嘗試失敗後,該項目會被標記為已耗盡,並從自動化填充中移除。系統無法告訴你,即使在其日誌中也無法,一個已耗盡的項目究竟是三次都模稜兩可,還是三次都運氣不佳。
有兩個細節讓情況比表面上看起來更糟。首先,部分解析遺失很常見。一個被要求判斷一批 15 個項目的模型,有時會回傳 14 個,且沒有任何地方出現錯誤。遺失的那個項目並非因其本身的過失而進入狀態 3。其次,單方面的失敗在時間上是相關的。當一個供應商狀況不佳時,整批項目會一次性地全部進入狀態 3,而其中的每個項目都會消耗掉一次重試機會。
重試預算才是真正的受害者
我們的關卡給予每個項目三次嘗試機會。三次真正的意見不合,可以合理地定義為「模型對此無法達成共識,應由人類介入檢視」。重試預算是一種用來過濾模糊不清情況的篩選器。
將「無回應」計入該預算,會悄悄地改變篩選器所篩選的對象。一個遇到兩次不穩定執行和一次真正意見不合的項目,其重試機會就會被耗盡,狀態與一個三次都模糊不清的項目相同。更糟的是,持續的單方面中斷會將基礎設施的故障直接轉換為資料的裁決:在中斷期間處理的每個項目都會向著耗盡邁進一步,而經過三次這樣的夜晚後,管線就對數百個它從未實際評估過的項目「得出」了某種結論。
當我們終於能看到數據時,這就是數據所顯示的結果。重試被標記為「意見不合」的項目,大約有三分之一的時間會改變結果(在第一次「意見不合」後重試的項目中,181 個後來解決為一致拒絕,139 個解決為一致接受,而 577 個仍維持意見不合)。其中一些不穩定性是模型對於邊界輸入的真正擺盪所致。但其中有未知的一部分是狀態 3 偽裝成狀態 2,而在修復之前,日誌格式使得這兩者無法區分:程式碼儲存的是固定的解釋性字串,而不是每個模型實際的輸出。
修正方法:記錄非答案,不予計算
這個變更很小,且包含三個部分。
首先,擴展 judgment 結構體,這樣 truth 就能在比較後保留下來:
type Judgment struct {
Agreed bool
Oneside bool // at least one model gave no verdict
GeminiOut string // raw verdict, "" means no answer
OpenAIOut string
}
在任何提早返回前,先填入各模型的欄位。在遇到遺失答案時,最自然的捷徑中斷點也正是該資訊即將遺失的地方,因此順序至關重要:先填寫,再返回。
其次,給予無答案的項目其專屬的記錄判定,並將其排除在重試計數之外。我們的耗盡規則是針對項目的日誌行,取 max(attempt) >= 3。將無答案的資料行寫入 attempt = 0,能使其在每次稽核查詢中都可見,同時對耗盡計數毫無貢獻。下一次的實際嘗試仍然會以 max(attempt) + 1 來計算其編號,因此真實嘗試的序列不會中斷。
第三,對「單邊」進行廣義定義。只要批次整體有返回,當任一模型遺失(包含兩者皆遺失)時,Oneside 都應為 true。一個批次中,若兩個模型都遺漏了同一個項目,這仍然是「無法比較」,而非「意見不合」。將完全中斷的路徑保留給兩個模型都沒有任何返回的情況,並在該情況下完全跳過日誌記錄,因為一個寂靜的夜晚完全不應留下任何逐項的追蹤記錄。
毒藥丸與三部分升級防護機制
將無回應從耗盡狀態中排除會產生新的風險。如果某個特定輸入總能讓某個供應商靜默(內容過濾器是典型原因),那麼該項目現在會永久重試。它每晚重新進入處理群體,消耗兩次 LLM 呼叫,且永遠無法解決。佇列中便產生了一顆毒藥丸。
針對此修正的再修正,是將持續的單方面失敗升級回到計數路徑中,並加上防護機制,以確保在一般服務中斷期間不會觸發升級:
- 在滑動視窗內(而非整個時間範圍內)計算無回應的資料列。我們使用了 14 天。一個項目若一年中每個月只遇到一個不穩定的夜晚,並不算毒藥丸,但若採生命週期計數,最終仍會將其升級。
- 在升級前,檢查該批次。如果今晚批次中的大多數項目都出現單方面失敗,這便是供應商事故,而非項目本身的屬性。應抑制整批的升級,並記錄為單純的無回應。這種抑制措施是為了安全起見:最壞的情況不過是多重試一晚。
- 當您確實要升級時,保持記錄的判定結果與真正的意見不一完全相同,這樣所有下游的消費者(耗盡查詢、儀表板、處理量估計)的行為模式才會與之前完全一致,並在一個獨立的原因欄位中放入一個固定的標記字串。日後讀取該項目的人員必須能看到「此項目因某供應商持續處理失敗而脫離自動化流程」,因為對此情況的正確回應與對真正模稜兩可情況的回應是不同的。
在三次嘗試的預算之上,再搭配三次觸發的升級機制,毒藥丸的最長生命週期便被限制在六個晚上,且在這段期間內完全不會隱瞞實際發生的情況。
一旦能看見差異,會有哪些改變
可觀察到的成效是立即的:夜間統計資料被分為「意見不合」和「單方面」,而管線也首次能夠回答某個糟糕的一週是模型品質問題還是基礎設施問題。在這次變更之前,這個問題不僅僅是困難,而是從資料中無法回答的,因為日誌列在應該儲存證據的地方儲存了一個常數字串。記錄每個模型的原始判定結果會耗費兩個欄位,並消除一整類的猜測。
還有一個值得說明的細微記帳規則:根據實際記錄的內容來報告統計數據,而不是分類器所看到的內容。一個以單方面形式送達,但被升級到計數路徑中的項目,應歸入今晚報告的「意見不合」欄位,因為資料庫現在是如此記錄的。如果總計數和儲存的資料列出現差異,未來每一次的除錯工作都得從核對它們開始。
為您自己的閘門建立的檢查清單
- 在程式碼中明確列舉四種結果:一致、分歧、單方面、中斷。如果其中兩種結果共用一個結構值,代表您已經將它們合併了。
- 在任何提早返回之前,先填入每個模型的原始判定並儲存起來。使用 "" 來表示遺失的答案是可以的;使用一個固定的解釋性字串則不行。
- 將無答案的情況排除在耗盡規則之外。在
max(attempt)規則下,一個attempt = 0的資料列是個記錄而不計數的低成本方法。 - 將「在一個原本健康的批次中,兩個模型都錯過了這個項目」視為單方面問題,而非分歧或中斷。
- 限制毒藥丸(poison pill)的影響:在滑動視窗內發生 N 次單方面失敗後進行上報,當整個批次都失敗時則抑制上報,並用明確的理由標記上報的資料列。
- 讓計數與紀錄保持一致。統計數據應根據寫入的內容計算,而不是根據所採用的分支。
這些都不專屬於大型語言模型(LLM)。任何有不可靠投票者的投票系統,都存在異議、缺席和全面中斷這三種區別。大型語言模型的管線只是讓這一點更容易被忽略,因為逾時和分歧都以同樣的形式出現:一個您今晚無法完成的比對。