工法 工程 · 資料管線
怎麼寫一個不會因為錯誤理由通過的檢查
3,146 字 · 約 9 分鐘
一天之內我自己寫的五道建置閘門全部出問題,而沒有一道報錯:比筆數的檢查換掉一支代號照樣綠、少一個字母的正則讓 718 頁都被誤報、換了尺卻留著舊門檻讓三頁躺著過。這一篇是五道的原始碼與修法,以及一條共通的判準:先問它會不會因為錯誤的理由通過,再問它對不對。
2026-08-27 這一天,我替自己的網站寫了七道建置閘門。其中五道在寫下的當天就出了問題,而五道的錯法沒有一個相同。
這五道全部沒有報錯。它們在建置輸出上印的字,跟真的在守的閘門一模一樣。
先給結論,五道各一列:
| 閘門 | 它報什麼 | 實際發生什麼 |
|---|---|---|
| 下市清單對帳 | 綠燈 | 換掉一支代號,筆數不變,照樣通過 |
| 段落長度 | 718 個假警報 | 正則吃掉了 <polyline> |
| 重複偵測 | 假的「重複」 | 抓到的是量的名字不是宣稱 |
| 元敘述比例 | 綠燈 | 換了尺沒重訂門檻,判定範圍裡沒東西會超標 |
| 跨語言配對 | grep hreflang 有東西 | 那個標記對 Google 不算數 |
下面是五道的原始碼、它們錯在哪、以及修法。沒有一段涉及交易策略 —— 它們是檢查本身的程式碼。
這些閘門守的是一個公開八條交易產品線檢定結果的站:四條判死、四條未證實、零條通過門檻。閘門錯了,那些結論就沒有憑據。
比筆數的閘門:29 對 29,把一支代號換成另一支照樣綠燈
這個站有一頁列出所有已經下市的永續合約。那一頁是「只有這裡查得到下市幣」這句話的憑據,所以我寫了一道閘門,確保頁面上列的跟資料裡的對得起來。
第一版比的是筆數。
# 第一版(錯的)
def delisted_rows(dist):
want = count_delisted_in_data() # 資料裡 delisted 非空的檔數
got = len(COIN_LINK.findall(page)) # 頁面上的代號數
return want == got
拿一支代號換成另一支,筆數不變,閘門照樣通過。而那正是最該被抓到的情況:頁面上的清單跟資料對不上,但對不上的方式不是「少了一支」。
修法是比集合不是比數量:
# 修好的版本
def delisted_rows(dist):
want = {d['symbol'] for d in data if d.get('delisted')}
got = set(COIN_LINK.findall(page.read_text(encoding='utf-8')))
return page, sorted(want - got), sorted(got - want) # 兩個方向都回
兩個方向都要回:want - got 是「有頁爬不到」,got - want 是「連到不存在的頁」。只回一邊的檢查,另一邊永遠不會被發現。
那一頁現在長這樣:已下市的永續合約清單,每一支的上市日、下市日與存活週數都在上面。
而這件事最尷尬的部分是:同一個檔案往上三十行,另一道檢查早就在比集合了。我寫第二道的時候沒有回頭看第一道。
少一個向前否定的正則:<p[^>]*> 吃掉了 <polyline>,718 頁全部假警報
我後來加了一道段落長度的檢查,抓「這一段有沒有長到讀者會整段跳過」。
// 第一版(錯的)
const PARA = /<p[^>]*>(.*?)<\/p>/s;
跑第一次,718 個幣種頁全部被報成「有一段 358 字」。而那段字是圖說,不是段落。
原因(2026-08-27 當天查出來的):<p[^>]*> 會匹配 <polyline class="ser" points="..."> —— <p 之後 [^>]* 把 olyline ... 一路吃掉。然後 .*? 跑到下一個真正的 </p>,中間所有東西都被算成一個段落。
// 修好的版本
const PARA = /<p(?![a-zA-Z])[^>]*>(.*?)<\/p>/s;
**一個向前否定,差一個字母。**而症狀是 718 個假警報。
假警報比漏報更貴,而這件事有人量過。Google 的測試部落格記錄一個團隊的資料:當一個原本穩定的測試變得不穩定、而且能追到某一次程式碼變更時,只有六分之一的情況真的是產品程式碼的缺陷(Google Testing Blog, 2017,查證日期 2026-08-27)。
六分之五是測試自己的問題。一道經常誤報的閘門,最後會被關掉或被忽略 —— 而它被關掉的那一天,它本來要守的東西也一起沒人守了。
抓詞不抓宣稱:「加總」抓到的是〈背離加總〉,那是一個量的名字
第三道抓重複:同一個意思在同一頁講超過兩次就報。判準不是字串是「口徑」,所以我用關鍵詞。
# 第一版(錯的)
CALIBERS = {
'不可加總/基準不同': r'加總|基準(不|一)',
'評測帳戶不是真錢': r'評測.{0,12}(不是|非真實|不計入)',
...
}
跑第一次抓到的原文是這些:
| 抓到的字串 | 它其實是什麼 |
|---|---|
| 背離加總 36.22 pp | 一個量的名字 |
| 15 注的進場價加總是 5.11 | 同上 |
| §02 本金與目前值 … 評測帳戶不計入 | 區塊標題被壓平 |
| §04 評測帳戶 非真實資金 | 同上 |
「加總」這個詞在這個站上是量的名字(背離加總、逐注加總),不是「不能加總」這個宣稱。我抓的是詞,而我要的是宣稱。
修法有兩層:樣式只抓否定形(不(能|可|得|該)加總),以及跟其他量測一樣扣掉 <table> 與區塊標題 —— 一個儲存格不是一句話。
而這裡我做了一件跟修法一樣重要的事:每一條樣式旁邊放它誤判到的原文,並且把那兩句寫進自檢的反向斷言。
for x in ('背離加總是 36.22 pp,而手續費吃掉了其中的七成以上',
'15 注的進場價加總是 5.11,逐注對得起來'):
assert not any(re.search(p, x) for _, p in CALIBERS.items()), f'誤判:{x}'
理由:**三次修正的方向全部是讓數字變小。**一把會誤判的尺報出來的「重複」,跟真的重複長得一樣 —— 所以調整量測工具這個動作本身需要留下證據。
換了尺沒重訂門檻:三頁躺著過,而它每次建置照印一行「通過」
第四道最難看。
我寫了一支腳本量「這一頁有多少句在講自己的版面」,門檻是驗收方訂的,照的是驗收方自己那把尺:三頁分別 20% / 25% / 30%。然後我換上一把自己寫的、窄了八倍的尺,門檻留著沒動。
驗收方的尺 32% / 43% / 54% 門檻 20 / 25 / 30 → 要求砍掉三分之一到一半
本站的尺 4% / 7% / 23% 門檻 20 / 25 / 30 → 三頁躺著過
而那支腳本的檔頭上,我自己寫著這一句:
門檻不是「元敘述的真實比例」,是「這把尺量出來的數」—— 換尺就得重訂門檻。
我寫完那句,然後換了尺,沒有重訂門檻。
最糟的不是它有洞,是它每次建置印一行「通過」(那道閘門從 2026-08-27 上午寫下到當天下午被抓到為止都是綠的)。一個有洞的閘門,你去測它的時候會露餡。一個判定範圍裡本來就沒有東西會超標的閘門,測它也會過 —— 因為它守的那個範圍是空的。
做了一個看起來像但不算的東西:<a hreflang> 對 Google 不算 hreflang
第五道是別人指出來的,而它是這五道裡最難自己發現的。
站上的文章清單有這樣的連結:
<a href="/notes/prop-firm-bot-breaks/" hreflang="en" lang="en">…</a>
那個 hreflang 屬性是給螢幕閱讀器用的 —— 中文語系的語音引擎不會用中文去念一句英文標題。它是對的,而且它不是跨語言配對。
Google 只認三個地方:<head> 裡的 <link rel="alternate" hreflang>、HTTP 回應標頭、以及 sitemap 的 xhtml:link。而且它要求互指:兩頁如果沒有互相指向對方,那組標記會被整組忽略(Google Search Central,查證日期 2026-08-27)。
所以站上的狀態不是「忘了做」,是做了一個看起來像但不算的東西。而那比忘了做危險:
任何人
grep hreflang都會看到有東西,於是不會再查。
修法是在 <head> 出真正的三條(自我指涉、對方、x-default),並且加一道閘門驗雙向:宣告了對方 → 對方必須存在而且指回來。閘門的註解裡寫死一句:
# 🔴 這道閘門檢查的是 <head> 裡的 <link rel="alternate">,
# 不是文件裡任何 hreflang 字樣。一個 grep 'hreflang' 的檢查會永遠是綠的。
五種錯法沒有一個相同,共通的判準只有一條
把上面四道排在一起,錯法沒有一個相同:
| # | 錯在哪 | 症狀 | 一句話 |
|---|---|---|---|
| 一 | 比錯了東西 | 綠燈 | 數量相等不等於內容相同 |
| 二 | 匹配範圍太寬 | 718 個假警報 | 假警報比漏報更快讓人關掉閘門 |
| 三 | 抓詞不抓宣稱 | 假的「重複」 | 誤判的尺報出來的東西,跟真的長得一樣 |
| 四 | 判定範圍是空的 | 綠燈 | 測它也會過 |
| 五 | 做了一個不算數的東西 | grep 得到 | 看起來像,於是沒有人再查 |
共通的只有一條,而它不是「多寫測試」:
先問「它會不會因為錯誤的理由通過」,再問「它對嗎」。順序不能反。
這個順序在文獻上有對應的東西。變異測試(mutation testing)的做法是故意把程式改壞,看測試會不會紅 —— Just 等人在 FSE 2014 用 5 個開源專案、321,000 行程式碼、357 個真實缺陷驗過,測試抓到人造缺陷的能力與抓到真實缺陷的能力存在統計顯著的相關,而且獨立於覆蓋率(ACM DL,查證日期 2026-08-27)。
覆蓋率量的是程式碼有沒有被執行到,而上面四道的程式碼全部都有被執行到,執行結果也都「正常」。
另一個角度是蛻變測試(metamorphic testing):當你沒有辦法直接判斷單一輸出對不對的時候,改用「輸入這樣變、輸出應該那樣變」的關係去驗。Chen 等人在 ACM Computing Surveys 51(1) 的回顧把它定位成 oracle problem 的解法之一(Victoria University 典藏,查證日期 2026-08-27)。
上面第一道的修法就是這個形狀:我判斷不了「29 這個數字對不對」,但我判斷得了「集合相減應該是空的」。
沉默失敗不是軟體獨有的:CPU 那一層也量過
值得記一筆的是:這一類「壞了但沒有人會知道」的問題在硬體那一層更嚴重,而且量過。
Dixit 等人分析 Meta 數十萬台機器的 CPU,發現靜默資料損毀(silent data corruption)跨世代地系統性存在:那些錯誤不會被 CPU 內建的錯誤回報機制捕捉,因此在硬體層追蹤不到,而它們會沿著整個技術堆疊往上傳播,最後以應用層的問題浮現(arXiv:2102.11245,查證日期 2026-08-27)。
把那個結論翻譯到資料管線上:你的檢查全綠,不代表資料是對的;它只代表你問的那幾個問題,答案是你預期的那個。
實務上怎麼做:五條
- 比集合不比數量,而且兩個方向都要回。
- 一道從來沒紅過的閘門,去確認它「能」紅 —— 不是確認它「該」紅。方法是把錯誤注入到產出物上(改
dist/裡的 HTML),不是改原始碼。改原始碼可能讓建置先在語法錯誤那一步失敗,而那會被誤讀成閘門生效(2026-08-27 就有一次差點這樣誤判)。 - **加嚴任何一道檢查之後,去看那些原本就綠的斷言是不是還在測同一件事。**它們不會變紅,所以沒有訊號會來找你。
- 換掉任何一把尺之後,門檻要跟著重推,而且推法要在看到新數字之前就定死。
- 讓閘門印出它放行了什麼,不只印它擋下了什麼。第三道那個 25px 的標籤就是這樣被看到的 —— 它合法通過,是報告模式把它印出來才發現的。
第 5 條是這五條裡最便宜也最少人做的。只看紅字的閘門,對「錯得還在範圍內」那一類是瞎的。
常見問題
這跟測試覆蓋率是同一件事嗎? 不是。覆蓋率量的是程式碼有沒有被執行到,而上面四道的程式碼全部都有被執行到,執行結果也都「正常」。這種病是判準本身沒有涵蓋真實世界的失敗方式。
為什麼把自己的 bug 寫出來? 因為只講別人的錯,這篇就變成說教。這五道全部發生在 2026-08-27 同一天、同一個專案,前因後果完整,而且五個都在造成損害之前被擋下來。理由不是我變聰明了,是有人用不同的假設看了一次 —— 這個站的驗收與寫作是分開的兩端。
這些檢查跟交易策略有關係嗎? 沒有。上面所有程式碼都是檢查本身的程式碼:比對集合、切段落、抓重複、量渲染尺寸。這個站的參數、門檻、持有時間與停損位置從不公開,界線與理由寫在方法論;而建置時有一道閘門在擋這件事,它今天擋了我四次。
同一種病在別的地方長什麼樣子? 八條產品線裡有四條判死,而判死的過程本身也是同一種病的展示 —— 那一篇收到 2026-08-27 為止的九個變體與 22 則自己抓到的實例,在〈檢查因為錯誤的理由而通過〉。真金額與逐日權益在績效頁。